mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-07-13 16:16:44 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
a96d9ef1c3
|
@@ -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
|
||||
@@ -4,21 +4,13 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "src/**"
|
||||
- "pom.xml"
|
||||
- "mvnw"
|
||||
- "mvnw.cmd"
|
||||
- ".github/workflows/nightly-artifacts.yml"
|
||||
schedule:
|
||||
- cron: "20 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-windows-zip:
|
||||
name: Build Windows ZIP
|
||||
@@ -38,11 +30,11 @@ jobs:
|
||||
Add-Content -Path $env:GITHUB_ENV -Value "SHORT_SHA=$shortSha"
|
||||
Add-Content -Path $env:GITHUB_ENV -Value "ASSET_BASENAME=praktiKST-$version-$shortSha"
|
||||
|
||||
- name: Set up Java 21
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
java-version: "17"
|
||||
|
||||
- name: Install WiX Toolset
|
||||
shell: pwsh
|
||||
@@ -62,7 +54,7 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
||||
jpackage --type app-image --name praktiKST --input target/dist-libs --main-jar app.jar --main-class kst4contest.view.Kst4ContestApplication --module-path target/dist-libs --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec --dest dist
|
||||
jpackage --type app-image --name praktiKST --input target/dist-libs --main-jar app.jar --main-class kst4contest.view.Kst4ContestApplication --module-path target/dist-libs --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql --dest dist
|
||||
|
||||
- name: Create Windows ZIP
|
||||
shell: pwsh
|
||||
@@ -91,13 +83,15 @@ jobs:
|
||||
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 21
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
java-version: "17"
|
||||
|
||||
- name: Ensure mvnw is executable
|
||||
run: chmod +x mvnw
|
||||
@@ -117,7 +111,7 @@ jobs:
|
||||
--main-jar app.jar \
|
||||
--main-class kst4contest.view.Kst4ContestApplication \
|
||||
--module-path target/dist-libs \
|
||||
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
|
||||
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql \
|
||||
--dest dist
|
||||
|
||||
- name: Create AppDir metadata
|
||||
@@ -159,429 +153,6 @@ jobs:
|
||||
path: dist/KST4Contest-*-linux-x86_64.AppImage
|
||||
retention-days: 14
|
||||
|
||||
build-linux-deb:
|
||||
name: Build Debian package
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.1.7
|
||||
|
||||
- name: Resolve nightly version info
|
||||
run: |
|
||||
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Java 21
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Install packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends fakeroot
|
||||
|
||||
- name: Ensure mvnw is executable
|
||||
run: chmod +x mvnw
|
||||
|
||||
- name: Build JAR and copy runtime dependencies
|
||||
run: |
|
||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
||||
|
||||
- name: Build Debian package
|
||||
run: |
|
||||
mkdir -p dist
|
||||
jpackage \
|
||||
--type deb \
|
||||
--name KST4Contest \
|
||||
--input target/dist-libs \
|
||||
--main-jar app.jar \
|
||||
--main-class kst4contest.view.Kst4ContestApplication \
|
||||
--module-path target/dist-libs \
|
||||
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
|
||||
--linux-package-deps "libgstreamer1.0-0,libgstreamer-plugins-base1.0-0,gstreamer1.0-plugins-good" \
|
||||
--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 21
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Install packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends rpm
|
||||
|
||||
- name: Ensure mvnw is executable
|
||||
run: chmod +x mvnw
|
||||
|
||||
- name: Build JAR and copy runtime dependencies
|
||||
run: |
|
||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
||||
|
||||
- name: Build Fedora package
|
||||
run: |
|
||||
mkdir -p dist
|
||||
jpackage \
|
||||
--type rpm \
|
||||
--name KST4Contest \
|
||||
--input target/dist-libs \
|
||||
--main-jar app.jar \
|
||||
--main-class kst4contest.view.Kst4ContestApplication \
|
||||
--module-path target/dist-libs \
|
||||
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
|
||||
--linux-package-deps "gstreamer1,gstreamer1-plugins-base,gstreamer1-plugins-good" \
|
||||
--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 21
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Install packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends zstd
|
||||
|
||||
- name: Ensure mvnw is executable
|
||||
run: chmod +x mvnw
|
||||
|
||||
- name: Build JAR and copy runtime dependencies
|
||||
run: |
|
||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
||||
|
||||
- name: Build app-image with jpackage
|
||||
run: |
|
||||
mkdir -p dist
|
||||
jpackage \
|
||||
--type app-image \
|
||||
--name KST4Contest \
|
||||
--input target/dist-libs \
|
||||
--main-jar app.jar \
|
||||
--main-class kst4contest.view.Kst4ContestApplication \
|
||||
--module-path target/dist-libs \
|
||||
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
|
||||
--dest dist
|
||||
|
||||
- name: Build Arch Linux package artifact
|
||||
run: |
|
||||
ARCH=$(uname -m)
|
||||
PKGVER=$(printf '%s' "${VERSION}-${SHORT_SHA}" | sed 's/[^[:alnum:].+_]/_/g')
|
||||
PKGROOT="target/archpkg"
|
||||
rm -rf "$PKGROOT"
|
||||
mkdir -p "$PKGROOT/usr/lib/KST4Contest" "$PKGROOT/usr/bin"
|
||||
cp -a dist/KST4Contest/. "$PKGROOT/usr/lib/KST4Contest/"
|
||||
cat > "$PKGROOT/usr/bin/KST4Contest" << 'EOF'
|
||||
#!/bin/sh
|
||||
exec /usr/lib/KST4Contest/bin/KST4Contest "$@"
|
||||
EOF
|
||||
chmod 755 "$PKGROOT/usr/bin/KST4Contest"
|
||||
mkdir -p "$PKGROOT/usr/share/applications" "$PKGROOT/usr/share/icons/hicolor/256x256/apps"
|
||||
cat > "$PKGROOT/usr/share/applications/KST4Contest.desktop" << 'EOF'
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=KST4Contest
|
||||
Comment=ON4KST Chat Client for VHF/UHF contest operation
|
||||
Exec=KST4Contest
|
||||
Icon=KST4Contest
|
||||
Categories=Network;HamRadio;
|
||||
Terminal=false
|
||||
EOF
|
||||
if [ -f "$PKGROOT/usr/lib/KST4Contest/lib/KST4Contest.png" ]; then
|
||||
cp "$PKGROOT/usr/lib/KST4Contest/lib/KST4Contest.png" "$PKGROOT/usr/share/icons/hicolor/256x256/apps/KST4Contest.png"
|
||||
fi
|
||||
INSTALLED_SIZE=$(du -sb "$PKGROOT" | cut -f1)
|
||||
BUILDDATE=$(date +%s)
|
||||
WORKFLOW_SHA256=$(sha256sum "$GITHUB_WORKSPACE/.github/workflows/nightly-artifacts.yml" | awk '{print $1}')
|
||||
{
|
||||
echo "pkgname = kst4contest"
|
||||
echo "pkgbase = kst4contest"
|
||||
echo "xdata = pkgtype=pkg"
|
||||
echo "pkgver = ${PKGVER}-1"
|
||||
echo "pkgdesc = KST4Contest amateur radio contest logger"
|
||||
echo "url = https://github.com/${{ github.repository }}"
|
||||
echo "builddate = ${BUILDDATE}"
|
||||
echo "packager = GitHub Actions"
|
||||
echo "size = ${INSTALLED_SIZE}"
|
||||
echo "arch = ${ARCH}"
|
||||
echo "license = custom"
|
||||
echo "depend = java-runtime"
|
||||
echo "depend = gst-plugins-base"
|
||||
echo "depend = gst-plugins-good"
|
||||
} > "$PKGROOT/.PKGINFO"
|
||||
{
|
||||
echo "format = 2"
|
||||
echo "pkgname = kst4contest"
|
||||
echo "pkgbase = kst4contest"
|
||||
echo "pkgver = ${PKGVER}-1"
|
||||
echo "pkgarch = ${ARCH}"
|
||||
echo "pkgbuild_sha256sum = ${WORKFLOW_SHA256}"
|
||||
echo "packager = GitHub Actions"
|
||||
echo "builddate = ${BUILDDATE}"
|
||||
echo "builddir = /build"
|
||||
echo "startdir = /build"
|
||||
echo "buildtool = makepkg"
|
||||
echo "buildtoolver = 7.0.0-1-x86_64"
|
||||
echo "buildenv = !distcc !color !ccache check !sign"
|
||||
echo "options = !strip docs libtool staticlibs emptydirs zipman purge !debug !lto"
|
||||
} > "$PKGROOT/.BUILDINFO"
|
||||
tar --zstd --transform 's|^\./||' -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 21
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Ensure mvnw is executable
|
||||
run: chmod +x mvnw
|
||||
|
||||
- name: Install Flatpak tooling
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder elfutils
|
||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak --user install -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08
|
||||
|
||||
- name: Build app-image with jpackage
|
||||
run: |
|
||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
||||
mkdir -p target/flatpak-src
|
||||
jpackage \
|
||||
--type app-image \
|
||||
--name KST4Contest \
|
||||
--input target/dist-libs \
|
||||
--main-jar app.jar \
|
||||
--main-class kst4contest.view.Kst4ContestApplication \
|
||||
--module-path target/dist-libs \
|
||||
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
|
||||
--dest target/flatpak-src
|
||||
|
||||
- name: Create Flatpak manifest
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cat > target/de.x08.KST4Contest.yml << 'EOF'
|
||||
app-id: de.x08.KST4Contest
|
||||
runtime: org.freedesktop.Platform
|
||||
runtime-version: "24.08"
|
||||
sdk: org.freedesktop.Sdk
|
||||
command: KST4Contest
|
||||
finish-args:
|
||||
- --socket=wayland
|
||||
- --socket=x11
|
||||
- --socket=pulseaudio
|
||||
- --share=network
|
||||
- --share=ipc
|
||||
- --device=dri
|
||||
- --filesystem=~/.praktiKST
|
||||
- --env=ALSA_CONFIG_PATH=/app/share/alsa/asound.conf
|
||||
modules:
|
||||
- name: kst4contest
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- install -d /app/lib/KST4Contest /app/bin /app/share/applications /app/share/alsa
|
||||
- printf '@include /usr/share/alsa/alsa.conf\npcm.!default { type pulse }\nctl.!default { type pulse }\n' > /app/share/alsa/asound.conf
|
||||
- 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 (nightly)
|
||||
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 nightly
|
||||
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo
|
||||
|
||||
- name: Create flatpakref (nightly)
|
||||
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.nightly.flatpakref" << EOF
|
||||
[Flatpak Ref]
|
||||
Name=de.x08.KST4Contest
|
||||
Branch=nightly
|
||||
Title=KST4Contest (Nightly) – 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.nightly.flatpakref
|
||||
|
||||
- name: Upload Flatpak OSTree repo
|
||||
uses: actions/upload-artifact@v4.3.4
|
||||
with:
|
||||
name: flatpak-ostree-repo
|
||||
path: target/flatpak-repo/
|
||||
|
||||
publish-flatpak-repo:
|
||||
name: Publish Flatpak OSTree Repo (nightly)
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-flatpak
|
||||
|
||||
steps:
|
||||
- name: Install Flatpak tooling
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends flatpak
|
||||
|
||||
- name: Import Flatpak signing key
|
||||
run: |
|
||||
echo "${{ secrets.FLATPAK_GPG_PRIVATE_KEY }}" | gpg --batch --import
|
||||
echo "FLATPAK_GPG_KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/{print $10; exit}')" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download OSTree repo artifact
|
||||
uses: actions/download-artifact@v4.1.3
|
||||
with:
|
||||
name: flatpak-ostree-repo
|
||||
path: flatpak-ostree-repo/
|
||||
|
||||
- name: Checkout existing flatpak-repo branch
|
||||
uses: actions/checkout@v4.1.7
|
||||
with:
|
||||
ref: flatpak-repo
|
||||
path: existing-flatpak-repo
|
||||
|
||||
- name: Merge nightly build into flatpak-repo
|
||||
run: |
|
||||
cd existing-flatpak-repo
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git config user.name "github-actions[bot]"
|
||||
# Copy the new OSTree build into the repo
|
||||
rsync -a ../flatpak-ostree-repo/ ./
|
||||
# Regenerate summary with all branches
|
||||
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" .
|
||||
# Generate .flatpakrepo with embedded GPG key so users can do remote-add without errors
|
||||
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 > kst4contest.flatpakrepo << EOF
|
||||
[Flatpak Repo]
|
||||
Title=KST4Contest
|
||||
Url=${PAGES_URL}
|
||||
Homepage=https://github.com/${GITHUB_REPOSITORY}
|
||||
Comment=KST4Contest – ON4KST Chat Client for VHF/UHF contests
|
||||
GPGKey=${GPG_KEY_B64}
|
||||
EOF
|
||||
# Stage all changes (new/updated refs, summary, objects in OSTree)
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "Nightly flatpak: $(echo ${{ github.sha }} | cut -c1-7)"
|
||||
git push https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git HEAD:flatpak-repo
|
||||
fi
|
||||
|
||||
build-macos-dmg:
|
||||
name: Build macOS DMG (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -603,11 +174,11 @@ jobs:
|
||||
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||
echo "ARCH=$ARCH" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Java 21
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
java-version: "17"
|
||||
|
||||
- name: Ensure mvnw is executable
|
||||
run: chmod +x mvnw
|
||||
@@ -627,7 +198,7 @@ jobs:
|
||||
--main-jar app.jar \
|
||||
--main-class kst4contest.view.Kst4ContestApplication \
|
||||
--module-path target/dist-libs \
|
||||
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
|
||||
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql \
|
||||
--dest dist
|
||||
|
||||
env:
|
||||
|
||||
@@ -11,18 +11,18 @@ env:
|
||||
|
||||
jobs:
|
||||
compile:
|
||||
name: Compile (Java 21)
|
||||
name: Compile (Java 17)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.1.7
|
||||
|
||||
- name: Set up Java 21
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
java-version: "17"
|
||||
|
||||
- name: Ensure mvnw is executable
|
||||
run: chmod +x mvnw
|
||||
|
||||
@@ -8,7 +8,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
@@ -22,11 +21,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.1.7
|
||||
|
||||
- name: Set up Java 21
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
java-version: "17"
|
||||
|
||||
- name: Install WiX Toolset
|
||||
shell: pwsh
|
||||
@@ -46,7 +45,7 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
||||
jpackage --type app-image --name praktiKST --input target/dist-libs --main-jar app.jar --main-class kst4contest.view.Kst4ContestApplication --module-path target/dist-libs --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec --dest dist
|
||||
jpackage --type app-image --name praktiKST --input target/dist-libs --main-jar app.jar --main-class kst4contest.view.Kst4ContestApplication --module-path target/dist-libs --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql --dest dist
|
||||
|
||||
- name: Create Windows ZIP
|
||||
shell: pwsh
|
||||
@@ -70,11 +69,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.1.7
|
||||
|
||||
- name: Set up Java 21
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
java-version: "17"
|
||||
|
||||
- name: Ensure mvnw is executable
|
||||
run: chmod +x mvnw
|
||||
@@ -94,7 +93,7 @@ jobs:
|
||||
--main-jar app.jar \
|
||||
--main-class kst4contest.view.Kst4ContestApplication \
|
||||
--module-path target/dist-libs \
|
||||
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
|
||||
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql \
|
||||
--dest dist
|
||||
|
||||
- name: Create AppDir metadata
|
||||
@@ -135,348 +134,6 @@ jobs:
|
||||
name: linux-appimage
|
||||
path: dist/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage
|
||||
|
||||
build-linux-deb:
|
||||
name: Build Debian package
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.1.7
|
||||
|
||||
- name: Set up Java 21
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Install packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends fakeroot
|
||||
|
||||
- name: Ensure mvnw is executable
|
||||
run: chmod +x mvnw
|
||||
|
||||
- name: Build JAR and copy runtime dependencies
|
||||
run: |
|
||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
||||
|
||||
- name: Build Debian package
|
||||
run: |
|
||||
mkdir -p dist
|
||||
jpackage \
|
||||
--type deb \
|
||||
--name KST4Contest \
|
||||
--input target/dist-libs \
|
||||
--main-jar app.jar \
|
||||
--main-class kst4contest.view.Kst4ContestApplication \
|
||||
--module-path target/dist-libs \
|
||||
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
|
||||
--linux-package-deps "libgstreamer1.0-0,libgstreamer-plugins-base1.0-0,gstreamer1.0-plugins-good" \
|
||||
--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 21
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Install packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends rpm
|
||||
|
||||
- name: Ensure mvnw is executable
|
||||
run: chmod +x mvnw
|
||||
|
||||
- name: Build JAR and copy runtime dependencies
|
||||
run: |
|
||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
||||
|
||||
- name: Build Fedora package
|
||||
run: |
|
||||
mkdir -p dist
|
||||
jpackage \
|
||||
--type rpm \
|
||||
--name KST4Contest \
|
||||
--input target/dist-libs \
|
||||
--main-jar app.jar \
|
||||
--main-class kst4contest.view.Kst4ContestApplication \
|
||||
--module-path target/dist-libs \
|
||||
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
|
||||
--linux-package-deps "gstreamer1,gstreamer1-plugins-base,gstreamer1-plugins-good" \
|
||||
--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 21
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Install packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends zstd
|
||||
|
||||
- name: Ensure mvnw is executable
|
||||
run: chmod +x mvnw
|
||||
|
||||
- name: Build JAR and copy runtime dependencies
|
||||
run: |
|
||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
||||
|
||||
- name: Build app-image with jpackage
|
||||
run: |
|
||||
mkdir -p dist
|
||||
jpackage \
|
||||
--type app-image \
|
||||
--name KST4Contest \
|
||||
--input target/dist-libs \
|
||||
--main-jar app.jar \
|
||||
--main-class kst4contest.view.Kst4ContestApplication \
|
||||
--module-path target/dist-libs \
|
||||
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
|
||||
--dest dist
|
||||
|
||||
- name: Build Arch Linux package artifact
|
||||
run: |
|
||||
ARCH=$(uname -m)
|
||||
PKGVER=$(printf '%s' "${{ github.ref_name }}" | sed 's/[^[:alnum:].+_]/_/g')
|
||||
PKGROOT="target/archpkg"
|
||||
rm -rf "$PKGROOT"
|
||||
mkdir -p "$PKGROOT/usr/lib/KST4Contest" "$PKGROOT/usr/bin"
|
||||
cp -a dist/KST4Contest/. "$PKGROOT/usr/lib/KST4Contest/"
|
||||
cat > "$PKGROOT/usr/bin/KST4Contest" << 'EOF'
|
||||
#!/bin/sh
|
||||
exec /usr/lib/KST4Contest/bin/KST4Contest "$@"
|
||||
EOF
|
||||
chmod 755 "$PKGROOT/usr/bin/KST4Contest"
|
||||
mkdir -p "$PKGROOT/usr/share/applications" "$PKGROOT/usr/share/icons/hicolor/256x256/apps"
|
||||
cat > "$PKGROOT/usr/share/applications/KST4Contest.desktop" << 'EOF'
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=KST4Contest
|
||||
Comment=ON4KST Chat Client for VHF/UHF contest operation
|
||||
Exec=KST4Contest
|
||||
Icon=KST4Contest
|
||||
Categories=Network;HamRadio;
|
||||
Terminal=false
|
||||
EOF
|
||||
if [ -f "$PKGROOT/usr/lib/KST4Contest/lib/KST4Contest.png" ]; then
|
||||
cp "$PKGROOT/usr/lib/KST4Contest/lib/KST4Contest.png" "$PKGROOT/usr/share/icons/hicolor/256x256/apps/KST4Contest.png"
|
||||
fi
|
||||
INSTALLED_SIZE=$(du -sb "$PKGROOT" | cut -f1)
|
||||
BUILDDATE=$(date +%s)
|
||||
WORKFLOW_SHA256=$(sha256sum "$GITHUB_WORKSPACE/.github/workflows/tagged-release.yml" | awk '{print $1}')
|
||||
{
|
||||
echo "pkgname = kst4contest"
|
||||
echo "pkgbase = kst4contest"
|
||||
echo "xdata = pkgtype=pkg"
|
||||
echo "pkgver = ${PKGVER}-1"
|
||||
echo "pkgdesc = KST4Contest amateur radio contest logger"
|
||||
echo "url = https://github.com/${{ github.repository }}"
|
||||
echo "builddate = ${BUILDDATE}"
|
||||
echo "packager = GitHub Actions"
|
||||
echo "size = ${INSTALLED_SIZE}"
|
||||
echo "arch = ${ARCH}"
|
||||
echo "license = custom"
|
||||
echo "depend = java-runtime"
|
||||
echo "depend = gst-plugins-base"
|
||||
echo "depend = gst-plugins-good"
|
||||
} > "$PKGROOT/.PKGINFO"
|
||||
{
|
||||
echo "format = 2"
|
||||
echo "pkgname = kst4contest"
|
||||
echo "pkgbase = kst4contest"
|
||||
echo "pkgver = ${PKGVER}-1"
|
||||
echo "pkgarch = ${ARCH}"
|
||||
echo "pkgbuild_sha256sum = ${WORKFLOW_SHA256}"
|
||||
echo "packager = GitHub Actions"
|
||||
echo "builddate = ${BUILDDATE}"
|
||||
echo "builddir = /build"
|
||||
echo "startdir = /build"
|
||||
echo "buildtool = makepkg"
|
||||
echo "buildtoolver = 7.0.0-1-x86_64"
|
||||
echo "buildenv = !distcc !color !ccache check !sign"
|
||||
echo "options = !strip docs libtool staticlibs emptydirs zipman purge !debug !lto"
|
||||
} > "$PKGROOT/.BUILDINFO"
|
||||
tar --zstd --transform 's|^\./||' -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 21
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Ensure mvnw is executable
|
||||
run: chmod +x mvnw
|
||||
|
||||
- name: Install Flatpak tooling
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder elfutils
|
||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak --user install -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08
|
||||
|
||||
- name: Build app-image with jpackage
|
||||
run: |
|
||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
||||
mkdir -p target/flatpak-src
|
||||
jpackage \
|
||||
--type app-image \
|
||||
--name KST4Contest \
|
||||
--input target/dist-libs \
|
||||
--main-jar app.jar \
|
||||
--main-class kst4contest.view.Kst4ContestApplication \
|
||||
--module-path target/dist-libs \
|
||||
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
|
||||
--dest target/flatpak-src
|
||||
|
||||
- name: Create Flatpak manifest
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cat > target/de.x08.KST4Contest.yml << 'EOF'
|
||||
app-id: de.x08.KST4Contest
|
||||
runtime: org.freedesktop.Platform
|
||||
runtime-version: "24.08"
|
||||
sdk: org.freedesktop.Sdk
|
||||
command: KST4Contest
|
||||
finish-args:
|
||||
- --socket=wayland
|
||||
- --socket=x11
|
||||
- --socket=pulseaudio
|
||||
- --share=network
|
||||
- --share=ipc
|
||||
- --device=dri
|
||||
- --filesystem=~/.praktiKST
|
||||
- --env=ALSA_CONFIG_PATH=/app/share/alsa/asound.conf
|
||||
modules:
|
||||
- name: kst4contest
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- install -d /app/lib/KST4Contest /app/bin /app/share/applications /app/share/alsa
|
||||
- printf '@include /usr/share/alsa/alsa.conf\npcm.!default { type pulse }\nctl.!default { type pulse }\n' > /app/share/alsa/asound.conf
|
||||
- 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: Determine OSTree branch
|
||||
run: |
|
||||
if [[ "${{ github.ref_name }}" == beta-* ]]; then
|
||||
echo "OSTREE_BRANCH=beta" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "OSTREE_BRANCH=stable" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- 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 "$OSTREE_BRANCH"
|
||||
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.${OSTREE_BRANCH}.flatpakref" << EOF
|
||||
[Flatpak Ref]
|
||||
Name=de.x08.KST4Contest
|
||||
Branch=${OSTREE_BRANCH}
|
||||
Title=KST4Contest (${{ startsWith(github.ref_name, 'beta-') && 'Beta' || 'Release' }}) – 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 }}
|
||||
@@ -488,11 +145,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.1.7
|
||||
|
||||
- name: Set up Java 21
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
java-version: "17"
|
||||
|
||||
- name: Ensure mvnw is executable
|
||||
run: chmod +x mvnw
|
||||
@@ -512,9 +169,9 @@ jobs:
|
||||
--main-jar app.jar \
|
||||
--main-class kst4contest.view.Kst4ContestApplication \
|
||||
--module-path target/dist-libs \
|
||||
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
|
||||
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql \
|
||||
--dest dist
|
||||
|
||||
|
||||
env:
|
||||
MACOSX_DEPLOYMENT_TARGET: "13.0"
|
||||
|
||||
@@ -610,75 +267,14 @@ jobs:
|
||||
name: docs-pdf
|
||||
path: dist/KST4Contest-${{ github.ref_name }}-manual-*.pdf
|
||||
|
||||
publish-flatpak-repo:
|
||||
name: Publish Flatpak OSTree Repo (${{ github.ref_name }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-flatpak
|
||||
|
||||
steps:
|
||||
- name: Install Flatpak tooling
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends flatpak
|
||||
|
||||
- name: Import Flatpak signing key
|
||||
run: |
|
||||
echo "${{ secrets.FLATPAK_GPG_PRIVATE_KEY }}" | gpg --batch --import
|
||||
echo "FLATPAK_GPG_KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/{print $10; exit}')" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download OSTree repo artifact
|
||||
uses: actions/download-artifact@v4.1.3
|
||||
with:
|
||||
name: flatpak-ostree-repo
|
||||
path: flatpak-ostree-repo/
|
||||
|
||||
- name: Checkout existing flatpak-repo branch
|
||||
uses: actions/checkout@v4.1.7
|
||||
with:
|
||||
ref: flatpak-repo
|
||||
path: existing-flatpak-repo
|
||||
|
||||
- name: Merge build into flatpak-repo
|
||||
run: |
|
||||
cd existing-flatpak-repo
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git config user.name "github-actions[bot]"
|
||||
rsync -a ../flatpak-ostree-repo/ ./
|
||||
# Regenerate summary with all branches (not just the one just built)
|
||||
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" .
|
||||
# Generate .flatpakrepo with embedded GPG key so users can do remote-add without errors
|
||||
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 > kst4contest.flatpakrepo << EOF
|
||||
[Flatpak Repo]
|
||||
Title=KST4Contest
|
||||
Url=${PAGES_URL}
|
||||
Homepage=https://github.com/${GITHUB_REPOSITORY}
|
||||
Comment=KST4Contest – ON4KST Chat Client for VHF/UHF contests
|
||||
GPGKey=${GPG_KEY_B64}
|
||||
EOF
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "Flatpak repo: ${{ github.ref_name }}"
|
||||
git push https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git HEAD:flatpak-repo
|
||||
fi
|
||||
|
||||
release-tag:
|
||||
name: Publish Tagged Release
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-windows-zip
|
||||
- build-linux-appimage
|
||||
- build-linux-deb
|
||||
- build-linux-rpm
|
||||
- build-linux-arch
|
||||
- build-macos-dmg
|
||||
- build-flatpak
|
||||
- build-docs-pdf
|
||||
- publish-flatpak-repo
|
||||
|
||||
steps:
|
||||
- name: Download Windows artifact
|
||||
@@ -700,30 +296,6 @@ jobs:
|
||||
merge-multiple: true
|
||||
path: release-assets/macos
|
||||
|
||||
- name: Download Debian artifact
|
||||
uses: actions/download-artifact@v4.1.3
|
||||
with:
|
||||
name: linux-debian
|
||||
path: release-assets/debian
|
||||
|
||||
- name: Download Fedora artifact
|
||||
uses: actions/download-artifact@v4.1.3
|
||||
with:
|
||||
name: linux-fedora
|
||||
path: release-assets/fedora
|
||||
|
||||
- name: Download Arch Linux artifact
|
||||
uses: actions/download-artifact@v4.1.3
|
||||
with:
|
||||
name: linux-arch
|
||||
path: release-assets/archlinux
|
||||
|
||||
- name: Download flatpakref
|
||||
uses: actions/download-artifact@v4.1.3
|
||||
with:
|
||||
name: flatpakref
|
||||
path: release-assets/flatpakref
|
||||
|
||||
- name: Download PDF manuals
|
||||
uses: actions/download-artifact@v4.1.3
|
||||
with:
|
||||
@@ -744,10 +316,6 @@ jobs:
|
||||
artifacts: >-
|
||||
release-assets/windows/praktiKST-${{ github.ref_name }}-windows-x64.zip,
|
||||
release-assets/linux/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage,
|
||||
release-assets/debian/KST4Contest-${{ github.ref_name }}-debian-amd64.deb,
|
||||
release-assets/fedora/KST4Contest-${{ github.ref_name }}-fedora-x86_64.rpm,
|
||||
release-assets/archlinux/KST4Contest-${{ github.ref_name }}-archlinux-*.pkg.tar.zst,
|
||||
release-assets/flatpakref/de.x08.KST4Contest.flatpakref,
|
||||
release-assets/macos/KST4Contest-${{ github.ref_name }}-macos-*.dmg,
|
||||
release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-en.pdf,
|
||||
release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-de.pdf
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
name: Build Website
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- website/**
|
||||
- github_docs/**
|
||||
- .github/workflows/website-build.yml
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- website/**
|
||||
- github_docs/**
|
||||
- .github/workflows/website-build.yml
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
concurrency:
|
||||
group: website-build-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-website:
|
||||
name: Build 11ty Website
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.1.7
|
||||
with:
|
||||
ssh-key: ${{ secrets.WEBSITE_DEPLOY_KEY }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
cache-dependency-path: website/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: website
|
||||
run: npm ci
|
||||
|
||||
- name: Build website
|
||||
working-directory: website
|
||||
run: npm run build
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload website artifact
|
||||
uses: actions/upload-artifact@v4.3.4
|
||||
with:
|
||||
name: kst4contest-website
|
||||
path: website/_site/
|
||||
retention-days: 7
|
||||
|
||||
- name: Commit rebuilt site
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add website/_site
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "chore: rebuild website [skip ci]"
|
||||
git push
|
||||
fi
|
||||
@@ -1,59 +0,0 @@
|
||||
name: Rebuild Roadmap
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited, closed, reopened, labeled, unlabeled, milestoned, demilestoned]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
concurrency:
|
||||
group: website-build-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
rebuild-roadmap:
|
||||
name: Rebuild website roadmap
|
||||
runs-on: ubuntu-latest
|
||||
# Only worth a rebuild if the "enhancement" label is involved: either the
|
||||
# issue currently carries it, or this event just added/removed it.
|
||||
if: contains(github.event.issue.labels.*.name, 'enhancement') || github.event.label.name == 'enhancement'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.1.7
|
||||
with:
|
||||
ref: main
|
||||
ssh-key: ${{ secrets.WEBSITE_DEPLOY_KEY }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
cache-dependency-path: website/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: website
|
||||
run: npm ci
|
||||
|
||||
- name: Build website
|
||||
working-directory: website
|
||||
run: npm run build
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Commit rebuilt site
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add website/_site
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "chore: rebuild website roadmap [skip ci]"
|
||||
git push
|
||||
fi
|
||||
+1
-4
@@ -34,7 +34,4 @@ build/
|
||||
dist/
|
||||
|
||||
#zip files for local backups
|
||||
*.zip
|
||||
|
||||
# node Modules in website
|
||||
website/node_modules/
|
||||
*.zip
|
||||
@@ -1,10 +1,6 @@
|
||||
# KST4Contest
|
||||
|
||||
KST4Contest (also known as pratiKST) is a Java-based chat client for [ON4KST](http://www.on4kst.com/chat), focused on VHF/UHF/SHF contest operation.
|
||||
|
||||
## Website
|
||||
|
||||
The offical Website of KST4Contest is now instead of [do5amf.funkerportal.de](https://do5amf.funkerportal.de) the new website [here](https://kst4contest.hamradioonline.de) [https://kst4contest.hamradioonline.de](https://kst4contest.hamradioonline.de)
|
||||
KST4Contest (also known as pratiKST) is a Java-based chat client for ON4KST, focused on VHF/UHF/SHF contest operation.
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -39,4 +35,4 @@ Wiki Publishing:
|
||||
|
||||
Builds:
|
||||
|
||||
[](https://github.com/praktimarc/kst4contest/actions/workflows/nightly-artifacts.yml)
|
||||
[](https://github.com/praktimarc/kst4contest/actions/workflows/nightly-artifacts.yml)
|
||||
@@ -0,0 +1,2 @@
|
||||
dr2x
|
||||
oe3cin
|
||||
+15832
File diff suppressed because it is too large
Load Diff
@@ -8,18 +8,6 @@ Versionsverlauf von KST4Contest / PraktiKST.
|
||||
|
||||
letzter Changelog bitte aus GitHub entnehmen. Der bisherige Changelog
|
||||
|
||||
## v1.41
|
||||
**Stationskarte, Performance, Reaktionsfähiges UI**
|
||||
|
||||
**Neu:**
|
||||
- **Stationskarte**: Interaktive OpenStreetMap-Karte zeigt die geografische Position aller aktiven Chatmember. Enthält Stationsmarker, Antennen-Kegel, Verbindungslinie zur ausgewählten Station, Maidenhead-Raster-Overlay und ein Wegprofil-Diagramm mit Geländehöhen-Analyse (Fresnel-Zonen, Horizonterkennung). Geländedaten aus Copernicus GLO-30, Open-Meteo API oder Offline-DEM-Import. Aircraft-Scatter-Weganalyse integriert. Funktioniert in AppImage und Flatpak ohne externe CDN-Verbindung (lokaler Tile-Proxy, eingebettetes Leaflet.js).
|
||||
|
||||
**Geändert:**
|
||||
- **Nachrichten-Tabellen-Limit auf 30.000 erhöht**: Chat- und Nachrichtentabellen sind auf 30.000 Einträge begrenzt. Ältere Nachrichten werden automatisch verworfen, was die Performance bei mehrtägigem Contest-Betrieb stabil hält.
|
||||
- **Bildschirmgerechte Fenstergröße**: Beim Start wird das Hauptfenster auf den aktuellen Bildschirm angepasst. Wenn KST4Contest zuletzt auf einem größeren Monitor betrieben wurde, wird das Fenster automatisch verkleinert. Das UI-Layout ist kompakter und reaktionsfähiger auf kleineren Bildschirmen.
|
||||
|
||||
---
|
||||
|
||||
## v1.40 (2026-02-16)
|
||||
**Großes Feature-Release: Score-System, AP-Timeline, Win-Test, PSTRotator**
|
||||
|
||||
|
||||
@@ -235,39 +235,3 @@ Dateibasierte Log-Auswertung per Regex. Details: [Log-Synchronisation](Log-Synch
|
||||
Ein separates Fenster zeigt den QSO-Fluss zwischen anderen Stationen. Besonders interessant in ruhigeren Nacht-Stunden während des Contests, wenn weniger Verkehr herrscht.
|
||||
|
||||
Dieses Fenster kann miniaturisiert werden, wenn es nicht benötigt wird. Zukünftig geplant: Filterung auf Stationen im ausgewählten QTF.
|
||||
|
||||
---
|
||||
|
||||
## Stationskarte (ab v1.41)
|
||||
|
||||
Eine interaktive OpenStreetMap-Karte zeigt die geografische Position aller aktiven Chatmember.
|
||||
|
||||
**Funktionen:**
|
||||
|
||||
- Stationsmarker mit Rufzeichen-Labels, farblich nach Aktivität und Sked-Status
|
||||
- **Antennen-Kegel** für die eigene Station
|
||||
- **Verbindungslinie** zur aktuell ausgewählten Station
|
||||
- **Maidenhead-Raster** (QRA-Locator-Gitter als Overlay)
|
||||
- **Wegprofil-Diagramm**: Geländehöhen-Querschnitt zwischen eigener und ausgewählter Station, inklusive Fresnel-Zonen-Analyse und Horizonterkennung
|
||||
- Mehrere Terrainquellen: **Copernicus GLO-30** (hochauflösendes DEM), **Open-Meteo API**, synthetischer Fallback und **Offline-DEM-Import** für den Betrieb ohne Internetverbindung
|
||||
- Aircraft-Scatter-Weganalyse verknüpft mit den Geländedaten
|
||||
|
||||
Die Karte funktioniert in gepackten Umgebungen (AppImage, Flatpak) ohne Zugriff auf externe CDNs: Die Kartenkacheln werden über einen lokalen Tile-Proxy abgerufen, die Leaflet.js-Bibliothek ist in der Anwendung eingebettet.
|
||||
|
||||
---
|
||||
|
||||
## Optimierte Nachrichtenverarbeitung / 30.000-Nachrichten-Limit (ab v1.41)
|
||||
|
||||
Die internen Chat- und Nachrichtentabellen sind auf **30.000 Einträge** begrenzt. Ältere Nachrichten werden automatisch verworfen, sobald das Limit erreicht wird. Damit bleiben Speicherverbrauch und Darstellungsperformance auch bei mehrtägigen Contest-Betrieb stabil.
|
||||
|
||||
---
|
||||
|
||||
## Bildschirmgerechte Fenstergröße (ab v1.41)
|
||||
|
||||
Beim Programmstart berechnet KST4Contest eine bildschirmgerechte Startgröße für das Hauptfenster:
|
||||
|
||||
- Die gespeicherte Fenstergröße aus der letzten Session wird verwendet – aber **niemals größer als der aktuelle Bildschirm**.
|
||||
- Wenn KST4Contest zuletzt auf einem größeren Monitor betrieben wurde, wird das Fenster automatisch auf die aktuelle Anzeige verkleinert.
|
||||
- Das UI-Layout ist **kompakter und reaktionsfähiger auf kleineren Bildschirmen**.
|
||||
|
||||
Damit werden unbrauchbare, abgeschnittene Fenster beim Wechsel zwischen Geräten oder Monitoren verhindert.
|
||||
|
||||
@@ -44,17 +44,6 @@ Der ON4KST-Chat ist der De-facto-Standard für Skeds auf den 144-MHz-und-höher-
|
||||
- **GitHub**: https://github.com/praktimarc/kst4contest
|
||||
- **Download**: https://github.com/praktimarc/kst4contest/releases/latest
|
||||
|
||||
### Fehler melden (Issue erstellen)
|
||||
|
||||
Beim Melden eines Fehlers bitte **immer die Logdatei anhängen**. KST4Contest schreibt automatisch eine Fehler-Logdatei (nur Fehlermeldungen, keine persönlichen Daten):
|
||||
|
||||
| Betriebssystem | Pfad zur Logdatei |
|
||||
|---|---|
|
||||
| Linux / macOS | `~/.praktiKST/kst4contest-errors.log` |
|
||||
| Windows | `C:\Users\<Benutzername>\.praktiKST\kst4contest-errors.log` |
|
||||
|
||||
Beim Erstellen eines Issues auf GitHub steht eine Vorlage bereit, die alle wichtigen Felder abfragt.
|
||||
|
||||
---
|
||||
|
||||
## Danksagungen
|
||||
|
||||
@@ -42,19 +42,11 @@ Der Dateiname hat das Format `praktiKST-v<Versionsnummer>-windows-x64.zip `.
|
||||
|
||||
### Linux
|
||||
|
||||
Mehrere Paketformate stehen auf der Releases-Seite zur Verfügung:
|
||||
Die aktuelle Version kann als AppImage heruntergeladen werden:
|
||||
|
||||
**https://github.com/praktimarc/kst4contest/releases/latest**
|
||||
|
||||
| Format | Dateiname | Geeignet für |
|
||||
|---|---|---|
|
||||
| AppImage | `KST4Contest-v<Version>-linux-x86_64.AppImage` | Alle Distributionen |
|
||||
| Debian-Paket | `KST4Contest-v<Version>-debian-amd64.deb` | Debian, Ubuntu, Linux Mint, … |
|
||||
| RPM-Paket | `KST4Contest-v<Version>-fedora-x86_64.rpm` | Fedora, RHEL, openSUSE, … |
|
||||
| Arch-Paket | `KST4Contest-v<Version>-archlinux-x86_64.pkg.tar.zst` | Arch Linux, Manjaro, … |
|
||||
| Flatpak | `de.x08.KST4Contest.flatpakref` | Alle Distributionen mit Flatpak |
|
||||
|
||||
> **Empfehlung für Linux:** Die Flatpak-Installation ist der einfachste Weg, immer aktuell zu bleiben – `flatpak update` erledigt alle zukünftigen Updates automatisch. Das Repository ist GPG-signiert.
|
||||
Der Dateiname hat das Format `KST4Contest-v<Versionsnummer>-linux-x86_64.AppImage`.
|
||||
|
||||
### macOS
|
||||
|
||||
@@ -80,82 +72,12 @@ Der Dateiname hat das Format `KST4Contest-v<Versionsnummer>-macos-<Architektur>.
|
||||
Die Einstellungen werden unter `%USERPROFILE%\.praktikst\preferences.xml` gespeichert.
|
||||
|
||||
### Linux
|
||||
|
||||
Die Einstellungen werden immer unter `~/.praktikst/preferences.xml` gespeichert.
|
||||
|
||||
#### AppImage
|
||||
|
||||
1. AppImage herunterladen.
|
||||
2. Ausführbar machen: `chmod +x KST4Contest-v<Version>-linux-x86_64.AppImage`
|
||||
3. Starten.
|
||||
2. AppImage in gewünschten Ordner entpacken.
|
||||
3. AppImage ausführbar machen (geht im Terminal mit `chmod +x KST4Contest-v<Versionsnummer>-linux-x86_64.AppImage`)
|
||||
4. AppImage ausführen.
|
||||
|
||||
#### 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 (empfohlen für Nightly/Beta-Zugriff):
|
||||
```bash
|
||||
flatpak remote-add --if-not-exists kst4contest https://praktimarc.github.io/kst4contest/kst4contest.flatpakrepo
|
||||
flatpak install kst4contest de.x08.KST4Contest
|
||||
```
|
||||
|
||||
#### Flatpak-Kanäle (nightly / beta / stable)
|
||||
|
||||
Das Flatpak-Repository enthält drei Kanäle, die alle im selben Remote liegen:
|
||||
|
||||
| Kanal | Inhalt | Wird gebaut bei |
|
||||
|---|---|---|
|
||||
| `stable` | Aktuelle stabile Version | Jedem normalen Release-Tag |
|
||||
| `beta` | Vorab-Version | Tags mit `beta-` Präfix |
|
||||
| `nightly` | Entwicklungsstand (main-Branch) | Täglich / jedem Push auf main |
|
||||
|
||||
Remote einmalig hinzufügen, dann den gewünschten Kanal installieren:
|
||||
|
||||
```bash
|
||||
flatpak remote-add --if-not-exists kst4contest https://praktimarc.github.io/kst4contest/kst4contest.flatpakrepo
|
||||
|
||||
# Stable (Standard)
|
||||
flatpak install kst4contest de.x08.KST4Contest
|
||||
|
||||
# Beta
|
||||
flatpak install kst4contest de.x08.KST4Contest//beta
|
||||
|
||||
# Nightly
|
||||
flatpak install kst4contest de.x08.KST4Contest//nightly
|
||||
```
|
||||
|
||||
> **Hinweis:** Flatpak erlaubt nur eine installierte Version pro App-ID gleichzeitig. Um den Kanal zu wechseln, zuerst die aktuelle Version deinstallieren:
|
||||
> ```bash
|
||||
> flatpak uninstall de.x08.KST4Contest
|
||||
> flatpak install kst4contest de.x08.KST4Contest//nightly
|
||||
> ```
|
||||
|
||||
Updates erfolgen wie gewohnt mit `flatpak update`.
|
||||
|
||||
Die `flatpakref`-Dateien für Beta und Stable liegen jeweils im entsprechenden [GitHub-Release](https://github.com/praktimarc/kst4contest/releases). Das Nightly-`flatpakref` (`de.x08.KST4Contest.nightly.flatpakref`) ist als Artifact im [GitHub Actions](https://github.com/praktimarc/kst4contest/actions)-Tab verfügbar (wird 14 Tage aufbewahrt) – der Remote-Weg oben ist für Nightly daher meist einfacher.
|
||||
Die Einstellungen werden unter `~/.praktikst/preferences.xml` gespeichert.
|
||||
|
||||
### macOS
|
||||
1. DMG-Datei für die eigene Architektur herunterladen (Apple Silicon oder Intel).
|
||||
@@ -192,11 +114,10 @@ Die Einstellungsdatei (`preferences.xml`) bleibt erhalten, da sie im Benutzerord
|
||||
|
||||
#### Linux
|
||||
|
||||
- **AppImage**: Neues AppImage herunterladen, ausführbar machen (`chmod +x`), altes optional löschen.
|
||||
- **Debian/Ubuntu**: `sudo apt install ./KST4Contest-v<Version>-debian-amd64.deb`
|
||||
- **Fedora/RHEL**: `sudo dnf upgrade ./KST4Contest-v<Version>-fedora-x86_64.rpm`
|
||||
- **Arch Linux**: `sudo pacman -U KST4Contest-v<Version>-archlinux-x86_64.pkg.tar.zst`
|
||||
- **Flatpak (Repository)**: `flatpak update` – aktualisiert alle Flatpak-Apps einschließlich KST4Contest.
|
||||
Derzeit folgendermaßen:
|
||||
1. neues AppImage herunterladen
|
||||
2. neues AppImage ausführbar makieren
|
||||
3. (optional) altes AppImage löschen.
|
||||
|
||||
#### macOS
|
||||
|
||||
|
||||
@@ -8,18 +8,6 @@ Version history of KST4Contest / PraktiKST.
|
||||
|
||||
For the latest changelog, please refer to GitHub. The previous changelog is below.
|
||||
|
||||
## v1.41
|
||||
**Station Map, Performance, Responsive UI**
|
||||
|
||||
**New:**
|
||||
- **Station Map**: Interactive OpenStreetMap-based map showing the geographic position of all active chat members. Includes station markers, antenna beam cone, connection line to the selected station, Maidenhead grid overlay, and a path profile chart with terrain elevation analysis (Fresnel zones, horizon detection). Terrain data from Copernicus GLO-30, Open-Meteo API, or offline DEM import. Aircraft scatter path analysis integrated. Works in AppImage and Flatpak without external CDN access (local tile proxy, bundled Leaflet.js).
|
||||
|
||||
**Changed:**
|
||||
- **Message table limit raised to 30,000**: Chat and message tables are capped at 30,000 entries. Older messages are automatically discarded, keeping performance stable during multi-day contest operations.
|
||||
- **Screen-aware window sizing**: On startup, the main window is sized to fit the current screen. If KST4Contest was last used on a larger monitor, the window is automatically scaled down. The UI layout is more compact and responsive on smaller screens.
|
||||
|
||||
---
|
||||
|
||||
## v1.40 (2026-02-16)
|
||||
**Major Feature Release: Score System, AP Timeline, Win-Test, PSTRotator**
|
||||
|
||||
|
||||
@@ -233,39 +233,3 @@ File-based log evaluation using regex. Details: [Log Synchronisation](Log-Sync#m
|
||||
## Cluster & QSO of Others
|
||||
|
||||
A separate window showing the QSO flow between other stations. Particularly interesting during quieter night-time hours of a contest. This window can be minimised when not needed. Future plan: filtering to stations in your selected QTF.
|
||||
|
||||
---
|
||||
|
||||
## Station Map (from v1.41)
|
||||
|
||||
An interactive OpenStreetMap-based map showing the geographic position of all active chat members.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Station markers with callsign labels, coloured by activity and sked state
|
||||
- Antenna **beam cone** visualisation for the own station
|
||||
- **Connection line** to the currently selected station
|
||||
- **Maidenhead grid** overlay (QRA locator grid)
|
||||
- **Path profile chart**: Terrain elevation cross-section between own station and the selected station, including Fresnel zone analysis and obstruction/horizon detection
|
||||
- Multiple terrain data sources: **Copernicus GLO-30** (high-resolution DEM), **Open-Meteo API**, synthetic fallback, and **offline DEM import** for air-gapped use
|
||||
- Aircraft scatter path analysis integrated with the terrain data
|
||||
|
||||
The map works in packaged environments (AppImage, Flatpak) without internet access to external CDNs: map tiles are fetched via a local tile proxy, and the Leaflet.js library is bundled inside the application.
|
||||
|
||||
---
|
||||
|
||||
## Optimised Message Handling / 30,000 Message Limit (from v1.41)
|
||||
|
||||
The internal chat and message tables are capped at **30,000 entries**. Older messages are automatically discarded when the limit is reached. This keeps memory usage and rendering performance stable during multi-day contest operations.
|
||||
|
||||
---
|
||||
|
||||
## Screen-Aware Window Sizing (from v1.41)
|
||||
|
||||
On startup, KST4Contest calculates a screen-aware size for the main window:
|
||||
|
||||
- The stored window size from the previous session is used – but **never larger than the current screen**.
|
||||
- If KST4Contest was last used on a larger monitor, the window is automatically scaled down to fit the current display without clipping.
|
||||
- The UI layout is more **compact and responsive on smaller screens**, showing the same information in less space.
|
||||
|
||||
This prevents unusable oversized windows when switching between machines or monitors.
|
||||
|
||||
@@ -44,17 +44,6 @@ The ON4KST Chat is the de-facto standard for skeds on the 144 MHz and higher ban
|
||||
- **GitHub**: https://github.com/praktimarc/kst4contest
|
||||
- **Download**: https://github.com/praktimarc/kst4contest/releases/latest
|
||||
|
||||
### Reporting a bug (creating an issue)
|
||||
|
||||
When reporting a bug, please **always attach the log file**. KST4Contest automatically writes an error log (error messages only, no personal data):
|
||||
|
||||
| Operating system | Log file location |
|
||||
|---|---|
|
||||
| Linux / macOS | `~/.praktiKST/kst4contest-errors.log` |
|
||||
| Windows | `C:\Users\<YourName>\.praktiKST\kst4contest-errors.log` |
|
||||
|
||||
When opening an issue on GitHub, a template is provided that guides you through all relevant fields.
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
@@ -42,19 +42,11 @@ The filename has the format `praktiKST-v<version_number>-windows-x64.zip`.
|
||||
|
||||
### Linux
|
||||
|
||||
Multiple package formats are available from the releases page:
|
||||
The latest version can be downloaded as an AppImage:
|
||||
|
||||
**https://github.com/praktimarc/kst4contest/releases/latest**
|
||||
|
||||
| Format | Filename | Suitable for |
|
||||
|---|---|---|
|
||||
| AppImage | `KST4Contest-v<version>-linux-x86_64.AppImage` | All distributions |
|
||||
| Debian package | `KST4Contest-v<version>-debian-amd64.deb` | Debian, Ubuntu, Linux Mint, … |
|
||||
| RPM package | `KST4Contest-v<version>-fedora-x86_64.rpm` | Fedora, RHEL, openSUSE, … |
|
||||
| Arch package | `KST4Contest-v<version>-archlinux-x86_64.pkg.tar.zst` | Arch Linux, Manjaro, … |
|
||||
| Flatpak | `de.x08.KST4Contest.flatpakref` | All distributions with Flatpak |
|
||||
|
||||
> **Recommended for Linux:** The Flatpak installation is the easiest way to stay up to date — `flatpak update` handles all future updates automatically. The repository is GPG-signed for security.
|
||||
The filename has the format `KST4Contest-v<version_number>-linux-x86_64.AppImage`.
|
||||
|
||||
### macOS
|
||||
|
||||
@@ -80,82 +72,12 @@ The filename has the format `KST4Contest-v<version_number>-macos-<arch>.dmg`, wh
|
||||
Settings are stored at `%USERPROFILE%\.praktikst\preferences.xml`.
|
||||
|
||||
### Linux
|
||||
|
||||
Settings are always stored at `~/.praktikst/preferences.xml`.
|
||||
|
||||
#### AppImage
|
||||
|
||||
1. Download the AppImage.
|
||||
2. Make it executable: `chmod +x KST4Contest-v<version>-linux-x86_64.AppImage`
|
||||
3. Run it.
|
||||
2. Unzip the AppImage into a folder of your choice.
|
||||
3. Make the AppImage executable (in the terminal with `chmod +x KST4Contest-v<version_number>-linux-x86_64.AppImage`)
|
||||
4. Run the AppImage.
|
||||
|
||||
#### 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 (recommended for nightly/beta access):
|
||||
```bash
|
||||
flatpak remote-add --if-not-exists kst4contest https://praktimarc.github.io/kst4contest/kst4contest.flatpakrepo
|
||||
flatpak install kst4contest de.x08.KST4Contest
|
||||
```
|
||||
|
||||
#### Flatpak Channels (nightly / beta / stable)
|
||||
|
||||
The Flatpak repository provides three channels, all served from the same remote:
|
||||
|
||||
| Channel | Content | Built on |
|
||||
|---|---|---|
|
||||
| `stable` | Current stable version | Every normal release tag |
|
||||
| `beta` | Pre-release version | Tags prefixed with `beta-` |
|
||||
| `nightly` | Development build (main branch) | Every push to main / daily |
|
||||
|
||||
Add the remote once, then install the channel you want:
|
||||
|
||||
```bash
|
||||
flatpak remote-add --if-not-exists kst4contest https://praktimarc.github.io/kst4contest/kst4contest.flatpakrepo
|
||||
|
||||
# Stable (default)
|
||||
flatpak install kst4contest de.x08.KST4Contest
|
||||
|
||||
# Beta
|
||||
flatpak install kst4contest de.x08.KST4Contest//beta
|
||||
|
||||
# Nightly
|
||||
flatpak install kst4contest de.x08.KST4Contest//nightly
|
||||
```
|
||||
|
||||
> **Note:** Flatpak only allows one installed version per App-ID at a time. To switch channels, uninstall the current version first:
|
||||
> ```bash
|
||||
> flatpak uninstall de.x08.KST4Contest
|
||||
> flatpak install kst4contest de.x08.KST4Contest//nightly
|
||||
> ```
|
||||
|
||||
Updates work as usual with `flatpak update`.
|
||||
|
||||
The `flatpakref` files for beta and stable are attached to their respective [GitHub Releases](https://github.com/praktimarc/kst4contest/releases). The nightly `flatpakref` (`de.x08.KST4Contest.nightly.flatpakref`) is available as an artifact on the [GitHub Actions](https://github.com/praktimarc/kst4contest/actions) tab (kept for 14 days) — for nightly builds, the manual remote approach above is usually more convenient.
|
||||
Settings are stored at `~/.praktikst/preferences.xml`.
|
||||
|
||||
### macOS
|
||||
1. Download the DMG file for your architecture (Apple Silicon or Intel).
|
||||
@@ -192,11 +114,10 @@ The settings file (`preferences.xml`) is preserved because it is stored in the u
|
||||
|
||||
#### Linux
|
||||
|
||||
- **AppImage**: Download the new AppImage, make it executable (`chmod +x`), optionally delete the old one.
|
||||
- **Debian/Ubuntu**: `sudo apt install ./KST4Contest-v<version>-debian-amd64.deb`
|
||||
- **Fedora/RHEL**: `sudo dnf upgrade ./KST4Contest-v<version>-fedora-x86_64.rpm`
|
||||
- **Arch Linux**: `sudo pacman -U KST4Contest-v<version>-archlinux-x86_64.pkg.tar.zst`
|
||||
- **Flatpak (repository)**: `flatpak update` – updates all Flatpak apps including KST4Contest.
|
||||
Currently as follows:
|
||||
1. Download the new AppImage
|
||||
2. Mark the new AppImage as executable
|
||||
3. (optional) Delete the old AppImage.
|
||||
|
||||
#### macOS
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<groupId>de.x08</groupId>
|
||||
<artifactId>praktiKST</artifactId>
|
||||
<version>1.42.0-nightly</version>
|
||||
<version>1.41.0-nightly</version>
|
||||
|
||||
<name>praktiKST</name>
|
||||
|
||||
@@ -24,12 +24,12 @@
|
||||
<launcher>${project.artifactId}</launcher>
|
||||
<appName>${project.artifactId}</appName>
|
||||
<main.class>kst4contest.view.Kst4ContestApplication</main.class>
|
||||
<java.version>21</java.version>
|
||||
<java.version>17</java.version>
|
||||
<required.maven.version>3.6.3</required.maven.version>
|
||||
<jar.filename>${project.artifactId}-${project.version}</jar.filename>
|
||||
|
||||
<!-- Dependency versions -->
|
||||
<javafx.version>21.0.5</javafx.version>
|
||||
<javafx.version>19.0.2.1</javafx.version>
|
||||
<jetbrains.annotations.version>24.0.1</jetbrains.annotations.version>
|
||||
<junit.version>5.10.1</junit.version>
|
||||
<lombok.version>1.18.44</lombok.version>
|
||||
@@ -50,8 +50,8 @@
|
||||
<maven.wrapper.plugin>3.2.0</maven.wrapper.plugin>
|
||||
<moditect.maven.plugin>1.0.0.RC2</moditect.maven.plugin>
|
||||
<jpackage.maven.plugin>0.1.3</jpackage.maven.plugin>
|
||||
<maven.pmd.version>3.28.0</maven.pmd.version>
|
||||
<pmd.version>7.17.0</pmd.version>
|
||||
<maven.pmd.version>3.21.2</maven.pmd.version>
|
||||
<pmd.version>6.55.0</pmd.version>
|
||||
<codehaus.version.plugin>2.16.1</codehaus.version.plugin>
|
||||
<javafx.maven.plugin>0.0.8</javafx.maven.plugin>
|
||||
<spotbugs.maven.plugin>4.9.8.2</spotbugs.maven.plugin>
|
||||
@@ -93,13 +93,6 @@
|
||||
<version>${javafx.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- JLayer: pure-Java MP3 decoder, used instead of JavaFX Media for Flatpak audio compatibility -->
|
||||
<dependency>
|
||||
<groupId>javazoom</groupId>
|
||||
<artifactId>jlayer</artifactId>
|
||||
<version>1.0.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- SQLite -->
|
||||
<dependency>
|
||||
<groupId>org.xerial</groupId>
|
||||
@@ -287,7 +280,30 @@
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-pmd-plugin</artifactId>
|
||||
<version>${maven.pmd.version}</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>net.sourceforge.pmd</groupId>
|
||||
<artifactId>pmd-core</artifactId>
|
||||
<version>${pmd.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.sourceforge.pmd</groupId>
|
||||
<artifactId>pmd-java</artifactId>
|
||||
<version>${pmd.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.sourceforge.pmd</groupId>
|
||||
<artifactId>pmd-javascript</artifactId>
|
||||
<version>${pmd.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.sourceforge.pmd</groupId>
|
||||
<artifactId>pmd-jsp</artifactId>
|
||||
<version>${pmd.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<configuration>
|
||||
<sourceEncoding>${project.build.sourceEncoding}</sourceEncoding>
|
||||
<minimumTokens>100</minimumTokens>
|
||||
<targetJdk>${java.version}</targetJdk>
|
||||
<linkXRef>false</linkXRef>
|
||||
@@ -420,10 +436,7 @@
|
||||
<addmodule>javafx.graphics</addmodule>
|
||||
<addmodule>javafx.fxml</addmodule>
|
||||
<addmodule>javafx.web</addmodule>
|
||||
<addmodule>javafx.media</addmodule>
|
||||
<addmodule>java.sql</addmodule>
|
||||
<addmodule>java.net.http</addmodule>
|
||||
<addmodule>jdk.crypto.ec</addmodule>
|
||||
</addmodules>
|
||||
<mainclass>${main.class}</mainclass>
|
||||
<input>${project.build.directory}/modules</input>
|
||||
|
||||
@@ -20,9 +20,9 @@ public class ApplicationConstants {
|
||||
/**
|
||||
* Name of file to store preferences in.
|
||||
*/
|
||||
public static final double APPLICATION_CURRENTVERSIONNUMBER = 1.42;
|
||||
public static final double APPLICATION_CURRENTVERSIONNUMBER = 1.41;
|
||||
|
||||
public static final String VERSIONINFOURLFORUPDATES_KST4CONTEST = "https://kst4contest.hamradioonline.de/kst4ContestVersionInfo.xml";
|
||||
public static final String VERSIONINFOURLFORUPDATES_KST4CONTEST = "https://do5amf.funkerportal.de/kst4ContestVersionInfo.xml";
|
||||
public static final String VERSIONINFDOWNLOADEDLOCALFILE = "kst4ContestVersionInfo.xml";
|
||||
|
||||
public static final String STYLECSSFILE_DEFAULT_DAYLIGHT = "KST4ContestDefaultDay.css";
|
||||
@@ -38,24 +38,7 @@ public class ApplicationConstants {
|
||||
|
||||
public static final String AUTOANSWER_PREFIX = "[KST4C Automsg] "; // hard-coded marker (user can't remove it)
|
||||
|
||||
/**
|
||||
* UI message retention limits.
|
||||
*
|
||||
* The global chat message list is the backing list for several FilteredLists
|
||||
* and TableViews. It must not grow without limit during long contest runs.
|
||||
*
|
||||
* The list is kept in newest-first order:
|
||||
* index 0 = newest message
|
||||
* last index = oldest message
|
||||
*/
|
||||
public static final int CHAT_MESSAGE_STORE_MAX_SIZE = 30000;
|
||||
public static final int CHAT_MESSAGE_STORE_TRIM_TO_SIZE = 25000;
|
||||
|
||||
/**
|
||||
* DXCluster table retention limits.
|
||||
*/
|
||||
public static final int CLUSTER_MESSAGE_STORE_MAX_SIZE = 10000;
|
||||
public static final int CLUSTER_MESSAGE_STORE_TRIM_TO_SIZE = 8000;
|
||||
|
||||
/**
|
||||
* generates a unique runtime id per session. Its used to feed the poison pill in order to kill only this one and
|
||||
|
||||
+7
-10
@@ -9,8 +9,6 @@ import java.net.NoRouteToHostException;
|
||||
import java.net.SocketException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.TimerTask;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import javafx.collections.ObservableList;
|
||||
import kst4contest.locatorUtils.Location;
|
||||
@@ -19,7 +17,6 @@ import kst4contest.model.ChatMember;
|
||||
|
||||
public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(AirScoutPeriodicalAPReflectionInquirerTask.class.getName());
|
||||
private ChatController client;
|
||||
|
||||
public AirScoutPeriodicalAPReflectionInquirerTask(ChatController client) {
|
||||
@@ -58,7 +55,7 @@ public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
|
||||
ownCallSign = this.client.getChatPreferences().getStn_loginCallSign();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] Error parsing callsign", e);
|
||||
System.out.println("[ASPERIODICAL, Error]: " + e.getMessage());
|
||||
}
|
||||
String myCallAndMyLocString = ownCallSign + "," + this.client.getChatPreferences().getStn_loginLocatorMainCat(); //bugfix, Airscout do not process 9A1W-2 but 9A1W like formatted calls
|
||||
|
||||
@@ -108,13 +105,13 @@ public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
|
||||
dsocket.send(packet);
|
||||
dsocket.close();
|
||||
} catch (UnknownHostException e1) {
|
||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] Unknown host", e1);
|
||||
e1.printStackTrace();
|
||||
} catch (NoRouteToHostException e) {
|
||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] No route to host", e);
|
||||
e.printStackTrace();
|
||||
} catch (SocketException e) {
|
||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] Socket error", e);
|
||||
e.printStackTrace();
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] IO error sending query", e);
|
||||
e.printStackTrace();
|
||||
}
|
||||
// System.out.println("[ASUDPTask, info:] sent query " + queryStringToAirScout);
|
||||
|
||||
@@ -139,9 +136,9 @@ public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
|
||||
dsocket.send(packet);
|
||||
dsocket.close();
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] IO error sending watchlist", e);
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
|
||||
// System.out.println("[ASUDPTask, info:] set watchlist: " + asWatchListStringSuffix);
|
||||
|
||||
|
||||
|
||||
@@ -32,8 +32,6 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* Central Chat kst4contest.controller. Instantiate only one time per category of kst Chat.
|
||||
@@ -1077,10 +1075,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
private DBController dbHandler;
|
||||
|
||||
private ReachabilityService reachabilityService;
|
||||
private final WorkedGrossFieldCache workedGrossFieldCache = new WorkedGrossFieldCache();
|
||||
|
||||
|
||||
private Socket socket;
|
||||
private ServerSocket cluster_telnetServerSocket; // socket that accepts telnet client connects (cluster client)
|
||||
// private ServerSocketChannel cluster_telnetServerSocketChannel;
|
||||
@@ -1133,27 +1127,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
private ObservableList<Predicate<ChatMember>> lst_chatMemberListFilterPredicates = FXCollections.observableArrayList();
|
||||
private ObservableList<ClusterMessage> lst_clusterMemberList = FXCollections.observableArrayList();
|
||||
|
||||
|
||||
/*
|
||||
* Message table update buffers.
|
||||
*
|
||||
* Do not write directly to lst_globalChatMessageList from worker threads.
|
||||
* Use publishChatMessage(...) instead.
|
||||
*
|
||||
* The actual ObservableList mutation is batched and executed on the JavaFX
|
||||
* application thread. The visible list order remains newest-first.
|
||||
*/
|
||||
private final Object pendingChatMessagesLock = new Object();
|
||||
private final List<ChatMessage> pendingChatMessages = new ArrayList<>();
|
||||
private boolean chatMessageFlushScheduled = false;
|
||||
|
||||
/*
|
||||
* Same idea for DXCluster messages.
|
||||
*/
|
||||
private final Object pendingClusterMessagesLock = new Object();
|
||||
private final List<ClusterMessage> pendingClusterMessages = new ArrayList<>();
|
||||
private boolean clusterMessageFlushScheduled = false;
|
||||
|
||||
private ObservableList<ChatMember> lst_DBBasedWkdCallSignList = FXCollections.observableArrayList();
|
||||
|
||||
// private HashMap<String, ChatMember> map_ucxLogInfoWorkedCalls = new HashMap<String, ChatMember>(); //Destination of ucx-log worked-messages
|
||||
@@ -1205,26 +1178,12 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Notifies the UI that station-list derived values have changed.
|
||||
*
|
||||
* <p>This method may be called from parser threads, UDP listener threads and
|
||||
* reachability worker threads. Therefore the listener is always invoked on the
|
||||
* JavaFX application thread.</p>
|
||||
*
|
||||
* @param reason short debug reason for the UI log
|
||||
*/
|
||||
public void fireUserListUpdate(String reason) {
|
||||
if (statusListener == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
statusListener.onUserListUpdated(reason);
|
||||
} else {
|
||||
Platform.runLater(() -> statusListener.onUserListUpdated(reason));
|
||||
}
|
||||
}
|
||||
public void fireUserListUpdate(String reason) {
|
||||
if (statusListener != null) {
|
||||
// Da UI Updates im JavaFX Thread passieren müssen, hier oder im Listener Platform.runLater nutzen
|
||||
statusListener.onUserListUpdated(reason);
|
||||
}
|
||||
}
|
||||
|
||||
// /**
|
||||
// * checks if the callsign-String of a given chatmember instance and a given list
|
||||
@@ -1283,145 +1242,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
return lst_globalChatMessageList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a chat message to the UI message store.
|
||||
*
|
||||
* Important:
|
||||
* - This method may be called from worker threads.
|
||||
* - The ObservableList is modified only on the JavaFX application thread.
|
||||
* - The backing list remains newest-first.
|
||||
* - Old messages are trimmed to avoid unlimited memory growth.
|
||||
*/
|
||||
public void publishChatMessage(ChatMessage message) {
|
||||
if (message == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
synchronized (pendingChatMessagesLock) {
|
||||
pendingChatMessages.add(message);
|
||||
|
||||
if (chatMessageFlushScheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
chatMessageFlushScheduled = true;
|
||||
}
|
||||
|
||||
Platform.runLater(this::flushPendingChatMessagesToUi);
|
||||
}
|
||||
|
||||
private void flushPendingChatMessagesToUi() {
|
||||
List<ChatMessage> batch;
|
||||
|
||||
synchronized (pendingChatMessagesLock) {
|
||||
batch = new ArrayList<>(pendingChatMessages);
|
||||
pendingChatMessages.clear();
|
||||
chatMessageFlushScheduled = false;
|
||||
}
|
||||
|
||||
if (batch.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* pendingChatMessages is collected in arrival order:
|
||||
* old -> new
|
||||
*
|
||||
* lst_globalChatMessageList must stay newest-first:
|
||||
* new -> old
|
||||
*/
|
||||
Collections.reverse(batch);
|
||||
|
||||
lst_globalChatMessageList.addAll(0, batch);
|
||||
|
||||
trimGlobalChatMessageListIfNeeded();
|
||||
}
|
||||
|
||||
private void trimGlobalChatMessageListIfNeeded() {
|
||||
int maxSize = ApplicationConstants.CHAT_MESSAGE_STORE_MAX_SIZE;
|
||||
int trimToSize = ApplicationConstants.CHAT_MESSAGE_STORE_TRIM_TO_SIZE;
|
||||
|
||||
if (maxSize <= 0 || trimToSize <= 0 || trimToSize >= maxSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
int currentSize = lst_globalChatMessageList.size();
|
||||
|
||||
if (currentSize <= maxSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* List order is newest-first.
|
||||
* Therefore old messages are at the end of the list.
|
||||
*/
|
||||
lst_globalChatMessageList.remove(trimToSize, currentSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a DXCluster message to the UI cluster message store.
|
||||
*
|
||||
* Same policy as for chat messages:
|
||||
* - batched UI update
|
||||
* - JavaFX thread only for ObservableList mutation
|
||||
* - newest-first visible order
|
||||
* - bounded list size
|
||||
*/
|
||||
public void publishClusterMessage(ClusterMessage message) {
|
||||
if (message == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
synchronized (pendingClusterMessagesLock) {
|
||||
pendingClusterMessages.add(message);
|
||||
|
||||
if (clusterMessageFlushScheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
clusterMessageFlushScheduled = true;
|
||||
}
|
||||
|
||||
Platform.runLater(this::flushPendingClusterMessagesToUi);
|
||||
}
|
||||
|
||||
private void flushPendingClusterMessagesToUi() {
|
||||
List<ClusterMessage> batch;
|
||||
|
||||
synchronized (pendingClusterMessagesLock) {
|
||||
batch = new ArrayList<>(pendingClusterMessages);
|
||||
pendingClusterMessages.clear();
|
||||
clusterMessageFlushScheduled = false;
|
||||
}
|
||||
|
||||
if (batch.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Collections.reverse(batch);
|
||||
|
||||
lst_clusterMemberList.addAll(0, batch);
|
||||
|
||||
trimClusterMessageListIfNeeded();
|
||||
}
|
||||
|
||||
private void trimClusterMessageListIfNeeded() {
|
||||
int maxSize = ApplicationConstants.CLUSTER_MESSAGE_STORE_MAX_SIZE;
|
||||
int trimToSize = ApplicationConstants.CLUSTER_MESSAGE_STORE_TRIM_TO_SIZE;
|
||||
|
||||
if (maxSize <= 0 || trimToSize <= 0 || trimToSize >= maxSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
int currentSize = lst_clusterMemberList.size();
|
||||
|
||||
if (currentSize <= maxSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
lst_clusterMemberList.remove(trimToSize, currentSize);
|
||||
}
|
||||
|
||||
public void setLst_globalChatMessageList(ObservableList<ChatMessage> lst_globalChatMessageList) {
|
||||
this.lst_globalChatMessageList = lst_globalChatMessageList;
|
||||
}
|
||||
@@ -1642,8 +1462,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
});
|
||||
|
||||
dbHandler = new DBController();
|
||||
reachabilityService = new ReachabilityService(this);
|
||||
rebuildWorkedGrossFieldCacheFromDatabase();
|
||||
|
||||
// chatPreferences = new ChatPreferences();
|
||||
// chatPreferences.readPreferencesFromXmlFile(); // set the praktikst Prefs by file or default if file is corrupted
|
||||
@@ -1661,7 +1479,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
}
|
||||
|
||||
|
||||
private void initLst_toMeMessageList() {
|
||||
// ObservableList<String> sniffedList = chatPreferences.getLstNotify_QSOSniffer_sniffedCallSignList();
|
||||
|
||||
@@ -1678,18 +1495,14 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
// --- NEUE LOGIK: Sniffer Liste prüfen ---
|
||||
// Wenn Absender ODER Empfänger in der Beobachtungsliste stehen -> Anzeigen
|
||||
// if ((lstNotify_QSOSniffer_sniffedCallSignList.contains(senderCall) ||
|
||||
// lstNotify_QSOSniffer_sniffedCallSignList.contains(receiverCall)) &&
|
||||
// (!receiverCall.equals(this.getChatPreferences().getStn_loginCallSignRaw()))) {
|
||||
//
|
||||
// msgText = ("Sniffed: " + "(" + senderCall + " > ") + receiverCall +") " + msgText;
|
||||
// chatMessage.setMessageText(msgText);
|
||||
// return true;
|
||||
// }
|
||||
if ((lstNotify_QSOSniffer_sniffedCallSignList.contains(senderCall) ||
|
||||
lstNotify_QSOSniffer_sniffedCallSignList.contains(receiverCall)) &&
|
||||
(!receiverCall.equals(this.getChatPreferences().getStn_loginCallSignRaw()))) {
|
||||
|
||||
if (isSniffedMessage(chatMessage)) {
|
||||
return true;
|
||||
}
|
||||
msgText = ("Sniffed: " + "(" + senderCall + " > ") + receiverCall +") " + msgText;
|
||||
chatMessage.setMessageText(msgText);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- BESTEHENDE LOGIK ---
|
||||
|
||||
@@ -2110,110 +1923,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the background reachability service used by the station table.
|
||||
*
|
||||
* @return reachability service
|
||||
*/
|
||||
public ReachabilityService getReachabilityService() {
|
||||
return reachabilityService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the runtime gross-field cache used by the new-locator filter.
|
||||
*
|
||||
* @return worked gross-field cache
|
||||
*/
|
||||
public WorkedGrossFieldCache getWorkedGrossFieldCache() {
|
||||
return workedGrossFieldCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the gross-field cache from persistent SQLite data and enriches it with
|
||||
* legacy ChatMember worked/qra rows where possible.
|
||||
*/
|
||||
public void rebuildWorkedGrossFieldCacheFromDatabase() {
|
||||
if (dbHandler == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
workedGrossFieldCache.rebuildFromDatabaseSnapshot(dbHandler.fetchWorkedGrossFieldsFromDB());
|
||||
workedGrossFieldCache.addWorkedBandsFromStoredChatMembers(dbHandler.fetchChatMemberWkdDataFromDB().values());
|
||||
} catch (Exception exception) {
|
||||
System.out.println("[ChatController, warning]: could not rebuild worked gross-field cache: "
|
||||
+ exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists and caches one worked gross field after a logger QSO packet.
|
||||
*
|
||||
* @param band worked band
|
||||
* @param locator6 six-character Maidenhead locator
|
||||
* @param workedCall worked station
|
||||
* @param source logger/source name
|
||||
*/
|
||||
public void registerWorkedGrossField(Band band, String locator6, ChatMember workedCall, String source) {
|
||||
if (band == null || locator6 == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String normalizedLocator6 = WorkedGrossFieldCache.extractLocator6(locator6);
|
||||
if (normalizedLocator6 == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String callSignRaw = workedCall == null ? null : workedCall.getCallSignRaw();
|
||||
|
||||
try {
|
||||
dbHandler.upsertWorkedGrossField(band, normalizedLocator6, callSignRaw, source);
|
||||
} catch (Exception exception) {
|
||||
System.out.println("[ChatController, warning]: could not persist worked gross field: "
|
||||
+ exception.getMessage());
|
||||
}
|
||||
|
||||
workedGrossFieldCache.addWorked(band, normalizedLocator6);
|
||||
fireUserListUpdate("Worked gross field updated");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the member's four-character grid square has already been
|
||||
* worked on any band.
|
||||
*
|
||||
* <p>This method intentionally ignores the currently enabled own bands. The grid
|
||||
* status in the user list is meant to be a simple and robust any-band indicator.</p>
|
||||
*
|
||||
* @param member member to inspect
|
||||
* @return true if the gross grid square was worked on any band
|
||||
*/
|
||||
public boolean isGridSquareWorkedAny(ChatMember member) {
|
||||
if (member == null || member.getQra() == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return workedGrossFieldCache.isGrossFieldWorkedAny(member.getQra());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the member's four-character grid square has not been worked
|
||||
* on any band yet.
|
||||
*
|
||||
* <p>This is the predicate behind the optional "Only new grids" filter. It no
|
||||
* longer depends on active station band settings.</p>
|
||||
*
|
||||
* @param member member to inspect
|
||||
* @return true if the gross grid square is still new
|
||||
*/
|
||||
public boolean isNewGridSquare(ChatMember member) {
|
||||
if (member == null || member.getQra() == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !workedGrossFieldCache.isGrossFieldWorkedAny(member.getQra());
|
||||
}
|
||||
|
||||
public long getCurrentEpochTime() {
|
||||
|
||||
OffsetDateTime currentTimeInUtc = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
@@ -2492,8 +2201,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
return;
|
||||
}
|
||||
|
||||
rebuildWorkedGrossFieldCacheFromDatabase();
|
||||
|
||||
HashMap<String, ChatMember> finalWorkedDataFromDatabase = workedDataFromDatabase;
|
||||
|
||||
Platform.runLater(() -> {
|
||||
@@ -2678,69 +2385,4 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
return DirectionUtils.isAngleInRange(targetAz, myAz, beamWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* decides if a message in the in-queue is directed to me or if its directed to another station and sniffed
|
||||
* @param chatMessage
|
||||
* @return
|
||||
*/
|
||||
public boolean isSniffedMessage(ChatMessage chatMessage) {
|
||||
if (chatMessage == null || chatMessage.getSender() == null || chatMessage.getReceiver() == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String senderCall = chatMessage.getSender().getCallSign();
|
||||
String receiverCall = chatMessage.getReceiver().getCallSign();
|
||||
|
||||
if (senderCall == null || receiverCall == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (lstNotify_QSOSniffer_sniffedCallSignList == null || lstNotify_QSOSniffer_sniffedCallSignList.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean observedCall =
|
||||
lstNotify_QSOSniffer_sniffedCallSignList.contains(senderCall)
|
||||
|| lstNotify_QSOSniffer_sniffedCallSignList.contains(receiverCall);
|
||||
|
||||
if (!observedCall) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String myCall = getChatPreferences() != null ? getChatPreferences().getStn_loginCallSign() : null;
|
||||
String myRawCall = getChatPreferences() != null ? getChatPreferences().getStn_loginCallSignRaw() : null;
|
||||
|
||||
/*
|
||||
* Sniffed messages should appear in the private table only if they are not
|
||||
* already direct messages to my own callsign.
|
||||
*/
|
||||
return !receiverCall.equals(myCall) && !receiverCall.equals(myRawCall);
|
||||
}
|
||||
|
||||
/**
|
||||
* changes the chatmessage if it had been a sniffed one and not directed to me. Only for marking.
|
||||
* @param chatMessage
|
||||
* @return
|
||||
*/
|
||||
public String formatChatMessageTextForDisplay(ChatMessage chatMessage) {
|
||||
if (chatMessage == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String msgText = chatMessage.getMessageText();
|
||||
|
||||
if (msgText == null) {
|
||||
msgText = "";
|
||||
}
|
||||
|
||||
if (!isSniffedMessage(chatMessage)) {
|
||||
return msgText;
|
||||
}
|
||||
|
||||
String senderCall = chatMessage.getSender() != null ? chatMessage.getSender().getCallSign() : "";
|
||||
String receiverCall = chatMessage.getReceiver() != null ? chatMessage.getReceiver().getCallSign() : "";
|
||||
|
||||
return "Sniffed: (" + senderCall + " > " + receiverCall + ") " + msgText;
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,6 @@ import kst4contest.ApplicationConstants;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.utils.ApplicationFileUtils;
|
||||
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import kst4contest.model.Band;
|
||||
|
||||
public class DBController {
|
||||
|
||||
/**
|
||||
@@ -43,7 +37,7 @@ public class DBController {
|
||||
* marker. The marker is stored in SQLite PRAGMA user_version so the expensive
|
||||
* normalization rebuild is executed only once per database file.
|
||||
*/
|
||||
private static final int CURRENT_DATABASE_SCHEMA_VERSION = 14;
|
||||
private static final int CURRENT_DATABASE_SCHEMA_VERSION = 13;
|
||||
|
||||
/**
|
||||
* Minimum interval between two expiration cleanup runs. This avoids repeated full
|
||||
@@ -142,7 +136,6 @@ public class DBController {
|
||||
*/
|
||||
private synchronized void ensureChatMemberTableCompatibility() {
|
||||
createChatMemberTableIfRequired();
|
||||
createWorkedGrossFieldTableIfRequired();
|
||||
versionUpdateOfDBCheckAndChangeV11ToV12();
|
||||
versionUpdateOfDBCheckAndChangeV12ToV13();
|
||||
|
||||
@@ -247,30 +240,6 @@ public class DBController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the persistent gross-field table used by the new-locator filter.
|
||||
* The primary key is band + gross field because the filter only needs to know
|
||||
* whether a large locator square has already been worked on a band.
|
||||
*/
|
||||
private synchronized void createWorkedGrossFieldTableIfRequired() {
|
||||
|
||||
String createTableSql =
|
||||
"CREATE TABLE IF NOT EXISTS WorkedGrossField ("
|
||||
+ "band TEXT NOT NULL, "
|
||||
+ "grossField TEXT NOT NULL, "
|
||||
+ "locator TEXT, "
|
||||
+ "callsign TEXT, "
|
||||
+ "source TEXT, "
|
||||
+ "lastWorkedEpochMs INTEGER NOT NULL, "
|
||||
+ "PRIMARY KEY (band, grossField)"
|
||||
+ ");";
|
||||
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.executeUpdate(createTableSql);
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("[DBH, ERROR:] Could not create WorkedGrossField table", e);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Updates old v1.1 databases to the v1.2 schema by adding the not-QRV fields if
|
||||
* they are missing.
|
||||
@@ -508,15 +477,7 @@ public class DBController {
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("[DBH, ERROR:] Could not reset expired worked data", e);
|
||||
}
|
||||
|
||||
try (PreparedStatement deleteGrossFieldsStatement = connection.prepareStatement(
|
||||
"DELETE FROM WorkedGrossField WHERE lastWorkedEpochMs > 0 AND lastWorkedEpochMs < ?;")) {
|
||||
deleteGrossFieldsStatement.setLong(1, expirationThresholdEpochMs);
|
||||
deleteGrossFieldsStatement.executeUpdate();
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a chatmember with its metadata in the database. The unique key is always
|
||||
@@ -651,14 +612,8 @@ public class DBController {
|
||||
+ "lastFlagsChangeEpochMs = 0;";
|
||||
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
// return statement.executeUpdate(resetAllWorkedDataSql);
|
||||
int affectedRows = statement.executeUpdate(resetAllWorkedDataSql);
|
||||
statement.executeUpdate("DELETE FROM WorkedGrossField;");
|
||||
return affectedRows;
|
||||
|
||||
|
||||
return statement.executeUpdate(resetAllWorkedDataSql);
|
||||
} catch (SQLException e) {
|
||||
|
||||
System.err.println("[DBH, ERROR:] Couldn't reset the worked data");
|
||||
e.printStackTrace();
|
||||
return -1;
|
||||
@@ -682,30 +637,16 @@ public class DBController {
|
||||
String workedBandColumnName = helper_resolveWorkedBandColumnName(chatMemberToStore);
|
||||
|
||||
if (workedBandColumnName == null) {
|
||||
return helper_updateWorkedAnyOnChatMember(chatMemberToStore);
|
||||
System.out.println("[DBCtrl, Error]: unknown at which band the qso had been!");
|
||||
return false;
|
||||
}
|
||||
|
||||
// String updateWorkedSql =
|
||||
// "UPDATE ChatMember SET worked = 1, " + workedBandColumnName + " = 1, lastFlagsChangeEpochMs = ? WHERE callsign = ?;";
|
||||
|
||||
String updateWorkedSql =
|
||||
"UPDATE ChatMember SET worked = 1, "
|
||||
+ workedBandColumnName
|
||||
+ " = 1, "
|
||||
+ "qra = CASE WHEN ? IS NOT NULL AND TRIM(?) <> '' AND LOWER(TRIM(?)) <> 'unknown' THEN ? ELSE qra END, "
|
||||
+ "lastFlagsChangeEpochMs = ? WHERE callsign = ?;";
|
||||
"UPDATE ChatMember SET worked = 1, " + workedBandColumnName + " = 1, lastFlagsChangeEpochMs = ? WHERE callsign = ?;";
|
||||
|
||||
try (PreparedStatement preparedStatement = connection.prepareStatement(updateWorkedSql)) {
|
||||
// preparedStatement.setLong(1, System.currentTimeMillis());
|
||||
// preparedStatement.setString(2, chatMemberToStore.getCallSignRaw());
|
||||
|
||||
String qra = chatMemberToStore.getQra();
|
||||
preparedStatement.setString(1, qra);
|
||||
preparedStatement.setString(2, qra);
|
||||
preparedStatement.setString(3, qra);
|
||||
preparedStatement.setString(4, qra);
|
||||
preparedStatement.setLong(5, System.currentTimeMillis());
|
||||
preparedStatement.setString(6, chatMemberToStore.getCallSignRaw());
|
||||
preparedStatement.setLong(1, System.currentTimeMillis());
|
||||
preparedStatement.setString(2, chatMemberToStore.getCallSignRaw());
|
||||
|
||||
int affectedRows = preparedStatement.executeUpdate();
|
||||
return affectedRows > 0;
|
||||
@@ -716,47 +657,6 @@ public class DBController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates only the global worked-any flag of a stored chatmember row.
|
||||
*
|
||||
* <p>This is used when an external logger confirms that a station was worked,
|
||||
* but the software cannot reliably map the QSO to one of the persisted band
|
||||
* columns. The UI status "x" is based on this global worked flag, so this method
|
||||
* makes no-band or unsupported-band log entries persistent as worked-any.</p>
|
||||
*
|
||||
* @param chatMemberToStore chatmember that contains the worked call and optional locator
|
||||
* @return true if an existing database row was updated
|
||||
* @throws SQLException if the database write fails
|
||||
*/
|
||||
private synchronized boolean helper_updateWorkedAnyOnChatMember(ChatMember chatMemberToStore) throws SQLException {
|
||||
|
||||
if (chatMemberToStore == null
|
||||
|| chatMemberToStore.getCallSignRaw() == null
|
||||
|| chatMemberToStore.getCallSignRaw().isBlank()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String updateWorkedAnySql =
|
||||
"UPDATE ChatMember SET worked = 1, "
|
||||
+ "qra = CASE WHEN ? IS NOT NULL AND TRIM(?) <> '' AND LOWER(TRIM(?)) <> 'unknown' THEN ? ELSE qra END, "
|
||||
+ "lastFlagsChangeEpochMs = ? WHERE callsign = ?;";
|
||||
|
||||
try (PreparedStatement preparedStatement = connection.prepareStatement(updateWorkedAnySql)) {
|
||||
String qra = chatMemberToStore.getQra();
|
||||
|
||||
preparedStatement.setString(1, qra);
|
||||
preparedStatement.setString(2, qra);
|
||||
preparedStatement.setString(3, qra);
|
||||
preparedStatement.setString(4, qra);
|
||||
preparedStatement.setLong(5, System.currentTimeMillis());
|
||||
preparedStatement.setString(6, chatMemberToStore.getCallSignRaw());
|
||||
|
||||
int affectedRows = preparedStatement.executeUpdate();
|
||||
return affectedRows > 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Updates all not-QRV flags for a chatmember row. The method uses the normalized
|
||||
* raw callsign and updates the timestamp so that automatic contest cleanup can
|
||||
@@ -1055,79 +955,4 @@ public class DBController {
|
||||
// dbc.storeChatMember(dummy);
|
||||
// dbc.updateWkdInfoOnChatMember(dummy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts or updates a worked gross field for the new-locator filter.
|
||||
*
|
||||
* @param band worked band
|
||||
* @param locator6 six-character Maidenhead locator
|
||||
* @param callsignRaw normalized raw callsign or null
|
||||
* @param source source identifier such as UCXLOG or WINTEST
|
||||
*/
|
||||
public synchronized void upsertWorkedGrossField(Band band, String locator6, String callsignRaw, String source) {
|
||||
|
||||
String grossField = WorkedGrossFieldCache.extractGrossField(locator6);
|
||||
String normalizedLocator6 = WorkedGrossFieldCache.extractLocator6(locator6);
|
||||
|
||||
if (band == null || grossField == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String upsertSql =
|
||||
"INSERT INTO WorkedGrossField (band, grossField, locator, callsign, source, lastWorkedEpochMs) "
|
||||
+ "VALUES (?, ?, ?, ?, ?, ?) "
|
||||
+ "ON CONFLICT(band, grossField) DO UPDATE SET "
|
||||
+ "locator = COALESCE(excluded.locator, WorkedGrossField.locator), "
|
||||
+ "callsign = COALESCE(excluded.callsign, WorkedGrossField.callsign), "
|
||||
+ "source = excluded.source, "
|
||||
+ "lastWorkedEpochMs = excluded.lastWorkedEpochMs;";
|
||||
|
||||
try (PreparedStatement preparedStatement = connection.prepareStatement(upsertSql)) {
|
||||
preparedStatement.setString(1, band.name());
|
||||
preparedStatement.setString(2, grossField);
|
||||
preparedStatement.setString(3, normalizedLocator6);
|
||||
preparedStatement.setString(4, callsignRaw);
|
||||
preparedStatement.setString(5, source == null ? "UNKNOWN" : source);
|
||||
preparedStatement.setLong(6, System.currentTimeMillis());
|
||||
preparedStatement.executeUpdate();
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("[DBH, ERROR:] Could not upsert worked gross field", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all non-expired worked gross fields from the database.
|
||||
*
|
||||
* @return map of band to worked gross fields
|
||||
*/
|
||||
public synchronized Map<Band, Set<String>> fetchWorkedGrossFieldsFromDB() {
|
||||
|
||||
resetExpiredWorkedDataIfRequired();
|
||||
|
||||
Map<Band, Set<String>> result = new EnumMap<>(Band.class);
|
||||
|
||||
try (Statement statement = connection.createStatement();
|
||||
ResultSet resultSet = statement.executeQuery("SELECT band, grossField FROM WorkedGrossField ORDER BY band, grossField;")) {
|
||||
|
||||
while (resultSet.next()) {
|
||||
Band band;
|
||||
try {
|
||||
band = Band.valueOf(resultSet.getString("band"));
|
||||
} catch (Exception ignored) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String grossField = WorkedGrossFieldCache.extractGrossField(resultSet.getString("grossField"));
|
||||
if (grossField == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.computeIfAbsent(band, ignored -> new HashSet<>()).add(grossField);
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("[DBH, ERROR:] Could not fetch worked gross fields", e);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,9 @@ import java.net.Socket;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
public class DXClusterThreadPooledServer implements Runnable{
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(DXClusterThreadPooledServer.class.getName());
|
||||
private List<Socket> clientSockets = Collections.synchronizedList(new ArrayList<>()); //list of all connected clients
|
||||
|
||||
private ThreadStatusCallback callBackToController;
|
||||
@@ -114,7 +111,8 @@ public class DXClusterThreadPooledServer implements Runnable{
|
||||
System.out.println("-------------> ORIGINALEE VAL: " + aChatMember.getFrequency().getValue());
|
||||
System.out.println("-------------> NORMALIZED VAL: " + Utils4KST.normalizeFrequencyString(aChatMember.getFrequency().getValue(), chatController.getChatPreferences().getNotify_optionalFrequencyPrefix()) + " ");
|
||||
} catch (Exception e) {
|
||||
LOGGER.log(Level.SEVERE, "DXCThPooledServer: Error accessing value in chatmember object", e);
|
||||
System.out.println("DXCThPooledServer: Error accessing value in chatmember object: " + e.getMessage());
|
||||
// e.printStackTrace();
|
||||
}
|
||||
|
||||
for (Socket socket : clientSockets) {
|
||||
@@ -148,7 +146,8 @@ public class DXClusterThreadPooledServer implements Runnable{
|
||||
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
|
||||
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.SEVERE, "[DXClusterSrvr] broadcasting DXC-message to clients went wrong", e);
|
||||
e.printStackTrace();
|
||||
System.out.println("[DXClusterSrvr, Error:] broadcasting DXC-message to clients went wrong!");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -160,7 +159,6 @@ public class DXClusterThreadPooledServer implements Runnable{
|
||||
|
||||
class DXClusterServerWorkerRunnable implements Runnable{
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(DXClusterServerWorkerRunnable.class.getName());
|
||||
protected Socket clientSocket = null;
|
||||
protected String serverText = null;
|
||||
private ChatController client = null;
|
||||
@@ -199,13 +197,14 @@ class DXClusterServerWorkerRunnable implements Runnable{
|
||||
output.write(("\r\n").getBytes());
|
||||
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.SEVERE, "[DXClusterSrvr] keep-alive broadcast to client failed", e);
|
||||
e.printStackTrace();
|
||||
System.out.println("[DXClusterSrvr, Error:] broadcasting DXC-message to clients went wrong!");
|
||||
dXCkeepAliveTimer.purge();
|
||||
|
||||
try {
|
||||
socket.close();
|
||||
} catch (IOException ex) {
|
||||
LOGGER.log(Level.SEVERE, "[DXClusterSrvr] error closing client socket", ex);
|
||||
ex.printStackTrace();
|
||||
}
|
||||
finally {
|
||||
this.cancel();
|
||||
@@ -225,7 +224,7 @@ class DXClusterServerWorkerRunnable implements Runnable{
|
||||
System.out.println("[DXClusterThreadPooledServer, Info:] New cluster client connected! "); //TODO: maybe integrate non blocking reader for client identification
|
||||
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.SEVERE, "[DXClusterSrvr] error in worker runnable", e);
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
synchronized(dxClusterClientSocketsConnectedList) {
|
||||
dxClusterClientSocketsConnectedList.remove(clientSocket); // Entferne den Client nach Verarbeitung
|
||||
|
||||
@@ -2,8 +2,6 @@ package kst4contest.controller;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.*;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import kst4contest.model.ChatMessage;
|
||||
|
||||
@@ -15,7 +13,6 @@ import kst4contest.model.ChatMessage;
|
||||
* No need for it as it´s not longer a console application
|
||||
*/
|
||||
public class InputReaderThread extends Thread {
|
||||
private static final Logger LOGGER = Logger.getLogger(InputReaderThread.class.getName());
|
||||
private PrintWriter writer;
|
||||
private Socket socket;
|
||||
private ChatController client;
|
||||
@@ -42,7 +39,8 @@ public class InputReaderThread extends Thread {
|
||||
try {
|
||||
sendThisMessage23001 = reader.readLine();
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.SEVERE, "Error reading from stdin", e);
|
||||
// TODO Auto-generated catch block
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
ownMSG.setMessageText("MSG|" + this.client.getChatCategoryMain().getCategoryNumber() + "|0|" + sendThisMessage23001 + "|0|");
|
||||
@@ -55,8 +53,8 @@ public class InputReaderThread extends Thread {
|
||||
try {
|
||||
this.sleep(500);
|
||||
} catch (InterruptedException e) {
|
||||
LOGGER.log(Level.SEVERE, "InputReaderThread interrupted", e);
|
||||
Thread.currentThread().interrupt();
|
||||
// TODO Auto-generated catch block
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -327,8 +327,6 @@ public class MessageBusManagementThread extends Thread {
|
||||
// 1. Store in the new Map (for future context/history)
|
||||
|
||||
sender.addKnownFrequency(finalDetectedBand, finalDetectedFrequency);
|
||||
// No automatic full terrain analysis here.
|
||||
// The frequency is stored for later map/manual reachability requests.
|
||||
|
||||
//propagate known frequency to all instances of the same callsign (callRaw may exist multiple times)
|
||||
try {
|
||||
@@ -337,8 +335,6 @@ public class MessageBusManagementThread extends Thread {
|
||||
ChatMember cm = client.getLst_chatMemberList().get(idx);
|
||||
if (cm != null && cm != sender) {
|
||||
cm.addKnownFrequency(finalDetectedBand, finalDetectedFrequency);
|
||||
// No automatic full terrain analysis here.
|
||||
// Avoids exhausting the online terrain API when many stations mention QRGs.
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
@@ -591,8 +587,6 @@ public class MessageBusManagementThread extends Thread {
|
||||
|
||||
if (!client.getChatPreferences().getStn_loginCallSign().equals(newMember.getCallSign())) {
|
||||
this.client.getLst_chatMemberList().add(newMember); //the own call will not be in the list
|
||||
// this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
|
||||
// Reachability is calculated on demand only: map click, selected station, or manual request.
|
||||
}
|
||||
|
||||
|
||||
@@ -638,7 +632,6 @@ public class MessageBusManagementThread extends Thread {
|
||||
newMember = this.client.getDbHandler().fetchChatMemberWkdDataForOnlyOneCallsignFromDB(newMember);
|
||||
|
||||
this.client.getLst_chatMemberList().add(newMember);
|
||||
// this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
|
||||
|
||||
this.client.getDbHandler().storeChatMember(newMember);
|
||||
}
|
||||
@@ -779,9 +772,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
dummy.setCallSign("ALL");
|
||||
newMessageArrived.setReceiver(dummy);
|
||||
|
||||
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
|
||||
|
||||
// this.client.getLst_globalChatMessageList().add(0, newMessageArrived); // sdtout to all message-List
|
||||
this.client.getLst_globalChatMessageList().add(0, newMessageArrived); // sdtout to all message-List
|
||||
|
||||
} else {
|
||||
//message is directed to another chatmember, process as such!
|
||||
@@ -826,9 +817,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
if (newMessageArrived.getReceiver().getCallSign()
|
||||
.equals(this.client.getChatPreferences().getStn_loginCallSign())) {
|
||||
|
||||
// this.client.getLst_globalChatMessageList().add(0, newMessageArrived);
|
||||
|
||||
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
|
||||
this.client.getLst_globalChatMessageList().add(0, newMessageArrived);
|
||||
|
||||
if (this.client.getChatPreferences().isNotify_playSimpleSounds()) {
|
||||
this.client.getPlayAudioUtils().playNoiseLauncher('P');
|
||||
@@ -971,9 +960,14 @@ public class MessageBusManagementThread extends Thread {
|
||||
String originalMessage = newMessageArrived.getMessageText();
|
||||
newMessageArrived
|
||||
.setMessageText("(>" + newMessageArrived.getReceiver().getCallSign() + ")" + originalMessage);
|
||||
// this.client.getLst_globalChatMessageList().add(0,newMessageArrived);
|
||||
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
|
||||
this.client.getLst_globalChatMessageList().add(0,newMessageArrived);
|
||||
|
||||
// If our message contained a frequency (e.g. "QRG is: 144.375"), record that
|
||||
// WE sent our QRG to this OM – used by SKED frequency resolution.
|
||||
if (originalMessage != null && newMessageArrived.getReceiver() != null
|
||||
&& originalMessage.matches(".*\\b\\d{3,5}[.,]\\d{1,3}.*")) {
|
||||
this.client.recordOutboundQRG(newMessageArrived.getReceiver().getCallSign());
|
||||
}
|
||||
|
||||
// if you sent the message to another station, it will be sorted in to
|
||||
// the "to me message list" with modified messagetext, added rxers callsign
|
||||
@@ -1037,8 +1031,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
newMessageArrived.getSender().setInAngleAndRange(false);
|
||||
}
|
||||
|
||||
// this.client.getLst_globalChatMessageList().add(0, newMessageArrived);
|
||||
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
|
||||
this.client.getLst_globalChatMessageList().add(0, newMessageArrived);
|
||||
// System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign());
|
||||
}
|
||||
} catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) {
|
||||
@@ -1141,8 +1134,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
dxcMsg.setMessageInhibited(splittedMessageLine[7]);
|
||||
dxcMsg.setQrgSpotted(splittedMessageLine[5]);
|
||||
|
||||
// this.client.getLst_clusterMemberList().add(0, dxcMsg);
|
||||
this.client.publishClusterMessage(dxcMsg);
|
||||
this.client.getLst_clusterMemberList().add(0, dxcMsg);
|
||||
|
||||
// System.out.println("[MSGBUSMGT:] DXCluster Message detected ");
|
||||
|
||||
@@ -1181,8 +1173,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
dxcMsg2.setMessageInhibited(splittedMessageLine[6]);
|
||||
dxcMsg2.setQrgSpotted(splittedMessageLine[4]);
|
||||
|
||||
// this.client.getLst_clusterMemberList().add(0, dxcMsg2);
|
||||
this.client.publishClusterMessage(dxcMsg2);
|
||||
this.client.getLst_clusterMemberList().add(0, dxcMsg2);
|
||||
|
||||
} else
|
||||
|
||||
@@ -1212,8 +1203,8 @@ public class MessageBusManagementThread extends Thread {
|
||||
dxcMsg3.setMessageInhibited("");
|
||||
dxcMsg3.setQrgSpotted("");
|
||||
|
||||
// this.client.getLst_clusterMemberList().add(0, dxcMsg3);
|
||||
this.client.publishClusterMessage(dxcMsg3);
|
||||
this.client.getLst_clusterMemberList().add(0, dxcMsg3);
|
||||
|
||||
} else
|
||||
|
||||
/**
|
||||
@@ -1380,8 +1371,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
dummy.setCallSign("ALL");
|
||||
newMessageArrived.setReceiver(dummy);
|
||||
|
||||
// this.client.getLst_globalChatMessageList().add(0, newMessageArrived); // sdtout to all message-List
|
||||
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
|
||||
this.client.getLst_globalChatMessageList().add(0, newMessageArrived); // sdtout to all message-List
|
||||
|
||||
} else {
|
||||
//message is directed to another chatmember, process as such!
|
||||
@@ -1425,8 +1415,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
if (newMessageArrived.getReceiver().getCallSign()
|
||||
.equals(this.client.getChatPreferences().getStn_loginCallSign())) {
|
||||
|
||||
// this.client.getLst_globalChatMessageList().add(0, newMessageArrived);
|
||||
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
|
||||
this.client.getLst_globalChatMessageList().add(0, newMessageArrived);
|
||||
|
||||
System.out.println("Historic message directed to me: " + newMessageArrived.getReceiver().getCallSign() + ".");
|
||||
|
||||
@@ -1439,9 +1428,8 @@ public class MessageBusManagementThread extends Thread {
|
||||
String originalMessage = newMessageArrived.getMessageText();
|
||||
newMessageArrived
|
||||
.setMessageText("(>" + newMessageArrived.getReceiver().getCallSign() + ")" + originalMessage);
|
||||
// this.client.getLst_globalChatMessageList().add(0,newMessageArrived);
|
||||
this.client.getLst_globalChatMessageList().add(0,newMessageArrived);
|
||||
|
||||
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
|
||||
// if you sent the message to another station, it will be sorted in to
|
||||
// the "to me message list" with modified messagetext, added rxers callsign
|
||||
|
||||
@@ -1460,8 +1448,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
newMessageArrived.getSender().setInAngleAndRange(false);
|
||||
}
|
||||
|
||||
// this.client.getLst_globalChatMessageList().add(0, newMessageArrived);
|
||||
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
|
||||
this.client.getLst_globalChatMessageList().add(0, newMessageArrived);
|
||||
// System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign());
|
||||
}
|
||||
} catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) {
|
||||
@@ -1761,6 +1748,190 @@ public class MessageBusManagementThread extends Thread {
|
||||
break;// TODO Change at may24, avoid uncloadability. Check if this could lead to further errors on instable link!
|
||||
// client.getMessageRXBus().clear();
|
||||
}
|
||||
{
|
||||
// System.out.println("MessagebusmgtThread: Readthread is interrupted! Queue will be resetted");
|
||||
// this.interrupt();
|
||||
// client.getMessageRXBus().clear();
|
||||
}
|
||||
|
||||
// if (client.getMessageRXBus().peek() == null) {
|
||||
//
|
||||
// Timer doNothingTimer = new Timer();
|
||||
// doNothingTimer.schedule(new TimerTask() {
|
||||
//
|
||||
// @Override
|
||||
// public void run() {
|
||||
//
|
||||
// //do nothing
|
||||
//
|
||||
// }
|
||||
// }, 100);// TODO: Temporary
|
||||
// }
|
||||
//
|
||||
//
|
||||
// if (client.getMessageRXBus().peek() == null && client.getMessageTXBus().peek() == null) {
|
||||
//
|
||||
// if (this.client.isDisconnectionPerformedByUser()) {
|
||||
// break;//TODO: what if it´s not the finally closage but a band channel change?
|
||||
// }
|
||||
// // do nothing
|
||||
//// try {
|
||||
//// this.sleep(20);
|
||||
//// } catch (InterruptedException e) {
|
||||
//// // TODO Auto-generated catch block
|
||||
//// e.printStackTrace();
|
||||
//// } catch (Exception e2) {
|
||||
//// // TODO Auto-generated catch block
|
||||
//// e2.printStackTrace();
|
||||
//// }
|
||||
// }
|
||||
// else
|
||||
{
|
||||
|
||||
// messageLine = messageTextRaw.getMessageText();
|
||||
//
|
||||
// /***********************************************
|
||||
// * CASE RX
|
||||
// ***********************************************/
|
||||
//
|
||||
//// if (client.getMessageRXBus().peek() != null) {
|
||||
//
|
||||
//// try {
|
||||
//// messageTextRaw = client.getMessageRXBus().take();
|
||||
////
|
||||
////// System.out.println("MSBGBUS: rxed: " + messageTextRaw);
|
||||
//// } catch (InterruptedException e) {
|
||||
//// // TODO Auto-generated catch block
|
||||
//// e.printStackTrace();
|
||||
//// }
|
||||
//
|
||||
// if (messageTextRaw.getMessageText() == null) {
|
||||
// System.out.println("[MSGBUSMGT:] ERROR! got NULL message! BYE!");
|
||||
//// this.interrupt();
|
||||
//// break;
|
||||
// }
|
||||
//
|
||||
// messageLine = messageTextRaw.getMessageText();
|
||||
//
|
||||
//// try {
|
||||
//// bufwrtrRawMSGOut.write(messageLine + "\n");
|
||||
//// bufwrtrRawMSGOut.flush();
|
||||
////
|
||||
//// } catch (IOException e) {
|
||||
//// // TODO Auto-generated catch block
|
||||
//// e.printStackTrace();
|
||||
//// }
|
||||
//
|
||||
// System.out.println(messageTextRaw.getMessageText() + " <- RXed"); // Stdout at
|
||||
// // Console#######################################################TODO:Wichtig
|
||||
//
|
||||
// try {
|
||||
// processRXMessage23001(messageTextRaw);
|
||||
// } catch (IOException e) {
|
||||
// // TODO Auto-generated catch block
|
||||
// e.printStackTrace();
|
||||
// } catch (SQLException e) {
|
||||
// // TODO Auto-generated catch block
|
||||
// e.printStackTrace();
|
||||
// }
|
||||
|
||||
// } //end peek != null
|
||||
|
||||
/**************************************************************
|
||||
* End of case RX
|
||||
**************************************************************/
|
||||
|
||||
/**************************************************************
|
||||
* Start of case TX
|
||||
**************************************************************/
|
||||
|
||||
// if (client.getMessageTXBus().peek() != null) {
|
||||
// /***********************************************
|
||||
// * CASE TX
|
||||
// ***********************************************/
|
||||
//
|
||||
// if (this.isServerready()) {
|
||||
// // then send the line
|
||||
//
|
||||
// try {
|
||||
// messageTextRaw = client.getMessageTXBus().take();
|
||||
//// this.setServerready(false); // after tx always wait for an answer prompt //23000
|
||||
// this.setServerready(true);
|
||||
//
|
||||
// } catch (InterruptedException e) {
|
||||
// // TODO Auto-generated catch block
|
||||
// e.printStackTrace();
|
||||
// }
|
||||
//
|
||||
// messageLine = messageTextRaw.getMessageText();
|
||||
//
|
||||
// if (messageTextRaw.isMessageDirectedToServer()) {
|
||||
// /**
|
||||
// * We have to check if we only commands the server (keepalive) or want do talk
|
||||
// * to the community
|
||||
// */
|
||||
//
|
||||
// try {
|
||||
// client.getWriteThread().tx(messageTextRaw);
|
||||
// System.out.println("BUS: tx: " + messageTextRaw.getMessageText());
|
||||
//
|
||||
// } catch (InterruptedException e) {
|
||||
// // TODO Auto-generated catch block
|
||||
// e.printStackTrace();
|
||||
// }
|
||||
//
|
||||
// //////////////////////// bgfx ab here//////////////////////////////
|
||||
//// try {
|
||||
//// bufwrtrRawMSGOut.write(messageLine + "\r");
|
||||
////// bw.write(messageLine + "\n");//kst4contest.test 4 23001
|
||||
//// bufwrtrRawMSGOut.flush();
|
||||
////
|
||||
//// } catch (IOException e) {
|
||||
//// // TODO Auto-generated catch block
|
||||
//// e.printStackTrace();
|
||||
//// }
|
||||
// ///////////////////////////////////////////////////////////////////
|
||||
// } else {
|
||||
//
|
||||
// ChatMessage ownMSG = new ChatMessage();
|
||||
//
|
||||
//// ownMSG.setMessageText(
|
||||
//// "MSG|" + this.client.getCategory().getCategoryNumber() + "|0|" + messageLine + "|0|");
|
||||
//
|
||||
// ownMSG.setMessageText(
|
||||
// "MSG|" + this.client.getChatPreferences().getLoginChatCategory().getCategoryNumber()
|
||||
// + "|0|" + messageLine + "|0|");
|
||||
//
|
||||
// try {
|
||||
// client.getWriteThread().tx(ownMSG);
|
||||
// System.out.println("BUS: tx: " + ownMSG.getMessageText());
|
||||
//
|
||||
// } catch (InterruptedException e) {
|
||||
// // TODO Auto-generated catch block
|
||||
// e.printStackTrace();
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if (messageTextRaw.equals("/QUIT")) {
|
||||
// try {
|
||||
// this.client.getReadThread().terminateConnection();
|
||||
// this.client.getReadThread().interrupt();
|
||||
// this.client.getWriteThread().terminateConnection();
|
||||
// this.client.getWriteThread().interrupt();
|
||||
// this.interrupt();
|
||||
//
|
||||
// } catch (IOException e) {
|
||||
// // TODO Auto-generated catch block
|
||||
// e.printStackTrace();
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// } else {
|
||||
//// System.out.println("msgbus no elements yet");
|
||||
// }
|
||||
// } //end tx.peek != null
|
||||
}
|
||||
|
||||
|
||||
} // while true end
|
||||
System.out.println("Msgbusmgt: interrupt");
|
||||
|
||||
@@ -1,657 +0,0 @@
|
||||
package kst4contest.controller;
|
||||
import kst4contest.view.map.MapCallsignRawSnapshot;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import kst4contest.locatorUtils.Location;
|
||||
import kst4contest.model.Band;
|
||||
import kst4contest.model.ChatCategory;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.model.ChatPreferences;
|
||||
import kst4contest.view.map.GeometryOnlyPathAnalysisService;
|
||||
|
||||
import kst4contest.view.map.OpenMeteoTerrainProfileProvider;
|
||||
import kst4contest.view.map.PathAnalysisRequest;
|
||||
import kst4contest.view.map.PathAnalysisResult;
|
||||
import kst4contest.view.map.PathAnalysisService;
|
||||
import kst4contest.view.map.PathGeometryUtils;
|
||||
import java.util.Comparator;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Central service for tropo/path reachability calculations.
|
||||
*
|
||||
* <p>This service is now the single calculation path for:
|
||||
* <ul>
|
||||
* <li>the station table Tropo column</li>
|
||||
* <li>the Tropo filter/sorter</li>
|
||||
* <li>the station map path-analysis detail panel</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The service always calculates a full {@link PathAnalysisResult}. When the
|
||||
* result contains a usable link budget, the bidirectional SSB margin is copied
|
||||
* into all matching {@link ChatMember} objects. The UI can then sort/filter by
|
||||
* a simple number while the map still receives the full path result.</p>
|
||||
*/
|
||||
public final class ReachabilityService {
|
||||
|
||||
private static final long FAILED_ANALYSIS_RETRY_DELAY_MS = 5L * 60L * 1000L;
|
||||
|
||||
private final ChatController chatController;
|
||||
private final PathAnalysisService pathAnalysisService;
|
||||
private final ExecutorService executor;
|
||||
|
||||
/**
|
||||
* Prevents duplicate jobs for the same path while a calculation is already
|
||||
* queued or running.
|
||||
*/
|
||||
private final Set<String> queuedCalculationKeys = ConcurrentHashMap.newKeySet();
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Stores completed usable path-analysis results. Failed/no-budget results are
|
||||
* not permanently cached so transient terrain/network problems can be retried.
|
||||
*/
|
||||
private final Map<String, PathAnalysisResult> pathAnalysisResultCache = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Temporary callback list for map requests waiting for an already running
|
||||
* background calculation.
|
||||
*/
|
||||
private final Map<String, List<Consumer<PathAnalysisResult>>> pendingMapCallbacksByKey = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Prevents immediate retry loops for failed/no-budget analyses, especially
|
||||
* when a TableView cell repeatedly asks for the same value.
|
||||
*/
|
||||
private final Map<String, Long> failedCalculationRetryAfterEpochMs = new ConcurrentHashMap<>();
|
||||
|
||||
public ReachabilityService(ChatController chatController) {
|
||||
this.chatController = Objects.requireNonNull(chatController, "chatController");
|
||||
this.pathAnalysisService = new GeometryOnlyPathAnalysisService(new OpenMeteoTerrainProfileProvider());
|
||||
this.executor = Executors.newSingleThreadExecutor(new ReachabilityThreadFactory());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the automatically selected reachability band is calculated.
|
||||
*
|
||||
* @param member chatmember to evaluate
|
||||
*/
|
||||
public void ensureAutoTropoMarginCalculated(ChatMember member) {
|
||||
ensureTropoMarginCalculated(member, resolveAutoBand(member));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that a station/band SSB-margin value exists if it is already cached.
|
||||
*
|
||||
* <p>This method deliberately does not start online terrain analysis anymore.
|
||||
* It is safe to call from table cells and filters because it will not consume
|
||||
* API quota. Full calculations are now started only by explicit map/manual
|
||||
* requests.</p>
|
||||
*
|
||||
* @param member chatmember to evaluate
|
||||
* @param band band to evaluate
|
||||
*/
|
||||
public void ensureTropoMarginCalculated(ChatMember member, Band band) {
|
||||
if (member == null || band == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (member.hasFiniteTropoSsbMarginDb(band)) {
|
||||
return;
|
||||
}
|
||||
|
||||
PathAnalysisRequest request = buildRequestForChatMember(member, band);
|
||||
if (request == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
PathAnalysisResult cachedResult = pathAnalysisResultCache.get(buildCalculationKey(request));
|
||||
if (cachedResult != null) {
|
||||
storePathAnalysisResultInChatMembers(member, band, cachedResult);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Requests a full path analysis for the station map.
|
||||
*
|
||||
* <p>This is the only normal automatic entry point for online/full terrain
|
||||
* analysis. The map needs the full PathAnalysisResult, and the station table
|
||||
* needs the SSB margin. Both are produced here through the same calculation.</p>
|
||||
*
|
||||
* @param member best matching ChatMember, may be null when only a map snapshot exists
|
||||
* @param selectedSnapshot selected map snapshot
|
||||
* @param fxCallback callback executed on the JavaFX thread
|
||||
*/
|
||||
public void requestPathAnalysisForMap(ChatMember member,
|
||||
MapCallsignRawSnapshot selectedSnapshot,
|
||||
Consumer<PathAnalysisResult> fxCallback) {
|
||||
|
||||
String ownLocator6 = normalizeLocator6(chatController.getChatPreferences().getStn_loginLocatorMainCat());
|
||||
|
||||
if (selectedSnapshot == null) {
|
||||
dispatchFxCallback(fxCallback, PathAnalysisResult.waitingForSelection(ownLocator6));
|
||||
return;
|
||||
}
|
||||
|
||||
String targetLocator6 = normalizeLocator6(selectedSnapshot.locator6());
|
||||
|
||||
if (ownLocator6.length() != 6) {
|
||||
dispatchFxCallback(fxCallback,
|
||||
PathAnalysisResult.waitingForValidHomeLocator(ownLocator6, targetLocator6));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedSnapshot.hasUsablePosition()) {
|
||||
dispatchFxCallback(fxCallback,
|
||||
PathAnalysisResult.waitingForValidTarget(ownLocator6, targetLocator6));
|
||||
return;
|
||||
}
|
||||
|
||||
double analysisFrequencyMHz = PathGeometryUtils.resolveAnalysisFrequencyMHz(
|
||||
selectedSnapshot.lastKnownFrequenciesByBand()
|
||||
);
|
||||
|
||||
if (!Double.isFinite(analysisFrequencyMHz) || analysisFrequencyMHz <= 0.0) {
|
||||
Band fallbackBand = member == null ? Band.B_144 : resolveAutoBand(member);
|
||||
analysisFrequencyMHz = resolveAnalysisFrequencyForBand(member, fallbackBand);
|
||||
}
|
||||
|
||||
Band analysisBand = Band.fromFrequency(analysisFrequencyMHz);
|
||||
if (analysisBand == null) {
|
||||
analysisBand = member == null ? Band.B_144 : resolveAutoBand(member);
|
||||
}
|
||||
|
||||
PathAnalysisRequest request = buildRequest(
|
||||
ownLocator6,
|
||||
selectedSnapshot.callSignRaw(),
|
||||
targetLocator6,
|
||||
selectedSnapshot.latitudeDeg(),
|
||||
selectedSnapshot.longitudeDeg(),
|
||||
analysisFrequencyMHz
|
||||
);
|
||||
|
||||
requestPathAnalysisAndStore(member, analysisBand, request, fxCallback);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Starts a full on-demand reachability calculation for one station row.
|
||||
*
|
||||
* <p>Use this for explicit operator actions only. It may call the online terrain
|
||||
* API and therefore must not be used from TableView cell factories or automatic
|
||||
* chat-join processing.</p>
|
||||
*
|
||||
* @param member station to calculate
|
||||
* @param band selected reachability band
|
||||
*/
|
||||
public void calculateSelectedStationOnDemand(ChatMember member, Band band) {
|
||||
if (member == null || band == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
PathAnalysisRequest request = buildRequestForChatMember(member, band);
|
||||
if (request == null) {
|
||||
member.setTropoSsbMarginDb(band, Double.NaN);
|
||||
chatController.fireUserListUpdate("Reachability unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
requestPathAnalysisAndStore(member, band, request, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the auto reachability band.
|
||||
*
|
||||
* <ol>
|
||||
* <li>Use the lowest band detected in this session.</li>
|
||||
* <li>If no session band exists and the station is in the microwave category, use 1296 MHz.</li>
|
||||
* <li>Otherwise use 144 MHz.</li>
|
||||
* </ol>
|
||||
*
|
||||
* @param member member to inspect
|
||||
* @return resolved band
|
||||
*/
|
||||
public Band resolveAutoBand(ChatMember member) {
|
||||
if (member != null && member.getKnownActiveBands() != null && !member.getKnownActiveBands().isEmpty()) {
|
||||
return member.getKnownActiveBands().keySet().stream()
|
||||
.filter(Objects::nonNull)
|
||||
.min(Comparator.comparingDouble(Band::getDefaultAnalysisFrequencyMHz))
|
||||
.orElse(Band.B_144);
|
||||
}
|
||||
|
||||
if (member != null
|
||||
&& member.getChatCategory() != null
|
||||
&& member.getChatCategory().getCategoryNumber() == ChatCategory.MICROWAVE) {
|
||||
return Band.B_1296;
|
||||
}
|
||||
|
||||
return Band.B_144;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the active own bands configured in the station preferences. High bands
|
||||
* above 10 GHz are intentionally ignored for the first version.
|
||||
*
|
||||
* @return set of enabled bands
|
||||
*/
|
||||
public EnumSet<Band> getEnabledStationBands() {
|
||||
ChatPreferences preferences = chatController.getChatPreferences();
|
||||
EnumSet<Band> enabledBands = EnumSet.noneOf(Band.class);
|
||||
|
||||
if (preferences == null) {
|
||||
return enabledBands;
|
||||
}
|
||||
|
||||
if (preferences.isStn_bandActive144()) enabledBands.add(Band.B_144);
|
||||
if (preferences.isStn_bandActive432()) enabledBands.add(Band.B_432);
|
||||
if (preferences.isStn_bandActive1240()) enabledBands.add(Band.B_1296);
|
||||
if (preferences.isStn_bandActive2300()) enabledBands.add(Band.B_2320);
|
||||
if (preferences.isStn_bandActive3400()) enabledBands.add(Band.B_3400);
|
||||
if (preferences.isStn_bandActive5600()) enabledBands.add(Band.B_5760);
|
||||
if (preferences.isStn_bandActive10G()) enabledBands.add(Band.B_10G);
|
||||
|
||||
return enabledBands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the background executor.
|
||||
*/
|
||||
public void shutdown() {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts or reuses one shared path-analysis calculation.
|
||||
*
|
||||
* @param member matching ChatMember, may be null for map-only snapshots
|
||||
* @param band band under which the value is stored in ChatMember
|
||||
* @param request full path-analysis request
|
||||
* @param fxCallback optional map callback
|
||||
*/
|
||||
private void requestPathAnalysisAndStore(ChatMember member,
|
||||
Band band,
|
||||
PathAnalysisRequest request,
|
||||
Consumer<PathAnalysisResult> fxCallback) {
|
||||
|
||||
if (request == null || band == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String calculationKey = buildCalculationKey(request);
|
||||
|
||||
PathAnalysisResult cachedResult = pathAnalysisResultCache.get(calculationKey);
|
||||
if (cachedResult != null) {
|
||||
storePathAnalysisResultInChatMembers(member, band, cachedResult);
|
||||
dispatchFxCallback(fxCallback, cachedResult);
|
||||
return;
|
||||
}
|
||||
|
||||
Long retryAfterEpochMs = failedCalculationRetryAfterEpochMs.get(calculationKey);
|
||||
if (retryAfterEpochMs != null && System.currentTimeMillis() < retryAfterEpochMs) {
|
||||
dispatchFxCallback(fxCallback, createNoProfileResult(
|
||||
request,
|
||||
"Previous path analysis failed or the terrain API limit was reached. Retry is delayed briefly."
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
addPendingCallback(calculationKey, fxCallback);
|
||||
|
||||
if (!queuedCalculationKeys.add(calculationKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
executor.submit(() -> {
|
||||
PathAnalysisResult result;
|
||||
|
||||
try {
|
||||
result = pathAnalysisService.analyze(request);
|
||||
if (result == null) {
|
||||
result = createNoProfileResult(request, "Path analysis returned no result.");
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
result = createNoProfileResult(
|
||||
request,
|
||||
"Path analysis failed: " + exception.getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
boolean usableBudget = hasUsableLinkBudget(result);
|
||||
|
||||
if (usableBudget) {
|
||||
pathAnalysisResultCache.put(calculationKey, result);
|
||||
failedCalculationRetryAfterEpochMs.remove(calculationKey);
|
||||
} else {
|
||||
failedCalculationRetryAfterEpochMs.put(
|
||||
calculationKey,
|
||||
System.currentTimeMillis() + FAILED_ANALYSIS_RETRY_DELAY_MS
|
||||
);
|
||||
}
|
||||
|
||||
queuedCalculationKeys.remove(calculationKey);
|
||||
|
||||
PathAnalysisResult finalResult = result;
|
||||
Runnable updateTask = () -> {
|
||||
storePathAnalysisResultInChatMembers(member, band, finalResult);
|
||||
dispatchAndClearPendingCallbacks(calculationKey, finalResult);
|
||||
chatController.fireUserListUpdate("Reachability calculated");
|
||||
};
|
||||
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
updateTask.run();
|
||||
} else {
|
||||
Platform.runLater(updateTask);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a path-analysis request from a ChatMember row.
|
||||
*
|
||||
* @param member station row
|
||||
* @param band selected reachability band
|
||||
* @return request or null if locators are not usable
|
||||
*/
|
||||
private PathAnalysisRequest buildRequestForChatMember(ChatMember member, Band band) {
|
||||
String ownLocator6 = normalizeLocator6(chatController.getChatPreferences().getStn_loginLocatorMainCat());
|
||||
String targetLocator6 = normalizeLocator6(member.getQra());
|
||||
|
||||
if (ownLocator6.length() != 6 || targetLocator6.length() != 6) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Location targetLocation = new Location(targetLocator6);
|
||||
double analysisFrequencyMHz = resolveAnalysisFrequencyForBand(member, band);
|
||||
|
||||
return buildRequest(
|
||||
ownLocator6,
|
||||
member.getCallSignRaw(),
|
||||
targetLocator6,
|
||||
targetLocation.getLatitude().toDegrees(),
|
||||
targetLocation.getLongitude().toDegrees(),
|
||||
analysisFrequencyMHz
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the shared PathAnalysisRequest used by map and table/manual requests.
|
||||
*/
|
||||
private PathAnalysisRequest buildRequest(String ownLocator6,
|
||||
String targetCallsignRaw,
|
||||
String targetLocator6,
|
||||
double targetLatitudeDeg,
|
||||
double targetLongitudeDeg,
|
||||
double analysisFrequencyMHz) {
|
||||
|
||||
Location homeLocation = new Location(ownLocator6);
|
||||
|
||||
return new PathAnalysisRequest(
|
||||
ownLocator6,
|
||||
homeLocation.getLatitude().toDegrees(),
|
||||
homeLocation.getLongitude().toDegrees(),
|
||||
targetCallsignRaw,
|
||||
targetLocator6,
|
||||
targetLatitudeDeg,
|
||||
targetLongitudeDeg,
|
||||
analysisFrequencyMHz,
|
||||
chatController.getChatPreferences().getStn_pathAnalysisOwnAntennaHeightMeters(),
|
||||
chatController.getChatPreferences().getStn_pathAnalysisDefaultTargetAntennaHeightMeters(),
|
||||
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
|
||||
chatController.getChatPreferences().buildPathLinkBudgetSettings()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the SSB margin from a completed analysis in all matching ChatMember
|
||||
* objects. Matching uses raw callsign and locator.
|
||||
*/
|
||||
private void storePathAnalysisResultInChatMembers(ChatMember primaryMember,
|
||||
Band band,
|
||||
PathAnalysisResult result) {
|
||||
|
||||
if (band == null || result == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
double marginDb = hasUsableLinkBudget(result)
|
||||
? result.linkBudgetSummary().bidirectionalSsbMarginDb()
|
||||
: Double.NaN;
|
||||
|
||||
if (primaryMember != null) {
|
||||
primaryMember.setTropoSsbMarginDb(band, marginDb);
|
||||
}
|
||||
|
||||
String resultCallSignRaw = normalizeCallsignRaw(result.toCallsignRaw());
|
||||
String resultLocator6 = normalizeLocator6(result.toLocator6());
|
||||
|
||||
for (ChatMember member : chatController.snapshotChatMembers()) {
|
||||
if (member == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String memberCallSignRaw = normalizeCallsignRaw(member.getCallSignRaw());
|
||||
if (!resultCallSignRaw.isBlank() && !resultCallSignRaw.equals(memberCallSignRaw)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String memberLocator6 = normalizeLocator6(member.getQra());
|
||||
if (!resultLocator6.isBlank() && !resultLocator6.equals(memberLocator6)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
member.setTropoSsbMarginDb(band, marginDb);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the analysis frequency from a map snapshot first, because the map
|
||||
* aggregates all visible ChatMember variants and often knows the best current
|
||||
* frequency per band.
|
||||
*
|
||||
* @param member fallback member
|
||||
* @param selectedSnapshot selected map snapshot
|
||||
* @return analysis frequency in MHz
|
||||
*/
|
||||
private double resolveAnalysisFrequencyForSnapshot(ChatMember member, MapCallsignRawSnapshot selectedSnapshot) {
|
||||
if (selectedSnapshot != null) {
|
||||
double snapshotFrequencyMHz =
|
||||
PathGeometryUtils.resolveAnalysisFrequencyMHz(selectedSnapshot.lastKnownFrequenciesByBand());
|
||||
|
||||
if (Double.isFinite(snapshotFrequencyMHz) && snapshotFrequencyMHz > 0.0) {
|
||||
return snapshotFrequencyMHz;
|
||||
}
|
||||
}
|
||||
|
||||
Band fallbackBand = member == null ? Band.B_144 : resolveAutoBand(member);
|
||||
return resolveAnalysisFrequencyForBand(member, fallbackBand);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the analysis frequency for one member/band pair.
|
||||
*
|
||||
* <p>Preference order:
|
||||
* <ol>
|
||||
* <li>knownActiveBands frequency for the band</li>
|
||||
* <li>current displayed QRG if it belongs to the same band</li>
|
||||
* <li>band default frequency</li>
|
||||
* </ol>
|
||||
*/
|
||||
private double resolveAnalysisFrequencyForBand(ChatMember member, Band band) {
|
||||
if (member != null && member.getKnownActiveBands() != null) {
|
||||
ChatMember.ActiveFrequencyInfo activeFrequencyInfo = member.getKnownActiveBands().get(band);
|
||||
if (activeFrequencyInfo != null
|
||||
&& Double.isFinite(activeFrequencyInfo.frequency)
|
||||
&& activeFrequencyInfo.frequency > 0.0) {
|
||||
return activeFrequencyInfo.frequency;
|
||||
}
|
||||
}
|
||||
|
||||
if (member != null && member.getFrequency() != null && member.getFrequency().getValue() != null) {
|
||||
double parsedFrequencyMHz = PathGeometryUtils.tryParseFrequencyMHz(member.getFrequency().getValue());
|
||||
if (Double.isFinite(parsedFrequencyMHz) && parsedFrequencyMHz > 0.0) {
|
||||
Band parsedBand = Band.fromFrequency(parsedFrequencyMHz);
|
||||
if (parsedBand == null || parsedBand == band) {
|
||||
return parsedFrequencyMHz;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return band.getDefaultAnalysisFrequencyMHz();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a map callback to the pending callback list for one calculation key.
|
||||
*
|
||||
* @param calculationKey path key
|
||||
* @param fxCallback callback to add
|
||||
*/
|
||||
private void addPendingCallback(String calculationKey, Consumer<PathAnalysisResult> fxCallback) {
|
||||
if (fxCallback == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingMapCallbacksByKey
|
||||
.computeIfAbsent(
|
||||
calculationKey,
|
||||
ignored -> Collections.synchronizedList(new ArrayList<>())
|
||||
)
|
||||
.add(fxCallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches and removes all callbacks waiting for one completed calculation.
|
||||
*
|
||||
* @param calculationKey path key
|
||||
* @param result completed result
|
||||
*/
|
||||
private void dispatchAndClearPendingCallbacks(String calculationKey, PathAnalysisResult result) {
|
||||
List<Consumer<PathAnalysisResult>> callbacks = pendingMapCallbacksByKey.remove(calculationKey);
|
||||
if (callbacks == null || callbacks.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<Consumer<PathAnalysisResult>> callbackSnapshot;
|
||||
synchronized (callbacks) {
|
||||
callbackSnapshot = new ArrayList<>(callbacks);
|
||||
}
|
||||
|
||||
for (Consumer<PathAnalysisResult> callback : callbackSnapshot) {
|
||||
dispatchFxCallback(callback, result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a map callback on the JavaFX application thread.
|
||||
*
|
||||
* @param fxCallback callback to execute
|
||||
* @param result result to pass
|
||||
*/
|
||||
private void dispatchFxCallback(Consumer<PathAnalysisResult> fxCallback, PathAnalysisResult result) {
|
||||
if (fxCallback == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
fxCallback.accept(result);
|
||||
} else {
|
||||
Platform.runLater(() -> fxCallback.accept(result));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the result contains a usable link-budget summary.
|
||||
*
|
||||
* @param result path-analysis result
|
||||
* @return true if SSB margin can be read
|
||||
*/
|
||||
private boolean hasUsableLinkBudget(PathAnalysisResult result) {
|
||||
return result != null
|
||||
&& result.linkBudgetSummary() != null
|
||||
&& result.linkBudgetSummary().hasUsableBudget();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a no-profile/no-budget result for failed service calculations.
|
||||
*
|
||||
* @param request original request
|
||||
* @param statusText status shown in the map detail panel
|
||||
* @return placeholder path result
|
||||
*/
|
||||
private PathAnalysisResult createNoProfileResult(PathAnalysisRequest request, String statusText) {
|
||||
return PathAnalysisResult.noProfile(
|
||||
"Reachability",
|
||||
request.fromLocator6(),
|
||||
request.toLocator6(),
|
||||
request.toCallsignRaw(),
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
request.homeAntennaHeightMeters(),
|
||||
request.targetAntennaHeightMeters(),
|
||||
request.frequencyMHz(),
|
||||
statusText
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a stable calculation key.
|
||||
*
|
||||
* <p>The key includes station, locators, frequency, antenna heights and link
|
||||
* budget settings. If any relevant input changes, a new calculation is allowed.</p>
|
||||
*/
|
||||
private String buildCalculationKey(PathAnalysisRequest request) {
|
||||
return normalizeLocator6(request.fromLocator6())
|
||||
+ "|"
|
||||
+ normalizeCallsignRaw(request.toCallsignRaw())
|
||||
+ "|"
|
||||
+ normalizeLocator6(request.toLocator6())
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.5f", request.toLatitudeDeg())
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.5f", request.toLongitudeDeg())
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.3f", request.frequencyMHz())
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.1f", request.homeAntennaHeightMeters())
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.1f", request.targetAntennaHeightMeters())
|
||||
+ "|"
|
||||
+ request.linkBudgetSettings();
|
||||
}
|
||||
|
||||
private String normalizeCallsignRaw(String callSignRaw) {
|
||||
return callSignRaw == null ? "" : callSignRaw.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String normalizeLocator6(String locator) {
|
||||
return locator == null ? "" : locator.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static final class ReachabilityThreadFactory implements ThreadFactory {
|
||||
@Override
|
||||
public Thread newThread(Runnable runnable) {
|
||||
Thread thread = new Thread(runnable, "reachability-service");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,6 @@ package kst4contest.controller;
|
||||
import java.io.*;
|
||||
import java.net.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import kst4contest.model.ChatMessage;
|
||||
|
||||
@@ -16,7 +14,6 @@ import kst4contest.model.ChatMessage;
|
||||
* @author www.codejava.net
|
||||
*/
|
||||
public class ReadThread extends Thread {
|
||||
private static final Logger LOGGER = Logger.getLogger(ReadThread.class.getName());
|
||||
private BufferedReader reader;
|
||||
private Socket socket;
|
||||
private ChatController client;
|
||||
@@ -46,7 +43,8 @@ public class ReadThread extends Thread {
|
||||
reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
|
||||
|
||||
} catch (IOException ex) {
|
||||
LOGGER.log(Level.SEVERE, "Error getting input stream", ex);
|
||||
System.out.println("Error getting input stream: " + ex.getMessage());
|
||||
ex.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,14 +82,15 @@ public class ReadThread extends Thread {
|
||||
|
||||
}
|
||||
catch (Exception sexc) {
|
||||
LOGGER.log(Level.SEVERE, "[ReadThread] Socket closed unexpectedly", sexc);
|
||||
System.out.println("[ReadThread, CRITICAL: ] Socket geschlossen: " + sexc.getMessage());
|
||||
try {
|
||||
this.client.getSocket().close();
|
||||
this.interrupt();
|
||||
break;
|
||||
|
||||
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.SEVERE, "[ReadThread] Error closing socket", e);
|
||||
// TODO Auto-generated catch block
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package kst4contest.controller;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import kst4contest.ApplicationConstants;
|
||||
import kst4contest.model.Band;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.model.ThreadStateMessage;
|
||||
import kst4contest.view.GuiUtils;
|
||||
@@ -287,72 +286,6 @@ public class ReadUDPByWintestThread extends Thread {
|
||||
// socket.send(new DatagramPacket(bytes, bytes.length, broadcast, 9871));
|
||||
// }
|
||||
|
||||
|
||||
/**
|
||||
* Resolves the project Band enum from Win-Test band IDs.
|
||||
*
|
||||
* <p>Only bands that exist in the current Band enum are returned. 50/70 MHz are
|
||||
* still represented as worked flags in ChatMember, but they are not part of the
|
||||
* current Reachability/New-Locator band enum.</p>
|
||||
*
|
||||
* @param bandId Win-Test band id from ADDQSO
|
||||
* @return matching Band or null
|
||||
*/
|
||||
private Band helper_resolveBandFromWinTestBandId(String bandId) {
|
||||
if (bandId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (bandId.trim()) {
|
||||
case "12": return Band.B_144;
|
||||
case "14": return Band.B_432;
|
||||
case "16": return Band.B_1296;
|
||||
case "17": return Band.B_2320;
|
||||
case "18": return Band.B_3400;
|
||||
case "19": return Band.B_5760;
|
||||
case "20": return Band.B_10G;
|
||||
case "21": return Band.B_24G;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the locator from a Win-Test ADDQSO packet.
|
||||
*
|
||||
* <p>Current parser model based on the existing split-by-quotes code:
|
||||
* <ul>
|
||||
* <li>{@code split("\"")[7]} = callsign</li>
|
||||
* <li>{@code split("\"")[11]} = received exchange, e.g. 599001</li>
|
||||
* <li>{@code split("\"")[13]} = locator, e.g. JO51UM</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>If the dedicated locator field is empty, the exchange is used as fallback.</p>
|
||||
*
|
||||
* @param msg raw ADDQSO message
|
||||
* @return normalized six-character locator or null
|
||||
*/
|
||||
private String helper_resolveLocatorFromWinTestAddQso(String msg) {
|
||||
if (msg == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String[] quotedParts = msg.split("\"");
|
||||
|
||||
if (quotedParts.length > 13) {
|
||||
String locator = WorkedGrossFieldCache.extractLocator6(quotedParts[13]);
|
||||
if (locator != null) {
|
||||
return locator;
|
||||
}
|
||||
}
|
||||
|
||||
if (quotedParts.length > 11) {
|
||||
return WorkedGrossFieldCache.extractLocator6(quotedParts[11]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Catches add-qso messages of wintest if a new qso gets into the log<br/>
|
||||
*
|
||||
@@ -377,22 +310,16 @@ public class ReadUDPByWintestThread extends Thread {
|
||||
// receivedQsos.put(qsoNumber, msg);
|
||||
// lastKnownQso = Math.max(lastKnownQso, qsoNumber);
|
||||
String callSignCatched = msg.split("\"") [7];
|
||||
String locatorFromLogger = helper_resolveLocatorFromWinTestAddQso(msg);
|
||||
|
||||
ChatMember workedCall = new ChatMember();
|
||||
workedCall.setCallSign(callSignCatched);
|
||||
workedCall.setWorked(true); //its worked at this place, for sure!
|
||||
|
||||
if (locatorFromLogger != null) {
|
||||
workedCall.setQra(locatorFromLogger);
|
||||
}
|
||||
|
||||
ArrayList<Integer> markTheseChattersAsWorked = client.checkListForChatMemberIndexesByCallSign(workedCall);
|
||||
|
||||
String bandId;
|
||||
bandId = msg.split("\"")[6].split(" ")[4].trim();
|
||||
|
||||
Band workedBand = helper_resolveBandFromWinTestBandId(bandId);
|
||||
switch (bandId) {
|
||||
case "10" -> workedCall.setWorked50(true);
|
||||
case "11" -> workedCall.setWorked70(true);
|
||||
@@ -409,10 +336,6 @@ public class ReadUDPByWintestThread extends Thread {
|
||||
default -> System.out.println("[WinTestUDPRcvr: warning] Unbekannte Band-ID: " + bandId);
|
||||
}
|
||||
|
||||
if (workedBand != null && locatorFromLogger != null) {
|
||||
this.client.registerWorkedGrossField(workedBand, locatorFromLogger, workedCall, "WINTEST");
|
||||
}
|
||||
|
||||
if (!markTheseChattersAsWorked.isEmpty()) {
|
||||
//Worked call is part of the current chatmember list
|
||||
|
||||
@@ -422,13 +345,6 @@ public class ReadUDPByWintestThread extends Thread {
|
||||
|
||||
modifyThat.setWorked(true); //worked its for sure
|
||||
|
||||
if (locatorFromLogger != null
|
||||
&& (modifyThat.getQra() == null
|
||||
|| modifyThat.getQra().isBlank()
|
||||
|| "unknown".equalsIgnoreCase(modifyThat.getQra()))) {
|
||||
modifyThat.setQra(locatorFromLogger);
|
||||
}
|
||||
|
||||
if (workedCall.isWorked50()) {
|
||||
modifyThat.setWorked50(true);
|
||||
} else if (workedCall.isWorked70()) {
|
||||
@@ -482,11 +398,7 @@ public class ReadUDPByWintestThread extends Thread {
|
||||
if (!isInChat) {
|
||||
|
||||
workedCall.setName("unknown");
|
||||
|
||||
if (workedCall.getQra() == null || workedCall.getQra().isBlank()) {
|
||||
workedCall.setQra("unknown");
|
||||
}
|
||||
|
||||
workedCall.setQra("unknown");
|
||||
workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
|
||||
this.client.getDbHandler().storeChatMember(workedCall);
|
||||
}
|
||||
|
||||
@@ -22,9 +22,6 @@ import org.xml.sax.InputSource;
|
||||
import org.xml.sax.SAXException;
|
||||
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.model.Band;
|
||||
|
||||
import javafx.application.Platform;
|
||||
|
||||
/**
|
||||
* This thread is responsible for reading server's input and printing it to the
|
||||
@@ -66,111 +63,7 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
System.out.println("UCXUDPRDR: catched error " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips binary logger framing bytes before the XML payload. Some UCXLog packets
|
||||
* contain transport bytes before the XML declaration.
|
||||
*
|
||||
* @param rawPacket raw UDP payload
|
||||
* @return cleaned XML string or trimmed original text
|
||||
*/
|
||||
private String helper_extractXmlPayload(String rawPacket) {
|
||||
if (rawPacket == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
int xmlStart = rawPacket.indexOf("<?xml");
|
||||
if (xmlStart < 0) {
|
||||
xmlStart = rawPacket.indexOf("<contactinfo");
|
||||
}
|
||||
if (xmlStart < 0) {
|
||||
xmlStart = rawPacket.indexOf("<RadioInfo");
|
||||
}
|
||||
|
||||
return xmlStart >= 0
|
||||
? rawPacket.substring(xmlStart).trim()
|
||||
: rawPacket.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an optional XML child node.
|
||||
*
|
||||
* @param element parent element
|
||||
* @param tagName tag to read
|
||||
* @return trimmed value or empty string
|
||||
*/
|
||||
private String helper_getOptionalElementText(Element element, String tagName) {
|
||||
if (element == null || tagName == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
NodeList nodeList = element.getElementsByTagName(tagName);
|
||||
if (nodeList == null || nodeList.getLength() == 0 || nodeList.item(0) == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String textContent = nodeList.item(0).getTextContent();
|
||||
return textContent == null ? "" : textContent.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the QSO locator from UCXLog contactinfo. gridsquare is preferred,
|
||||
* rcvnr is used as fallback for exchanges such as 001JO41HK.
|
||||
*
|
||||
* @param element contactinfo XML element
|
||||
* @return normalized six-character locator or null
|
||||
*/
|
||||
private String helper_resolveLocatorFromContactInfo(Element element) {
|
||||
String gridSquare = WorkedGrossFieldCache.extractLocator6(helper_getOptionalElementText(element, "gridsquare"));
|
||||
if (gridSquare != null) {
|
||||
return gridSquare;
|
||||
}
|
||||
|
||||
return WorkedGrossFieldCache.extractLocator6(helper_getOptionalElementText(element, "rcvnr"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the project Band enum from logger band values.
|
||||
*
|
||||
* @param band logger band text
|
||||
* @return matching Band or null
|
||||
*/
|
||||
private Band helper_resolveBandFromLoggerBand(String band) {
|
||||
if (band == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (band.trim()) {
|
||||
case "144":
|
||||
case "2m":
|
||||
return Band.B_144;
|
||||
case "432":
|
||||
case "70cm":
|
||||
return Band.B_432;
|
||||
case "1240":
|
||||
case "1296":
|
||||
case "23cm":
|
||||
return Band.B_1296;
|
||||
case "2300":
|
||||
case "2320":
|
||||
case "13cm":
|
||||
return Band.B_2320;
|
||||
case "3400":
|
||||
case "9cm":
|
||||
return Band.B_3400;
|
||||
case "5600":
|
||||
case "5760":
|
||||
case "6cm":
|
||||
return Band.B_5760;
|
||||
case "10G":
|
||||
case "10368":
|
||||
case "3cm":
|
||||
return Band.B_10G;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void run() {
|
||||
|
||||
System.out.println("ReadUDPByUCXLogThread: started Thread for UCXLog getUDP");
|
||||
@@ -284,7 +177,7 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
|
||||
File logUDPMessageToThisFile;
|
||||
|
||||
String udpMsg = helper_extractXmlPayload(udpPacketToProcess);
|
||||
String udpMsg = udpPacketToProcess;
|
||||
|
||||
ChatMember modifyThat = null;
|
||||
|
||||
@@ -328,9 +221,10 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
Element element = (Element) node;
|
||||
|
||||
String call = element.getElementsByTagName("call").item(0).getTextContent();
|
||||
String band = helper_getOptionalElementText(element, "band");
|
||||
String gridSquare = helper_resolveLocatorFromContactInfo(element);
|
||||
String points = helper_getOptionalElementText(element, "points");
|
||||
// call = call.toLowerCase();
|
||||
String band = element.getElementsByTagName("band").item(0).getTextContent();
|
||||
|
||||
String points = element.getElementsByTagName("points").item(0).getTextContent();
|
||||
|
||||
System.out.println("[Readudp, info ]: received Current Element :" + node.getNodeName()
|
||||
+ "call: " + call + " / " + band + " ----> " + points + " POINTS");
|
||||
@@ -341,69 +235,89 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
workedCall.setCallSign(call);
|
||||
workedCall.setWorked(true);
|
||||
|
||||
if (gridSquare != null) {
|
||||
workedCall.setQra(gridSquare);
|
||||
}
|
||||
|
||||
Band workedBand = helper_resolveBandFromLoggerBand(band);
|
||||
|
||||
switch (band) {
|
||||
case "144":
|
||||
case "2m": //minos contest logger
|
||||
{
|
||||
case "144": {
|
||||
workedCall.setWorked144(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "432":
|
||||
case "70cm":
|
||||
{
|
||||
workedCall.setWorked432(true);
|
||||
break;
|
||||
}
|
||||
case "432": {
|
||||
workedCall.setWorked432(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "1240": //ucxlog style
|
||||
case "1296": //used for n1mm / Dxlog
|
||||
case "23cm": //minos contest logger
|
||||
{
|
||||
case "1240": {
|
||||
workedCall.setWorked1240(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "2300":
|
||||
case "13cm":
|
||||
{
|
||||
case "2300": {
|
||||
workedCall.setWorked2300(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "3400":
|
||||
case "9cm":
|
||||
{
|
||||
case "3400": {
|
||||
workedCall.setWorked3400(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "5600":
|
||||
case "6cm":
|
||||
{
|
||||
case "5600": {
|
||||
workedCall.setWorked5600(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "10G":
|
||||
case "3cm":
|
||||
{
|
||||
case "10G": {
|
||||
workedCall.setWorked10G(true);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
default:
|
||||
System.out.println("[ReadUDPFromUCX, Error:] unexpected band value: \"" + band + "\"");
|
||||
break;
|
||||
/**
|
||||
* cases hotfix for MINOS logger, which tells band like "2m", not "144"
|
||||
*/
|
||||
case "2m": {
|
||||
workedCall.setWorked144(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "70cm": {
|
||||
workedCall.setWorked432(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "23cm": {
|
||||
workedCall.setWorked1240(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "13cm": {
|
||||
workedCall.setWorked2300(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "9cm": {
|
||||
workedCall.setWorked3400(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "6cm": {
|
||||
workedCall.setWorked5600(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "3cm": {
|
||||
workedCall.setWorked10G(true);
|
||||
|
||||
}
|
||||
|
||||
default:
|
||||
System.out.println("[ReadUDPFromUCX, Error:] unexpected band value: \"" + band + "\"");
|
||||
break;
|
||||
}
|
||||
|
||||
// if (!client.getMap_ucxLogInfoWorkedCalls().containsKey("call")) {
|
||||
// client.getMap_ucxLogInfoWorkedCalls().put(call, workedCall);
|
||||
|
||||
// } else
|
||||
{
|
||||
/**
|
||||
* That means, the station is worked already but maybe at another band. So we
|
||||
@@ -538,22 +452,14 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
*/
|
||||
}
|
||||
|
||||
if (workedBand != null && gridSquare != null) {
|
||||
this.client.registerWorkedGrossField(workedBand, gridSquare, workedCall, "UCXLOG");
|
||||
}
|
||||
|
||||
boolean isInChat = this.client.getDbHandler().updateWkdInfoOnChatMember(workedCall);
|
||||
// This will update the worked info on a worked chatmember. DBHandler will
|
||||
// check, if an entry at the db had been modified. If not, then the worked
|
||||
// station had not been stored. DBHandler will store the information then.
|
||||
if (!isInChat) {
|
||||
|
||||
|
||||
workedCall.setName("unknown");
|
||||
|
||||
if (workedCall.getQra() == null || workedCall.getQra().isBlank()) {
|
||||
workedCall.setQra("unknown");
|
||||
}
|
||||
|
||||
workedCall.setQra("unknown");
|
||||
workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
|
||||
this.client.getDbHandler().storeChatMember(workedCall);
|
||||
}
|
||||
@@ -644,13 +550,8 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
// System.out.println("Radio Mode: " + mode);
|
||||
// System.out.println("[ReadUDPFromUCX, Info:] Setted QRG pref to: \"" + qrg + "\"" );
|
||||
|
||||
// this.client.getChatPreferences().getMYQRGFirstCat().set(formattedQRG);
|
||||
|
||||
final String finalFormattedQRG = formattedQRG;
|
||||
helper_runOnFxThread(() ->
|
||||
this.client.getChatPreferences().getMYQRGFirstCat().set(finalFormattedQRG)
|
||||
);
|
||||
|
||||
this.client.getChatPreferences().getMYQRGFirstCat().set(formattedQRG);
|
||||
|
||||
// System.out.println("[ReadUDPbyUCXTh: ] Radioinfo processed: " + formattedQRG);
|
||||
}
|
||||
}
|
||||
@@ -703,28 +604,6 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
this.socket.close();
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs UI-bound changes on the JavaFX application thread.
|
||||
*
|
||||
* <p>UCXLog UDP packets are processed in a background thread. Some preference
|
||||
* properties are bound to JavaFX controls, so setting them directly from this
|
||||
* thread can crash JavaFX with "Not on FX application thread".</p>
|
||||
*
|
||||
* @param runnable UI-bound update
|
||||
*/
|
||||
private void helper_runOnFxThread(Runnable runnable) {
|
||||
if (runnable == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
runnable.run();
|
||||
} else {
|
||||
Platform.runLater(runnable);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -182,7 +182,6 @@ public final class ScoreService {
|
||||
|
||||
// 4) Publish to UI in ONE batched runLater
|
||||
Platform.runLater(() -> {
|
||||
applyScoreSnapshotToChatMembers(snap);
|
||||
topCandidatesFx.setAll(snap.getTopCandidates());
|
||||
updateSelectedScoreFromSnapshot(snap);
|
||||
uiPulse.set(uiPulse.get() + 1);
|
||||
@@ -238,31 +237,6 @@ public final class ScoreService {
|
||||
|
||||
return representative;
|
||||
}
|
||||
/**
|
||||
* Projects the immutable score snapshot back into ChatMember display fields so
|
||||
* the normal station table can sort/filter by score without knowing the score
|
||||
* calculation internals.
|
||||
*
|
||||
* @param snap latest score snapshot
|
||||
*/
|
||||
private void applyScoreSnapshotToChatMembers(ScoreSnapshot snap) {
|
||||
if (snap == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Double> scoreByCallSignRaw = snap.getScoreByCallSignRaw();
|
||||
|
||||
for (ChatMember member : controller.snapshotChatMembers()) {
|
||||
if (member == null || member.getCallSignRaw() == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Double score = scoreByCallSignRaw.get(normalizeCallRaw(member.getCallSignRaw()));
|
||||
member.setCurrentPriorityScore(score == null ? 0.0 : score);
|
||||
}
|
||||
|
||||
controller.fireUserListUpdate("Priority scores projected to ChatMember");
|
||||
}
|
||||
|
||||
private void updateSelectedScoreFromSnapshot(ScoreSnapshot snap) {
|
||||
if (snap == null || selectedCallSignRaw == null) {
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import kst4contest.model.Band;
|
||||
import kst4contest.model.ChatMember;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Runtime cache for worked Maidenhead gross fields per band.
|
||||
*
|
||||
* <p>The cache is used by the "new locator" station filter. It is kept in memory
|
||||
* for fast UI predicates and rebuilt from SQLite on startup/refresh. New QSO log
|
||||
* packets update both SQLite and this cache immediately.</p>
|
||||
*/
|
||||
public final class WorkedGrossFieldCache {
|
||||
|
||||
private static final Pattern MAIDENHEAD_6_PATTERN =
|
||||
Pattern.compile("(?i)([A-R]{2}[0-9]{2}[A-X]{2})");
|
||||
|
||||
private final Map<Band, Set<String>> workedGrossFieldsByBand = new EnumMap<>(Band.class);
|
||||
|
||||
/**
|
||||
* Replaces the full cache content with data read from the database.
|
||||
*
|
||||
* @param databaseSnapshot map of band to worked gross fields
|
||||
*/
|
||||
public synchronized void rebuildFromDatabaseSnapshot(Map<Band, Set<String>> databaseSnapshot) {
|
||||
workedGrossFieldsByBand.clear();
|
||||
|
||||
if (databaseSnapshot == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (Map.Entry<Band, Set<String>> entry : databaseSnapshot.entrySet()) {
|
||||
Band band = entry.getKey();
|
||||
Set<String> grossFields = entry.getValue();
|
||||
|
||||
if (band == null || grossFields == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Set<String> normalizedGrossFields = workedGrossFieldsByBand.computeIfAbsent(band, ignored -> new HashSet<>());
|
||||
for (String grossField : grossFields) {
|
||||
String normalizedGrossField = normalizeGrossField(grossField);
|
||||
if (normalizedGrossField != null) {
|
||||
normalizedGrossFields.add(normalizedGrossField);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one worked locator to the cache.
|
||||
*
|
||||
* @param band worked band
|
||||
* @param locatorOrGrossField six-character locator or four-character gross field
|
||||
*/
|
||||
public synchronized void addWorked(Band band, String locatorOrGrossField) {
|
||||
String grossField = extractGrossField(locatorOrGrossField);
|
||||
if (band == null || grossField == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
workedGrossFieldsByBand
|
||||
.computeIfAbsent(band, ignored -> new HashSet<>())
|
||||
.add(grossField);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds all worked-band flags from stored ChatMember rows. This is only a fallback
|
||||
* for legacy data before WorkedGrossField existed or when logger packets did not
|
||||
* provide a locator.
|
||||
*
|
||||
* @param storedMembers stored ChatMember rows
|
||||
*/
|
||||
public synchronized void addWorkedBandsFromStoredChatMembers(Collection<ChatMember> storedMembers) {
|
||||
if (storedMembers == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (ChatMember member : storedMembers) {
|
||||
if (member == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String locator = member.getQra();
|
||||
if (member.isWorked144()) addWorked(Band.B_144, locator);
|
||||
if (member.isWorked432()) addWorked(Band.B_432, locator);
|
||||
if (member.isWorked1240()) addWorked(Band.B_1296, locator);
|
||||
if (member.isWorked2300()) addWorked(Band.B_2320, locator);
|
||||
if (member.isWorked3400()) addWorked(Band.B_3400, locator);
|
||||
if (member.isWorked5600()) addWorked(Band.B_5760, locator);
|
||||
if (member.isWorked10G()) addWorked(Band.B_10G, locator);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a locator gross field is already worked on a band.
|
||||
*
|
||||
* @param band band to check
|
||||
* @param locatorOrGrossField six-character locator or four-character gross field
|
||||
* @return true if the gross field is already worked on that band
|
||||
*/
|
||||
public synchronized boolean isGrossFieldWorked(Band band, String locatorOrGrossField) {
|
||||
String grossField = extractGrossField(locatorOrGrossField);
|
||||
if (band == null || grossField == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return workedGrossFieldsByBand
|
||||
.getOrDefault(band, Set.of())
|
||||
.contains(grossField);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a locator gross field has already been worked on any band.
|
||||
*
|
||||
* <p>The UI grid status and the "Only new grids" filter use this any-band logic.
|
||||
* This avoids user errors caused by wrong active-band settings and fits single
|
||||
* band contests such as NAC better.</p>
|
||||
*
|
||||
* @param locatorOrGrossField six-character locator or four-character gross field
|
||||
* @return true if the gross field exists in the cache on any band
|
||||
*/
|
||||
public synchronized boolean isGrossFieldWorkedAny(String locatorOrGrossField) {
|
||||
String grossField = extractGrossField(locatorOrGrossField);
|
||||
if (grossField == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (Set<String> workedGrossFields : workedGrossFieldsByBand.values()) {
|
||||
if (workedGrossFields != null && workedGrossFields.contains(grossField)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to normalize a locator. Accepts a plain six-character locator or extracts
|
||||
* one from exchange strings such as "001JO41HK".
|
||||
*
|
||||
* @param rawLocatorOrExchange raw locator/exchange text
|
||||
* @return normalized six-character locator, or null if none was found
|
||||
*/
|
||||
public static String extractLocator6(String rawLocatorOrExchange) {
|
||||
if (rawLocatorOrExchange == null || rawLocatorOrExchange.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Matcher matcher = MAIDENHEAD_6_PATTERN.matcher(rawLocatorOrExchange.trim());
|
||||
if (!matcher.find()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return matcher.group(1).toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts and normalizes the four-character gross field.
|
||||
*
|
||||
* @param rawLocatorOrGrossField six-character locator, four-character gross field or exchange text
|
||||
* @return normalized gross field, or null if no valid value is available
|
||||
*/
|
||||
public static String extractGrossField(String rawLocatorOrGrossField) {
|
||||
if (rawLocatorOrGrossField == null || rawLocatorOrGrossField.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String trimmed = rawLocatorOrGrossField.trim().toUpperCase(Locale.ROOT);
|
||||
|
||||
if (trimmed.matches("[A-R]{2}[0-9]{2}")) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
String locator6 = extractLocator6(trimmed);
|
||||
if (locator6 == null || locator6.length() < 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return locator6.substring(0, 4);
|
||||
}
|
||||
|
||||
private static String normalizeGrossField(String grossField) {
|
||||
if (grossField == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String normalized = grossField.trim().toUpperCase(Locale.ROOT);
|
||||
return normalized.matches("[A-R]{2}[0-9]{2}") ? normalized : null;
|
||||
}
|
||||
}
|
||||
@@ -29,35 +29,6 @@ public enum Band {
|
||||
return prefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the lower edge used as practical analysis frequency when only the band
|
||||
* is known. This keeps the batch reachability calculation deterministic.
|
||||
*
|
||||
* @return frequency in MHz
|
||||
*/
|
||||
public double getDefaultAnalysisFrequencyMHz() {
|
||||
return minFreq;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a compact label for table display and filter controls.
|
||||
*
|
||||
* @return human readable band label
|
||||
*/
|
||||
public String getDisplayLabel() {
|
||||
switch (this) {
|
||||
case B_144: return "144";
|
||||
case B_432: return "432";
|
||||
case B_1296: return "1296";
|
||||
case B_2320: return "2320";
|
||||
case B_3400: return "3400";
|
||||
case B_5760: return "5760";
|
||||
case B_10G: return "10G";
|
||||
case B_24G: return "24G";
|
||||
default: return prefix;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific frequency falls within this band's limits.
|
||||
*/
|
||||
|
||||
@@ -10,13 +10,10 @@ import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import java.util.OptionalDouble;
|
||||
|
||||
public class ChatMember {
|
||||
|
||||
|
||||
|
||||
|
||||
long lastFlagsChangeEpochMs; // timestamp of the last worked/not-QRV flag change in the internal DB
|
||||
|
||||
// private final BooleanProperty workedInfoChangeFireListEventTrigger = new SimpleBooleanProperty();
|
||||
@@ -77,10 +74,6 @@ public class ChatMember {
|
||||
// Stores the last known frequency per band (Context History)
|
||||
private final Map<Band, ActiveFrequencyInfo> knownActiveBands = new ConcurrentHashMap<>();
|
||||
|
||||
// Stores the calculated bidirectional SSB tropo margin per band.
|
||||
// Values are calculated by the reachability backend and used only for UI sorting/filtering.
|
||||
private final Map<Band, Double> tropoSsbMarginDbByBand = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
// --- INNER CLASS FOR QRG HISTORY ---
|
||||
public class ActiveFrequencyInfo {
|
||||
@@ -627,108 +620,6 @@ public class ChatMember {
|
||||
return knownActiveBands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the calculated bidirectional SSB tropo margin for one band.
|
||||
*
|
||||
* <p>The value is deliberately stored in ChatMember because the station table
|
||||
* can then sort and filter directly without triggering a new RF calculation.
|
||||
* The actual calculation stays outside ChatMember.</p>
|
||||
*
|
||||
* @param band band for which the margin was calculated
|
||||
* @param marginDb bidirectional SSB margin in dB; NaN marks an attempted but failed analysis
|
||||
*/
|
||||
public void setTropoSsbMarginDb(Band band, double marginDb) {
|
||||
if (band == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tropoSsbMarginDbByBand.put(band, marginDb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a tropo analysis result already exists for the given band.
|
||||
*
|
||||
* <p>A stored NaN also counts as an existing result because it means the analysis
|
||||
* was attempted and failed. This prevents endless retry loops when a terrain
|
||||
* provider is temporarily unavailable.</p>
|
||||
*
|
||||
* @param band band to check
|
||||
* @return true if a value or NaN marker is stored
|
||||
*/
|
||||
public boolean hasTropoSsbMarginDb(Band band) {
|
||||
return band != null && this.tropoSsbMarginDbByBand.containsKey(band);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Returns true only when a finite, usable tropo SSB margin is stored for the band.
|
||||
*
|
||||
* <p>This is intentionally different from {@link #hasTropoSsbMarginDb(Band)}:
|
||||
* a stored NaN means that an analysis was attempted but did not produce a usable
|
||||
* link budget. Such failed values should not permanently block later retries.</p>
|
||||
*
|
||||
* @param band band to check
|
||||
* @return true if a finite SSB margin exists
|
||||
*/
|
||||
public boolean hasFiniteTropoSsbMarginDb(Band band) {
|
||||
if (band == null || !this.tropoSsbMarginDbByBand.containsKey(band)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Double storedValue = this.tropoSsbMarginDbByBand.get(band);
|
||||
return storedValue != null && Double.isFinite(storedValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the calculated tropo SSB margin for one band.
|
||||
*
|
||||
* @param band band to read
|
||||
* @return OptionalDouble with the stored value, or empty if no calculation exists yet
|
||||
*/
|
||||
public OptionalDouble getTropoSsbMarginDb(Band band) {
|
||||
if (band == null || !this.tropoSsbMarginDbByBand.containsKey(band)) {
|
||||
return OptionalDouble.empty();
|
||||
}
|
||||
|
||||
Double storedValue = this.tropoSsbMarginDbByBand.get(band);
|
||||
if (storedValue == null) {
|
||||
return OptionalDouble.empty();
|
||||
}
|
||||
|
||||
return OptionalDouble.of(storedValue);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Formats the calculated tropo margin for direct table display.
|
||||
*
|
||||
* <p>The method distinguishes three states:
|
||||
* <ul>
|
||||
* <li>{@code ... @144}: analysis has not run yet</li>
|
||||
* <li>{@code ? @144}: analysis ran, but no usable link budget was produced</li>
|
||||
* <li>{@code +8.4 dB @144}: finite SSB margin exists</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param band selected/auto-resolved reachability band
|
||||
* @return display text such as "+8.4 dB @144", "? @144" or "... @144"
|
||||
*/
|
||||
public String formatTropoSsbMarginForBand(Band band) {
|
||||
String bandLabel = band == null ? "?" : band.getDisplayLabel();
|
||||
|
||||
if (band == null || !this.tropoSsbMarginDbByBand.containsKey(band)) {
|
||||
return "... @" + bandLabel;
|
||||
}
|
||||
|
||||
Double storedValue = this.tropoSsbMarginDbByBand.get(band);
|
||||
|
||||
if (storedValue == null || !Double.isFinite(storedValue)) {
|
||||
return "? @" + bandLabel;
|
||||
}
|
||||
|
||||
return String.format(Locale.US, "%+.1f dB @%s", storedValue, bandLabel);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* If a sked fails and the user tells this to the client, this counter will be increased to give the station a
|
||||
|
||||
@@ -173,23 +173,6 @@ public class ChatPreferences {
|
||||
double stn_maxQRBDefault = 900;
|
||||
double stn_qtfDefault = 135;
|
||||
|
||||
double stn_pathAnalysisOwnAntennaHeightMeters = 10.0;
|
||||
double stn_pathAnalysisDefaultTargetAntennaHeightMeters = 10.0;
|
||||
String stn_pathAnalysisDemRootDirectory = "";
|
||||
String stn_pathAnalysisDemDatasetId = "copernicus_glo_30";
|
||||
double stn_pathAnalysisOwnTxPowerWatts = 750.0;
|
||||
double stn_pathAnalysisOwnAntennaGainDbi = 8.0;
|
||||
double stn_pathAnalysisDefaultTargetTxPowerWatts = 100.0;
|
||||
double stn_pathAnalysisDefaultTargetAntennaGainDbi = 8.0;
|
||||
|
||||
double stn_pathAnalysisVhfFeederLossPerStationDb = 2.0;
|
||||
double stn_pathAnalysisFeederLossIncreaseDbPer200MHz = 2.0;
|
||||
double stn_pathAnalysisMaxEstimatedFeederLossPerStationDb = 20.0;
|
||||
|
||||
double stn_pathAnalysisRequiredSsbSignalDbm = -126.0;
|
||||
double stn_pathAnalysisRequiredCwSignalDbm = -132.0;
|
||||
double stn_pathAnalysisContestMarginDb = 6.0;
|
||||
|
||||
ChatCategory loginChatCategoryMain = new ChatCategory(2);
|
||||
ChatCategory loginChatCategorySecond = new ChatCategory(3);
|
||||
boolean loginToSecondChatEnabled;
|
||||
@@ -326,9 +309,6 @@ public class ChatPreferences {
|
||||
boolean guiOptions_defaultFilterPmToOther;
|
||||
boolean guiOptions_defaultFilterPublicMsgs;
|
||||
|
||||
private double[] GUIstationMapStageSceneSizeHW = new double[] { 1000, 800 };
|
||||
private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN };
|
||||
|
||||
|
||||
/*********************************************************************************
|
||||
*
|
||||
@@ -391,42 +371,6 @@ public class ChatPreferences {
|
||||
this.MYQRGFirstCat.set(MYQRGFirstCat);
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisOwnAntennaHeightMeters() {
|
||||
return stn_pathAnalysisOwnAntennaHeightMeters;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisOwnAntennaHeightMeters(double stn_pathAnalysisOwnAntennaHeightMeters) {
|
||||
this.stn_pathAnalysisOwnAntennaHeightMeters = Math.max(0.0, stn_pathAnalysisOwnAntennaHeightMeters);
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisDefaultTargetAntennaHeightMeters() {
|
||||
return stn_pathAnalysisDefaultTargetAntennaHeightMeters;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisDefaultTargetAntennaHeightMeters(double stn_pathAnalysisDefaultTargetAntennaHeightMeters) {
|
||||
this.stn_pathAnalysisDefaultTargetAntennaHeightMeters = Math.max(0.0, stn_pathAnalysisDefaultTargetAntennaHeightMeters);
|
||||
}
|
||||
|
||||
public String getStn_pathAnalysisDemRootDirectory() {
|
||||
return stn_pathAnalysisDemRootDirectory;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisDemRootDirectory(String stn_pathAnalysisDemRootDirectory) {
|
||||
this.stn_pathAnalysisDemRootDirectory = stn_pathAnalysisDemRootDirectory == null
|
||||
? ""
|
||||
: stn_pathAnalysisDemRootDirectory.trim();
|
||||
}
|
||||
|
||||
public String getStn_pathAnalysisDemDatasetId() {
|
||||
return stn_pathAnalysisDemDatasetId;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisDemDatasetId(String stn_pathAnalysisDemDatasetId) {
|
||||
this.stn_pathAnalysisDemDatasetId = (stn_pathAnalysisDemDatasetId == null || stn_pathAnalysisDemDatasetId.isBlank())
|
||||
? "copernicus_glo_30"
|
||||
: stn_pathAnalysisDemDatasetId.trim().toLowerCase();
|
||||
}
|
||||
|
||||
public String getStn_loginNameSecondCat() {
|
||||
return stn_loginNameSecondCat;
|
||||
}
|
||||
@@ -603,22 +547,6 @@ public class ChatPreferences {
|
||||
this.loginToSecondChatEnabled = loginToSecondChatEnabled;
|
||||
}
|
||||
|
||||
public double[] getGUIstationMapStageSceneSizeHW() {
|
||||
return GUIstationMapStageSceneSizeHW;
|
||||
}
|
||||
|
||||
public void setGUIstationMapStageSceneSizeHW(double[] GUIstationMapStageSceneSizeHW) {
|
||||
this.GUIstationMapStageSceneSizeHW = GUIstationMapStageSceneSizeHW;
|
||||
}
|
||||
|
||||
public double[] getGUIstationMapStagePositionXY() {
|
||||
return GUIstationMapStagePositionXY;
|
||||
}
|
||||
|
||||
public void setGUIstationMapStagePositionXY(double[] GUIstationMapStagePositionXY) {
|
||||
this.GUIstationMapStagePositionXY = GUIstationMapStagePositionXY;
|
||||
}
|
||||
|
||||
public boolean isGuiOptions_defaultFilterNothing() {
|
||||
return guiOptions_defaultFilterNothing;
|
||||
}
|
||||
@@ -1317,63 +1245,6 @@ public class ChatPreferences {
|
||||
stn_qtfDefault.setTextContent(this.stn_qtfDefault+"");
|
||||
station.appendChild(stn_qtfDefault);
|
||||
|
||||
Element stn_pathAnalysisOwnAntennaHeightMeters = doc.createElement("stn_pathAnalysisOwnAntennaHeightMeters");
|
||||
stn_pathAnalysisOwnAntennaHeightMeters.setTextContent(this.stn_pathAnalysisOwnAntennaHeightMeters + "");
|
||||
station.appendChild(stn_pathAnalysisOwnAntennaHeightMeters);
|
||||
|
||||
Element stn_pathAnalysisDefaultTargetAntennaHeightMeters = doc.createElement("stn_pathAnalysisDefaultTargetAntennaHeightMeters");
|
||||
stn_pathAnalysisDefaultTargetAntennaHeightMeters.setTextContent(this.stn_pathAnalysisDefaultTargetAntennaHeightMeters + "");
|
||||
station.appendChild(stn_pathAnalysisDefaultTargetAntennaHeightMeters);
|
||||
|
||||
Element stn_pathAnalysisDemRootDirectory = doc.createElement("stn_pathAnalysisDemRootDirectory");
|
||||
stn_pathAnalysisDemRootDirectory.setTextContent(this.stn_pathAnalysisDemRootDirectory);
|
||||
station.appendChild(stn_pathAnalysisDemRootDirectory);
|
||||
|
||||
Element stn_pathAnalysisDemDatasetId = doc.createElement("stn_pathAnalysisDemDatasetId");
|
||||
stn_pathAnalysisDemDatasetId.setTextContent(this.stn_pathAnalysisDemDatasetId);
|
||||
station.appendChild(stn_pathAnalysisDemDatasetId);
|
||||
Element stn_pathAnalysisOwnTxPowerWatts = doc.createElement("stn_pathAnalysisOwnTxPowerWatts");
|
||||
stn_pathAnalysisOwnTxPowerWatts.setTextContent(this.stn_pathAnalysisOwnTxPowerWatts + "");
|
||||
station.appendChild(stn_pathAnalysisOwnTxPowerWatts);
|
||||
|
||||
Element stn_pathAnalysisOwnAntennaGainDbi = doc.createElement("stn_pathAnalysisOwnAntennaGainDbi");
|
||||
stn_pathAnalysisOwnAntennaGainDbi.setTextContent(this.stn_pathAnalysisOwnAntennaGainDbi + "");
|
||||
station.appendChild(stn_pathAnalysisOwnAntennaGainDbi);
|
||||
|
||||
Element stn_pathAnalysisDefaultTargetTxPowerWatts = doc.createElement("stn_pathAnalysisDefaultTargetTxPowerWatts");
|
||||
stn_pathAnalysisDefaultTargetTxPowerWatts.setTextContent(this.stn_pathAnalysisDefaultTargetTxPowerWatts + "");
|
||||
station.appendChild(stn_pathAnalysisDefaultTargetTxPowerWatts);
|
||||
|
||||
Element stn_pathAnalysisDefaultTargetAntennaGainDbi = doc.createElement("stn_pathAnalysisDefaultTargetAntennaGainDbi");
|
||||
stn_pathAnalysisDefaultTargetAntennaGainDbi.setTextContent(this.stn_pathAnalysisDefaultTargetAntennaGainDbi + "");
|
||||
station.appendChild(stn_pathAnalysisDefaultTargetAntennaGainDbi);
|
||||
|
||||
Element stn_pathAnalysisVhfFeederLossPerStationDb = doc.createElement("stn_pathAnalysisVhfFeederLossPerStationDb");
|
||||
stn_pathAnalysisVhfFeederLossPerStationDb.setTextContent(this.stn_pathAnalysisVhfFeederLossPerStationDb + "");
|
||||
station.appendChild(stn_pathAnalysisVhfFeederLossPerStationDb);
|
||||
|
||||
Element stn_pathAnalysisFeederLossIncreaseDbPer200MHz = doc.createElement("stn_pathAnalysisFeederLossIncreaseDbPer200MHz");
|
||||
stn_pathAnalysisFeederLossIncreaseDbPer200MHz.setTextContent(this.stn_pathAnalysisFeederLossIncreaseDbPer200MHz + "");
|
||||
station.appendChild(stn_pathAnalysisFeederLossIncreaseDbPer200MHz);
|
||||
|
||||
Element stn_pathAnalysisMaxEstimatedFeederLossPerStationDb = doc.createElement("stn_pathAnalysisMaxEstimatedFeederLossPerStationDb");
|
||||
stn_pathAnalysisMaxEstimatedFeederLossPerStationDb.setTextContent(this.stn_pathAnalysisMaxEstimatedFeederLossPerStationDb + "");
|
||||
station.appendChild(stn_pathAnalysisMaxEstimatedFeederLossPerStationDb);
|
||||
|
||||
Element stn_pathAnalysisRequiredSsbSignalDbm = doc.createElement("stn_pathAnalysisRequiredSsbSignalDbm");
|
||||
stn_pathAnalysisRequiredSsbSignalDbm.setTextContent(this.stn_pathAnalysisRequiredSsbSignalDbm + "");
|
||||
station.appendChild(stn_pathAnalysisRequiredSsbSignalDbm);
|
||||
|
||||
Element stn_pathAnalysisRequiredCwSignalDbm = doc.createElement("stn_pathAnalysisRequiredCwSignalDbm");
|
||||
stn_pathAnalysisRequiredCwSignalDbm.setTextContent(this.stn_pathAnalysisRequiredCwSignalDbm + "");
|
||||
station.appendChild(stn_pathAnalysisRequiredCwSignalDbm);
|
||||
|
||||
Element stn_pathAnalysisContestMarginDb = doc.createElement("stn_pathAnalysisContestMarginDb");
|
||||
stn_pathAnalysisContestMarginDb.setTextContent(this.stn_pathAnalysisContestMarginDb + "");
|
||||
station.appendChild(stn_pathAnalysisContestMarginDb);
|
||||
|
||||
|
||||
|
||||
Element stn_bandActive144 = doc.createElement("stn_bandActive144");
|
||||
stn_bandActive144.setTextContent(this.stn_bandActive144+"");
|
||||
station.appendChild(stn_bandActive144);
|
||||
@@ -1869,18 +1740,6 @@ public class ChatPreferences {
|
||||
GUIpnl_directedMSGWin_dividerpositionDefault.setTextContent(doubleArrayToCSVString(getGUIpnl_directedMSGWin_dividerpositionDefault()));
|
||||
guiOptions.appendChild(GUIpnl_directedMSGWin_dividerpositionDefault);
|
||||
|
||||
Element GUIstationMapStageSceneSizeHW = doc.createElement("GUIstationMapStageSceneSizeHW");
|
||||
GUIstationMapStageSceneSizeHW.setTextContent(
|
||||
this.getGUIstationMapStageSceneSizeHW()[0] + ";" + this.getGUIstationMapStageSceneSizeHW()[1]
|
||||
);
|
||||
guiOptions.appendChild(GUIstationMapStageSceneSizeHW);
|
||||
|
||||
Element GUIstationMapStagePositionXY = doc.createElement("GUIstationMapStagePositionXY");
|
||||
GUIstationMapStagePositionXY.setTextContent(
|
||||
this.getGUIstationMapStagePositionXY()[0] + ";" + this.getGUIstationMapStagePositionXY()[1]
|
||||
);
|
||||
guiOptions.appendChild(GUIstationMapStagePositionXY);
|
||||
|
||||
/****************************************************************************************
|
||||
****************************** now write this XML! *************************************
|
||||
****************************************************************************************/
|
||||
@@ -1997,90 +1856,6 @@ public class ChatPreferences {
|
||||
stn_maxQRBDefault = getDouble(stationEl, stn_maxQRBDefault, "stn_maxQRBDefault");
|
||||
stn_qtfDefault = getDouble(stationEl, stn_qtfDefault, "stn_qtfDefault");
|
||||
|
||||
stn_pathAnalysisOwnAntennaHeightMeters = getDouble(
|
||||
stationEl,
|
||||
stn_pathAnalysisOwnAntennaHeightMeters,
|
||||
"stn_pathAnalysisOwnAntennaHeightMeters"
|
||||
);
|
||||
|
||||
stn_pathAnalysisDefaultTargetAntennaHeightMeters = getDouble(
|
||||
stationEl,
|
||||
stn_pathAnalysisDefaultTargetAntennaHeightMeters,
|
||||
"stn_pathAnalysisDefaultTargetAntennaHeightMeters"
|
||||
);
|
||||
|
||||
stn_pathAnalysisDemRootDirectory = getText(
|
||||
stationEl,
|
||||
stn_pathAnalysisDemRootDirectory,
|
||||
"stn_pathAnalysisDemRootDirectory"
|
||||
);
|
||||
|
||||
stn_pathAnalysisDemDatasetId = getText(
|
||||
stationEl,
|
||||
stn_pathAnalysisDemDatasetId,
|
||||
"stn_pathAnalysisDemDatasetId"
|
||||
);
|
||||
|
||||
stn_pathAnalysisOwnTxPowerWatts = getDouble(
|
||||
stationEl,
|
||||
stn_pathAnalysisOwnTxPowerWatts,
|
||||
"stn_pathAnalysisOwnTxPowerWatts"
|
||||
);
|
||||
|
||||
stn_pathAnalysisOwnAntennaGainDbi = getDouble(
|
||||
stationEl,
|
||||
stn_pathAnalysisOwnAntennaGainDbi,
|
||||
"stn_pathAnalysisOwnAntennaGainDbi"
|
||||
);
|
||||
|
||||
stn_pathAnalysisDefaultTargetTxPowerWatts = getDouble(
|
||||
stationEl,
|
||||
stn_pathAnalysisDefaultTargetTxPowerWatts,
|
||||
"stn_pathAnalysisDefaultTargetTxPowerWatts"
|
||||
);
|
||||
|
||||
stn_pathAnalysisDefaultTargetAntennaGainDbi = getDouble(
|
||||
stationEl,
|
||||
stn_pathAnalysisDefaultTargetAntennaGainDbi,
|
||||
"stn_pathAnalysisDefaultTargetAntennaGainDbi"
|
||||
);
|
||||
|
||||
stn_pathAnalysisVhfFeederLossPerStationDb = getDouble(
|
||||
stationEl,
|
||||
stn_pathAnalysisVhfFeederLossPerStationDb,
|
||||
"stn_pathAnalysisVhfFeederLossPerStationDb"
|
||||
);
|
||||
|
||||
stn_pathAnalysisFeederLossIncreaseDbPer200MHz = getDouble(
|
||||
stationEl,
|
||||
stn_pathAnalysisFeederLossIncreaseDbPer200MHz,
|
||||
"stn_pathAnalysisFeederLossIncreaseDbPer200MHz"
|
||||
);
|
||||
|
||||
stn_pathAnalysisMaxEstimatedFeederLossPerStationDb = getDouble(
|
||||
stationEl,
|
||||
stn_pathAnalysisMaxEstimatedFeederLossPerStationDb,
|
||||
"stn_pathAnalysisMaxEstimatedFeederLossPerStationDb"
|
||||
);
|
||||
|
||||
stn_pathAnalysisRequiredSsbSignalDbm = getDouble(
|
||||
stationEl,
|
||||
stn_pathAnalysisRequiredSsbSignalDbm,
|
||||
"stn_pathAnalysisRequiredSsbSignalDbm"
|
||||
);
|
||||
|
||||
stn_pathAnalysisRequiredCwSignalDbm = getDouble(
|
||||
stationEl,
|
||||
stn_pathAnalysisRequiredCwSignalDbm,
|
||||
"stn_pathAnalysisRequiredCwSignalDbm"
|
||||
);
|
||||
|
||||
stn_pathAnalysisContestMarginDb = getDouble(
|
||||
stationEl,
|
||||
stn_pathAnalysisContestMarginDb,
|
||||
"stn_pathAnalysisContestMarginDb"
|
||||
);
|
||||
|
||||
// Band activity flags (introduced later; if missing -> keep defaults)
|
||||
stn_bandActive144 = getBoolean(stationEl, stn_bandActive144, "stn_bandActive144");
|
||||
stn_bandActive432 = getBoolean(stationEl, stn_bandActive432, "stn_bandActive432");
|
||||
@@ -2489,16 +2264,6 @@ public class ChatPreferences {
|
||||
parseSemicolonDoublesInto(getText(element, null, "GUIstage_updateStage_SceneSizeHW"), this.getGUIstage_updateStage_SceneSizeHW());
|
||||
parseSemicolonDoublesInto(getText(element, null, "GUIsettingsStageSceneSizeHW"), this.getGUIsettingsStageSceneSizeHW());
|
||||
|
||||
parseSemicolonDoublesInto(
|
||||
getText(element, null, "GUIstationMapStageSceneSizeHW"),
|
||||
this.getGUIstationMapStageSceneSizeHW()
|
||||
);
|
||||
|
||||
parseSemicolonDoublesInto(
|
||||
getText(element, null, "GUIstationMapStagePositionXY"),
|
||||
this.getGUIstationMapStagePositionXY()
|
||||
);
|
||||
|
||||
// Splitpane divider positions
|
||||
String s1 = getText(element, null, "GUIselectedCallSignSplitPane_dividerposition");
|
||||
if (s1 != null) {
|
||||
@@ -2895,100 +2660,6 @@ public class ChatPreferences {
|
||||
}
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisOwnTxPowerWatts() {
|
||||
return stn_pathAnalysisOwnTxPowerWatts;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisOwnTxPowerWatts(double stn_pathAnalysisOwnTxPowerWatts) {
|
||||
this.stn_pathAnalysisOwnTxPowerWatts = stn_pathAnalysisOwnTxPowerWatts;
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisOwnAntennaGainDbi() {
|
||||
return stn_pathAnalysisOwnAntennaGainDbi;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisOwnAntennaGainDbi(double stn_pathAnalysisOwnAntennaGainDbi) {
|
||||
this.stn_pathAnalysisOwnAntennaGainDbi = stn_pathAnalysisOwnAntennaGainDbi;
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisDefaultTargetTxPowerWatts() {
|
||||
return stn_pathAnalysisDefaultTargetTxPowerWatts;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisDefaultTargetTxPowerWatts(double stn_pathAnalysisDefaultTargetTxPowerWatts) {
|
||||
this.stn_pathAnalysisDefaultTargetTxPowerWatts = stn_pathAnalysisDefaultTargetTxPowerWatts;
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisDefaultTargetAntennaGainDbi() {
|
||||
return stn_pathAnalysisDefaultTargetAntennaGainDbi;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisDefaultTargetAntennaGainDbi(double stn_pathAnalysisDefaultTargetAntennaGainDbi) {
|
||||
this.stn_pathAnalysisDefaultTargetAntennaGainDbi = stn_pathAnalysisDefaultTargetAntennaGainDbi;
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisVhfFeederLossPerStationDb() {
|
||||
return stn_pathAnalysisVhfFeederLossPerStationDb;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisVhfFeederLossPerStationDb(double stn_pathAnalysisVhfFeederLossPerStationDb) {
|
||||
this.stn_pathAnalysisVhfFeederLossPerStationDb = stn_pathAnalysisVhfFeederLossPerStationDb;
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisFeederLossIncreaseDbPer200MHz() {
|
||||
return stn_pathAnalysisFeederLossIncreaseDbPer200MHz;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisFeederLossIncreaseDbPer200MHz(double stn_pathAnalysisFeederLossIncreaseDbPer200MHz) {
|
||||
this.stn_pathAnalysisFeederLossIncreaseDbPer200MHz = stn_pathAnalysisFeederLossIncreaseDbPer200MHz;
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisMaxEstimatedFeederLossPerStationDb() {
|
||||
return stn_pathAnalysisMaxEstimatedFeederLossPerStationDb;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisMaxEstimatedFeederLossPerStationDb(double stn_pathAnalysisMaxEstimatedFeederLossPerStationDb) {
|
||||
this.stn_pathAnalysisMaxEstimatedFeederLossPerStationDb = stn_pathAnalysisMaxEstimatedFeederLossPerStationDb;
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisRequiredSsbSignalDbm() {
|
||||
return stn_pathAnalysisRequiredSsbSignalDbm;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisRequiredSsbSignalDbm(double stn_pathAnalysisRequiredSsbSignalDbm) {
|
||||
this.stn_pathAnalysisRequiredSsbSignalDbm = stn_pathAnalysisRequiredSsbSignalDbm;
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisRequiredCwSignalDbm() {
|
||||
return stn_pathAnalysisRequiredCwSignalDbm;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisRequiredCwSignalDbm(double stn_pathAnalysisRequiredCwSignalDbm) {
|
||||
this.stn_pathAnalysisRequiredCwSignalDbm = stn_pathAnalysisRequiredCwSignalDbm;
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisContestMarginDb() {
|
||||
return stn_pathAnalysisContestMarginDb;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisContestMarginDb(double stn_pathAnalysisContestMarginDb) {
|
||||
this.stn_pathAnalysisContestMarginDb = stn_pathAnalysisContestMarginDb;
|
||||
}
|
||||
|
||||
public kst4contest.view.map.PathLinkBudgetSettings buildPathLinkBudgetSettings() {
|
||||
return new kst4contest.view.map.PathLinkBudgetSettings(
|
||||
stn_pathAnalysisOwnTxPowerWatts,
|
||||
stn_pathAnalysisOwnAntennaGainDbi,
|
||||
stn_pathAnalysisDefaultTargetTxPowerWatts,
|
||||
stn_pathAnalysisDefaultTargetAntennaGainDbi,
|
||||
stn_pathAnalysisVhfFeederLossPerStationDb,
|
||||
stn_pathAnalysisFeederLossIncreaseDbPer200MHz,
|
||||
stn_pathAnalysisMaxEstimatedFeederLossPerStationDb,
|
||||
stn_pathAnalysisRequiredSsbSignalDbm,
|
||||
stn_pathAnalysisRequiredCwSignalDbm,
|
||||
stn_pathAnalysisContestMarginDb
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
package kst4contest.service.path;
|
||||
|
||||
/**
|
||||
* Utility methods for Fresnel zone calculations.
|
||||
*
|
||||
* <p>This helper intentionally contains only pure mathematical functions.
|
||||
* It has no dependency on UI code or terrain providers.</p>
|
||||
*/
|
||||
public final class FresnelMathUtils {
|
||||
|
||||
/**
|
||||
* Speed of light in vacuum in meters per second.
|
||||
*/
|
||||
public static final double SPEED_OF_LIGHT_METERS_PER_SECOND = 299_792_458.0;
|
||||
|
||||
private FresnelMathUtils() {
|
||||
// Utility class
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the wavelength in meters for the given frequency.
|
||||
*
|
||||
* @param frequencyHz signal frequency in Hz
|
||||
* @return wavelength in meters, or 0 if the input is invalid
|
||||
*/
|
||||
public static double computeWavelengthMeters(final double frequencyHz) {
|
||||
if (frequencyHz <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
return SPEED_OF_LIGHT_METERS_PER_SECOND / frequencyHz;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the radius of the first Fresnel zone at a specific point
|
||||
* along the path.
|
||||
*
|
||||
* <p>Formula:
|
||||
* r = sqrt(lambda * d1 * d2 / (d1 + d2))</p>
|
||||
*
|
||||
* @param frequencyHz signal frequency in Hz
|
||||
* @param distanceFromTxMeters distance from TX to the current point in meters
|
||||
* @param totalPathDistanceMeters full TX-to-RX path length in meters
|
||||
* @return Fresnel radius in meters, or 0 at invalid inputs / path ends
|
||||
*/
|
||||
public static double computeFirstFresnelRadiusMeters(
|
||||
final double frequencyHz,
|
||||
final double distanceFromTxMeters,
|
||||
final double totalPathDistanceMeters) {
|
||||
|
||||
if (frequencyHz <= 0.0 || totalPathDistanceMeters <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
final double clampedDistanceFromTxMeters = Math.max(
|
||||
0.0,
|
||||
Math.min(distanceFromTxMeters, totalPathDistanceMeters)
|
||||
);
|
||||
|
||||
final double distanceFromPointToRxMeters = totalPathDistanceMeters - clampedDistanceFromTxMeters;
|
||||
|
||||
if (clampedDistanceFromTxMeters <= 0.0 || distanceFromPointToRxMeters <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
final double wavelengthMeters = computeWavelengthMeters(frequencyHz);
|
||||
|
||||
return Math.sqrt(
|
||||
wavelengthMeters
|
||||
* clampedDistanceFromTxMeters
|
||||
* distanceFromPointToRxMeters
|
||||
/ totalPathDistanceMeters
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,12 @@ import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Path;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* This class has utility methods to handle application files inside the home directory.
|
||||
*/
|
||||
public class ApplicationFileUtils {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(ApplicationFileUtils.class.getName());
|
||||
|
||||
/**
|
||||
* Gets the path of a file inside the home directory of the user.
|
||||
* @param applicationName Name off the application which is used for the hidden directory
|
||||
@@ -65,7 +61,8 @@ public class ApplicationFileUtils {
|
||||
|
||||
resourceStream.transferTo(fileOutputStream);
|
||||
} catch (IOException ex) {
|
||||
LOGGER.log(Level.SEVERE, "Exception when copying Application file: " + ex.getMessage(), ex);
|
||||
System.err.println("Exception when copying Application file: " + ex.getMessage());
|
||||
ex.printStackTrace(System.err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
package kst4contest.utils;
|
||||
|
||||
import javafx.collections.ObservableListBase;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* A bounded ObservableList backed by a circular buffer (ring buffer).
|
||||
* <p>
|
||||
* Provides O(1) {@link #addFirst} and {@link #addLast} as well as O(1)
|
||||
* random access via {@link #get}. When the list reaches {@code maxCapacity},
|
||||
* adding a new element at the front automatically evicts the oldest element
|
||||
* at the back — and vice versa.
|
||||
* <p>
|
||||
* This is a drop-in replacement for {@code FXCollections.observableArrayList()}
|
||||
* wherever elements are prepended frequently, e.g. chat message lists.
|
||||
*/
|
||||
public class BoundedDequeObservableList<E> extends ObservableListBase<E> {
|
||||
|
||||
private final int maxCapacity;
|
||||
private final Object[] elements;
|
||||
private int head = 0;
|
||||
private int size = 0;
|
||||
|
||||
public BoundedDequeObservableList(int maxCapacity) {
|
||||
if (maxCapacity <= 0) throw new IllegalArgumentException("maxCapacity must be > 0");
|
||||
this.maxCapacity = maxCapacity;
|
||||
this.elements = new Object[maxCapacity];
|
||||
}
|
||||
|
||||
// ── read access ──────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public int size() {
|
||||
return size;
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public E get(int index) {
|
||||
checkIndex(index);
|
||||
return (E) elements[physicalIndex(index)];
|
||||
}
|
||||
|
||||
// ── O(1) deque operations ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Inserts {@code element} at index 0 (newest-first order).
|
||||
* If the list is already at capacity the oldest element (last index) is
|
||||
* removed first — both changes are reported as a single compound change.
|
||||
*/
|
||||
public void addFirst(E element) {
|
||||
beginChange();
|
||||
if (size == maxCapacity) {
|
||||
// evict last element
|
||||
int lastPhysical = physicalIndex(size - 1);
|
||||
@SuppressWarnings("unchecked")
|
||||
E evicted = (E) elements[lastPhysical];
|
||||
elements[lastPhysical] = null;
|
||||
size--;
|
||||
nextRemove(size, evicted); // index after decrement == old last index
|
||||
}
|
||||
head = (head - 1 + maxCapacity) % maxCapacity;
|
||||
elements[head] = element;
|
||||
size++;
|
||||
nextAdd(0, 1);
|
||||
endChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends {@code element} at the last index (oldest-first order).
|
||||
* If the list is already at capacity the newest element (index 0) is
|
||||
* removed first.
|
||||
*/
|
||||
public void addLast(E element) {
|
||||
beginChange();
|
||||
if (size == maxCapacity) {
|
||||
// evict first element
|
||||
@SuppressWarnings("unchecked")
|
||||
E evicted = (E) elements[head];
|
||||
elements[head] = null;
|
||||
head = (head + 1) % maxCapacity;
|
||||
size--;
|
||||
nextRemove(0, evicted);
|
||||
}
|
||||
elements[physicalIndex(size)] = element;
|
||||
size++;
|
||||
nextAdd(size - 1, size);
|
||||
endChange();
|
||||
}
|
||||
|
||||
// ── standard List mutation (O(n) — use addFirst/addLast for hot path) ─────
|
||||
|
||||
@Override
|
||||
public void add(int index, E element) {
|
||||
if (index == 0) {
|
||||
addFirst(element);
|
||||
return;
|
||||
}
|
||||
if (index == size) {
|
||||
addLast(element);
|
||||
return;
|
||||
}
|
||||
checkIndexForAdd(index);
|
||||
beginChange();
|
||||
if (size == maxCapacity) {
|
||||
int lastPhysical = physicalIndex(size - 1);
|
||||
@SuppressWarnings("unchecked")
|
||||
E evicted = (E) elements[lastPhysical];
|
||||
elements[lastPhysical] = null;
|
||||
size--;
|
||||
nextRemove(size, evicted);
|
||||
}
|
||||
// shift elements [index .. size-1] one position towards the end
|
||||
for (int i = size; i > index; i--) {
|
||||
elements[physicalIndex(i)] = elements[physicalIndex(i - 1)];
|
||||
}
|
||||
elements[physicalIndex(index)] = element;
|
||||
size++;
|
||||
nextAdd(index, index + 1);
|
||||
endChange();
|
||||
}
|
||||
|
||||
@Override
|
||||
public E remove(int index) {
|
||||
checkIndex(index);
|
||||
beginChange();
|
||||
@SuppressWarnings("unchecked")
|
||||
E removed = (E) elements[physicalIndex(index)];
|
||||
// shift elements [index+1 .. size-1] one position towards the front
|
||||
for (int i = index; i < size - 1; i++) {
|
||||
elements[physicalIndex(i)] = elements[physicalIndex(i + 1)];
|
||||
}
|
||||
elements[physicalIndex(size - 1)] = null;
|
||||
size--;
|
||||
nextRemove(index, removed);
|
||||
endChange();
|
||||
return removed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public E set(int index, E element) {
|
||||
checkIndex(index);
|
||||
beginChange();
|
||||
@SuppressWarnings("unchecked")
|
||||
E old = (E) elements[physicalIndex(index)];
|
||||
elements[physicalIndex(index)] = element;
|
||||
nextSet(index, old);
|
||||
endChange();
|
||||
return old;
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private int physicalIndex(int virtualIndex) {
|
||||
return (head + virtualIndex) % maxCapacity;
|
||||
}
|
||||
|
||||
private void checkIndex(int index) {
|
||||
if (index < 0 || index >= size)
|
||||
throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size);
|
||||
}
|
||||
|
||||
private void checkIndexForAdd(int index) {
|
||||
if (index < 0 || index > size)
|
||||
throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,13 @@
|
||||
package kst4contest.utils;
|
||||
|
||||
import javazoom.jl.decoder.JavaLayerException;
|
||||
import javazoom.jl.player.Player;
|
||||
import javafx.scene.media.Media;
|
||||
import javafx.scene.media.MediaPlayer;
|
||||
import kst4contest.ApplicationConstants;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.File;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedList;
|
||||
import java.util.Queue;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* This part of the client drives the sounds. Its a singleton instance. All audio outs are directed to this instance.<br>
|
||||
@@ -118,16 +113,8 @@ public class PlayAudioUtils {
|
||||
|
||||
}
|
||||
|
||||
private final Queue<String> musicList = new ConcurrentLinkedQueue<>();
|
||||
/** True once audio fails; prevents repeated error logs and further play attempts. */
|
||||
private final AtomicBoolean audioUnavailable = new AtomicBoolean(false);
|
||||
/** True while the drain loop is running; prevents duplicate concurrent drains. */
|
||||
private final AtomicBoolean playing = new AtomicBoolean(false);
|
||||
private final ExecutorService audioThread = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "audio-player");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
private Queue<Media> musicList = new LinkedList<Media>();
|
||||
private MediaPlayer mediaPlayer ;
|
||||
|
||||
/**
|
||||
* Plays notification sounds out of the windws 95 box by given action character<br/>
|
||||
@@ -144,31 +131,40 @@ public class PlayAudioUtils {
|
||||
*/
|
||||
public void playNoiseLauncher(char actionChar) {
|
||||
|
||||
switch (actionChar){
|
||||
case '-':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/tick.mp3"));
|
||||
break;
|
||||
case '!':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISESTARTUP.mp3"));
|
||||
break;
|
||||
case 'C':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISECQWINDOW.mp3"));
|
||||
break;
|
||||
case 'P':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISEPMWINDOW.mp3"));
|
||||
break;
|
||||
case 'E':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISEERROR.mp3"));
|
||||
break;
|
||||
case 'N':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISENOTIFY.mp3"));
|
||||
break;
|
||||
|
||||
default:
|
||||
System.out.println("[KST4ContestApp, warning, letter not defined!]");
|
||||
|
||||
// ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISESTARTUP.mp3");
|
||||
|
||||
|
||||
switch (actionChar){
|
||||
case '-':
|
||||
musicList.add(new Media(new File (ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/tick.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '!':
|
||||
musicList.add(new Media(new File (ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISESTARTUP.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'C':
|
||||
musicList.add(new Media(new File (ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISECQWINDOW.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'P':
|
||||
musicList.add(new Media(new File (ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISEPMWINDOW.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'E':
|
||||
musicList.add(new Media(new File (ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISEERROR.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'N':
|
||||
musicList.add(new Media(new File (ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISENOTIFY.mp3")).toURI().toString()));
|
||||
break;
|
||||
// case 'M':
|
||||
// musicList.add(new Media(new File ("VOICE.mp3").toURI().toString()));
|
||||
// break;
|
||||
|
||||
default:
|
||||
System.out.println("[KST4ContestApp, warning, letter not defined!]");
|
||||
|
||||
}
|
||||
playMusic();
|
||||
// mediaPlayer.dispose();
|
||||
|
||||
}
|
||||
|
||||
@@ -187,124 +183,125 @@ public class PlayAudioUtils {
|
||||
for (char letterToPlay: playThisInCW){
|
||||
switch (letterToPlay){
|
||||
case 'A':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRA.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRA.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'B':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRB.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRB.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'C':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRC.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRC.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'D':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRD.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRD.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'E':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRE.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRE.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'F':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRF.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRF.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'G':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRG.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRG.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'H':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRH.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRH.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'I':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRI.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRI.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'J':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRJ.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRJ.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'K':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRK.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRK.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'L':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRL.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRL.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'M':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRM.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRM.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'N':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRN.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRN.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'O':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRO.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRO.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'P':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRP.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRP.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'Q':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRQ.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRQ.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'R':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRR.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRR.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'S':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRS.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRS.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'T':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRT.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRT.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'U':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRU.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRU.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'V':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRV.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRV.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'W':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRW.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRW.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'X':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRX.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRX.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'Y':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRY.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRY.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'Z':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRZ.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRZ.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '1':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR1.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR1.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '2':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR2.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR2.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '3':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR3.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR3.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '4':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR4.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR4.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '5':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR5.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR5.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '6':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR6.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR6.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '7':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR7.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR7.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '8':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR8.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR8.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '9':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR9.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR9.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '0':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR0.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR0.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '/':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRSTROKE.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRSTROKE.mp3")).toURI().toString()));
|
||||
break;
|
||||
case ' ':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRSPACE.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRSPACE.mp3")).toURI().toString()));
|
||||
break;
|
||||
default:
|
||||
System.out.println("[KST4ContestApp, warning, letter not defined:] cwLetters = " + Arrays.toString(playThisInCW));
|
||||
}
|
||||
}
|
||||
playMusic();
|
||||
// mediaPlayer.dispose();
|
||||
|
||||
}
|
||||
|
||||
@@ -326,166 +323,162 @@ public class PlayAudioUtils {
|
||||
for (char letterToPlay: spellThisWithVoice){
|
||||
switch (letterToPlay){
|
||||
case '!':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEBELL.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEBELL.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '?':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEYOUGOTMAIL.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEYOUGOTMAIL.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '#':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEHELLO.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEHELLO.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '*':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE73.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE73.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '$':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICESTROKEPORTABLE.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICESTROKEPORTABLE.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'A':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEA.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEA.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'B':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEB.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEB.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'C':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEC.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEC.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'D':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICED.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICED.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'E':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEE.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEE.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'F':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEF.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEF.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'G':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEG.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEG.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'H':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEH.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEH.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'I':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEI.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEI.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'J':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEJ.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEJ.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'K':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEK.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEK.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'L':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEL.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEL.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'M':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEM.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEM.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'N':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEN.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEN.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'O':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEO.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEO.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'P':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEP.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEP.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'Q':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEQ.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEQ.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'R':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICER.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICER.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'S':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICES.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICES.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'T':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICET.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICET.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'U':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEU.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEU.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'V':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEV.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEV.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'W':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEW.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEW.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'X':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEX.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEX.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'Y':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEY.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEY.mp3")).toURI().toString()));
|
||||
break;
|
||||
case 'Z':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEZ.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEZ.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '1':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE1.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE1.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '2':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE2.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE2.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '3':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE3.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE3.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '4':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE4.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE4.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '5':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE5.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE5.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '6':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE6.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE6.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '7':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE7.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE7.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '8':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE8.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE8.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '9':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE9.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE9.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '0':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE0.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE0.mp3")).toURI().toString()));
|
||||
break;
|
||||
case '/':
|
||||
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICESTROKE.mp3"));
|
||||
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICESTROKE.mp3")).toURI().toString()));
|
||||
break;
|
||||
// case ' ':
|
||||
// musicList.add(new Media(new File ("VOICESPACE.mp3").toURI().toString()));
|
||||
// break;
|
||||
default:
|
||||
System.out.println("[KST4ContestApp, warning, letter not defined:] cwLetters = " + Arrays.toString(spellThisWithVoice));
|
||||
}
|
||||
}
|
||||
playMusic();
|
||||
// mediaPlayer.dispose();
|
||||
|
||||
}
|
||||
|
||||
private void playMusic() {
|
||||
if (audioUnavailable.get()) {
|
||||
musicList.clear();
|
||||
|
||||
// System.out.println("Kst4ContestApplication.playMusic");
|
||||
if(musicList.peek() == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Only start a drain loop if none is running; the running loop will pick up new items itself.
|
||||
if (!playing.compareAndSet(false, true)) return;
|
||||
audioThread.submit(() -> {
|
||||
String path;
|
||||
while ((path = musicList.poll()) != null) {
|
||||
if (audioUnavailable.get()) break;
|
||||
try (FileInputStream fis = new FileInputStream(path);
|
||||
BufferedInputStream bis = new BufferedInputStream(fis)) {
|
||||
new Player(bis).play();
|
||||
} catch (IOException | JavaLayerException e) {
|
||||
audioUnavailable.set(true);
|
||||
musicList.clear();
|
||||
System.out.println("[KST4ContestApp, warning, audio playback disabled (could not create media player): " + e + "]");
|
||||
break;
|
||||
}
|
||||
}
|
||||
playing.set(false);
|
||||
// Items may have been enqueued between the last poll() and playing.set(false);
|
||||
// restart the drain loop if so.
|
||||
if (!musicList.isEmpty() && !audioUnavailable.get()) {
|
||||
mediaPlayer = new MediaPlayer(musicList.poll());
|
||||
mediaPlayer.setRate(1.0);
|
||||
|
||||
mediaPlayer.setOnReady(() -> {
|
||||
mediaPlayer.play();
|
||||
mediaPlayer.setOnEndOfMedia(() -> {
|
||||
// mediaPlayer.dispose();
|
||||
playMusic();
|
||||
}
|
||||
if (musicList.isEmpty()) {
|
||||
// mediaPlayer.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,21 +51,26 @@ public class GuiUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a safe UI refresh of the filtered ChatMember list.
|
||||
*
|
||||
* <p>Older versions used the trick of adding/removing a dummy predicate. That can
|
||||
* break JavaFX SortedList internals when the table is sorted and a FilteredList
|
||||
* refilter happens at the same time. The controller-level refresh path is safer
|
||||
* because Kst4ContestApplication now re-applies the existing predicates directly.</p>
|
||||
*
|
||||
* @param chatController central controller
|
||||
*/
|
||||
private static void triggerUpdate(ChatController chatController) {
|
||||
if (chatController == null) {
|
||||
return;
|
||||
}
|
||||
private static void triggerUpdate(ChatController chatController) {
|
||||
{
|
||||
//trick to trigger gui changes on property changes of obects
|
||||
|
||||
chatController.fireUserListUpdate("Forced filtered ChatMember refresh");
|
||||
}
|
||||
Predicate<ChatMember> dummyPredicate = new Predicate<ChatMember>() {
|
||||
@Override
|
||||
public boolean test(ChatMember chatMember) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* //TODO: following 2 lines are a quick fix to making disappear worked chatmembers of the list
|
||||
* Thats uncomfortable due to this also causes selection changes,
|
||||
* Better way is to change all worked and qrv values to observables and then trigger the underlying
|
||||
* list to fire an invalidationevent. Really Todo!
|
||||
*/
|
||||
chatController.getLst_chatMemberListFilterPredicates().add(dummyPredicate);
|
||||
chatController.getLst_chatMemberListFilterPredicates().remove(dummyPredicate);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,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,80 +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 offersSelectedBand,
|
||||
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() {
|
||||
String baseLabel = bandSummary.isBlank()
|
||||
? displayCallSign
|
||||
: displayCallSign + " (" + bandSummary + ")";
|
||||
return offersSelectedBand ? baseLabel + " B+" : baseLabel;
|
||||
}
|
||||
|
||||
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,390 +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.EnumSet;
|
||||
import java.util.EnumMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
||||
private static final Pattern TOKEN_SPLIT_PATTERN = Pattern.compile("[^A-Z0-9]+");
|
||||
|
||||
public List<MapCallsignRawSnapshot> buildSnapshots(Collection<ChatMember> visibleChatMembers,
|
||||
ChatMember selectedChatMember,
|
||||
EnumSet<Band> selectedBands) {
|
||||
|
||||
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);
|
||||
EnumSet<Band> sureBands = collectSureBands(variants, frequenciesByBand);
|
||||
String bandSummary = buildBandSummary(sureBands);
|
||||
boolean offersSelectedBand = hasAnySelectedBand(sureBands, selectedBands);
|
||||
|
||||
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,
|
||||
offersSelectedBand,
|
||||
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;
|
||||
}
|
||||
|
||||
private EnumSet<Band> collectSureBands(List<ChatMember> variants,
|
||||
LinkedHashMap<String, String> frequenciesByBand) {
|
||||
EnumSet<Band> sureBands = EnumSet.noneOf(Band.class);
|
||||
|
||||
if (frequenciesByBand != null) {
|
||||
for (String label : frequenciesByBand.keySet()) {
|
||||
Band mappedBand = bandFromDisplayLabel(label);
|
||||
if (mappedBand != null) {
|
||||
sureBands.add(mappedBand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (ChatMember variant : variants) {
|
||||
if (variant == null || variant.getName() == null || variant.getName().isBlank()) {
|
||||
continue;
|
||||
}
|
||||
sureBands.addAll(detectBandsFromStationName(variant.getName()));
|
||||
}
|
||||
|
||||
return sureBands;
|
||||
}
|
||||
|
||||
private EnumSet<Band> detectBandsFromStationName(String stationName) {
|
||||
EnumSet<Band> detectedBands = EnumSet.noneOf(Band.class);
|
||||
if (stationName == null || stationName.isBlank()) {
|
||||
return detectedBands;
|
||||
}
|
||||
|
||||
String normalized = stationName.toUpperCase(Locale.ROOT);
|
||||
String[] tokens = TOKEN_SPLIT_PATTERN.split(normalized);
|
||||
for (String token : tokens) {
|
||||
if (token == null || token.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (token) {
|
||||
case "2", "2M", "144", "144MHZ" -> detectedBands.add(Band.B_144);
|
||||
case "70", "70CM", "432", "432MHZ" -> detectedBands.add(Band.B_432);
|
||||
case "23", "23CM", "1296", "1296MHZ" -> detectedBands.add(Band.B_1296);
|
||||
case "13", "13CM", "2300", "2320", "2320MHZ" -> detectedBands.add(Band.B_2320);
|
||||
case "9", "9CM", "3400", "3400MHZ" -> detectedBands.add(Band.B_3400);
|
||||
case "6", "6CM", "5600", "5760", "5760MHZ" -> detectedBands.add(Band.B_5760);
|
||||
case "3", "3CM", "10G", "10GHZ", "10368", "10368MHZ" -> detectedBands.add(Band.B_10G);
|
||||
case "24G", "24GHZ", "24048", "24048MHZ" -> detectedBands.add(Band.B_24G);
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return detectedBands;
|
||||
}
|
||||
|
||||
private String buildBandSummary(EnumSet<Band> sureBands) {
|
||||
if (sureBands == null || sureBands.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
List<String> labels = sureBands.stream()
|
||||
.sorted()
|
||||
.map(this::toBandDisplayLabel)
|
||||
.toList();
|
||||
return String.join(", ", labels);
|
||||
}
|
||||
|
||||
private boolean hasAnySelectedBand(EnumSet<Band> sureBands, EnumSet<Band> selectedBands) {
|
||||
if (sureBands == null || sureBands.isEmpty() || selectedBands == null || selectedBands.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (Band band : selectedBands) {
|
||||
if (sureBands.contains(band)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Band bandFromDisplayLabel(String label) {
|
||||
if (label == null || label.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (label) {
|
||||
case "144" -> Band.B_144;
|
||||
case "432" -> Band.B_432;
|
||||
case "1296" -> Band.B_1296;
|
||||
case "2320" -> Band.B_2320;
|
||||
case "3400" -> Band.B_3400;
|
||||
case "5760" -> Band.B_5760;
|
||||
case "10368" -> Band.B_10G;
|
||||
case "24048" -> Band.B_24G;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,759 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* HTML host for the JavaFX WebView map.
|
||||
*
|
||||
* This version keeps DOM-based station markers, but exposes helper APIs so the
|
||||
* JavaFX WebView can decide interactions directly:
|
||||
* - inspectPoint(x,y) returns what is under the cursor
|
||||
* - zoomIn()/zoomOut() are callable from Java
|
||||
* - grid / beam / connection use non-interactive panes
|
||||
* - JavaScript logs are forwarded to Java through javaMapBridge
|
||||
* - setTheme(light|dark) aligns the map with the JavaFX application theme
|
||||
*/
|
||||
public final class MapHtmlResources {
|
||||
|
||||
private MapHtmlResources() {
|
||||
}
|
||||
|
||||
private static String readRequiredResource(String resourcePath) {
|
||||
try (InputStream inputStream = MapHtmlResources.class.getResourceAsStream(resourcePath)) {
|
||||
if (inputStream == null) {
|
||||
throw new IllegalStateException("Missing map resource: " + resourcePath);
|
||||
}
|
||||
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
} catch (IOException exception) {
|
||||
throw new UncheckedIOException("Could not read map resource: " + resourcePath, exception);
|
||||
}
|
||||
}
|
||||
|
||||
public static String createStationMapHtml(int tileProxyPort) {
|
||||
String leafletCss = readRequiredResource("/web/leaflet/leaflet.css");
|
||||
String leafletJs = readRequiredResource("/web/leaflet/leaflet.js");
|
||||
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>KST4Contest Station Map</title>
|
||||
<style>
|
||||
""" + leafletCss + """
|
||||
</style>
|
||||
<style>
|
||||
:root {
|
||||
--map-background: #ede9df;
|
||||
--station-label-bg: rgba(248, 248, 248, 0.95);
|
||||
--station-label-color: #1c1c1c;
|
||||
--station-label-border: rgba(0, 0, 0, 0.20);
|
||||
--grid-label-bg: rgba(255, 255, 255, 0.18);
|
||||
--grid-label-color: rgba(20, 20, 20, 0.38);
|
||||
--control-bg: rgba(255, 255, 255, 0.96);
|
||||
--control-fg: #242424;
|
||||
--control-border: #b7b7b7;
|
||||
--attribution-bg: rgba(255, 255, 255, 0.88);
|
||||
--attribution-fg: #2d2d2d;
|
||||
--attribution-link: #145fa3;
|
||||
}
|
||||
|
||||
body.kst-theme-dark {
|
||||
--map-background: #23282d;
|
||||
--station-label-bg: rgba(36, 40, 45, 0.96);
|
||||
--station-label-color: #f1f3f5;
|
||||
--station-label-border: rgba(255, 255, 255, 0.18);
|
||||
--grid-label-bg: rgba(34, 38, 43, 0.20);
|
||||
--grid-label-color: rgba(235, 240, 245, 0.42);
|
||||
--control-bg: rgba(55, 62, 67, 0.96);
|
||||
--control-fg: #e2e6ea;
|
||||
--control-border: #556068;
|
||||
--attribution-bg: rgba(34, 38, 43, 0.86);
|
||||
--attribution-fg: #d2d8dd;
|
||||
--attribution-link: #88c7ff;
|
||||
}
|
||||
|
||||
html, body, #map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: var(--map-background);
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.leaflet-container {
|
||||
background: var(--map-background);
|
||||
}
|
||||
|
||||
body.kst-theme-dark .leaflet-tile-pane {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.leaflet-bar {
|
||||
border: 1px solid var(--control-border);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.leaflet-bar a,
|
||||
.leaflet-bar a:hover {
|
||||
background: var(--control-bg);
|
||||
color: var(--control-fg);
|
||||
border-bottom: 1px solid var(--control-border);
|
||||
}
|
||||
|
||||
.leaflet-control-attribution {
|
||||
background: var(--attribution-bg);
|
||||
color: var(--attribution-fg);
|
||||
}
|
||||
|
||||
.leaflet-control-attribution a {
|
||||
color: var(--attribution-link);
|
||||
}
|
||||
|
||||
.station-marker-wrapper {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.station-marker-root {
|
||||
position: relative;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.station-dot {
|
||||
position: absolute;
|
||||
left: -6px;
|
||||
top: -6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #1d1d1d;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid #4da6ff;
|
||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.25);
|
||||
}
|
||||
|
||||
.station-dot.worked {
|
||||
border-color: #ffd24d;
|
||||
}
|
||||
|
||||
.station-dot.warning {
|
||||
border-color: #00ff66;
|
||||
}
|
||||
|
||||
.station-dot.selected {
|
||||
left: -8px;
|
||||
top: -8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-width: 3px;
|
||||
border-color: #ff9900;
|
||||
}
|
||||
|
||||
.station-label {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: -22px;
|
||||
display: inline-block;
|
||||
background: var(--station-label-bg);
|
||||
color: var(--station-label-color);
|
||||
border: 1px solid var(--station-label-border);
|
||||
border-radius: 5px;
|
||||
padding: 2px 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.station-label.warning {
|
||||
color: #00ff66;
|
||||
border-color: rgba(0, 255, 102, 0.75);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.maidenhead-grid-label-wrapper {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.maidenhead-grid-label-wrapper .maidenhead-grid-label {
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.maidenhead-grid-label {
|
||||
display: inline-block;
|
||||
background: var(--grid-label-bg);
|
||||
#color: var(--grid-label-color);
|
||||
color: #63067a;
|
||||
border-radius: 3px;
|
||||
padding: 0 3px;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 0 2px rgba(0, 0, 0, 0.18);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body class="kst-theme-light">
|
||||
<div id="map"></div>
|
||||
|
||||
<script>
|
||||
// JavaFX 21 WebView can render Leaflet's CSS translate3d tile positioning incorrectly.
|
||||
// Disable Leaflet 3D transforms before leaflet.js is loaded to keep tile rendering stable.
|
||||
window.L_DISABLE_3D = true;
|
||||
</script>
|
||||
<script>
|
||||
""" + leafletJs + """
|
||||
</script>
|
||||
<script>window._kstTileProxyPort=__TILE_PROXY_PORT__;</script>
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
|
||||
window.kstMapApi = (function () {
|
||||
let map;
|
||||
let stationLayer;
|
||||
let gridLayer;
|
||||
let beamLayer;
|
||||
let connectionLayer;
|
||||
let profileHoverMarker;
|
||||
let markersByCallsignRaw = {};
|
||||
let activeTheme = 'light';
|
||||
let invalidateNotifyTimer = 0;
|
||||
|
||||
function jsLog(message) {
|
||||
try {
|
||||
if (window.javaMapBridge) {
|
||||
window.javaMapBridge.onJsLog(String(message));
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[KST map fallback log]', message, e);
|
||||
}
|
||||
}
|
||||
|
||||
function jsError(message) {
|
||||
try {
|
||||
if (window.javaMapBridge) {
|
||||
window.javaMapBridge.onJsError(String(message));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[KST map fallback error]', message);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function notifyMapReady() {
|
||||
try {
|
||||
if (window.javaMapBridge) {
|
||||
window.javaMapBridge.onMapReady();
|
||||
}
|
||||
} catch (e) {
|
||||
jsError('notifyMapReady failed: ' + e);
|
||||
}
|
||||
}
|
||||
|
||||
function notifyViewport() {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!window.javaMapBridge) {
|
||||
jsError('notifyViewport skipped: no javaMapBridge');
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = map.getBounds();
|
||||
const zoom = map.getZoom();
|
||||
|
||||
jsLog('notifyViewport south=' + bounds.getSouth()
|
||||
+ ' west=' + bounds.getWest()
|
||||
+ ' north=' + bounds.getNorth()
|
||||
+ ' east=' + bounds.getEast()
|
||||
+ ' zoom=' + zoom);
|
||||
|
||||
window.javaMapBridge.onViewportChanged(
|
||||
bounds.getSouth(),
|
||||
bounds.getWest(),
|
||||
bounds.getNorth(),
|
||||
bounds.getEast(),
|
||||
zoom
|
||||
);
|
||||
} catch (e) {
|
||||
jsError('notifyViewport failed: ' + e);
|
||||
}
|
||||
}
|
||||
|
||||
function gridLineColor() {
|
||||
return activeTheme === 'dark' ? '#e1e7ec' : '#46586c';
|
||||
}
|
||||
|
||||
function gridLineOpacity() {
|
||||
return activeTheme === 'dark' ? 0.48 : 0.56;
|
||||
}
|
||||
|
||||
function connectionColor() {
|
||||
return activeTheme === 'dark' ? '#2fd7ff' : '#00a4cf';
|
||||
}
|
||||
|
||||
function applyThemeClass() {
|
||||
document.body.classList.remove('kst-theme-light', 'kst-theme-dark');
|
||||
document.body.classList.add(activeTheme === 'dark' ? 'kst-theme-dark' : 'kst-theme-light');
|
||||
}
|
||||
|
||||
function setTheme(themeName) {
|
||||
activeTheme = themeName === 'dark' ? 'dark' : 'light';
|
||||
applyThemeClass();
|
||||
jsLog('setTheme ' + activeTheme);
|
||||
}
|
||||
|
||||
function buildStationMarkerHtml(station) {
|
||||
let dotClasses = 'station-dot';
|
||||
if (station.selected) {
|
||||
dotClasses += ' selected';
|
||||
} else if (station.warningToMyDirection) {
|
||||
dotClasses += ' warning';
|
||||
} else if (station.worked) {
|
||||
dotClasses += ' worked';
|
||||
}
|
||||
|
||||
let labelClasses = 'station-label';
|
||||
if (station.warningToMyDirection) {
|
||||
labelClasses += ' warning';
|
||||
}
|
||||
|
||||
return '<div class="station-marker-root" data-callsignraw="' + escapeHtml(station.callSignRaw) + '">'
|
||||
+ '<div class="' + dotClasses + '"></div>'
|
||||
+ '<div class="' + labelClasses + '">' + escapeHtml(station.markerLabel) + '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (map) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof L === 'undefined') {
|
||||
jsError('Leaflet is not loaded. Station map cannot initialize.');
|
||||
return false;
|
||||
}
|
||||
|
||||
applyThemeClass();
|
||||
|
||||
map = L.map('map', {
|
||||
zoomControl: true,
|
||||
zoomAnimation: false,
|
||||
fadeAnimation: false,
|
||||
markerZoomAnimation: false,
|
||||
inertia: false
|
||||
}).setView([51.0, 10.0], 6);
|
||||
|
||||
jsLog('Leaflet map initialized');
|
||||
|
||||
const tileLayer = L.tileLayer(
|
||||
'http://127.0.0.1:' + window._kstTileProxyPort + '/tiles/{s}/{z}/{x}/{y}.png',
|
||||
{
|
||||
maxZoom: 18,
|
||||
updateWhenZooming: false,
|
||||
keepBuffer: 4,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}
|
||||
);
|
||||
|
||||
tileLayer.on('tileerror', function (event) {
|
||||
const source = event && event.tile ? event.tile.src : 'unknown';
|
||||
jsError('OSM tile load failed: ' + source);
|
||||
});
|
||||
|
||||
tileLayer.addTo(map);
|
||||
|
||||
map.createPane('beamPane');
|
||||
map.getPane('beamPane').style.zIndex = 410;
|
||||
map.getPane('beamPane').style.pointerEvents = 'none';
|
||||
|
||||
map.createPane('gridPane');
|
||||
map.getPane('gridPane').style.zIndex = 420;
|
||||
map.getPane('gridPane').style.pointerEvents = 'none';
|
||||
|
||||
map.createPane('gridLabelPane');
|
||||
map.getPane('gridLabelPane').style.zIndex = 430;
|
||||
map.getPane('gridLabelPane').style.pointerEvents = 'none';
|
||||
|
||||
map.createPane('connectionPane');
|
||||
map.getPane('connectionPane').style.zIndex = 440;
|
||||
map.getPane('connectionPane').style.pointerEvents = 'none';
|
||||
|
||||
stationLayer = L.layerGroup().addTo(map);
|
||||
gridLayer = L.layerGroup().addTo(map);
|
||||
beamLayer = L.layerGroup().addTo(map);
|
||||
connectionLayer = L.layerGroup().addTo(map);
|
||||
|
||||
map.on('zoomend', function () {
|
||||
jsLog('zoomend -> ' + map.getZoom());
|
||||
notifyViewport();
|
||||
});
|
||||
|
||||
map.on('moveend', function () {
|
||||
jsLog('moveend');
|
||||
notifyViewport();
|
||||
});
|
||||
|
||||
notifyMapReady();
|
||||
notifyViewport();
|
||||
return true;
|
||||
}
|
||||
|
||||
function invalidateSize() {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mapElement = document.getElementById('map');
|
||||
const domWidth = mapElement ? mapElement.clientWidth : -1;
|
||||
const domHeight = mapElement ? mapElement.clientHeight : -1;
|
||||
|
||||
jsLog('invalidateSize dom=' + domWidth + 'x' + domHeight
|
||||
+ ' leafletBefore=' + map.getSize().x + 'x' + map.getSize().y);
|
||||
|
||||
window.requestAnimationFrame(function () {
|
||||
map.invalidateSize({
|
||||
animate: false,
|
||||
pan: false,
|
||||
debounceMoveend: true
|
||||
});
|
||||
|
||||
window.setTimeout(function () {
|
||||
map.invalidateSize({
|
||||
animate: false,
|
||||
pan: false,
|
||||
debounceMoveend: true
|
||||
});
|
||||
|
||||
jsLog('invalidateSize leafletAfter=' + map.getSize().x + 'x' + map.getSize().y);
|
||||
|
||||
if (invalidateNotifyTimer) {
|
||||
window.clearTimeout(invalidateNotifyTimer);
|
||||
}
|
||||
|
||||
invalidateNotifyTimer = window.setTimeout(function () {
|
||||
notifyViewport();
|
||||
}, 120);
|
||||
}, 80);
|
||||
});
|
||||
}
|
||||
|
||||
function resize() {
|
||||
invalidateSize();
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
map.setZoom(map.getZoom() + 1, { animate: false });
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
map.setZoom(map.getZoom() - 1, { animate: false });
|
||||
}
|
||||
|
||||
function getViewportState() {
|
||||
if (!map) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const bounds = map.getBounds();
|
||||
const zoom = map.getZoom();
|
||||
|
||||
return [
|
||||
bounds.getSouth(),
|
||||
bounds.getWest(),
|
||||
bounds.getNorth(),
|
||||
bounds.getEast(),
|
||||
zoom
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function inspectPoint(x, y) {
|
||||
try {
|
||||
const el = document.elementFromPoint(x, y);
|
||||
if (!el) {
|
||||
return 'none||||';
|
||||
}
|
||||
|
||||
const stationRoot = el.closest('.station-marker-root');
|
||||
if (stationRoot) {
|
||||
const callSignRaw = stationRoot.getAttribute('data-callsignraw') || '';
|
||||
return 'station|' + callSignRaw + '|' + el.tagName + '|' + (el.className || '') + '|' + (el.textContent || '').trim();
|
||||
}
|
||||
|
||||
const zoomInButton = el.closest('.leaflet-control-zoom-in');
|
||||
if (zoomInButton) {
|
||||
return 'zoomIn||' + el.tagName + '|' + (el.className || '') + '|' + (el.textContent || '').trim();
|
||||
}
|
||||
|
||||
const zoomOutButton = el.closest('.leaflet-control-zoom-out');
|
||||
if (zoomOutButton) {
|
||||
return 'zoomOut||' + el.tagName + '|' + (el.className || '') + '|' + (el.textContent || '').trim();
|
||||
}
|
||||
|
||||
return 'none||' + el.tagName + '|' + (el.className || '') + '|' + (el.textContent || '').trim();
|
||||
} catch (e) {
|
||||
return 'error||ERROR|' + e + '|';
|
||||
}
|
||||
}
|
||||
|
||||
function setHome(lat, lon, zoom) {
|
||||
if (!init()) {
|
||||
return;
|
||||
}
|
||||
jsLog('setHome lat=' + lat + ' lon=' + lon + ' zoom=' + zoom);
|
||||
map.setView([lat, lon], zoom);
|
||||
}
|
||||
|
||||
function setStations(stationsJson) {
|
||||
if (!init()) {
|
||||
return;
|
||||
}
|
||||
|
||||
stationLayer.clearLayers();
|
||||
markersByCallsignRaw = {};
|
||||
|
||||
const stations = JSON.parse(stationsJson);
|
||||
jsLog('setStations count=' + stations.length);
|
||||
|
||||
stations.forEach(station => {
|
||||
const marker = L.marker(
|
||||
[station.latitudeDeg, station.longitudeDeg],
|
||||
{
|
||||
interactive: true,
|
||||
keyboard: false,
|
||||
icon: L.divIcon({
|
||||
className: 'station-marker-wrapper',
|
||||
html: buildStationMarkerHtml(station),
|
||||
iconSize: [1, 1],
|
||||
iconAnchor: [0, 0]
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
marker.addTo(stationLayer);
|
||||
markersByCallsignRaw[station.callSignRaw] = marker;
|
||||
});
|
||||
}
|
||||
|
||||
function setBeam(beamJson) {
|
||||
if (!init()) {
|
||||
return;
|
||||
}
|
||||
beamLayer.clearLayers();
|
||||
|
||||
if (!beamJson || beamJson === 'null') {
|
||||
return;
|
||||
}
|
||||
|
||||
const points = JSON.parse(beamJson);
|
||||
if (!points || points.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
jsLog('setBeam points=' + points.length);
|
||||
|
||||
const latLngs = points.map(point => [point.lat, point.lon]);
|
||||
|
||||
L.polygon(latLngs, {
|
||||
pane: 'beamPane',
|
||||
color: '#ff4d4d',
|
||||
weight: 2,
|
||||
fillColor: '#ff4d4d',
|
||||
fillOpacity: 0.12,
|
||||
interactive: false
|
||||
}).addTo(beamLayer);
|
||||
}
|
||||
|
||||
function setConnection(connectionJson) {
|
||||
if (!init()) {
|
||||
return;
|
||||
}
|
||||
connectionLayer.clearLayers();
|
||||
|
||||
if (!connectionJson || connectionJson === 'null') {
|
||||
return;
|
||||
}
|
||||
|
||||
const points = JSON.parse(connectionJson);
|
||||
if (!points || points.length !== 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
jsLog('setConnection');
|
||||
|
||||
L.polyline(points.map(point => [point.lat, point.lon]), {
|
||||
pane: 'connectionPane',
|
||||
color: connectionColor(),
|
||||
weight: 2,
|
||||
dashArray: '6,6',
|
||||
opacity: 0.85,
|
||||
interactive: false
|
||||
}).addTo(connectionLayer);
|
||||
}
|
||||
|
||||
function setProfileHoverPoint(point) {
|
||||
if (!init()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (profileHoverMarker) {
|
||||
map.removeLayer(profileHoverMarker);
|
||||
profileHoverMarker = null;
|
||||
}
|
||||
|
||||
if (!point || !isFinite(point.lat) || !isFinite(point.lon)) {
|
||||
return;
|
||||
}
|
||||
|
||||
profileHoverMarker = L.circleMarker([point.lat, point.lon], {
|
||||
radius: 6,
|
||||
color: '#ffcc00',
|
||||
weight: 2,
|
||||
fillColor: '#ffcc00',
|
||||
fillOpacity: 0.85,
|
||||
interactive: false
|
||||
}).addTo(map);
|
||||
|
||||
if (point.label) {
|
||||
profileHoverMarker.bindTooltip(point.label, {
|
||||
permanent: false,
|
||||
direction: 'top'
|
||||
}).openTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
function setGrid(gridJson) {
|
||||
if (!init()) {
|
||||
return;
|
||||
}
|
||||
gridLayer.clearLayers();
|
||||
|
||||
const cells = JSON.parse(gridJson);
|
||||
jsLog('setGrid cells=' + cells.length);
|
||||
|
||||
cells.forEach(cell => {
|
||||
const rectangle = L.rectangle(
|
||||
[
|
||||
[cell.southLat, cell.westLon],
|
||||
[cell.northLat, cell.eastLon]
|
||||
],
|
||||
{
|
||||
pane: 'gridPane',
|
||||
color: gridLineColor(),
|
||||
opacity: gridLineOpacity(),
|
||||
weight: 1.4,
|
||||
fillOpacity: 0.0,
|
||||
interactive: false
|
||||
}
|
||||
);
|
||||
|
||||
rectangle.addTo(gridLayer);
|
||||
|
||||
if (cell.showLabel) {
|
||||
const centerLat = (cell.southLat + cell.northLat) / 2.0;
|
||||
const centerLon = (cell.westLon + cell.eastLon) / 2.0;
|
||||
const labelFontPx = cell.labelFontPx || 12;
|
||||
|
||||
L.marker([centerLat, centerLon], {
|
||||
pane: 'gridLabelPane',
|
||||
interactive: false,
|
||||
keyboard: false,
|
||||
icon: L.divIcon({
|
||||
className: 'maidenhead-grid-label-wrapper',
|
||||
html: '<div class="maidenhead-grid-label" style="font-size:' + labelFontPx + 'px;">' + escapeHtml(cell.locatorLabel) + '</div>',
|
||||
iconSize: [1, 1],
|
||||
iconAnchor: [0, 0]
|
||||
})
|
||||
}).addTo(gridLayer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function focusCallsignRaw(callSignRaw) {
|
||||
if (!map || !callSignRaw) {
|
||||
return;
|
||||
}
|
||||
|
||||
const marker = markersByCallsignRaw[callSignRaw];
|
||||
if (!marker) {
|
||||
jsLog('focusCallsignRaw skipped for ' + callSignRaw);
|
||||
return;
|
||||
}
|
||||
|
||||
jsLog('focusCallsignRaw ' + callSignRaw);
|
||||
|
||||
map.panTo(marker.getLatLng(), {
|
||||
animate: false
|
||||
});
|
||||
|
||||
notifyViewport();
|
||||
}
|
||||
|
||||
|
||||
|
||||
return {
|
||||
init: init,
|
||||
invalidateSize: invalidateSize,
|
||||
resize: resize,
|
||||
zoomIn: zoomIn,
|
||||
zoomOut: zoomOut,
|
||||
inspectPoint: inspectPoint,
|
||||
getViewportState: getViewportState,
|
||||
setHome: setHome,
|
||||
setStations: setStations,
|
||||
setBeam: setBeam,
|
||||
setConnection: setConnection,
|
||||
setProfileHoverPoint: setProfileHoverPoint,
|
||||
setGrid: setGrid,
|
||||
focusCallsignRaw: focusCallsignRaw,
|
||||
setTheme: setTheme
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""".replace("__TILE_PROXY_PORT__", String.valueOf(tileProxyPort));
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
/**
|
||||
* Placeholder terrain provider.
|
||||
*/
|
||||
public final class NoOpTerrainProfileProvider implements TerrainProfileProvider {
|
||||
|
||||
@Override
|
||||
public TerrainProfileData loadProfile(TerrainProfileRequest request) {
|
||||
return TerrainProfileData.empty("No terrain provider");
|
||||
}
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import kst4contest.ApplicationConstants;
|
||||
import kst4contest.utils.ApplicationFileUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Small helper service for preparing and filling the local offline DEM directory.
|
||||
*
|
||||
* <p>This is intentionally the first practical step before adding a real network
|
||||
* downloader:
|
||||
* <ul>
|
||||
* <li>create a known default Copernicus DEM root directory below .praktiKST</li>
|
||||
* <li>copy manually selected local *_DEM.tif files into that directory</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The active terrain provider already scans the configured root directory
|
||||
* recursively. Therefore it is sufficient to import valid Copernicus DGED
|
||||
* GeoTIFF files into one dedicated folder tree.</p>
|
||||
*/
|
||||
public final class OfflineDemImportService {
|
||||
|
||||
/**
|
||||
* Default relative DEM directory below the application's hidden home folder.
|
||||
*/
|
||||
public static final String DEFAULT_RELATIVE_COPERNICUS_ROOT_DIRECTORY = "dem/copernicus_glo30";
|
||||
|
||||
/**
|
||||
* Resolves the default local Copernicus DEM root directory below .praktiKST.
|
||||
*
|
||||
* @return default DEM root directory path
|
||||
*/
|
||||
public Path resolveDefaultCopernicusRootDirectory() {
|
||||
return Path.of(
|
||||
ApplicationFileUtils.getFilePath(
|
||||
ApplicationConstants.APPLICATION_NAME,
|
||||
DEFAULT_RELATIVE_COPERNICUS_ROOT_DIRECTORY
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the default local Copernicus DEM root directory exists.
|
||||
*
|
||||
* @return preparation result including the effective target directory
|
||||
*/
|
||||
public ImportResult ensureDefaultCopernicusRootDirectory() {
|
||||
Path targetRootDirectory = resolveDefaultCopernicusRootDirectory();
|
||||
return ensureTargetDirectoryExists(targetRootDirectory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports manually selected Copernicus DGED GeoTIFF tiles into the configured
|
||||
* DEM root directory.
|
||||
*
|
||||
* <p>If the configured DEM root directory is blank, the default directory
|
||||
* below .praktiKST is used automatically.</p>
|
||||
*
|
||||
* @param selectedFiles selected local files from the file chooser
|
||||
* @param configuredDemRootDirectory current configured DEM root directory text
|
||||
* @return import result with counts and a user-friendly summary message
|
||||
*/
|
||||
public ImportResult importTiles(List<File> selectedFiles, String configuredDemRootDirectory) {
|
||||
Path targetRootDirectory = resolveEffectiveTargetRootDirectory(configuredDemRootDirectory);
|
||||
|
||||
ImportResult directoryPreparationResult = ensureTargetDirectoryExists(targetRootDirectory);
|
||||
if (!directoryPreparationResult.success()) {
|
||||
return directoryPreparationResult;
|
||||
}
|
||||
|
||||
if (selectedFiles == null || selectedFiles.isEmpty()) {
|
||||
return new ImportResult(
|
||||
targetRootDirectory,
|
||||
true,
|
||||
0,
|
||||
0,
|
||||
List.of(),
|
||||
"No files were selected for import."
|
||||
);
|
||||
}
|
||||
|
||||
int importedFileCount = 0;
|
||||
int skippedFileCount = 0;
|
||||
List<String> skippedFilenames = new ArrayList<>();
|
||||
|
||||
for (File selectedFile : selectedFiles) {
|
||||
if (selectedFile == null || !selectedFile.isFile() || !selectedFile.canRead()) {
|
||||
skippedFileCount++;
|
||||
skippedFilenames.add(selectedFile == null ? "<null>" : selectedFile.getName());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!OfflineDemManager.isSupportedCopernicusGlo30DemFilename(selectedFile.getName())) {
|
||||
skippedFileCount++;
|
||||
skippedFilenames.add(selectedFile.getName());
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
Files.copy(
|
||||
selectedFile.toPath(),
|
||||
targetRootDirectory.resolve(selectedFile.getName()),
|
||||
StandardCopyOption.REPLACE_EXISTING
|
||||
);
|
||||
importedFileCount++;
|
||||
} catch (Exception exception) {
|
||||
skippedFileCount++;
|
||||
skippedFilenames.add(selectedFile.getName());
|
||||
}
|
||||
}
|
||||
|
||||
StringBuilder message = new StringBuilder();
|
||||
message.append(String.format(
|
||||
Locale.US,
|
||||
"Imported %d DEM tile(s) into:%n%s",
|
||||
importedFileCount,
|
||||
targetRootDirectory.toAbsolutePath()
|
||||
));
|
||||
|
||||
if (skippedFileCount > 0) {
|
||||
message.append(String.format(
|
||||
Locale.US,
|
||||
"%n%nSkipped %d file(s) because they were unreadable or did not match the supported Copernicus *_DEM.tif naming scheme.",
|
||||
skippedFileCount
|
||||
));
|
||||
|
||||
int previewCount = Math.min(8, skippedFilenames.size());
|
||||
if (previewCount > 0) {
|
||||
message.append(String.format(Locale.US, "%n%nSkipped examples:%n"));
|
||||
for (int i = 0; i < previewCount; i++) {
|
||||
message.append("- ").append(skippedFilenames.get(i)).append(System.lineSeparator());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new ImportResult(
|
||||
targetRootDirectory,
|
||||
true,
|
||||
importedFileCount,
|
||||
skippedFileCount,
|
||||
List.copyOf(skippedFilenames),
|
||||
message.toString().trim()
|
||||
);
|
||||
}
|
||||
|
||||
private Path resolveEffectiveTargetRootDirectory(String configuredDemRootDirectory) {
|
||||
if (configuredDemRootDirectory == null || configuredDemRootDirectory.isBlank()) {
|
||||
return resolveDefaultCopernicusRootDirectory();
|
||||
}
|
||||
|
||||
return Path.of(configuredDemRootDirectory.trim());
|
||||
}
|
||||
|
||||
private ImportResult ensureTargetDirectoryExists(Path targetRootDirectory) {
|
||||
if (targetRootDirectory == null) {
|
||||
return new ImportResult(
|
||||
null,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
List.of(),
|
||||
"DEM target directory is undefined."
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (Files.exists(targetRootDirectory) && !Files.isDirectory(targetRootDirectory)) {
|
||||
return new ImportResult(
|
||||
targetRootDirectory,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
List.of(),
|
||||
"Configured DEM root path exists but is not a directory:\n"
|
||||
+ targetRootDirectory.toAbsolutePath()
|
||||
);
|
||||
}
|
||||
|
||||
Files.createDirectories(targetRootDirectory);
|
||||
|
||||
return new ImportResult(
|
||||
targetRootDirectory,
|
||||
true,
|
||||
0,
|
||||
0,
|
||||
List.of(),
|
||||
"Using local Copernicus DEM directory:\n"
|
||||
+ targetRootDirectory.toAbsolutePath()
|
||||
+ "\n\nYou can now import extracted Copernicus *_DEM.tif tiles into this folder."
|
||||
);
|
||||
} catch (Exception exception) {
|
||||
return new ImportResult(
|
||||
targetRootDirectory,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
List.of(),
|
||||
"Could not create DEM root directory:\n"
|
||||
+ targetRootDirectory.toAbsolutePath()
|
||||
+ "\n\nReason: "
|
||||
+ exception.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable result of a DEM directory preparation or tile import action.
|
||||
*
|
||||
* @param targetRootDirectory effective DEM root directory
|
||||
* @param success true if the action succeeded
|
||||
* @param importedFileCount number of imported files
|
||||
* @param skippedFileCount number of skipped files
|
||||
* @param skippedFilenames skipped filenames for diagnostics
|
||||
* @param message user-friendly summary message
|
||||
*/
|
||||
public record ImportResult(
|
||||
Path targetRootDirectory,
|
||||
boolean success,
|
||||
int importedFileCount,
|
||||
int skippedFileCount,
|
||||
List<String> skippedFilenames,
|
||||
String message
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Small manager for offline DEM discovery and indexing.
|
||||
*
|
||||
* This class intentionally supports both intermediate API shapes that appeared
|
||||
* during development:
|
||||
*
|
||||
* - old style:
|
||||
* inspect(...) -> OfflineDemStatus
|
||||
*
|
||||
* - new style:
|
||||
* inspectAndIndex(...) -> OfflineDemIndex
|
||||
*
|
||||
* This keeps the current codebase compilable even if older helper/provider
|
||||
* classes are still present in the source tree.
|
||||
*/
|
||||
public final class OfflineDemManager {
|
||||
|
||||
/**
|
||||
* Matches official DGED DEM filenames like:
|
||||
* Copernicus_DSM_10_N50_00_E020_00_DEM.tif
|
||||
*/
|
||||
private static final Pattern COPERNICUS_GLO_30_DEM_FILE_PATTERN =
|
||||
Pattern.compile("(?i)^Copernicus_[A-Z]{3}_10_([NS])(\\d{2})_(\\d{2})_([EW])(\\d{3})_(\\d{2})_DEM\\.tif$");
|
||||
|
||||
/**
|
||||
* Returns true if the filename matches the currently supported Copernicus
|
||||
* GLO-30 DGED GeoTIFF tile naming scheme.
|
||||
*
|
||||
* <p>This helper is intentionally public so that UI/import helpers can
|
||||
* validate manually selected files before copying them into the DEM root.</p>
|
||||
*
|
||||
* @param filename candidate filename
|
||||
* @return true if the filename looks like a supported local DEM tile
|
||||
*/
|
||||
public static boolean isSupportedCopernicusGlo30DemFilename(String filename) {
|
||||
return filename != null
|
||||
&& COPERNICUS_GLO_30_DEM_FILE_PATTERN.matcher(filename).matches();
|
||||
}
|
||||
|
||||
private String lastIndexedRootDirectory = null;
|
||||
private DemDataset lastIndexedDataset = null;
|
||||
private OfflineDemIndex lastIndex = OfflineDemIndex.empty("Offline DEM root directory is not configured.");
|
||||
|
||||
/**
|
||||
* Newer API used by the active Copernicus provider.
|
||||
*/
|
||||
public synchronized OfflineDemIndex inspectAndIndex(String demRootDirectory, DemDataset dataset) {
|
||||
DemDataset effectiveDataset = dataset == null ? DemDataset.COPERNICUS_GLO_30 : dataset;
|
||||
|
||||
if (demRootDirectory == null || demRootDirectory.isBlank()) {
|
||||
lastIndexedRootDirectory = demRootDirectory;
|
||||
lastIndexedDataset = effectiveDataset;
|
||||
lastIndex = OfflineDemIndex.empty("Offline DEM root directory is not configured.");
|
||||
return lastIndex;
|
||||
}
|
||||
|
||||
String normalizedRootDirectory = demRootDirectory.trim();
|
||||
|
||||
if (normalizedRootDirectory.equals(lastIndexedRootDirectory) && effectiveDataset == lastIndexedDataset) {
|
||||
return lastIndex;
|
||||
}
|
||||
|
||||
lastIndexedRootDirectory = normalizedRootDirectory;
|
||||
lastIndexedDataset = effectiveDataset;
|
||||
lastIndex = buildIndex(normalizedRootDirectory, effectiveDataset);
|
||||
|
||||
return lastIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Older compatibility API still referenced by older intermediate provider code.
|
||||
*/
|
||||
public synchronized OfflineDemStatus inspect(String demRootDirectory, DemDataset dataset) {
|
||||
OfflineDemIndex index = inspectAndIndex(demRootDirectory, dataset);
|
||||
|
||||
return new OfflineDemStatus(
|
||||
index.dataset(),
|
||||
index.rootDirectory(),
|
||||
demRootDirectory != null && !demRootDirectory.isBlank(),
|
||||
index.usable(),
|
||||
index.message()
|
||||
);
|
||||
}
|
||||
|
||||
private OfflineDemIndex buildIndex(String demRootDirectory, DemDataset dataset) {
|
||||
Path rootPath = Paths.get(demRootDirectory);
|
||||
|
||||
if (!Files.exists(rootPath)) {
|
||||
return OfflineDemIndex.empty("Configured DEM root directory does not exist.");
|
||||
}
|
||||
|
||||
if (!Files.isDirectory(rootPath)) {
|
||||
return OfflineDemIndex.empty("Configured DEM root path is not a directory.");
|
||||
}
|
||||
|
||||
if (!Files.isReadable(rootPath)) {
|
||||
return OfflineDemIndex.empty("Configured DEM root directory is not readable.");
|
||||
}
|
||||
|
||||
Map<String, Path> tilePathByGeocellKey = new LinkedHashMap<>();
|
||||
|
||||
try (var pathStream = Files.walk(rootPath)) {
|
||||
pathStream
|
||||
.filter(Files::isRegularFile)
|
||||
.forEach(path -> tryRegisterTile(path, tilePathByGeocellKey));
|
||||
} catch (IOException exception) {
|
||||
return OfflineDemIndex.empty("Scanning DEM root directory failed: " + exception.getMessage());
|
||||
}
|
||||
|
||||
if (tilePathByGeocellKey.isEmpty()) {
|
||||
return new OfflineDemIndex(
|
||||
dataset,
|
||||
rootPath.toAbsolutePath().toString(),
|
||||
false,
|
||||
Collections.emptyMap(),
|
||||
"No local Copernicus GLO-30 DEM tiles were found below the configured root directory."
|
||||
);
|
||||
}
|
||||
|
||||
return new OfflineDemIndex(
|
||||
dataset,
|
||||
rootPath.toAbsolutePath().toString(),
|
||||
true,
|
||||
Map.copyOf(tilePathByGeocellKey),
|
||||
"Found " + tilePathByGeocellKey.size() + " local Copernicus GLO-30 DEM tiles."
|
||||
);
|
||||
}
|
||||
|
||||
private void tryRegisterTile(Path path, Map<String, Path> tilePathByGeocellKey) {
|
||||
String filename = path.getFileName().toString();
|
||||
Matcher matcher = COPERNICUS_GLO_30_DEM_FILE_PATTERN.matcher(filename);
|
||||
|
||||
if (!matcher.matches()) {
|
||||
return;
|
||||
}
|
||||
|
||||
int southDeg = signedDegrees(matcher.group(1), matcher.group(2), matcher.group(3));
|
||||
int westDeg = signedDegrees(matcher.group(4), matcher.group(5), matcher.group(6));
|
||||
|
||||
tilePathByGeocellKey.put(toGeocellKey(southDeg, westDeg), path.toAbsolutePath());
|
||||
}
|
||||
|
||||
private int signedDegrees(String hemisphereOrDirection, String integerPart, String decimalPart) {
|
||||
int sign = ("S".equalsIgnoreCase(hemisphereOrDirection) || "W".equalsIgnoreCase(hemisphereOrDirection)) ? -1 : 1;
|
||||
int degrees = Integer.parseInt(integerPart);
|
||||
|
||||
if (!"00".equals(decimalPart)) {
|
||||
// The current reader expects whole-degree LL-corner geocells.
|
||||
return sign * degrees;
|
||||
}
|
||||
|
||||
return sign * degrees;
|
||||
}
|
||||
|
||||
private String toGeocellKey(int southDeg, int westDeg) {
|
||||
return southDeg + ":" + westDeg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Newer indexed offline DEM state used by the active Copernicus provider.
|
||||
*/
|
||||
public record OfflineDemIndex(
|
||||
DemDataset dataset,
|
||||
String rootDirectory,
|
||||
boolean usable,
|
||||
Map<String, Path> tilePathByGeocellKey,
|
||||
String message
|
||||
) {
|
||||
|
||||
public static OfflineDemIndex empty(String message) {
|
||||
return new OfflineDemIndex(
|
||||
DemDataset.COPERNICUS_GLO_30,
|
||||
"",
|
||||
false,
|
||||
Collections.emptyMap(),
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
public Path findTilePath(double latitudeDeg, double longitudeDeg) {
|
||||
int southDeg = (int) Math.floor(latitudeDeg);
|
||||
int westDeg = (int) Math.floor(longitudeDeg);
|
||||
return tilePathByGeocellKey.get(southDeg + ":" + westDeg);
|
||||
}
|
||||
|
||||
public int availableTileCount() {
|
||||
return tilePathByGeocellKey.size();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Older compatibility status shape still referenced by older intermediate code.
|
||||
*/
|
||||
public record OfflineDemStatus(
|
||||
DemDataset dataset,
|
||||
String demRootDirectory,
|
||||
boolean configured,
|
||||
boolean usableRootDirectory,
|
||||
String message
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Compatibility offline-first provider.
|
||||
*
|
||||
* This class is currently not part of the active StationMap pipeline anymore,
|
||||
* but it is kept compilable so the source tree stays consistent while the
|
||||
* Copernicus provider is the primary offline implementation.
|
||||
*/
|
||||
public final class OfflineDemTerrainProfileProvider implements TerrainProfileProvider {
|
||||
|
||||
private final Supplier<String> demRootDirectorySupplier;
|
||||
private final Supplier<DemDataset> datasetSupplier;
|
||||
private final OfflineDemManager offlineDemManager;
|
||||
|
||||
public OfflineDemTerrainProfileProvider(Supplier<String> demRootDirectorySupplier,
|
||||
Supplier<DemDataset> datasetSupplier,
|
||||
OfflineDemManager offlineDemManager) {
|
||||
this.demRootDirectorySupplier = Objects.requireNonNull(demRootDirectorySupplier, "demRootDirectorySupplier");
|
||||
this.datasetSupplier = Objects.requireNonNull(datasetSupplier, "datasetSupplier");
|
||||
this.offlineDemManager = Objects.requireNonNull(offlineDemManager, "offlineDemManager");
|
||||
}
|
||||
|
||||
@Override
|
||||
public TerrainProfileData loadProfile(TerrainProfileRequest request) {
|
||||
DemDataset dataset = datasetSupplier.get();
|
||||
if (dataset == null) {
|
||||
dataset = DemDataset.COPERNICUS_GLO_30;
|
||||
}
|
||||
|
||||
OfflineDemManager.OfflineDemIndex index =
|
||||
offlineDemManager.inspectAndIndex(demRootDirectorySupplier.get(), dataset);
|
||||
|
||||
if (!index.usable()) {
|
||||
return TerrainProfileData.empty(dataset.displayName() + " offline DEM unavailable");
|
||||
}
|
||||
|
||||
return TerrainProfileData.empty(dataset.displayName() + " offline DEM indexed but not used by this compatibility provider");
|
||||
}
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Terrain provider using the Open-Meteo Elevation API.
|
||||
*
|
||||
* <p>Important API constraint: Open-Meteo accepts up to 100 coordinate pairs per
|
||||
* elevation request. Earlier KST4Contest profile calculations could ask for up
|
||||
* to 1201 samples, which forced many HTTP requests for one visible profile. This
|
||||
* provider intentionally clamps the online profile to one API request. The
|
||||
* offline Copernicus path can later keep using the denser local sample count.</p>
|
||||
*
|
||||
* <p>The provider keeps a small in-memory cache for the running session. This is
|
||||
* deliberately local and lightweight: it avoids repeated API calls when the user
|
||||
* re-selects the same station, but it does not replace the existing long-term
|
||||
* offline DEM/download architecture.</p>
|
||||
*/
|
||||
public final class OpenMeteoTerrainProfileProvider implements TerrainProfileProvider {
|
||||
|
||||
private static final String SOURCE_NAME = "Open-Meteo Copernicus GLO-90";
|
||||
private static final String DEFAULT_BASE_URL = "https://api.open-meteo.com/v1/elevation";
|
||||
|
||||
/**
|
||||
* Open-Meteo elevation requests accept up to 100 coordinate pairs. Keeping the
|
||||
* online provider at exactly one request per path makes limit behavior
|
||||
* predictable and avoids request bursts while clicking through stations.
|
||||
*/
|
||||
private static final int MAX_COORDINATES_PER_REQUEST = 100;
|
||||
private static final int MAX_ONLINE_PROFILE_SAMPLE_COUNT = MAX_COORDINATES_PER_REQUEST;
|
||||
private static final int MIN_ONLINE_PROFILE_SAMPLE_COUNT = 2;
|
||||
|
||||
private static final int MAX_CACHED_PROFILES = 256;
|
||||
|
||||
/**
|
||||
* The free tier allows many more calls per minute, but this soft limiter keeps
|
||||
* accidental click/refresh bursts polite and easier to diagnose.
|
||||
*/
|
||||
private static final Duration MINIMUM_REQUEST_INTERVAL = Duration.ofMillis(250);
|
||||
|
||||
private static final Pattern ELEVATION_ARRAY_PATTERN =
|
||||
Pattern.compile("\\\"elevation\\\"\\s*:\\s*\\[(.*?)]", Pattern.DOTALL);
|
||||
|
||||
private static final Pattern ERROR_REASON_PATTERN =
|
||||
Pattern.compile("\\\"reason\\\"\\s*:\\s*\\\"(.*?)\\\"", Pattern.DOTALL);
|
||||
|
||||
private final HttpClient httpClient;
|
||||
private final Duration requestTimeout;
|
||||
private final String baseUrl;
|
||||
|
||||
private final Map<RequestCacheKey, TerrainProfileData> profileCache =
|
||||
new LinkedHashMap<>(32, 0.75f, true) {
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<RequestCacheKey, TerrainProfileData> eldest) {
|
||||
return size() > MAX_CACHED_PROFILES;
|
||||
}
|
||||
};
|
||||
|
||||
private long lastRequestStartEpochMillis = 0L;
|
||||
|
||||
public OpenMeteoTerrainProfileProvider() {
|
||||
this(
|
||||
HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(4))
|
||||
.build(),
|
||||
Duration.ofSeconds(8),
|
||||
DEFAULT_BASE_URL
|
||||
);
|
||||
}
|
||||
|
||||
public OpenMeteoTerrainProfileProvider(HttpClient httpClient,
|
||||
Duration requestTimeout,
|
||||
String baseUrl) {
|
||||
this.httpClient = httpClient;
|
||||
this.requestTimeout = requestTimeout == null ? Duration.ofSeconds(8) : requestTimeout;
|
||||
this.baseUrl = (baseUrl == null || baseUrl.isBlank()) ? DEFAULT_BASE_URL : baseUrl.trim();
|
||||
}
|
||||
|
||||
@Override
|
||||
public TerrainProfileData loadProfile(TerrainProfileRequest request) {
|
||||
if (request == null || !request.hasUsableEndpoints() || request.requestedSampleCount() < 2) {
|
||||
return TerrainProfileData.empty(SOURCE_NAME);
|
||||
}
|
||||
|
||||
int sampleCount = resolveOnlineSampleCount(request.requestedSampleCount());
|
||||
RequestCacheKey cacheKey = RequestCacheKey.from(request, sampleCount);
|
||||
|
||||
TerrainProfileData cachedProfile = loadFromCache(cacheKey);
|
||||
if (cachedProfile != null) {
|
||||
return cachedProfile;
|
||||
}
|
||||
|
||||
List<SamplePoint> samplePoints = buildSamplePoints(request, sampleCount);
|
||||
TerrainProfileData profileData = fetchProfile(samplePoints);
|
||||
|
||||
if (profileData.hasUsableProfile()) {
|
||||
saveToCache(cacheKey, profileData);
|
||||
}
|
||||
|
||||
return profileData;
|
||||
}
|
||||
|
||||
private int resolveOnlineSampleCount(int requestedSampleCount) {
|
||||
return Math.max(
|
||||
MIN_ONLINE_PROFILE_SAMPLE_COUNT,
|
||||
Math.min(requestedSampleCount, MAX_ONLINE_PROFILE_SAMPLE_COUNT)
|
||||
);
|
||||
}
|
||||
|
||||
private TerrainProfileData loadFromCache(RequestCacheKey cacheKey) {
|
||||
synchronized (profileCache) {
|
||||
return profileCache.get(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveToCache(RequestCacheKey cacheKey, TerrainProfileData profileData) {
|
||||
synchronized (profileCache) {
|
||||
profileCache.put(cacheKey, profileData);
|
||||
}
|
||||
}
|
||||
|
||||
private TerrainProfileData fetchProfile(List<SamplePoint> samplePoints) {
|
||||
try {
|
||||
List<Double> elevations = fetchElevations(samplePoints);
|
||||
|
||||
if (elevations.size() != samplePoints.size()) {
|
||||
return TerrainProfileData.empty(SOURCE_NAME + " returned an incomplete elevation array");
|
||||
}
|
||||
|
||||
List<PathProfilePoint> profilePoints = new ArrayList<>(samplePoints.size());
|
||||
|
||||
for (int i = 0; i < samplePoints.size(); i++) {
|
||||
SamplePoint samplePoint = samplePoints.get(i);
|
||||
double elevationMeters = elevations.get(i);
|
||||
|
||||
if (!Double.isFinite(elevationMeters)) {
|
||||
return TerrainProfileData.empty(SOURCE_NAME + " returned no-data elevation samples");
|
||||
}
|
||||
|
||||
profilePoints.add(new PathProfilePoint(
|
||||
samplePoint.distanceKm(),
|
||||
samplePoint.latitudeDeg(),
|
||||
samplePoint.longitudeDeg(),
|
||||
elevationMeters
|
||||
));
|
||||
}
|
||||
|
||||
return new TerrainProfileData(profilePoints, SOURCE_NAME, false);
|
||||
} catch (Exception exception) {
|
||||
return TerrainProfileData.empty(SOURCE_NAME + " failed: " + buildShortFailureMessage(exception));
|
||||
}
|
||||
}
|
||||
|
||||
private List<SamplePoint> buildSamplePoints(TerrainProfileRequest request, int sampleCount) {
|
||||
List<SamplePoint> points = new ArrayList<>(sampleCount);
|
||||
|
||||
for (int i = 0; i < sampleCount; i++) {
|
||||
double t = sampleCount == 1 ? 0.0 : (double) i / (double) (sampleCount - 1);
|
||||
|
||||
PathGeometryUtils.GeoPoint interpolatedPoint =
|
||||
PathGeometryUtils.interpolateGreatCirclePoint(
|
||||
request.fromLatitudeDeg(),
|
||||
request.fromLongitudeDeg(),
|
||||
request.toLatitudeDeg(),
|
||||
request.toLongitudeDeg(),
|
||||
t
|
||||
);
|
||||
|
||||
double latitudeDeg = interpolatedPoint.latitudeDeg();
|
||||
double longitudeDeg = interpolatedPoint.longitudeDeg();
|
||||
|
||||
if (!Double.isFinite(latitudeDeg) || !Double.isFinite(longitudeDeg)) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
points.add(new SamplePoint(
|
||||
request.totalDistanceKm() * t,
|
||||
latitudeDeg,
|
||||
longitudeDeg
|
||||
));
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
private List<Double> fetchElevations(List<SamplePoint> samplePoints) throws Exception {
|
||||
if (samplePoints == null || samplePoints.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
if (samplePoints.size() > MAX_COORDINATES_PER_REQUEST) {
|
||||
throw new IllegalArgumentException("Open-Meteo request would contain more than 100 coordinates");
|
||||
}
|
||||
|
||||
waitForRateLimitSlot();
|
||||
|
||||
String latitudeParameter = samplePoints.stream()
|
||||
.map(point -> formatCoordinate(point.latitudeDeg()))
|
||||
.collect(Collectors.joining(","));
|
||||
|
||||
String longitudeParameter = samplePoints.stream()
|
||||
.map(point -> formatCoordinate(point.longitudeDeg()))
|
||||
.collect(Collectors.joining(","));
|
||||
|
||||
String requestUrl = baseUrl
|
||||
+ "?latitude=" + latitudeParameter
|
||||
+ "&longitude=" + longitudeParameter;
|
||||
|
||||
HttpRequest httpRequest = HttpRequest.newBuilder(URI.create(requestUrl))
|
||||
.timeout(requestTimeout)
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "KST4Contest path-analysis")
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() / 100 != 2) {
|
||||
String reason = extractErrorReason(response.body());
|
||||
throw new IOException("HTTP " + response.statusCode() + (reason.isBlank() ? "" : " - " + reason));
|
||||
}
|
||||
|
||||
return parseElevationArray(response.body());
|
||||
}
|
||||
|
||||
private synchronized void waitForRateLimitSlot() throws InterruptedException {
|
||||
long now = System.currentTimeMillis();
|
||||
long waitMillis = MINIMUM_REQUEST_INTERVAL.toMillis() - (now - lastRequestStartEpochMillis);
|
||||
|
||||
if (waitMillis > 0L) {
|
||||
Thread.sleep(waitMillis);
|
||||
}
|
||||
|
||||
lastRequestStartEpochMillis = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
private List<Double> parseElevationArray(String responseBody) throws IOException {
|
||||
if (responseBody == null || responseBody.isBlank()) {
|
||||
throw new IOException("Empty response body");
|
||||
}
|
||||
|
||||
Matcher elevationArrayMatcher = ELEVATION_ARRAY_PATTERN.matcher(responseBody);
|
||||
if (!elevationArrayMatcher.find()) {
|
||||
String reason = extractErrorReason(responseBody);
|
||||
throw new IOException("No elevation array found" + (reason.isBlank() ? "" : ": " + reason));
|
||||
}
|
||||
|
||||
String arrayContent = elevationArrayMatcher.group(1).trim();
|
||||
if (arrayContent.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
String[] parts = arrayContent.split("\\s*,\\s*");
|
||||
List<Double> elevations = new ArrayList<>(parts.length);
|
||||
|
||||
for (String part : parts) {
|
||||
String value = part.trim();
|
||||
|
||||
if (value.isBlank()
|
||||
|| "null".equalsIgnoreCase(value)
|
||||
|| "nan".equalsIgnoreCase(value)) {
|
||||
elevations.add(Double.NaN);
|
||||
} else {
|
||||
elevations.add(Double.parseDouble(value));
|
||||
}
|
||||
}
|
||||
|
||||
return elevations;
|
||||
}
|
||||
|
||||
private String extractErrorReason(String responseBody) {
|
||||
Matcher matcher = ERROR_REASON_PATTERN.matcher(responseBody == null ? "" : responseBody);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String buildShortFailureMessage(Exception exception) {
|
||||
if (exception == null) {
|
||||
return "unknown error";
|
||||
}
|
||||
|
||||
Throwable current = exception;
|
||||
while (current.getCause() != null) {
|
||||
current = current.getCause();
|
||||
}
|
||||
|
||||
String message = current.getMessage();
|
||||
if (message == null || message.isBlank()) {
|
||||
message = exception.getMessage();
|
||||
}
|
||||
|
||||
return message == null || message.isBlank()
|
||||
? current.getClass().getSimpleName()
|
||||
: message;
|
||||
}
|
||||
|
||||
private String formatCoordinate(double value) {
|
||||
return String.format(Locale.US, "%.6f", value);
|
||||
}
|
||||
|
||||
private record SamplePoint(double distanceKm, double latitudeDeg, double longitudeDeg) {
|
||||
}
|
||||
|
||||
private record RequestCacheKey(
|
||||
String fromLatitudeDeg,
|
||||
String fromLongitudeDeg,
|
||||
String toLatitudeDeg,
|
||||
String toLongitudeDeg,
|
||||
int sampleCount
|
||||
) {
|
||||
private static RequestCacheKey from(TerrainProfileRequest request, int sampleCount) {
|
||||
return new RequestCacheKey(
|
||||
normalizeCoordinate(request.fromLatitudeDeg()),
|
||||
normalizeCoordinate(request.fromLongitudeDeg()),
|
||||
normalizeCoordinate(request.toLatitudeDeg()),
|
||||
normalizeCoordinate(request.toLongitudeDeg()),
|
||||
sampleCount
|
||||
);
|
||||
}
|
||||
|
||||
private static String normalizeCoordinate(double value) {
|
||||
return String.format(Locale.US, "%.6f", value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Immutable input for one path analysis run.
|
||||
*/
|
||||
public record PathAnalysisRequest(
|
||||
String fromLocator6,
|
||||
double fromLatitudeDeg,
|
||||
double fromLongitudeDeg,
|
||||
String toCallsignRaw,
|
||||
String toLocator6,
|
||||
double toLatitudeDeg,
|
||||
double toLongitudeDeg,
|
||||
double frequencyMHz,
|
||||
double homeAntennaHeightMeters,
|
||||
double targetAntennaHeightMeters,
|
||||
double effectiveEarthRadiusFactor,
|
||||
PathLinkBudgetSettings linkBudgetSettings
|
||||
) {
|
||||
|
||||
public PathAnalysisRequest(String fromLocator6,
|
||||
double fromLatitudeDeg,
|
||||
double fromLongitudeDeg,
|
||||
String toCallsignRaw,
|
||||
String toLocator6,
|
||||
double toLatitudeDeg,
|
||||
double toLongitudeDeg,
|
||||
double frequencyMHz,
|
||||
double homeAntennaHeightMeters,
|
||||
double targetAntennaHeightMeters) {
|
||||
this(
|
||||
fromLocator6,
|
||||
fromLatitudeDeg,
|
||||
fromLongitudeDeg,
|
||||
toCallsignRaw,
|
||||
toLocator6,
|
||||
toLatitudeDeg,
|
||||
toLongitudeDeg,
|
||||
frequencyMHz,
|
||||
homeAntennaHeightMeters,
|
||||
targetAntennaHeightMeters,
|
||||
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
|
||||
PathLinkBudgetSettings.defaults()
|
||||
);
|
||||
}
|
||||
|
||||
public PathAnalysisRequest {
|
||||
fromLocator6 = normalizeLocator(fromLocator6);
|
||||
toCallsignRaw = normalizeUpper(toCallsignRaw);
|
||||
toLocator6 = normalizeLocator(toLocator6);
|
||||
|
||||
if (!Double.isFinite(frequencyMHz) || frequencyMHz <= 0.0) {
|
||||
frequencyMHz = Double.NaN;
|
||||
}
|
||||
|
||||
if (!Double.isFinite(homeAntennaHeightMeters) || homeAntennaHeightMeters < 0.0) {
|
||||
homeAntennaHeightMeters = Double.NaN;
|
||||
}
|
||||
|
||||
if (!Double.isFinite(targetAntennaHeightMeters) || targetAntennaHeightMeters < 0.0) {
|
||||
targetAntennaHeightMeters = Double.NaN;
|
||||
}
|
||||
|
||||
PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor);
|
||||
|
||||
linkBudgetSettings = linkBudgetSettings == null
|
||||
? PathLinkBudgetSettings.defaults()
|
||||
: linkBudgetSettings;
|
||||
}
|
||||
|
||||
private static String normalizeLocator(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static String normalizeUpper(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
public boolean hasUsableHome() {
|
||||
return fromLocator6.length() == 6
|
||||
&& Double.isFinite(fromLatitudeDeg)
|
||||
&& Double.isFinite(fromLongitudeDeg);
|
||||
}
|
||||
|
||||
public boolean hasUsableTarget() {
|
||||
return toLocator6.length() == 6
|
||||
&& Double.isFinite(toLatitudeDeg)
|
||||
&& Double.isFinite(toLongitudeDeg);
|
||||
}
|
||||
|
||||
public boolean hasUsableFrequency() {
|
||||
return Double.isFinite(frequencyMHz) && frequencyMHz > 0.0;
|
||||
}
|
||||
}
|
||||
@@ -1,710 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Immutable UI-facing result for one path analysis run.
|
||||
*
|
||||
* <p>This result intentionally combines:
|
||||
* <ul>
|
||||
* <li>basic path metadata used by the detail panel</li>
|
||||
* <li>the enriched path profile used by the preview chart</li>
|
||||
* <li>formatted state helpers for concise UI binding</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The chart and the numeric evaluation both consume the same already
|
||||
* computed profile data to avoid drift between visual and numeric output.</p>
|
||||
*/
|
||||
public record PathAnalysisResult(
|
||||
String analysisMode,
|
||||
String fromLocator6,
|
||||
String toLocator6,
|
||||
String toCallsignRaw,
|
||||
double distanceKm,
|
||||
double bearingDeg,
|
||||
double homeAntennaHeightMeters,
|
||||
double targetAntennaHeightMeters,
|
||||
double analysisFrequencyMHz,
|
||||
boolean lineOfSightClear,
|
||||
boolean fresnelClear,
|
||||
double minimumLineOfSightClearanceMeters,
|
||||
double minimumLowerFresnelClearanceMeters,
|
||||
double worstFresnelIntrusionMeters,
|
||||
double worstFresnelIntrusionRatio,
|
||||
double worstFresnelDistanceKm,
|
||||
int worstFresnelSampleIndex,
|
||||
double effectiveEarthRadiusFactor,
|
||||
PathHorizonSummary horizonSummary,
|
||||
PathObstructionSummary obstructionSummary,
|
||||
PathPropagationAssessment propagationAssessment,
|
||||
String statusText,
|
||||
List<PathProfilePoint> profilePoints,
|
||||
PathLinkBudgetSummary linkBudgetSummary
|
||||
) {
|
||||
|
||||
public PathAnalysisResult {
|
||||
analysisMode = normalizeText(analysisMode);
|
||||
fromLocator6 = normalizeUpper(fromLocator6);
|
||||
toLocator6 = normalizeUpper(toLocator6);
|
||||
toCallsignRaw = normalizeUpper(toCallsignRaw);
|
||||
statusText = normalizeText(statusText);
|
||||
profilePoints = profilePoints == null ? List.of() : List.copyOf(profilePoints);
|
||||
effectiveEarthRadiusFactor =
|
||||
PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor);
|
||||
|
||||
horizonSummary = horizonSummary == null ? PathHorizonSummary.empty() : horizonSummary;
|
||||
obstructionSummary = obstructionSummary == null ? PathObstructionSummary.empty() : obstructionSummary;
|
||||
|
||||
propagationAssessment = propagationAssessment == null
|
||||
? PathPropagationAssessment.unknown()
|
||||
: propagationAssessment;
|
||||
|
||||
linkBudgetSummary = linkBudgetSummary == null
|
||||
? PathLinkBudgetSummary.empty()
|
||||
: linkBudgetSummary;
|
||||
}
|
||||
|
||||
public PathAnalysisResult(String analysisMode,
|
||||
String fromLocator6,
|
||||
String toLocator6,
|
||||
String toCallsignRaw,
|
||||
double distanceKm,
|
||||
double bearingDeg,
|
||||
double homeAntennaHeightMeters,
|
||||
double targetAntennaHeightMeters,
|
||||
double analysisFrequencyMHz,
|
||||
boolean lineOfSightClear,
|
||||
boolean fresnelClear,
|
||||
double minimumLineOfSightClearanceMeters,
|
||||
double minimumLowerFresnelClearanceMeters,
|
||||
double worstFresnelIntrusionMeters,
|
||||
double worstFresnelIntrusionRatio,
|
||||
double worstFresnelDistanceKm,
|
||||
int worstFresnelSampleIndex,
|
||||
String statusText,
|
||||
List<PathProfilePoint> profilePoints) {
|
||||
this(
|
||||
analysisMode,
|
||||
fromLocator6,
|
||||
toLocator6,
|
||||
toCallsignRaw,
|
||||
distanceKm,
|
||||
bearingDeg,
|
||||
homeAntennaHeightMeters,
|
||||
targetAntennaHeightMeters,
|
||||
analysisFrequencyMHz,
|
||||
lineOfSightClear,
|
||||
fresnelClear,
|
||||
minimumLineOfSightClearanceMeters,
|
||||
minimumLowerFresnelClearanceMeters,
|
||||
worstFresnelIntrusionMeters,
|
||||
worstFresnelIntrusionRatio,
|
||||
worstFresnelDistanceKm,
|
||||
worstFresnelSampleIndex,
|
||||
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
|
||||
PathHorizonSummary.empty(),
|
||||
PathObstructionSummary.empty(),
|
||||
PathPropagationAssessment.unknown(),
|
||||
statusText,
|
||||
profilePoints,
|
||||
PathLinkBudgetSummary.empty()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a placeholder result shown while no target station is selected.
|
||||
*
|
||||
* @param fromLocator6 normalized own locator if known
|
||||
* @return waiting result
|
||||
*/
|
||||
public static PathAnalysisResult waitingForSelection(String fromLocator6) {
|
||||
return new PathAnalysisResult(
|
||||
"Waiting",
|
||||
fromLocator6,
|
||||
"",
|
||||
"",
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
false,
|
||||
false,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
-1,
|
||||
"Select a station to prepare path and terrain analysis.",
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a placeholder result shown while analysis is currently running.
|
||||
*
|
||||
* @param fromLocator6 own locator
|
||||
* @param toLocator6 target locator if known
|
||||
* @param toCallsignRaw target callsign if known
|
||||
* @return loading result
|
||||
*/
|
||||
public static PathAnalysisResult loading(String fromLocator6,
|
||||
String toLocator6,
|
||||
String toCallsignRaw) {
|
||||
return new PathAnalysisResult(
|
||||
"Loading",
|
||||
fromLocator6,
|
||||
toLocator6,
|
||||
toCallsignRaw,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
false,
|
||||
false,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
-1,
|
||||
"Loading terrain/profile data and evaluating the path.",
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a placeholder result shown when the own locator is not usable.
|
||||
*
|
||||
* @param fromLocator6 own locator candidate
|
||||
* @param toLocator6 target locator candidate
|
||||
* @return waiting result with home-locator warning
|
||||
*/
|
||||
public static PathAnalysisResult waitingForValidHomeLocator(String fromLocator6,
|
||||
String toLocator6) {
|
||||
return new PathAnalysisResult(
|
||||
"Waiting",
|
||||
fromLocator6,
|
||||
toLocator6,
|
||||
"",
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
false,
|
||||
false,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
-1,
|
||||
"Own locator is missing or invalid. Path analysis cannot start yet.",
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a placeholder result shown when the selected target has no usable position.
|
||||
*
|
||||
* @param fromLocator6 own locator
|
||||
* @param toLocator6 target locator candidate
|
||||
* @return waiting result with target-position warning
|
||||
*/
|
||||
public static PathAnalysisResult waitingForValidTarget(String fromLocator6,
|
||||
String toLocator6) {
|
||||
return new PathAnalysisResult(
|
||||
"Waiting",
|
||||
fromLocator6,
|
||||
toLocator6,
|
||||
"",
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
false,
|
||||
false,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
-1,
|
||||
"Selected station has no usable locator/position for path analysis.",
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a finished result without a usable terrain profile.
|
||||
*
|
||||
* @param analysisMode source/mode text
|
||||
* @param fromLocator6 own locator
|
||||
* @param toLocator6 target locator
|
||||
* @param toCallsignRaw target callsign
|
||||
* @param distanceKm path distance in kilometers
|
||||
* @param bearingDeg initial bearing in degrees
|
||||
* @param homeAntennaHeightMeters own antenna height in meters AGL
|
||||
* @param targetAntennaHeightMeters target antenna height in meters AGL
|
||||
* @param analysisFrequencyMHz analysis frequency in MHz
|
||||
* @param statusText human-readable status text
|
||||
* @return finished result without profile points
|
||||
*/
|
||||
public static PathAnalysisResult noProfile(String analysisMode,
|
||||
String fromLocator6,
|
||||
String toLocator6,
|
||||
String toCallsignRaw,
|
||||
double distanceKm,
|
||||
double bearingDeg,
|
||||
double homeAntennaHeightMeters,
|
||||
double targetAntennaHeightMeters,
|
||||
double analysisFrequencyMHz,
|
||||
String statusText) {
|
||||
return new PathAnalysisResult(
|
||||
analysisMode,
|
||||
fromLocator6,
|
||||
toLocator6,
|
||||
toCallsignRaw,
|
||||
distanceKm,
|
||||
bearingDeg,
|
||||
homeAntennaHeightMeters,
|
||||
targetAntennaHeightMeters,
|
||||
analysisFrequencyMHz,
|
||||
false,
|
||||
false,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
-1,
|
||||
statusText,
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fully evaluated result.
|
||||
*
|
||||
* @param analysisMode source/mode text
|
||||
* @param fromLocator6 own locator
|
||||
* @param toLocator6 target locator
|
||||
* @param toCallsignRaw target callsign
|
||||
* @param distanceKm path distance in kilometers
|
||||
* @param bearingDeg initial bearing in degrees
|
||||
* @param homeAntennaHeightMeters own antenna height in meters AGL
|
||||
* @param targetAntennaHeightMeters target antenna height in meters AGL
|
||||
* @param analysisFrequencyMHz analysis frequency in MHz
|
||||
* @param lineOfSightClear true if the direct path is clear
|
||||
* @param fresnelClear true if the lower Fresnel hull is not intruded
|
||||
* @param minimumLineOfSightClearanceMeters minimum LOS clearance in meters
|
||||
* @param minimumLowerFresnelClearanceMeters minimum lower Fresnel clearance in meters
|
||||
* @param worstFresnelIntrusionMeters maximum lower Fresnel intrusion in meters
|
||||
* @param worstFresnelIntrusionRatio intrusion divided by local Fresnel radius
|
||||
* @param worstFresnelDistanceKm distance of the worst intrusion from TX in kilometers
|
||||
* @param worstFresnelSampleIndex sample index of the worst intrusion
|
||||
* @param statusText human-readable status text
|
||||
* @param profilePoints enriched profile points
|
||||
* @return completed path analysis result
|
||||
*/
|
||||
public static PathAnalysisResult completed(String analysisMode,
|
||||
String fromLocator6,
|
||||
String toLocator6,
|
||||
String toCallsignRaw,
|
||||
double distanceKm,
|
||||
double bearingDeg,
|
||||
double homeAntennaHeightMeters,
|
||||
double targetAntennaHeightMeters,
|
||||
double analysisFrequencyMHz,
|
||||
boolean lineOfSightClear,
|
||||
boolean fresnelClear,
|
||||
double minimumLineOfSightClearanceMeters,
|
||||
double minimumLowerFresnelClearanceMeters,
|
||||
double worstFresnelIntrusionMeters,
|
||||
double worstFresnelIntrusionRatio,
|
||||
double worstFresnelDistanceKm,
|
||||
int worstFresnelSampleIndex,
|
||||
String statusText,
|
||||
List<PathProfilePoint> profilePoints) {
|
||||
return new PathAnalysisResult(
|
||||
analysisMode,
|
||||
fromLocator6,
|
||||
toLocator6,
|
||||
toCallsignRaw,
|
||||
distanceKm,
|
||||
bearingDeg,
|
||||
homeAntennaHeightMeters,
|
||||
targetAntennaHeightMeters,
|
||||
analysisFrequencyMHz,
|
||||
lineOfSightClear,
|
||||
fresnelClear,
|
||||
minimumLineOfSightClearanceMeters,
|
||||
minimumLowerFresnelClearanceMeters,
|
||||
worstFresnelIntrusionMeters,
|
||||
worstFresnelIntrusionRatio,
|
||||
worstFresnelDistanceKm,
|
||||
worstFresnelSampleIndex,
|
||||
statusText,
|
||||
profilePoints
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fully evaluated result including horizon/refraction metadata.
|
||||
*
|
||||
* @return completed path analysis result
|
||||
*/
|
||||
public static PathAnalysisResult completed(String analysisMode,
|
||||
String fromLocator6,
|
||||
String toLocator6,
|
||||
String toCallsignRaw,
|
||||
double distanceKm,
|
||||
double bearingDeg,
|
||||
double homeAntennaHeightMeters,
|
||||
double targetAntennaHeightMeters,
|
||||
double analysisFrequencyMHz,
|
||||
boolean lineOfSightClear,
|
||||
boolean fresnelClear,
|
||||
double minimumLineOfSightClearanceMeters,
|
||||
double minimumLowerFresnelClearanceMeters,
|
||||
double worstFresnelIntrusionMeters,
|
||||
double worstFresnelIntrusionRatio,
|
||||
double worstFresnelDistanceKm,
|
||||
int worstFresnelSampleIndex,
|
||||
double effectiveEarthRadiusFactor,
|
||||
PathHorizonSummary horizonSummary,
|
||||
PathObstructionSummary obstructionSummary,
|
||||
PathLinkBudgetSummary linkBudgetSummary,
|
||||
PathPropagationAssessment propagationAssessment,
|
||||
String statusText,
|
||||
List<PathProfilePoint> profilePoints) {
|
||||
return new PathAnalysisResult(
|
||||
analysisMode,
|
||||
fromLocator6,
|
||||
toLocator6,
|
||||
toCallsignRaw,
|
||||
distanceKm,
|
||||
bearingDeg,
|
||||
homeAntennaHeightMeters,
|
||||
targetAntennaHeightMeters,
|
||||
analysisFrequencyMHz,
|
||||
lineOfSightClear,
|
||||
fresnelClear,
|
||||
minimumLineOfSightClearanceMeters,
|
||||
minimumLowerFresnelClearanceMeters,
|
||||
worstFresnelIntrusionMeters,
|
||||
worstFresnelIntrusionRatio,
|
||||
worstFresnelDistanceKm,
|
||||
worstFresnelSampleIndex,
|
||||
effectiveEarthRadiusFactor,
|
||||
horizonSummary,
|
||||
obstructionSummary,
|
||||
propagationAssessment,
|
||||
statusText,
|
||||
profilePoints,
|
||||
linkBudgetSummary = linkBudgetSummary == null
|
||||
? PathLinkBudgetSummary.empty()
|
||||
: linkBudgetSummary
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the result contains a profile usable for charting/evaluation.
|
||||
*
|
||||
* @return true if at least two profile points are available
|
||||
*/
|
||||
public boolean hasUsableProfile() {
|
||||
return profilePoints.size() >= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the dominant obstruction / diffraction candidate text.
|
||||
*
|
||||
* @return obstruction summary text
|
||||
*/
|
||||
public String obstructionText() {
|
||||
return obstructionSummary == null
|
||||
? "-"
|
||||
: obstructionSummary.obstructionText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a short UI text for direct LOS state.
|
||||
*
|
||||
* @return LOS summary text
|
||||
*/
|
||||
public String losText() {
|
||||
if (!hasUsableProfile()) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return lineOfSightClear
|
||||
? String.format(Locale.US, "Geometric LOS clear (min %+,.1f m)", minimumLineOfSightClearanceMeters)
|
||||
: String.format(Locale.US, "Geometric LOS blocked (min %+,.1f m)", minimumLineOfSightClearanceMeters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a short UI text for the minimum LOS clearance.
|
||||
*
|
||||
* @return worst direct-path clearance text
|
||||
*/
|
||||
public String worstClearanceText() {
|
||||
if (!hasUsableProfile() || !Double.isFinite(minimumLineOfSightClearanceMeters)) {
|
||||
return "-";
|
||||
}
|
||||
return String.format(Locale.US, "%.1f m", minimumLineOfSightClearanceMeters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a short UI text for the analysis frequency.
|
||||
*
|
||||
* @return frequency text or "-"
|
||||
*/
|
||||
public String analysisFrequencyText() {
|
||||
if (!Double.isFinite(analysisFrequencyMHz) || analysisFrequencyMHz <= 0.0) {
|
||||
return "-";
|
||||
}
|
||||
return String.format(Locale.US, "%.3f MHz", analysisFrequencyMHz);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a short UI text for the Fresnel state.
|
||||
*
|
||||
* @return Fresnel summary text
|
||||
*/
|
||||
public String fresnelText() {
|
||||
if (!hasUsableProfile()) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
if (fresnelClear) {
|
||||
return String.format(
|
||||
Locale.US,
|
||||
"1st Fresnel clear (min %+,.1f m)",
|
||||
minimumLowerFresnelClearanceMeters
|
||||
);
|
||||
}
|
||||
|
||||
return String.format(
|
||||
Locale.US,
|
||||
"1st Fresnel intruded (worst %.1f m)",
|
||||
worstFresnelIntrusionMeters
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a short UI text for the worst Fresnel intrusion.
|
||||
*
|
||||
* @return worst Fresnel intrusion text
|
||||
*/
|
||||
public String worstFresnelClearanceText() {
|
||||
if (!hasUsableProfile()) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
if (Double.isFinite(worstFresnelIntrusionMeters) && worstFresnelIntrusionMeters > 0.0) {
|
||||
return String.format(
|
||||
Locale.US,
|
||||
"%.1f m @ %.1f km (%.0f%%)",
|
||||
worstFresnelIntrusionMeters,
|
||||
worstFresnelDistanceKm,
|
||||
worstFresnelIntrusionRatio * 100.0
|
||||
);
|
||||
}
|
||||
|
||||
if (Double.isFinite(minimumLowerFresnelClearanceMeters)) {
|
||||
return String.format(
|
||||
Locale.US,
|
||||
"No intrusion (min %.1f m)",
|
||||
minimumLowerFresnelClearanceMeters
|
||||
);
|
||||
}
|
||||
|
||||
return "-";
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the effective Earth radius / refraction model text used by this
|
||||
* analysis result.
|
||||
*
|
||||
* @return k-factor text
|
||||
*/
|
||||
public String effectiveEarthRadiusText() {
|
||||
return horizonSummary == null
|
||||
? "-"
|
||||
: horizonSummary.effectiveEarthRadiusText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a concise simple radio-horizon summary for both endpoint antenna
|
||||
* heights.
|
||||
*
|
||||
* @return radio-horizon summary
|
||||
*/
|
||||
public String radioHorizonText() {
|
||||
return horizonSummary == null
|
||||
? "-"
|
||||
: horizonSummary.simpleRadioHorizonText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the actual terrain horizon derived from the current terrain profile.
|
||||
*
|
||||
* @return terrain-horizon summary
|
||||
*/
|
||||
public String terrainHorizonText() {
|
||||
return horizonSummary == null
|
||||
? "-"
|
||||
: horizonSummary.terrainHorizonText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the distance as formatted text.
|
||||
*
|
||||
* @return distance text or "-"
|
||||
*/
|
||||
public String distanceText() {
|
||||
if (!Double.isFinite(distanceKm) || distanceKm < 0.0) {
|
||||
return "-";
|
||||
}
|
||||
return String.format(Locale.US, "%.1f km", distanceKm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the initial path bearing as formatted text.
|
||||
*
|
||||
* @return bearing text or "-"
|
||||
*/
|
||||
public String bearingText() {
|
||||
if (!Double.isFinite(bearingDeg)) {
|
||||
return "-";
|
||||
}
|
||||
return String.format(Locale.US, "%.0f°", bearingDeg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a concise endpoint summary with ground level and configured antenna heights.
|
||||
*
|
||||
* @return endpoint/antenna summary text
|
||||
*/
|
||||
public String endpointSummaryText() {
|
||||
PathProfilePoint firstPoint = firstProfilePoint();
|
||||
PathProfilePoint lastPoint = lastProfilePoint();
|
||||
|
||||
boolean hasHomeGround = firstPoint != null && Double.isFinite(firstPoint.elevationMeters());
|
||||
boolean hasTargetGround = lastPoint != null && Double.isFinite(lastPoint.elevationMeters());
|
||||
|
||||
boolean hasHomeAntenna = Double.isFinite(homeAntennaHeightMeters);
|
||||
boolean hasTargetAntenna = Double.isFinite(targetAntennaHeightMeters);
|
||||
|
||||
String homeText;
|
||||
if (hasHomeGround && hasHomeAntenna) {
|
||||
homeText = String.format(
|
||||
Locale.US,
|
||||
"Home %.0f m ASL + %.0f m AGL",
|
||||
firstPoint.elevationMeters(),
|
||||
homeAntennaHeightMeters
|
||||
);
|
||||
} else if (hasHomeAntenna) {
|
||||
homeText = String.format(Locale.US, "Home +%.0f m AGL", homeAntennaHeightMeters);
|
||||
} else {
|
||||
homeText = "Home -";
|
||||
}
|
||||
|
||||
String targetText;
|
||||
if (hasTargetGround && hasTargetAntenna) {
|
||||
targetText = String.format(
|
||||
Locale.US,
|
||||
"DX %.0f m ASL + %.0f m AGL",
|
||||
lastPoint.elevationMeters(),
|
||||
targetAntennaHeightMeters
|
||||
);
|
||||
} else if (hasTargetAntenna) {
|
||||
targetText = String.format(Locale.US, "DX +%.0f m AGL", targetAntennaHeightMeters);
|
||||
} else {
|
||||
targetText = "DX -";
|
||||
}
|
||||
|
||||
return homeText + " | " + targetText;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the operator-facing propagation assessment.
|
||||
*
|
||||
* @return short propagation assessment text
|
||||
*/
|
||||
public String propagationAssessmentText() {
|
||||
return propagationAssessment == null
|
||||
? "-"
|
||||
: propagationAssessment.shortText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the likely propagation mechanisms suggested by the current assessment.
|
||||
*
|
||||
* @return mechanism summary
|
||||
*/
|
||||
public String propagationMechanismsText() {
|
||||
return propagationAssessment == null
|
||||
? "-"
|
||||
: propagationAssessment.likelyMechanisms();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a numeric severity level from 0 to 5.
|
||||
*
|
||||
* @return severity level
|
||||
*/
|
||||
public int propagationSeverityLevel() {
|
||||
return propagationAssessment == null
|
||||
? 0
|
||||
: propagationAssessment.severityLevel();
|
||||
}
|
||||
|
||||
private PathProfilePoint firstProfilePoint() {
|
||||
return profilePoints.isEmpty() ? null : profilePoints.get(0);
|
||||
}
|
||||
|
||||
private PathProfilePoint lastProfilePoint() {
|
||||
return profilePoints.isEmpty() ? null : profilePoints.get(profilePoints.size() - 1);
|
||||
}
|
||||
|
||||
private static String normalizeText(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static String normalizeUpper(String value) {
|
||||
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
public String linkBudgetText() {
|
||||
return linkBudgetSummary == null
|
||||
? "-"
|
||||
: linkBudgetSummary.ssbMarginText();
|
||||
}
|
||||
|
||||
public String linkBudgetRxPowerText() {
|
||||
return linkBudgetSummary == null
|
||||
? "-"
|
||||
: linkBudgetSummary.rxPowerText();
|
||||
}
|
||||
|
||||
public String linkBudgetDetailText() {
|
||||
return linkBudgetSummary == null
|
||||
? "-"
|
||||
: linkBudgetSummary.linkBudgetDetailText();
|
||||
}
|
||||
|
||||
public String cwHintText() {
|
||||
return linkBudgetSummary == null
|
||||
? "-"
|
||||
: linkBudgetSummary.cwHintText();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
/**
|
||||
* Abstraction for path analysis.
|
||||
*
|
||||
* First implementation can be geometry-only.
|
||||
* Later this can call terrain APIs, caching layers, LOS checks, etc.
|
||||
*/
|
||||
public interface PathAnalysisService {
|
||||
|
||||
PathAnalysisResult analyze(PathAnalysisRequest request);
|
||||
}
|
||||
@@ -1,698 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Shared helper methods for path geometry and radio-path calculations.
|
||||
*
|
||||
* <p>This class intentionally centralizes:
|
||||
* <ul>
|
||||
* <li>great-circle geometry</li>
|
||||
* <li>Earth curvature calculations</li>
|
||||
* <li>Fresnel calculations</li>
|
||||
* <li>adaptive profile sampling heuristics</li>
|
||||
* <li>default/fallback frequency handling</li>
|
||||
* <li>tolerant frequency parsing from UI/chat strings</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class PathGeometryUtils {
|
||||
|
||||
private static final double EARTH_RADIUS_METERS = 6_371_009.0;
|
||||
private static final double EARTH_RADIUS_KM = EARTH_RADIUS_METERS / 1000.0;
|
||||
private static final double SPEED_OF_LIGHT_METERS_PER_SECOND = 299_792_458.0;
|
||||
|
||||
/**
|
||||
* Default effective Earth radius factor used for VHF/UHF path geometry.
|
||||
*
|
||||
* <p>k = 4/3 is the common standard-atmosphere approximation. It bends the
|
||||
* radio path slightly with the atmosphere and therefore reduces the apparent
|
||||
* Earth bulge compared with pure optical geometry.</p>
|
||||
*
|
||||
* <p>This is still only a geometric/refraction approximation. It does not model
|
||||
* troposcatter, aircraft scatter, ducting or diffraction loss numerically.</p>
|
||||
*/
|
||||
public static final double DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR = 4.0 / 3.0;
|
||||
|
||||
/**
|
||||
* Central fallback frequency for path analysis when no station frequency
|
||||
* could be extracted from the chat member / aggregated marker data.
|
||||
*
|
||||
* Easy to change later if users mainly work on another band.
|
||||
*/
|
||||
public static final double DEFAULT_ANALYSIS_FREQUENCY_MHZ = 144.0;
|
||||
|
||||
/**
|
||||
* Common practical recommendation: at least 60% of the first Fresnel zone
|
||||
* should remain clear.
|
||||
*/
|
||||
public static final double DEFAULT_FRESNEL_CLEARANCE_FACTOR = 0.60;
|
||||
|
||||
/**
|
||||
* Current sampling heuristic for terrain/profile analysis.
|
||||
*
|
||||
* <p>The active goal is to sample approximately every 0.5 km while
|
||||
* keeping the UI responsive. A later batch/service variant can use
|
||||
* different limits.</p>
|
||||
*/
|
||||
public static final double DEFAULT_TARGET_SAMPLE_STEP_KM = 0.5;
|
||||
public static final int MIN_PROFILE_SAMPLE_COUNT = 121;
|
||||
public static final int MAX_PROFILE_SAMPLE_COUNT = 1201;
|
||||
|
||||
private static final Pattern FREQUENCY_MHZ_PATTERN =
|
||||
Pattern.compile("(?i)(\\d{2,6}(?:[\\.,]\\d+)?)\\s*(?:mhz)?");
|
||||
|
||||
private PathGeometryUtils() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Great-circle distance in kilometers using the haversine formula.
|
||||
*
|
||||
* @param fromLatitudeDeg source latitude in degrees
|
||||
* @param fromLongitudeDeg source longitude in degrees
|
||||
* @param toLatitudeDeg target latitude in degrees
|
||||
* @param toLongitudeDeg target longitude in degrees
|
||||
* @return great-circle distance in kilometers
|
||||
*/
|
||||
public static double calculateGreatCircleDistanceKm(double fromLatitudeDeg,
|
||||
double fromLongitudeDeg,
|
||||
double toLatitudeDeg,
|
||||
double toLongitudeDeg) {
|
||||
|
||||
if (!Double.isFinite(fromLatitudeDeg)
|
||||
|| !Double.isFinite(fromLongitudeDeg)
|
||||
|| !Double.isFinite(toLatitudeDeg)
|
||||
|| !Double.isFinite(toLongitudeDeg)) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
double fromLatitudeRad = Math.toRadians(fromLatitudeDeg);
|
||||
double fromLongitudeRad = Math.toRadians(fromLongitudeDeg);
|
||||
double toLatitudeRad = Math.toRadians(toLatitudeDeg);
|
||||
double toLongitudeRad = Math.toRadians(toLongitudeDeg);
|
||||
|
||||
double deltaLatitude = toLatitudeRad - fromLatitudeRad;
|
||||
double deltaLongitude = toLongitudeRad - fromLongitudeRad;
|
||||
|
||||
double a = Math.sin(deltaLatitude / 2.0) * Math.sin(deltaLatitude / 2.0)
|
||||
+ Math.cos(fromLatitudeRad) * Math.cos(toLatitudeRad)
|
||||
* Math.sin(deltaLongitude / 2.0) * Math.sin(deltaLongitude / 2.0);
|
||||
|
||||
double c = 2.0 * Math.atan2(Math.sqrt(a), Math.sqrt(Math.max(0.0, 1.0 - a)));
|
||||
|
||||
return EARTH_RADIUS_KM * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial great-circle bearing in degrees from north.
|
||||
*
|
||||
* @param fromLatitudeDeg source latitude in degrees
|
||||
* @param fromLongitudeDeg source longitude in degrees
|
||||
* @param toLatitudeDeg target latitude in degrees
|
||||
* @param toLongitudeDeg target longitude in degrees
|
||||
* @return initial bearing in degrees within [0, 360)
|
||||
*/
|
||||
public static double calculateInitialBearingDeg(double fromLatitudeDeg,
|
||||
double fromLongitudeDeg,
|
||||
double toLatitudeDeg,
|
||||
double toLongitudeDeg) {
|
||||
|
||||
if (!Double.isFinite(fromLatitudeDeg)
|
||||
|| !Double.isFinite(fromLongitudeDeg)
|
||||
|| !Double.isFinite(toLatitudeDeg)
|
||||
|| !Double.isFinite(toLongitudeDeg)) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
double fromLatitudeRad = Math.toRadians(fromLatitudeDeg);
|
||||
double toLatitudeRad = Math.toRadians(toLatitudeDeg);
|
||||
double deltaLongitudeRad = Math.toRadians(toLongitudeDeg - fromLongitudeDeg);
|
||||
|
||||
double y = Math.sin(deltaLongitudeRad) * Math.cos(toLatitudeRad);
|
||||
double x = Math.cos(fromLatitudeRad) * Math.sin(toLatitudeRad)
|
||||
- Math.sin(fromLatitudeRad) * Math.cos(toLatitudeRad) * Math.cos(deltaLongitudeRad);
|
||||
|
||||
double bearingDeg = Math.toDegrees(Math.atan2(y, x));
|
||||
|
||||
return normalizeBearingDeg(bearingDeg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolates one point on the great-circle path between two endpoints.
|
||||
*
|
||||
* <p>This uses spherical linear interpolation (slerp) on the unit sphere.
|
||||
* It avoids the path distortion that appears when latitude and longitude
|
||||
* are interpolated independently.</p>
|
||||
*
|
||||
* @param fromLatitudeDeg source latitude in degrees
|
||||
* @param fromLongitudeDeg source longitude in degrees
|
||||
* @param toLatitudeDeg target latitude in degrees
|
||||
* @param toLongitudeDeg target longitude in degrees
|
||||
* @param t interpolation factor in [0, 1]
|
||||
* @return interpolated great-circle point
|
||||
*/
|
||||
public static GeoPoint interpolateGreatCirclePoint(double fromLatitudeDeg,
|
||||
double fromLongitudeDeg,
|
||||
double toLatitudeDeg,
|
||||
double toLongitudeDeg,
|
||||
double t) {
|
||||
|
||||
if (!Double.isFinite(fromLatitudeDeg)
|
||||
|| !Double.isFinite(fromLongitudeDeg)
|
||||
|| !Double.isFinite(toLatitudeDeg)
|
||||
|| !Double.isFinite(toLongitudeDeg)
|
||||
|| !Double.isFinite(t)) {
|
||||
return new GeoPoint(Double.NaN, Double.NaN);
|
||||
}
|
||||
|
||||
double clampedT = clamp(t, 0.0, 1.0);
|
||||
|
||||
if (clampedT <= 0.0) {
|
||||
return new GeoPoint(fromLatitudeDeg, normalizeLongitudeDeg(fromLongitudeDeg));
|
||||
}
|
||||
|
||||
if (clampedT >= 1.0) {
|
||||
return new GeoPoint(toLatitudeDeg, normalizeLongitudeDeg(toLongitudeDeg));
|
||||
}
|
||||
|
||||
double fromLatitudeRad = Math.toRadians(fromLatitudeDeg);
|
||||
double fromLongitudeRad = Math.toRadians(fromLongitudeDeg);
|
||||
double toLatitudeRad = Math.toRadians(toLatitudeDeg);
|
||||
double toLongitudeRad = Math.toRadians(toLongitudeDeg);
|
||||
|
||||
double x1 = Math.cos(fromLatitudeRad) * Math.cos(fromLongitudeRad);
|
||||
double y1 = Math.cos(fromLatitudeRad) * Math.sin(fromLongitudeRad);
|
||||
double z1 = Math.sin(fromLatitudeRad);
|
||||
|
||||
double x2 = Math.cos(toLatitudeRad) * Math.cos(toLongitudeRad);
|
||||
double y2 = Math.cos(toLatitudeRad) * Math.sin(toLongitudeRad);
|
||||
double z2 = Math.sin(toLatitudeRad);
|
||||
|
||||
double dot = clamp(x1 * x2 + y1 * y2 + z1 * z2, -1.0, 1.0);
|
||||
double omega = Math.acos(dot);
|
||||
|
||||
if (omega < 1e-12) {
|
||||
double latitudeDeg = fromLatitudeDeg + (toLatitudeDeg - fromLatitudeDeg) * clampedT;
|
||||
double longitudeDeg = normalizeLongitudeDeg(fromLongitudeDeg + (toLongitudeDeg - fromLongitudeDeg) * clampedT);
|
||||
return new GeoPoint(latitudeDeg, longitudeDeg);
|
||||
}
|
||||
|
||||
double sinOmega = Math.sin(omega);
|
||||
double a = Math.sin((1.0 - clampedT) * omega) / sinOmega;
|
||||
double b = Math.sin(clampedT * omega) / sinOmega;
|
||||
|
||||
double x = a * x1 + b * x2;
|
||||
double y = a * y1 + b * y2;
|
||||
double z = a * z1 + b * z2;
|
||||
|
||||
double latitudeRad = Math.atan2(z, Math.sqrt(x * x + y * y));
|
||||
double longitudeRad = Math.atan2(y, x);
|
||||
|
||||
return new GeoPoint(
|
||||
Math.toDegrees(latitudeRad),
|
||||
normalizeLongitudeDeg(Math.toDegrees(longitudeRad))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an adaptive terrain/profile sample count from the full path distance.
|
||||
*
|
||||
* <p>Current heuristic:
|
||||
* <ul>
|
||||
* <li>target spacing about 0.5 km</li>
|
||||
* <li>minimum 121 samples</li>
|
||||
* <li>maximum 1201 samples</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param totalDistanceKm full path distance in kilometers
|
||||
* @return clamped sample count
|
||||
*/
|
||||
public static int resolveAdaptiveSampleCount(double totalDistanceKm) {
|
||||
if (!Double.isFinite(totalDistanceKm) || totalDistanceKm <= 0.0) {
|
||||
return MIN_PROFILE_SAMPLE_COUNT;
|
||||
}
|
||||
|
||||
int computedSampleCount = (int) Math.ceil(totalDistanceKm / DEFAULT_TARGET_SAMPLE_STEP_KM) + 1;
|
||||
|
||||
return clampInt(computedSampleCount, MIN_PROFILE_SAMPLE_COUNT, MAX_PROFILE_SAMPLE_COUNT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Earth bulge above the straight endpoint chord at one point
|
||||
* along the path.
|
||||
*
|
||||
* <p>Distances are given along the path in kilometers.</p>
|
||||
*
|
||||
* <p>Approximation:
|
||||
* bulge = d1 * d2 / (2 * R_eff)</p>
|
||||
*/
|
||||
public static double calculateEarthBulgeMeters(double distanceFromStartKm,
|
||||
double totalDistanceKm) {
|
||||
return calculateEarthBulgeMeters(
|
||||
distanceFromStartKm,
|
||||
totalDistanceKm,
|
||||
DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR
|
||||
);
|
||||
}
|
||||
|
||||
public static double calculateEarthBulgeMeters(double distanceFromStartKm,
|
||||
double totalDistanceKm,
|
||||
double effectiveEarthRadiusFactor) {
|
||||
|
||||
if (!Double.isFinite(distanceFromStartKm)
|
||||
|| !Double.isFinite(totalDistanceKm)
|
||||
|| !Double.isFinite(effectiveEarthRadiusFactor)
|
||||
|| totalDistanceKm <= 0.0
|
||||
|| effectiveEarthRadiusFactor <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
double clampedDistanceFromStartKm = Math.max(0.0, Math.min(distanceFromStartKm, totalDistanceKm));
|
||||
double distanceToTargetKm = totalDistanceKm - clampedDistanceFromStartKm;
|
||||
|
||||
double distanceFromStartMeters = clampedDistanceFromStartKm * 1000.0;
|
||||
double distanceToTargetMeters = distanceToTargetKm * 1000.0;
|
||||
double effectiveEarthRadiusMeters = EARTH_RADIUS_METERS * effectiveEarthRadiusFactor;
|
||||
|
||||
return (distanceFromStartMeters * distanceToTargetMeters) / (2.0 * effectiveEarthRadiusMeters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns terrain height plus Earth curvature using the default effective Earth
|
||||
* radius factor.
|
||||
*
|
||||
* @param point terrain/profile point
|
||||
* @param totalDistanceKm full path distance in kilometers
|
||||
* @return curvature-adjusted terrain height in meters
|
||||
*/
|
||||
public static double calculateCurvatureAdjustedElevationMeters(PathProfilePoint point,
|
||||
double totalDistanceKm) {
|
||||
return calculateCurvatureAdjustedElevationMeters(
|
||||
point,
|
||||
totalDistanceKm,
|
||||
DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns terrain height plus Earth curvature using the given effective Earth
|
||||
* radius factor.
|
||||
*
|
||||
* <p>k = 1.0 means optical/geometric Earth curvature. k = 4/3 is the common
|
||||
* standard radio-refraction approximation for VHF/UHF path previews.</p>
|
||||
*
|
||||
* @param point terrain/profile point
|
||||
* @param totalDistanceKm full path distance in kilometers
|
||||
* @param effectiveEarthRadiusFactor k-factor for effective Earth radius
|
||||
* @return curvature-adjusted terrain height in meters
|
||||
*/
|
||||
public static double calculateCurvatureAdjustedElevationMeters(PathProfilePoint point,
|
||||
double totalDistanceKm,
|
||||
double effectiveEarthRadiusFactor) {
|
||||
if (point == null || !Double.isFinite(point.elevationMeters())) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
return point.elevationMeters()
|
||||
+ calculateEarthBulgeMeters(
|
||||
point.distanceKm(),
|
||||
totalDistanceKm,
|
||||
sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the simple radio horizon distance from antenna height above ground.
|
||||
*
|
||||
* <p>The result is based on the effective Earth radius model:
|
||||
* d = sqrt(2 * R_eff * h)</p>
|
||||
*
|
||||
* <p>This is a local tangent-horizon approximation. It is useful as an operator
|
||||
* hint, but it is not a complete propagation prediction.</p>
|
||||
*
|
||||
* @param antennaHeightMeters antenna height above local ground in meters
|
||||
* @param effectiveEarthRadiusFactor k-factor for effective Earth radius
|
||||
* @return radio horizon distance in kilometers
|
||||
*/
|
||||
public static double calculateRadioHorizonDistanceKm(double antennaHeightMeters,
|
||||
double effectiveEarthRadiusFactor) {
|
||||
if (!Double.isFinite(antennaHeightMeters) || antennaHeightMeters < 0.0) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
double sanitizedFactor = sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor);
|
||||
double effectiveEarthRadiusMeters = EARTH_RADIUS_METERS * sanitizedFactor;
|
||||
|
||||
return Math.sqrt(2.0 * effectiveEarthRadiusMeters * antennaHeightMeters) / 1000.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the apparent elevation angle from an observer height to a target
|
||||
* height over a given distance.
|
||||
*
|
||||
* @param observerHeightMeters observer height in the already curvature-adjusted profile space
|
||||
* @param targetHeightMeters target height in the same profile space
|
||||
* @param distanceKm distance between observer and target in kilometers
|
||||
* @return elevation angle in degrees
|
||||
*/
|
||||
public static double calculateElevationAngleDeg(double observerHeightMeters,
|
||||
double targetHeightMeters,
|
||||
double distanceKm) {
|
||||
if (!Double.isFinite(observerHeightMeters)
|
||||
|| !Double.isFinite(targetHeightMeters)
|
||||
|| !Double.isFinite(distanceKm)
|
||||
|| distanceKm <= 0.0) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
double distanceMeters = distanceKm * 1000.0;
|
||||
return Math.toDegrees(Math.atan2(targetHeightMeters - observerHeightMeters, distanceMeters));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sanitizes the effective Earth radius factor.
|
||||
*
|
||||
* <p>Values outside a practical range are folded back to the standard default
|
||||
* so broken preferences or malformed future XML values cannot destabilize path
|
||||
* analysis.</p>
|
||||
*
|
||||
* @param effectiveEarthRadiusFactor raw k-factor
|
||||
* @return usable k-factor
|
||||
*/
|
||||
public static double sanitizeEffectiveEarthRadiusFactor(double effectiveEarthRadiusFactor) {
|
||||
if (!Double.isFinite(effectiveEarthRadiusFactor)
|
||||
|| effectiveEarthRadiusFactor < 0.5
|
||||
|| effectiveEarthRadiusFactor > 10.0) {
|
||||
return DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR;
|
||||
}
|
||||
|
||||
return effectiveEarthRadiusFactor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a frequency in MHz to wavelength in meters.
|
||||
*
|
||||
* @param frequencyMHz signal frequency in MHz
|
||||
* @return wavelength in meters
|
||||
*/
|
||||
public static double calculateWavelengthMeters(double frequencyMHz) {
|
||||
if (!Double.isFinite(frequencyMHz) || frequencyMHz <= 0.0) {
|
||||
return Double.NaN;
|
||||
}
|
||||
return SPEED_OF_LIGHT_METERS_PER_SECOND / (frequencyMHz * 1_000_000.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* First Fresnel zone radius at one point along the path.
|
||||
*
|
||||
* @param distanceFromStartKm distance from TX in kilometers
|
||||
* @param totalDistanceKm total path distance in kilometers
|
||||
* @param frequencyMHz signal frequency in MHz
|
||||
* @return first Fresnel radius in meters
|
||||
*/
|
||||
public static double calculateFirstFresnelRadiusMeters(double distanceFromStartKm,
|
||||
double totalDistanceKm,
|
||||
double frequencyMHz) {
|
||||
|
||||
if (!Double.isFinite(distanceFromStartKm)
|
||||
|| !Double.isFinite(totalDistanceKm)
|
||||
|| !Double.isFinite(frequencyMHz)
|
||||
|| totalDistanceKm <= 0.0
|
||||
|| frequencyMHz <= 0.0) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
double clampedDistanceFromStartKm = Math.max(0.0, Math.min(distanceFromStartKm, totalDistanceKm));
|
||||
double distanceToTargetKm = totalDistanceKm - clampedDistanceFromStartKm;
|
||||
|
||||
double d1Meters = clampedDistanceFromStartKm * 1000.0;
|
||||
double d2Meters = distanceToTargetKm * 1000.0;
|
||||
|
||||
if (d1Meters <= 0.0 || d2Meters <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
double wavelengthMeters = calculateWavelengthMeters(frequencyMHz);
|
||||
if (!Double.isFinite(wavelengthMeters) || wavelengthMeters <= 0.0) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
return Math.sqrt((wavelengthMeters * d1Meters * d2Meters) / (d1Meters + d2Meters));
|
||||
}
|
||||
|
||||
/**
|
||||
* Recommended minimum clearance, currently 60% of the first Fresnel zone.
|
||||
*
|
||||
* @param distanceFromStartKm distance from TX in kilometers
|
||||
* @param totalDistanceKm total path distance in kilometers
|
||||
* @param frequencyMHz signal frequency in MHz
|
||||
* @return recommended clearance in meters
|
||||
*/
|
||||
public static double calculateRecommendedFresnelClearanceMeters(double distanceFromStartKm,
|
||||
double totalDistanceKm,
|
||||
double frequencyMHz) {
|
||||
|
||||
double firstFresnelRadiusMeters =
|
||||
calculateFirstFresnelRadiusMeters(distanceFromStartKm, totalDistanceKm, frequencyMHz);
|
||||
|
||||
if (!Double.isFinite(firstFresnelRadiusMeters)) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
return firstFresnelRadiusMeters * DEFAULT_FRESNEL_CLEARANCE_FACTOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to extract a MHz frequency from strings such as:
|
||||
* <ul>
|
||||
* <li>"144.300"</li>
|
||||
* <li>"144,300"</li>
|
||||
* <li>"144.300 MHz"</li>
|
||||
* <li>"QRG 432.174 MHz"</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param rawText free-form raw text
|
||||
* @return parsed frequency in MHz or NaN
|
||||
*/
|
||||
public static double tryParseFrequencyMHz(String rawText) {
|
||||
if (rawText == null || rawText.isBlank()) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
Matcher matcher = FREQUENCY_MHZ_PATTERN.matcher(rawText.trim());
|
||||
if (!matcher.find()) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
String numericText = matcher.group(1).replace(',', '.');
|
||||
|
||||
try {
|
||||
double parsedFrequencyMHz = Double.parseDouble(numericText);
|
||||
return parsedFrequencyMHz > 0.0 ? parsedFrequencyMHz : Double.NaN;
|
||||
} catch (NumberFormatException ignored) {
|
||||
return Double.NaN;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves one usable analysis frequency from aggregated marker frequency data.
|
||||
*
|
||||
* <p>Strategy:
|
||||
* <ol>
|
||||
* <li>Prefer 144 MHz data if available</li>
|
||||
* <li>Otherwise use the first parsable known station frequency</li>
|
||||
* <li>Otherwise use the central default frequency</li>
|
||||
* </ol>
|
||||
*
|
||||
* @param frequenciesByBand known frequencies grouped by band
|
||||
* @return resolved analysis frequency in MHz
|
||||
*/
|
||||
public static double resolveAnalysisFrequencyMHz(Map<String, String> frequenciesByBand) {
|
||||
if (frequenciesByBand != null && !frequenciesByBand.isEmpty()) {
|
||||
String band144Text = frequenciesByBand.get("144");
|
||||
double parsed144 = tryParseFrequencyMHz(band144Text);
|
||||
if (Double.isFinite(parsed144) && parsed144 > 0.0) {
|
||||
return parsed144;
|
||||
}
|
||||
|
||||
for (String value : frequenciesByBand.values()) {
|
||||
double parsedFrequencyMHz = tryParseFrequencyMHz(value);
|
||||
if (Double.isFinite(parsedFrequencyMHz) && parsedFrequencyMHz > 0.0) {
|
||||
return parsedFrequencyMHz;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_ANALYSIS_FREQUENCY_MHZ;
|
||||
}
|
||||
|
||||
/**
|
||||
* Small immutable geographic point used by great-circle interpolation.
|
||||
*
|
||||
* @param latitudeDeg latitude in degrees
|
||||
* @param longitudeDeg longitude in degrees
|
||||
*/
|
||||
public record GeoPoint(double latitudeDeg, double longitudeDeg) {
|
||||
}
|
||||
|
||||
private static double normalizeLongitudeDeg(double longitudeDeg) {
|
||||
if (!Double.isFinite(longitudeDeg)) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
double normalized = longitudeDeg % 360.0;
|
||||
if (normalized > 180.0) {
|
||||
normalized -= 360.0;
|
||||
} else if (normalized <= -180.0) {
|
||||
normalized += 360.0;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static double normalizeBearingDeg(double bearingDeg) {
|
||||
if (!Double.isFinite(bearingDeg)) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
double normalized = bearingDeg % 360.0;
|
||||
if (normalized < 0.0) {
|
||||
normalized += 360.0;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static double clamp(double value, double minValue, double maxValue) {
|
||||
return Math.max(minValue, Math.min(maxValue, value));
|
||||
}
|
||||
|
||||
private static int clampInt(int value, int minValue, int maxValue) {
|
||||
return Math.max(minValue, Math.min(maxValue, value));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Calculates the Fresnel-Kirchhoff diffraction parameter v for a single
|
||||
* obstruction.
|
||||
*
|
||||
* <p>Positive height means the obstruction is above the direct line of sight.
|
||||
* This method is useful for a rough single-knife-edge severity estimate.</p>
|
||||
*
|
||||
* @param heightAboveLosMeters obstruction height above direct LOS in meters
|
||||
* @param distanceFromHomeKm distance from home endpoint to obstruction
|
||||
* @param distanceFromTargetKm distance from target endpoint to obstruction
|
||||
* @param frequencyMHz analysis frequency in MHz
|
||||
* @return diffraction parameter v
|
||||
*/
|
||||
public static double calculateKnifeEdgeVParameter(double heightAboveLosMeters,
|
||||
double distanceFromHomeKm,
|
||||
double distanceFromTargetKm,
|
||||
double frequencyMHz) {
|
||||
if (!Double.isFinite(heightAboveLosMeters)
|
||||
|| !Double.isFinite(distanceFromHomeKm)
|
||||
|| !Double.isFinite(distanceFromTargetKm)
|
||||
|| distanceFromHomeKm <= 0.0
|
||||
|| distanceFromTargetKm <= 0.0) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
double wavelengthMeters = calculateWavelengthMeters(frequencyMHz);
|
||||
if (!Double.isFinite(wavelengthMeters) || wavelengthMeters <= 0.0) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
double d1Meters = distanceFromHomeKm * 1000.0;
|
||||
double d2Meters = distanceFromTargetKm * 1000.0;
|
||||
|
||||
return heightAboveLosMeters
|
||||
* Math.sqrt(2.0 * (d1Meters + d2Meters) / (wavelengthMeters * d1Meters * d2Meters));
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates the additional diffraction loss for a single knife edge.
|
||||
*
|
||||
* <p>This is a rough operator-facing indicator. It must not be interpreted as a
|
||||
* complete path-loss model.</p>
|
||||
*
|
||||
* @param v Fresnel-Kirchhoff diffraction parameter
|
||||
* @return estimated additional loss in dB
|
||||
*/
|
||||
public static double calculateSingleKnifeEdgeLossDb(double v) {
|
||||
if (!Double.isFinite(v)) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
if (v <= -0.78) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return 6.9 + 20.0 * Math.log10(
|
||||
Math.sqrt(Math.pow(v - 0.1, 2.0) + 1.0) + v - 0.1
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts RF power from watts to dBm.
|
||||
*
|
||||
* @param watts power in watts
|
||||
* @return power in dBm
|
||||
*/
|
||||
public static double wattsToDbm(double watts) {
|
||||
if (!Double.isFinite(watts) || watts <= 0.0) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
return 10.0 * Math.log10(watts * 1000.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates free-space path loss.
|
||||
*
|
||||
* @param distanceKm path distance in kilometers
|
||||
* @param frequencyMHz frequency in MHz
|
||||
* @return free-space path loss in dB
|
||||
*/
|
||||
public static double calculateFreeSpacePathLossDb(double distanceKm, double frequencyMHz) {
|
||||
if (!Double.isFinite(distanceKm)
|
||||
|| !Double.isFinite(frequencyMHz)
|
||||
|| distanceKm <= 0.0
|
||||
|| frequencyMHz <= 0.0) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
return 32.44 + 20.0 * Math.log10(distanceKm) + 20.0 * Math.log10(frequencyMHz);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates per-station feeder loss from a simple VHF baseline and a frequency
|
||||
* dependent increase.
|
||||
*
|
||||
* <p>The result is capped because a linear MHz-based feeder heuristic becomes
|
||||
* unrealistic on microwave bands where transverters are often placed near the
|
||||
* antenna. Later this should become a per-band user setting.</p>
|
||||
*
|
||||
* @param frequencyMHz frequency in MHz
|
||||
* @param settings link-budget settings
|
||||
* @return estimated per-station feeder loss in dB
|
||||
*/
|
||||
public static double estimateFeederLossPerStationDb(double frequencyMHz,
|
||||
PathLinkBudgetSettings settings) {
|
||||
PathLinkBudgetSettings safeSettings = settings == null
|
||||
? PathLinkBudgetSettings.defaults()
|
||||
: settings;
|
||||
|
||||
double baseLossDb = safeSettings.vhfFeederLossPerStationDb();
|
||||
|
||||
if (!Double.isFinite(frequencyMHz) || frequencyMHz <= 0.0) {
|
||||
return baseLossDb;
|
||||
}
|
||||
|
||||
double additionalLossDb = Math.max(0.0, (frequencyMHz - 144.0) / 200.0)
|
||||
* safeSettings.feederLossIncreaseDbPer200MHz();
|
||||
|
||||
double estimatedLossDb = baseLossDb + additionalLossDb;
|
||||
|
||||
return Math.min(estimatedLossDb, safeSettings.maxEstimatedFeederLossPerStationDb());
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Immutable summary of simple radio-horizon and terrain-profile horizon data.
|
||||
*
|
||||
* <p>The simple radio horizon is based only on antenna height and effective
|
||||
* Earth radius. The terrain horizon is derived from the actual path profile and
|
||||
* therefore represents the highest apparent terrain angle from each endpoint.</p>
|
||||
*/
|
||||
public record PathHorizonSummary(
|
||||
double effectiveEarthRadiusFactor,
|
||||
|
||||
double homeSimpleRadioHorizonKm,
|
||||
double targetSimpleRadioHorizonKm,
|
||||
double combinedSimpleRadioHorizonKm,
|
||||
|
||||
double homeTerrainHorizonPathDistanceKm,
|
||||
double homeTerrainHorizonElevationAngleDeg,
|
||||
int homeTerrainHorizonSampleIndex,
|
||||
|
||||
double targetTerrainHorizonPathDistanceKm,
|
||||
double targetTerrainHorizonDistanceFromTargetKm,
|
||||
double targetTerrainHorizonElevationAngleDeg,
|
||||
int targetTerrainHorizonSampleIndex
|
||||
) {
|
||||
|
||||
public PathHorizonSummary {
|
||||
effectiveEarthRadiusFactor =
|
||||
PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor);
|
||||
}
|
||||
|
||||
public static PathHorizonSummary empty() {
|
||||
return new PathHorizonSummary(
|
||||
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
-1,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
-1
|
||||
);
|
||||
}
|
||||
|
||||
public boolean hasHomeTerrainHorizon() {
|
||||
return homeTerrainHorizonSampleIndex >= 0
|
||||
&& Double.isFinite(homeTerrainHorizonPathDistanceKm)
|
||||
&& Double.isFinite(homeTerrainHorizonElevationAngleDeg);
|
||||
}
|
||||
|
||||
public boolean hasTargetTerrainHorizon() {
|
||||
return targetTerrainHorizonSampleIndex >= 0
|
||||
&& Double.isFinite(targetTerrainHorizonPathDistanceKm)
|
||||
&& Double.isFinite(targetTerrainHorizonDistanceFromTargetKm)
|
||||
&& Double.isFinite(targetTerrainHorizonElevationAngleDeg);
|
||||
}
|
||||
|
||||
public String effectiveEarthRadiusText() {
|
||||
return String.format(
|
||||
Locale.US,
|
||||
"k = %.2f effective Earth radius",
|
||||
effectiveEarthRadiusFactor
|
||||
);
|
||||
}
|
||||
|
||||
public String simpleRadioHorizonText() {
|
||||
if (!Double.isFinite(homeSimpleRadioHorizonKm)
|
||||
|| !Double.isFinite(targetSimpleRadioHorizonKm)
|
||||
|| !Double.isFinite(combinedSimpleRadioHorizonKm)) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return String.format(
|
||||
Locale.US,
|
||||
"Home %.1f km | DX %.1f km | combined %.1f km",
|
||||
homeSimpleRadioHorizonKm,
|
||||
targetSimpleRadioHorizonKm,
|
||||
combinedSimpleRadioHorizonKm
|
||||
);
|
||||
}
|
||||
|
||||
public String terrainHorizonText() {
|
||||
String homeText = hasHomeTerrainHorizon()
|
||||
? String.format(
|
||||
Locale.US,
|
||||
"Home %.1f km / %+.2f°",
|
||||
homeTerrainHorizonPathDistanceKm,
|
||||
homeTerrainHorizonElevationAngleDeg
|
||||
)
|
||||
: "Home -";
|
||||
|
||||
String targetText = hasTargetTerrainHorizon()
|
||||
? String.format(
|
||||
Locale.US,
|
||||
"DX %.1f km from DX / %+.2f°",
|
||||
targetTerrainHorizonDistanceFromTargetKm,
|
||||
targetTerrainHorizonElevationAngleDeg
|
||||
)
|
||||
: "DX -";
|
||||
|
||||
return homeText + " | " + targetText;
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
/**
|
||||
* User/configuration values for the path link-budget estimate.
|
||||
*
|
||||
* <p>All antenna gains are dBi. If the user knows dBd values, add 2.15 dB
|
||||
* before entering them as dBi. Example: 12 dBd = 14.15 dBi.</p>
|
||||
*/
|
||||
public record PathLinkBudgetSettings(
|
||||
double ownTxPowerWatts,
|
||||
double ownAntennaGainDbi,
|
||||
double targetTxPowerWatts,
|
||||
double targetAntennaGainDbi,
|
||||
double vhfFeederLossPerStationDb,
|
||||
double feederLossIncreaseDbPer200MHz,
|
||||
double maxEstimatedFeederLossPerStationDb,
|
||||
double requiredSsbSignalDbm,
|
||||
double requiredCwSignalDbm,
|
||||
double contestMarginDb
|
||||
) {
|
||||
|
||||
public static PathLinkBudgetSettings defaults() {
|
||||
return new PathLinkBudgetSettings(
|
||||
750.0,
|
||||
8.0,
|
||||
100.0,
|
||||
8.0,
|
||||
2.0,
|
||||
2.0,
|
||||
20.0,
|
||||
-126.0,
|
||||
-132.0,
|
||||
6.0
|
||||
);
|
||||
}
|
||||
|
||||
public PathLinkBudgetSettings {
|
||||
ownTxPowerWatts = sanitizePositive(ownTxPowerWatts, 750.0);
|
||||
ownAntennaGainDbi = sanitizeFinite(ownAntennaGainDbi, 8.0);
|
||||
|
||||
targetTxPowerWatts = sanitizePositive(targetTxPowerWatts, 100.0);
|
||||
targetAntennaGainDbi = sanitizeFinite(targetAntennaGainDbi, 8.0);
|
||||
|
||||
vhfFeederLossPerStationDb = sanitizeNonNegative(vhfFeederLossPerStationDb, 2.0);
|
||||
feederLossIncreaseDbPer200MHz = sanitizeNonNegative(feederLossIncreaseDbPer200MHz, 2.0);
|
||||
maxEstimatedFeederLossPerStationDb = sanitizeNonNegative(maxEstimatedFeederLossPerStationDb, 20.0);
|
||||
|
||||
requiredSsbSignalDbm = sanitizeFinite(requiredSsbSignalDbm, -126.0);
|
||||
requiredCwSignalDbm = sanitizeFinite(requiredCwSignalDbm, -132.0);
|
||||
contestMarginDb = sanitizeNonNegative(contestMarginDb, 6.0);
|
||||
}
|
||||
|
||||
private static double sanitizePositive(double value, double fallback) {
|
||||
return Double.isFinite(value) && value > 0.0 ? value : fallback;
|
||||
}
|
||||
|
||||
private static double sanitizeNonNegative(double value, double fallback) {
|
||||
return Double.isFinite(value) && value >= 0.0 ? value : fallback;
|
||||
}
|
||||
|
||||
private static double sanitizeFinite(double value, double fallback) {
|
||||
return Double.isFinite(value) ? value : fallback;
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Bidirectional link-budget estimate for one path.
|
||||
*
|
||||
* <p>The estimate is intentionally operator-facing. It combines free-space
|
||||
* loss, the current diffraction/obstruction estimate, antenna gain, feeder loss,
|
||||
* TX power and required SSB/CW receiver levels.</p>
|
||||
*/
|
||||
public record PathLinkBudgetSummary(
|
||||
double frequencyMHz,
|
||||
|
||||
double ownTxPowerDbm,
|
||||
double targetTxPowerDbm,
|
||||
double ownAntennaGainDbi,
|
||||
double targetAntennaGainDbi,
|
||||
|
||||
double ownFeederLossDb,
|
||||
double targetFeederLossDb,
|
||||
double freeSpacePathLossDb,
|
||||
double diffractionLossDb,
|
||||
|
||||
double homeToTargetRxPowerDbm,
|
||||
double targetToHomeRxPowerDbm,
|
||||
|
||||
double requiredSsbSignalDbm,
|
||||
double requiredCwSignalDbm,
|
||||
double contestMarginDb,
|
||||
|
||||
double homeToTargetSsbMarginDb,
|
||||
double targetToHomeSsbMarginDb,
|
||||
double bidirectionalSsbMarginDb,
|
||||
|
||||
double homeToTargetCwMarginDb,
|
||||
double targetToHomeCwMarginDb,
|
||||
double bidirectionalCwMarginDb
|
||||
) {
|
||||
|
||||
public static PathLinkBudgetSummary empty() {
|
||||
return new PathLinkBudgetSummary(
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN
|
||||
);
|
||||
}
|
||||
|
||||
public boolean hasUsableBudget() {
|
||||
return Double.isFinite(bidirectionalSsbMarginDb);
|
||||
}
|
||||
|
||||
public String ssbMarginText() {
|
||||
if (!hasUsableBudget()) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return String.format(
|
||||
Locale.US,
|
||||
"SSB QSO margin %+.1f dB | H→DX %+.1f dB | DX→H %+.1f dB",
|
||||
bidirectionalSsbMarginDb,
|
||||
homeToTargetSsbMarginDb,
|
||||
targetToHomeSsbMarginDb
|
||||
);
|
||||
}
|
||||
|
||||
public String rxPowerText() {
|
||||
if (!Double.isFinite(homeToTargetRxPowerDbm)
|
||||
|| !Double.isFinite(targetToHomeRxPowerDbm)) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return String.format(
|
||||
Locale.US,
|
||||
"H→DX %.1f dBm | DX→H %.1f dBm",
|
||||
homeToTargetRxPowerDbm,
|
||||
targetToHomeRxPowerDbm
|
||||
);
|
||||
}
|
||||
|
||||
public String linkBudgetDetailText() {
|
||||
if (!hasUsableBudget()) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return String.format(
|
||||
Locale.US,
|
||||
"FSPL %.1f dB, diffraction %.1f dB, feeder H %.1f dB / DX %.1f dB, required SSB %.1f dBm + %.1f dB margin",
|
||||
freeSpacePathLossDb,
|
||||
diffractionLossDb,
|
||||
ownFeederLossDb,
|
||||
targetFeederLossDb,
|
||||
requiredSsbSignalDbm,
|
||||
contestMarginDb
|
||||
);
|
||||
}
|
||||
|
||||
public String cwHintText() {
|
||||
if (!Double.isFinite(bidirectionalCwMarginDb)) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
if (bidirectionalSsbMarginDb >= 0.0) {
|
||||
return String.format(Locale.US, "SSB workable; CW margin %+.1f dB.", bidirectionalCwMarginDb);
|
||||
}
|
||||
|
||||
if (bidirectionalCwMarginDb >= 0.0) {
|
||||
return String.format(Locale.US, "SSB marginal/weak, but CW may still be workable (%+.1f dB).", bidirectionalCwMarginDb);
|
||||
}
|
||||
|
||||
return String.format(Locale.US, "Even CW budget is negative (%+.1f dB).", bidirectionalCwMarginDb);
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Summary of the dominant terrain obstruction and a rough single-knife-edge
|
||||
* diffraction estimate.
|
||||
*
|
||||
* <p>This is intentionally only a severity indicator. It does not replace a full
|
||||
* propagation model with multiple diffraction edges, clutter, refractivity,
|
||||
* troposcatter, aircraft scatter or ducting.</p>
|
||||
*/
|
||||
public record PathObstructionSummary(
|
||||
double analysisFrequencyMHz,
|
||||
|
||||
int dominantObstructionSampleIndex,
|
||||
double dominantObstructionPathDistanceKm,
|
||||
double dominantObstructionHeightAboveLosMeters,
|
||||
double localFirstFresnelRadiusMeters,
|
||||
double obstructionFresnelRatio,
|
||||
double diffractionVParameter,
|
||||
double estimatedKnifeEdgeLossDb,
|
||||
|
||||
int worstFresnelIntrusionSampleIndex,
|
||||
double worstFresnelIntrusionPathDistanceKm,
|
||||
double worstFresnelIntrusionMeters,
|
||||
double worstFresnelIntrusionRatio
|
||||
) {
|
||||
|
||||
public static PathObstructionSummary empty() {
|
||||
return new PathObstructionSummary(
|
||||
Double.NaN,
|
||||
-1,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
-1,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN
|
||||
);
|
||||
}
|
||||
|
||||
public boolean hasDominantLosObstruction() {
|
||||
return dominantObstructionSampleIndex >= 0
|
||||
&& Double.isFinite(dominantObstructionPathDistanceKm)
|
||||
&& Double.isFinite(dominantObstructionHeightAboveLosMeters)
|
||||
&& dominantObstructionHeightAboveLosMeters > 0.0;
|
||||
}
|
||||
|
||||
public boolean hasFresnelIntrusion() {
|
||||
return worstFresnelIntrusionSampleIndex >= 0
|
||||
&& Double.isFinite(worstFresnelIntrusionMeters)
|
||||
&& worstFresnelIntrusionMeters > 0.0;
|
||||
}
|
||||
|
||||
public String obstructionText() {
|
||||
if (hasDominantLosObstruction()) {
|
||||
return String.format(
|
||||
Locale.US,
|
||||
"%.1f km | %.1f m above LOS | %.2f Fresnel radii | knife-edge ≈ %.1f dB (%s)",
|
||||
dominantObstructionPathDistanceKm,
|
||||
dominantObstructionHeightAboveLosMeters,
|
||||
obstructionFresnelRatio,
|
||||
estimatedKnifeEdgeLossDb,
|
||||
severityText()
|
||||
);
|
||||
}
|
||||
|
||||
if (hasFresnelIntrusion()) {
|
||||
return String.format(
|
||||
Locale.US,
|
||||
"No LOS-blocking edge. Fresnel intrusion %.1f m at %.1f km (%.0f%%).",
|
||||
worstFresnelIntrusionMeters,
|
||||
worstFresnelIntrusionPathDistanceKm,
|
||||
worstFresnelIntrusionRatio * 100.0
|
||||
);
|
||||
}
|
||||
|
||||
return "No dominant LOS obstruction detected.";
|
||||
}
|
||||
|
||||
public String severityText() {
|
||||
if (!Double.isFinite(estimatedKnifeEdgeLossDb)) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (estimatedKnifeEdgeLossDb < 6.0) {
|
||||
return "low";
|
||||
}
|
||||
|
||||
if (estimatedKnifeEdgeLossDb < 15.0) {
|
||||
return "moderate";
|
||||
}
|
||||
|
||||
if (estimatedKnifeEdgeLossDb < 25.0) {
|
||||
return "high";
|
||||
}
|
||||
|
||||
return "severe";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,212 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Immutable terrain sample plus optional derived radio-path geometry.
|
||||
*
|
||||
* <p>The first four properties are the raw terrain profile payload:
|
||||
* <ul>
|
||||
* <li>distance along the path in kilometers</li>
|
||||
* <li>sample latitude in degrees</li>
|
||||
* <li>sample longitude in degrees</li>
|
||||
* <li>terrain elevation in meters above mean sea level</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The remaining properties are optional derived values filled by
|
||||
* {@link GeometryOnlyPathAnalysisService}. They are all expressed in the
|
||||
* same chart/analysis space as the curvature-adjusted terrain profile:
|
||||
* <ul>
|
||||
* <li>curvature-adjusted terrain elevation</li>
|
||||
* <li>direct line-of-sight height</li>
|
||||
* <li>upper / lower first Fresnel hull</li>
|
||||
* <li>LOS and Fresnel clearances</li>
|
||||
* <li>worst-intrusion support values</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class PathProfilePoint {
|
||||
|
||||
private final int sampleIndex;
|
||||
private final double distanceKm;
|
||||
private final double latitudeDeg;
|
||||
private final double longitudeDeg;
|
||||
private final double elevationMeters;
|
||||
|
||||
private final double curvatureAdjustedElevationMeters;
|
||||
private final double lineOfSightHeightMeters;
|
||||
private final double fresnelUpperHeightMeters;
|
||||
private final double fresnelLowerHeightMeters;
|
||||
private final double lineOfSightClearanceMeters;
|
||||
private final double lowerFresnelClearanceMeters;
|
||||
private final double fresnelIntrusionMeters;
|
||||
|
||||
/**
|
||||
* Creates a raw terrain profile sample without derived LOS/Fresnel values.
|
||||
*
|
||||
* <p>This constructor is used by terrain providers and cache deserialization.
|
||||
* Derived geometry values remain unavailable until the path analysis service
|
||||
* enriches the profile.</p>
|
||||
*
|
||||
* @param distanceKm distance from the path origin in kilometers
|
||||
* @param latitudeDeg sample latitude in degrees
|
||||
* @param longitudeDeg sample longitude in degrees
|
||||
* @param elevationMeters terrain elevation in meters above mean sea level
|
||||
*/
|
||||
public PathProfilePoint(double distanceKm,
|
||||
double latitudeDeg,
|
||||
double longitudeDeg,
|
||||
double elevationMeters) {
|
||||
this(
|
||||
-1,
|
||||
distanceKm,
|
||||
latitudeDeg,
|
||||
longitudeDeg,
|
||||
elevationMeters,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a terrain profile sample enriched with derived radio-path geometry.
|
||||
*
|
||||
* @param sampleIndex zero-based sample index in the analyzed path
|
||||
* @param distanceKm distance from the path origin in kilometers
|
||||
* @param latitudeDeg sample latitude in degrees
|
||||
* @param longitudeDeg sample longitude in degrees
|
||||
* @param elevationMeters raw terrain elevation in meters above mean sea level
|
||||
* @param curvatureAdjustedElevationMeters terrain elevation plus Earth bulge in meters
|
||||
* @param lineOfSightHeightMeters direct path height in the same chart space
|
||||
* @param fresnelUpperHeightMeters upper first Fresnel hull in the same chart space
|
||||
* @param fresnelLowerHeightMeters lower first Fresnel hull in the same chart space
|
||||
* @param lineOfSightClearanceMeters direct LOS clearance against curvature-adjusted terrain
|
||||
* @param lowerFresnelClearanceMeters lower Fresnel clearance against curvature-adjusted terrain
|
||||
* @param fresnelIntrusionMeters terrain intrusion into the lower Fresnel hull
|
||||
*/
|
||||
public PathProfilePoint(int sampleIndex,
|
||||
double distanceKm,
|
||||
double latitudeDeg,
|
||||
double longitudeDeg,
|
||||
double elevationMeters,
|
||||
double curvatureAdjustedElevationMeters,
|
||||
double lineOfSightHeightMeters,
|
||||
double fresnelUpperHeightMeters,
|
||||
double fresnelLowerHeightMeters,
|
||||
double lineOfSightClearanceMeters,
|
||||
double lowerFresnelClearanceMeters,
|
||||
double fresnelIntrusionMeters) {
|
||||
|
||||
this.sampleIndex = sampleIndex;
|
||||
this.distanceKm = distanceKm;
|
||||
this.latitudeDeg = latitudeDeg;
|
||||
this.longitudeDeg = longitudeDeg;
|
||||
this.elevationMeters = elevationMeters;
|
||||
this.curvatureAdjustedElevationMeters = curvatureAdjustedElevationMeters;
|
||||
this.lineOfSightHeightMeters = lineOfSightHeightMeters;
|
||||
this.fresnelUpperHeightMeters = fresnelUpperHeightMeters;
|
||||
this.fresnelLowerHeightMeters = fresnelLowerHeightMeters;
|
||||
this.lineOfSightClearanceMeters = lineOfSightClearanceMeters;
|
||||
this.lowerFresnelClearanceMeters = lowerFresnelClearanceMeters;
|
||||
this.fresnelIntrusionMeters = fresnelIntrusionMeters;
|
||||
}
|
||||
|
||||
public int sampleIndex() {
|
||||
return sampleIndex;
|
||||
}
|
||||
|
||||
public double distanceKm() {
|
||||
return distanceKm;
|
||||
}
|
||||
|
||||
public double latitudeDeg() {
|
||||
return latitudeDeg;
|
||||
}
|
||||
|
||||
public double longitudeDeg() {
|
||||
return longitudeDeg;
|
||||
}
|
||||
|
||||
public double elevationMeters() {
|
||||
return elevationMeters;
|
||||
}
|
||||
|
||||
public double curvatureAdjustedElevationMeters() {
|
||||
return curvatureAdjustedElevationMeters;
|
||||
}
|
||||
|
||||
public double lineOfSightHeightMeters() {
|
||||
return lineOfSightHeightMeters;
|
||||
}
|
||||
|
||||
public double fresnelUpperHeightMeters() {
|
||||
return fresnelUpperHeightMeters;
|
||||
}
|
||||
|
||||
public double fresnelLowerHeightMeters() {
|
||||
return fresnelLowerHeightMeters;
|
||||
}
|
||||
|
||||
public double lineOfSightClearanceMeters() {
|
||||
return lineOfSightClearanceMeters;
|
||||
}
|
||||
|
||||
public double lowerFresnelClearanceMeters() {
|
||||
return lowerFresnelClearanceMeters;
|
||||
}
|
||||
|
||||
public double fresnelIntrusionMeters() {
|
||||
return fresnelIntrusionMeters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if enriched LOS/Fresnel geometry is available.
|
||||
*
|
||||
* @return true if this sample was enriched by the path analysis service
|
||||
*/
|
||||
public boolean hasDerivedGeometry() {
|
||||
return Double.isFinite(curvatureAdjustedElevationMeters)
|
||||
|| Double.isFinite(lineOfSightHeightMeters)
|
||||
|| Double.isFinite(fresnelUpperHeightMeters)
|
||||
|| Double.isFinite(fresnelLowerHeightMeters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the terrain blocks the direct line of sight at this sample.
|
||||
*
|
||||
* @return true if the LOS clearance is negative
|
||||
*/
|
||||
public boolean isLineOfSightBlocked() {
|
||||
return Double.isFinite(lineOfSightClearanceMeters) && lineOfSightClearanceMeters < 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the terrain intrudes into the lower Fresnel hull.
|
||||
*
|
||||
* @return true if Fresnel intrusion is positive
|
||||
*/
|
||||
public boolean hasFresnelIntrusion() {
|
||||
return Double.isFinite(fresnelIntrusionMeters) && fresnelIntrusionMeters > 0.0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"PathProfilePoint{index=%d, distanceKm=%.3f, lat=%.6f, lon=%.6f, elevation=%.2f, curvature=%.2f, los=%.2f, fresnelLower=%.2f, intrusion=%.2f}",
|
||||
sampleIndex,
|
||||
distanceKm,
|
||||
latitudeDeg,
|
||||
longitudeDeg,
|
||||
elevationMeters,
|
||||
curvatureAdjustedElevationMeters,
|
||||
lineOfSightHeightMeters,
|
||||
fresnelLowerHeightMeters,
|
||||
fresnelIntrusionMeters
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Operator-facing propagation assessment derived from geometric path analysis.
|
||||
*
|
||||
* <p>This class deliberately avoids binary "possible/impossible" wording.
|
||||
* VHF/UHF paths can work despite blocked geometric LOS due to diffraction,
|
||||
* troposcatter, enhanced tropospheric refraction, ducting or aircraft scatter.</p>
|
||||
*/
|
||||
public record PathPropagationAssessment(
|
||||
String category,
|
||||
String shortText,
|
||||
String detailText,
|
||||
String likelyMechanisms,
|
||||
int severityLevel
|
||||
) {
|
||||
|
||||
public static PathPropagationAssessment unknown() {
|
||||
return new PathPropagationAssessment(
|
||||
"Unknown",
|
||||
"No propagation assessment available.",
|
||||
"No usable terrain/profile data is available.",
|
||||
"-",
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
public static PathPropagationAssessment directFavorable() {
|
||||
return new PathPropagationAssessment(
|
||||
"Direct path favorable",
|
||||
"Direct path likely",
|
||||
"The geometric line of sight and the first Fresnel zone are clear. A direct tropospheric path is plausible.",
|
||||
"Direct tropospheric path",
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
public static PathPropagationAssessment directLossy(double fresnelIntrusionRatio) {
|
||||
return new PathPropagationAssessment(
|
||||
"Direct path lossy",
|
||||
"Direct path plausible, but lossy",
|
||||
String.format(
|
||||
Locale.US,
|
||||
"The direct line of sight is clear, but the first Fresnel zone is obstructed by about %.0f%% of the local Fresnel radius. Expect additional loss.",
|
||||
fresnelIntrusionRatio * 100.0
|
||||
),
|
||||
"Direct path with Fresnel loss, possible mild diffraction",
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
public static PathPropagationAssessment diffractionPlausible(double knifeEdgeLossDb) {
|
||||
return new PathPropagationAssessment(
|
||||
"Diffraction plausible",
|
||||
"Obstructed, diffraction may still be plausible",
|
||||
String.format(
|
||||
Locale.US,
|
||||
"The direct geometric path is blocked, but the rough single-knife-edge estimate is moderate at about %.1f dB. A QSO may still be possible with sufficient antennas, power and conditions.",
|
||||
knifeEdgeLossDb
|
||||
),
|
||||
"Terrain diffraction, tropo enhancement",
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
public static PathPropagationAssessment obstructedNeedsHelp(double knifeEdgeLossDb) {
|
||||
return new PathPropagationAssessment(
|
||||
"Obstructed",
|
||||
"Obstructed, enhanced propagation likely required",
|
||||
String.format(
|
||||
Locale.US,
|
||||
"The geometric path is blocked and the rough single-knife-edge estimate is high at about %.1f dB. Direct diffraction alone may be weak; tropo enhancement or scatter mechanisms become more relevant.",
|
||||
knifeEdgeLossDb
|
||||
),
|
||||
"Diffraction, troposcatter, tropo enhancement, aircraft scatter",
|
||||
4
|
||||
);
|
||||
}
|
||||
|
||||
public static PathPropagationAssessment severelyObstructed(double knifeEdgeLossDb) {
|
||||
return new PathPropagationAssessment(
|
||||
"Severely obstructed",
|
||||
"Severely obstructed, special propagation probably required",
|
||||
String.format(
|
||||
Locale.US,
|
||||
"The geometric path is strongly blocked. The rough single-knife-edge estimate is about %.1f dB, so a normal direct path is unlikely. This does not mean impossible on VHF/UHF, but special propagation is probably needed.",
|
||||
knifeEdgeLossDb
|
||||
),
|
||||
"Aircraft scatter, tropo ducting/enhancement, troposcatter, strong diffraction only in exceptional cases",
|
||||
5
|
||||
);
|
||||
}
|
||||
|
||||
public static PathPropagationAssessment blockedNoLossEstimate() {
|
||||
return new PathPropagationAssessment(
|
||||
"Blocked",
|
||||
"Geometrically blocked",
|
||||
"The geometric line of sight is blocked. No reliable diffraction-loss estimate is available for this profile.",
|
||||
"Diffraction, tropo enhancement, troposcatter, aircraft scatter",
|
||||
4
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,369 +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.model.ChatMember;
|
||||
import kst4contest.model.ChatPreferences;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import kst4contest.model.Band;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/**
|
||||
* Synchronizes the application state with the station map window.
|
||||
*
|
||||
* 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 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 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());
|
||||
|
||||
|
||||
}
|
||||
|
||||
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()
|
||||
);
|
||||
|
||||
chatController.getLst_chatMemberListFilterPredicates().addListener(
|
||||
(ListChangeListener<Predicate<ChatMember>>) change -> requestImmediateRefresh()
|
||||
);
|
||||
|
||||
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();
|
||||
EnumSet<Band> selectedBands = chatController.getReachabilityService().getEnabledStationBands();
|
||||
|
||||
List<MapCallsignRawSnapshot> snapshots = snapshotBuilder.buildSnapshots(
|
||||
visibleChatMembers,
|
||||
selectedChatMember,
|
||||
selectedBands
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the selected station path analysis through the central reachability
|
||||
* service.
|
||||
*
|
||||
* <p>The map no longer owns a separate PathAnalysisService. This ensures that
|
||||
* the map detail panel, the Tropo table column and the station filters all use
|
||||
* exactly the same path/link-budget calculation.</p>
|
||||
*
|
||||
* @param ownLocator6 own locator from preferences
|
||||
* @param selectedSnapshot selected map marker snapshot
|
||||
*/
|
||||
private void requestPathAnalysisAsync(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) {
|
||||
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)
|
||||
);
|
||||
|
||||
ChatMember selectedMember = resolveBestChatMember(targetCallsignRaw);
|
||||
|
||||
chatController.getReachabilityService().requestPathAnalysisForMap(
|
||||
selectedMember,
|
||||
selectedSnapshot,
|
||||
result -> {
|
||||
if (generation != pathAnalysisGeneration.get()) {
|
||||
return;
|
||||
}
|
||||
stationMapView.setPathAnalysisResult(result);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates pending map callbacks.
|
||||
*
|
||||
* <p>The actual calculation executor is owned by ReachabilityService now, so
|
||||
* this bridge no longer shuts down any path-analysis thread directly.</p>
|
||||
*/
|
||||
public void dispose() {
|
||||
pathAnalysisGeneration.incrementAndGet();
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
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,159 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.*;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* Minimal HTTP server that proxies OSM tile requests via Java's HttpClient,
|
||||
* so JavaFX WebView only needs to connect to localhost (avoids WebKit SSL/sandbox
|
||||
* issues in AppImage and Flatpak packaging). Uses only java.base + java.net.http.
|
||||
*/
|
||||
final class TileProxyServer {
|
||||
|
||||
private static final int CACHE_MAX = 2048;
|
||||
private static final String USER_AGENT = "kst4contest/1.0 amateur-radio-contest-tool";
|
||||
private static final int TILE_PROXY_THREADS = 12;
|
||||
private static final int TILE_PROXY_BACKLOG = 128;
|
||||
|
||||
private final ServerSocket serverSocket;
|
||||
private final ExecutorService executor;
|
||||
private final HttpClient httpClient;
|
||||
private final Map<String, byte[]> cache;
|
||||
|
||||
TileProxyServer() throws IOException {
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
this.cache = Collections.synchronizedMap(new LinkedHashMap<>(CACHE_MAX, 0.75f, true) {
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<String, byte[]> eldest) {
|
||||
return size() > CACHE_MAX;
|
||||
}
|
||||
});
|
||||
|
||||
this.serverSocket = new ServerSocket(0, TILE_PROXY_BACKLOG, InetAddress.getByName("127.0.0.1"));
|
||||
|
||||
this.executor = Executors.newFixedThreadPool(TILE_PROXY_THREADS, r -> {
|
||||
Thread t = new Thread(r, "tile-proxy");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
executor.submit(this::acceptLoop);
|
||||
}
|
||||
|
||||
int getPort() {
|
||||
return serverSocket.getLocalPort();
|
||||
}
|
||||
|
||||
void stop() {
|
||||
try { serverSocket.close(); } catch (IOException ignored) {}
|
||||
executor.shutdownNow();
|
||||
}
|
||||
|
||||
private void acceptLoop() {
|
||||
while (!serverSocket.isClosed()) {
|
||||
try {
|
||||
Socket client = serverSocket.accept();
|
||||
executor.submit(() -> handleClient(client));
|
||||
} catch (IOException e) {
|
||||
if (!serverSocket.isClosed()) {
|
||||
System.err.println("[TileProxy] accept error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleClient(Socket client) {
|
||||
try (client;
|
||||
BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream()));
|
||||
OutputStream out = client.getOutputStream()) {
|
||||
|
||||
String requestLine = in.readLine();
|
||||
if (requestLine == null || !requestLine.startsWith("GET ")) {
|
||||
sendError(out, 400, "Bad Request");
|
||||
return;
|
||||
}
|
||||
|
||||
// Drain headers
|
||||
String line;
|
||||
while ((line = in.readLine()) != null && !line.isEmpty()) { /* skip */ }
|
||||
|
||||
// Parse: GET /tiles/{s}/{z}/{x}/{y}.png HTTP/1.1
|
||||
String[] parts = requestLine.split(" ");
|
||||
if (parts.length < 2) { sendError(out, 400, "Bad Request"); return; }
|
||||
String path = parts[1];
|
||||
|
||||
String[] segments = path.split("/");
|
||||
// segments: ["", "tiles", s, z, x, "y.png"]
|
||||
if (segments.length != 6
|
||||
|| !segments[2].matches("[abc]")
|
||||
|| !segments[3].matches("\\d{1,2}")
|
||||
|| !segments[4].matches("\\d+")
|
||||
|| !segments[5].matches("\\d+\\.png")) {
|
||||
sendError(out, 404, "Not Found");
|
||||
return;
|
||||
}
|
||||
|
||||
String cacheKey = segments[2] + "/" + segments[3] + "/" + segments[4] + "/" + segments[5];
|
||||
byte[] tileData = cache.get(cacheKey);
|
||||
|
||||
if (tileData == null) {
|
||||
String tileUrl = "https://" + segments[2] + ".tile.openstreetmap.org/"
|
||||
+ segments[3] + "/" + segments[4] + "/" + segments[5];
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(tileUrl))
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.timeout(Duration.ofSeconds(15))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
sendError(out, 502, "OSM returned " + response.statusCode());
|
||||
return;
|
||||
}
|
||||
|
||||
tileData = response.body();
|
||||
cache.put(cacheKey, tileData);
|
||||
}
|
||||
|
||||
String header = "HTTP/1.1 200 OK\r\n"
|
||||
+ "Content-Type: image/png\r\n"
|
||||
+ "Content-Length: " + tileData.length + "\r\n"
|
||||
+ "Cache-Control: max-age=86400\r\n"
|
||||
+ "Access-Control-Allow-Origin: *\r\n"
|
||||
+ "Connection: close\r\n"
|
||||
+ "\r\n";
|
||||
out.write(header.getBytes());
|
||||
out.write(tileData);
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception e) {
|
||||
System.err.println("[TileProxy] error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void sendError(OutputStream out, int code, String message) throws IOException {
|
||||
byte[] body = message.getBytes();
|
||||
String response = "HTTP/1.1 " + code + " " + message + "\r\n"
|
||||
+ "Content-Length: " + body.length + "\r\n"
|
||||
+ "Connection: close\r\n"
|
||||
+ "\r\n";
|
||||
out.write(response.getBytes());
|
||||
out.write(body);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,9 @@ module praktiKST {
|
||||
requires jdk.xml.dom;
|
||||
requires java.sql;
|
||||
requires javafx.media;
|
||||
requires jdk.jsobject;
|
||||
requires jlayer;
|
||||
requires java.net.http;
|
||||
requires java.desktop;
|
||||
requires jdk.crypto.ec;
|
||||
exports kst4contest.controller.interfaces;
|
||||
exports kst4contest.controller;
|
||||
exports kst4contest.locatorUtils;
|
||||
exports kst4contest.model;
|
||||
exports kst4contest.view;
|
||||
|
||||
|
||||
opens kst4contest.view.map to javafx.web;
|
||||
}
|
||||
}
|
||||
@@ -1,661 +0,0 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,661 +0,0 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,56 +0,0 @@
|
||||
package kst4contest.test;
|
||||
|
||||
import kst4contest.model.Band;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.view.map.MapCallsignRawSnapshot;
|
||||
import kst4contest.view.map.MapCallsignRawSnapshotBuilder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class MapCallsignRawSnapshotBuilderTest {
|
||||
|
||||
@Test
|
||||
void marksSnapshotWhenNameAdvertisesSelectedBand() {
|
||||
ChatMember station = buildStation("DL1ABC", "QRV 2-70-23", "JN58TD", 1_000L);
|
||||
|
||||
MapCallsignRawSnapshotBuilder builder = new MapCallsignRawSnapshotBuilder();
|
||||
List<MapCallsignRawSnapshot> snapshots = builder.buildSnapshots(
|
||||
List.of(station),
|
||||
null,
|
||||
EnumSet.of(Band.B_1296)
|
||||
);
|
||||
|
||||
assertEquals(1, snapshots.size());
|
||||
assertTrue(snapshots.get(0).offersSelectedBand());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsSnapshotUnmarkedWhenNoSelectedBandMatches() {
|
||||
ChatMember station = buildStation("DL1ABC", "QRV 2m only", "JN58TD", 1_000L);
|
||||
|
||||
MapCallsignRawSnapshotBuilder builder = new MapCallsignRawSnapshotBuilder();
|
||||
List<MapCallsignRawSnapshot> snapshots = builder.buildSnapshots(
|
||||
List.of(station),
|
||||
null,
|
||||
EnumSet.of(Band.B_2320)
|
||||
);
|
||||
|
||||
assertEquals(1, snapshots.size());
|
||||
assertFalse(snapshots.get(0).offersSelectedBand());
|
||||
}
|
||||
|
||||
private ChatMember buildStation(String callSign, String name, String locator, long activityEpoch) {
|
||||
ChatMember chatMember = new ChatMember();
|
||||
chatMember.setCallSign(callSign);
|
||||
chatMember.setName(name);
|
||||
chatMember.setQra(locator);
|
||||
chatMember.setActivityTimeLastInEpoch(activityEpoch);
|
||||
return chatMember;
|
||||
}
|
||||
}
|
||||
+1
-591
@@ -1780,594 +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
|
||||
OL70KEA;70/23CM;JN89EJ;StringProperty [value: 144.389 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
SN7L;144.180;JO70SS;StringProperty [value: 144.180 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL2L;2m;JN68DT;StringProperty [value: 365 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL5EC/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL3IAS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DJ7GS;Marek;JN47KW;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DO8PAT/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
S59DEM;RC Proteus;JN75DS;StringProperty [value: 144.326 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK7CM/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OE1W;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1HMP;Martin 2m QRO;JO70EB;StringProperty [value: 144.291 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
TM5R;Didier;JN19BQ;StringProperty [value: 144.279.81 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
F1PHB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK5RA;Radek;JN89IW;StringProperty [value: 144,092 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OK1NPF;Roman 2m QRP;JO70UK;StringProperty [value: 144.330 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
SP6CPF;Franek 2/70/23CW;JO71PD;StringProperty [value: 123 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DG0VOG;xAC 2m qro;JO60QU;StringProperty [value: 163 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OK1TEH;Matej ssb/cw;JO70FD;StringProperty [value: 144.390 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OZ1ALS;144307;JO44XX;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OZ1ALS;144307;JO44XX;StringProperty [value: 307 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OZ1DLD/P;Bent;JO45SK;StringProperty [value: 144.275 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
5P5T;Team;JO64GX;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL5NEN;Tom;JN59OP;StringProperty [value: 144.380 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DA0FF;144.248;JO40XL;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DM2BKB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1SUZ;Uwe 2m Contest;JO53UN;StringProperty [value: 275 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
7S7V;Samir;JO65SN;StringProperty [value: 144345 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DR7C;team 2m;JO50WB;StringProperty [value: 338 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL3SYA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL3SYA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL3SYA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OL7C;Radio Club;JO60JJ;StringProperty [value: 144,213 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OL4N;Club;JO60VR;StringProperty [value: 237 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF0YY;Berlin 144.325;JO62GD;StringProperty [value: 324 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF0LU;DF0LU;JO43UA;StringProperty [value: 281 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DG8LG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DR2L;144MHz;JO41PU;StringProperty [value: 144.227 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OK2ADM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH8BQA;Olli;JO73CE;StringProperty [value: 144293 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DR2X;Team 144.355;JO40QL;StringProperty [value: 355 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OK1KKD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6KDS/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM5M;KST4ContestVUHF;JO51IJ;StringProperty [value: 144.315.00 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
SM7VUK;Bengt/2m/SSB;JO66LI;StringProperty [value: 197 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DM5D;144.382.50;JO61OC;StringProperty [value: 144.369 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL3LAR/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK0NA;club;JO50TI;StringProperty [value: 144.230 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DG5BRE;Ronny 2m-9cm;JO62VM;StringProperty [value: 199 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DM7EE;Chris, 2m, 750W;JO52JJ;StringProperty [value: 144,177,3 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF7KF;Dithmar;JO30FK;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
PA9R;Rob;JO22JK;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DR1A;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DD0VF;Steffen 2-70-23;JO61TB;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
EI3KD;Mark 6/2/70/23;IO51VW;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DM2RN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
F1NZC;Jean-Louis JN15;JN15MR;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
F5DYD;JLouis 23/3;IN86XW;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DH1WHM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
EA4SG;David;IN80CP;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DH1WM;Mathias not qrv;JN49CD;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
2E0WED;Daniel;IO83LI;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
F5ODA;Eric;JN13LE;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
G4KUX;Nick;IO94BP;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
HB9SJV;Ben;JN36FL;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
HB9SJV;Ben;JN36FL;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
G8ONK;Tony 432.390;IO83MR;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
G8ONK;Tony 432.390;IO83MR;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
M0IAM;Clive;IO91QE;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OY4TN;Trygvi 11EL/100W;IP62NB;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OY4TN;Trygvi 11EL/100W;IP62NB;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
F2CT;Guy 432.225;IN93GJ;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
F2CT;Guy 432.225;IN93GJ;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
9A2HM;Kreso;JN82UQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
EI3KD;Mark 6/2/70/23;IO51VW;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
EI3KD;Mark 6/2/70/23;IO51VW;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK2T;Sven;JO41UM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
SP6LX;Bob;JO42ON;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
9A2HM;Kreso;JN82UQ;StringProperty [value: 2320.184 ]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DM5M;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF0GEB;test;JO51HK;StringProperty [value: 432.234]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OM5AW;Joe QRO144;JN98AH;StringProperty [value: 432.321]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
EI9BM;Alain;IO45PT;StringProperty [value: 144.599]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
F1NZC;Jean-Louis JN15;JN15MR;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
F6HTJ;Michel not QRV;JN12KQ;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
F6HTJ;Michel not QRV;JN12KQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL9L;Frank;JN36XE;StringProperty [value: 432.599]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL1TEST;TestOp;JO50XX;StringProperty [value: 144.599]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
EA1Q;Alain;IN61MD;StringProperty [value: 432.599]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DJ3AX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
HB9CEV/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6KDS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1SE;Michael 2m;JO50LQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL6UHA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM5UE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK2CB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1RLB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM3ZF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO2TL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6CNG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM6AT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL4PT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL5OU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DD6YR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DA1E;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DN9HC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH2UHE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK1WB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK5AJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK2AR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2RZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK1IJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL8MLD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF7CP/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL9ZX/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DJ9WJ/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
SC7W;Torleif 2m only;JO65NK;StringProperty [value: 144.31]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK2TN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2AQI/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL4LAM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1MER;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DA6KH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK8XY;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO1OIB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2OBF/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK1FY;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2AKT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1RWO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO4HBK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM2CF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM6MS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OE5XXL/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL4WK/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL4MN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1RDO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK2TX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL4SKH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6NEJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK7VN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0CG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL4NWM/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6UAL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO1AYJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK2AF;Milan 2m;JN89AR;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL0DLE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK6AC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK2IT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK2LB;Torsten;JO53LQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF3AS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DA0UDS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DJ1FZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0TZ;Clubstation;JN59LN;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DJ4MH;Marco (2m);JO42BB;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
ON5DRE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1NGS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK1MF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK2YCT;Chris (23cm);JO32RG;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DK6QO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL9FBF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DR5W;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DJ3QB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL5SBY;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF7RA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK6SR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2YDS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DJ8WK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DJ5KW/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH3KR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO1FDK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
ON6ZY;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH1PS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1DAW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF2QZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK5KT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF8TM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM2BHG;Heinz;JO51MW;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DM5ML;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF9FD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2FFW;70cm;JO50LQ;StringProperty [value: 144.28]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DB9OH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK5OA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM3DG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF3OL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1BFR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6CWM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1AAO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DQ8N-70;Team Wetzstein;JO50RK;StringProperty [value: 432.165]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DG0PF;Gilbert 2m;JO50LQ;StringProperty [value: 432.16]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DJ7YP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1HBG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO4CBN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK7AC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DJ9FC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO8THW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0YE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
PI4AMF/P;contest club;JO22XB;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL2HXE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2FQ;Tzetzo - 23;JN49EW;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DC7QY;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL7AX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1HTT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF0WF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6OO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0MI;Horst;JO42KH;StringProperty [value: 1296.2]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DG6ME;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL3RHN;Rüdiger 2m;JO63PM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL0NF;Harry 13 cw;JN59PL;StringProperty [value: 432.38]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
OK1KWV;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL9BBD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DA6SB;DG1HR/DA6SB 2/70;JO52HI;StringProperty [value: 144.273]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL2FFW;2m;JO50LQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL2LSM;Guenter;JO61GH;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL6ZXG;Klaus;JO51KU;StringProperty [value: 144.22]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DJ2FR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OE5JSL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL5DWF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6FKR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL3LAR;Rolf;JO52GE;StringProperty [value: 1296.215]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DJ6OL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG1MH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO4ADK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DJ1OB;Olli 2m;JN48UG;StringProperty [value: 144.043]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DO6XA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OE1W;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DB8LE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2VK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM5B;only 2m;JO62XE;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL4M-70;Club 70cm;JO31QX;StringProperty [value: 144.248]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL5RX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DC5IMM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF0YY-3;9-6-3-1,2;JO62GD;StringProperty [value: 432.155]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DA2R;DA2R;JN69EM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL8TB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
HB9TTY;10Y 144/432 650W;JN46BX;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
HB9GF;Contest;JN47BC;StringProperty [value: 144.388]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OK1LN;Lada;JN79AI;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL4MW;Ralf 2m;JO50KQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
PA0C;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2YEY;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF1DS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM3KP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
F6IHC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DA1AM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DJ2KP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1ATI;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL9DBF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG8BS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF2BR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DR1T;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DR2L;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL5AAZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL4YDR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL4YDR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO9MHG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK0AU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG6MC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2NDL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF0MU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL9MKA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK2AJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DN9MD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL5MO;23;JO50LQ;StringProperty [value: 144.38]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DL3IAS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK5RA;Radek;JN89IW;StringProperty [value: 144.317]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DD6OM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1KAD;Karel 23cm;JO60LJ;StringProperty [value: 1296.292]; wkd true; wkd144 false; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DL2IKE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DQ8N-70;Team Wetzstein;JO50RK;StringProperty [value: 144.165]; wkd true; wkd144 true; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DJ8UHU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL7YS;23/13cm 80W@LYs;JO62NM;StringProperty [value: 1296.19]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DG4OP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DR5W;Club;JO61OX;StringProperty [value: 144.27]; wkd true; wkd144 true; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DL1C;OT Club;JO72AL;StringProperty [value: 144.184]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DR5W;Club;JO61OX;StringProperty [value: 144.27]; wkd true; wkd144 true; wkd432true; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DK6EE;Andreas;JO52KL;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL7YS;23/13cm 80W@LYs;JO62NM;StringProperty [value: 1296.19]; wkd true; wkd144 true; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DL8IK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL7YS;23/13cm 80W@LYs;JO62NM;StringProperty [value: 1296.19]; wkd true; wkd144 true; wkd432true; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DL4ASK;Guenther 2m;JO50LQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL0WSF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF6KB;Kai 70cm only;JO42JC;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF1HF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK5KT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1OPT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1SE;Michael 70cm;JO50LQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DO1MLH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM2DXG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6UJH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DQ8N-23;Ralf;JN59US;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DL6ZEJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL9NM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL4M;1296.230;JO31QX;StringProperty [value: 144.3745]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DL1OKB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG8LG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH6ABE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM2D;2x8 2m only QRO;JO64ND;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF8OI;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL8NAS;Sigi-70cm;JN59LE;StringProperty [value: 432.225]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DM7EE;Chris 2m/70cm;JO52JJ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DC7BK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6MIG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG0PF;Gilbert 70cm;JO50LQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL5CAT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL5MO;70cm;JO50LQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DF7NX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DN9APW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL3LAR;Rolf 23/13/9/6/3;JO52GE;StringProperty [value: 1296.115]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DL5C;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1KKI-70CM;ok1kki;JN79NF;StringProperty [value: 144.255]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DG4MH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL8LR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH8WE;Frank;JO50TJ;StringProperty [value: 144.11]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK4VW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL5HF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OE3EFS/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1KCB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF9YY;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
PI4GN-23;Club;JO33II;StringProperty [value: 144.235]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DM5F;Marcel 2/70/23;JO71ES;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DM2DXG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OE3UFC/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK5WO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1HSF;Micha 2m-24G;JO61FR;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
SP6CPF;Franek 2/70/23CW;JO71PD;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK2PZ;Manfred 70cm;JN57MT;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL6ON;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1FLC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
SN6R;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO4ADK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6ON;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK5EZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
HA1CA;HA1CA;JN86HN;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DG1HR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM3MS;Tino;JO62IH;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK0NA;Club;JO50TI;StringProperty [value: 10368.1]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DL4MFM/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0NF;Harry 13 cw;JN59PL;StringProperty [value: 432.24]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DC8RI;Uwe 23&13;JN68JQ;StringProperty [value: 144.1]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DH4EAK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK6FX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG1NPJ;Juergen-2m;JN59LE;StringProperty [value: 144.31]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL4DAW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG4VW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0WSF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK5T;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK9OG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1NAO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK2L;Club;JN99BN;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF0YY-3;9-6-3-1,2;JO62GD;StringProperty [value: 432.1]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DM5D-2;DM5D-2m;JO61OC;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DM3OA;erni;JO63UW;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
PI4GN-23;Club;JO33II;StringProperty [value: 144.235]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DK4RA;Ragna 2m;JO50KQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OK1KKP;Lukas 23cm+3cm;JO70DG;StringProperty [value: 144.163]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
OK1IB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH1AKY;Jens 70;JO50LQ;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL4MA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1KCR;Club 2m;JN79VS;StringProperty [value: 144.162]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DR7C;Team 10G and up;JO50WB;StringProperty [value: 144.298]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
IQ5NN;Monte Nerone;JN63GN;StringProperty [value: 144.205]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OL3Z-7;club 432,210;JN79FX;StringProperty [value: 432.21]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL1YDI;Dirk 9Ele/400W;JO42FA;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DM7A;Team TUD;JO60OM;StringProperty [value: 432.175]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL2NBU;Peter;JN59KQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DM7A;Team TUD;JO60OM;StringProperty [value: 432.175]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DM7A;Team TUD;JO60OM;StringProperty [value: 432.175]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
PA0WMX;Wim 2m 70 23 3;JO21XI;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DG6KBG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL3ABL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK0NA;Club;JO50TI;StringProperty [value: 10368.1]; wkd true; wkd144 false; wkd432true; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
OV3T;Thomas;JO46CM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL5DBT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM7A;Team TUD;JO60OM;StringProperty [value: 144.175]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
SN7L;144.185 Contest;JO91QF;StringProperty [value: 144.185]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OL7M;Team 1100asl;JO80FG;StringProperty [value: 144.191]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK0PU-10;Team 10GHz;JO31JN;StringProperty [value: 144.1]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
ON4EI/P;Oli 2M ONLY;JO20EP;StringProperty [value: 144.272]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
IO2V-432;Team 70cm;JN54WE;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
G8T;144.153;JO01NC;StringProperty [value: 144.153]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
S51IV;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1YGH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL8SCD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
S59P-2;23,13,9,3 cm;JN86AO;StringProperty [value: 5760.1]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DK2R;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH1AKY;Jens 2m;JO50LQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DJ5NE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
SQ3GJS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
F8KID;Club;JN38AT;StringProperty [value: 144.254]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DR2Q;Stefan;JO50SF;StringProperty [value: 144.1]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF0LU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1KAD;Karel 23cm;JO60LJ;StringProperty [value: 144.178]; wkd true; wkd144 true; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OL7C;Radio Club;JO60JJ;StringProperty [value: 144.212]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OL4N;Vlasta;JO60VR;StringProperty [value: 144.316]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DA6UU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
SM6VTZ;Chris .235 .135;JO58UJ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
OK1NPF;Roman 2m/ssb;JO70UK;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DM7D;Ronald;JO62LI;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OK1MWW;Jiri 2m/70cm;JN89DW;StringProperty [value: 144.27]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL2WM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK7AM;Uwe;JN59SR;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF0A/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
G4PIQ;Andy 144.312;JO02OD;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DR1D;Detlef;JO30JU;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK6FE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
G4LPP;Phil;JO02SS;StringProperty [value: 144.348]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL1SX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF1QR;Holger;JO31LJ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL4VAI;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
ON5CLR/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG4MH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM5D-2;DM5D-2m;JO61OC;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DL5ZBS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0RN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DR1H;144.380;JN59OP;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL6NDW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OE8SDR;/P 2x10 QRO;JN76KO;StringProperty [value: 144.144303]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK0NA;Club;JO50TI;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
OK2KOJ;OK2KOJ Club;JN89GF;StringProperty [value: 144.144]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DG5BRE;Ronny 2m-6cm;JO62VM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DG5BRE;Ronny 2m-6cm;JO62VM;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
OL3Z-7;club 432,210;JN79FX;StringProperty [value: 432.21]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK0A;Club 144.225;JN48CO;StringProperty [value: 144.225]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL4FDD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO8PAT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO1JKO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM5EI;Peter;JO43DC;StringProperty [value: 144.169]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DJ6VX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
PC2K;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DJ7ZZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1DIX;Lada 2m;JN79IP;StringProperty [value: 144.087]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DG7SCB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1FD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL3AAV;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0PP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6MHX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6MHW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO1DJJ/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL8EX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL3HAH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
F0EGV/P;Thierry;JN19MI;StringProperty [value: 144.206]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DG9ZA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM3AW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG1YBN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0RN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
ON4PS/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL8MV;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK5HQ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2JST;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL5JS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DA2M;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1YEG;Uwe;JO42HG;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DO1GPP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2ROA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2DRG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
F1TRE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG2SER;Carsten 2m;JN58OH;StringProperty [value: 144.215]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DF8TM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DR2Q;Stefan;JO50SF;StringProperty [value: 144.28]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK2BO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0XD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK2C;team 70cm-76GHz;JN99AJ;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DD5AJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO1DW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL5NUA;Klaus(70_only);JO63PO;StringProperty [value: 144.266]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK2ZO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO1DW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM2EUN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL7ATR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH9FAW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG4FCX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG5NFF;23cm10G24GMartin;JO50DO;StringProperty [value: 1296.15]; wkd true; wkd144 false; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DO1XRK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG3AWN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
SM7VUK;Bengt MGM FT8/4;JO66LI;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK7RC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK5HM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG0ONW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1VDJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
F6KFH;Radioclub;JN39OC;StringProperty [value: 432.257]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
HG7M;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1PMA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM5CT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH1DX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK2EA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK2WC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
HB9GF-70;HFI;JN47BC;StringProperty [value: 432.22]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
SP6FXF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG1BHA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0NAU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF9ME/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO5STS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK4C-7;Klondajk 70cm;JN79BU;StringProperty [value: 432.255]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL8AMB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OM3W;Club 2m only;JN99CH;StringProperty [value: 144.325]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL8R;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1JHR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL25WIKI;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG8MDA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL8GL/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG1HQK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0AU/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH1PAL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK2LA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG1NGR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF1AK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG1E;DG1E-23 .240;JO31LE;StringProperty [value: 144.24]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DR3K;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM4K;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK7DCM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG8KV;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL8LR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK2HZ/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DN9SFM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1DT/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF9OO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH1UZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF7JU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL5BCQ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
PA0DDB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0RD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM7KN/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL5BAW/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DJ4FV;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL4YBZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK5WO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6GCK;Konrad 2m;JN47OR;StringProperty [value: 144.36]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL9FCM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DB4LL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK9CK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DB7PN/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF3TE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DN9RME;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DC6HG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF4WO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK4REX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK5EZ;George;JO31NH;StringProperty [value: 144.345]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL1FKB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL9RP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1EIP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OM5AW;Joe QRO144;JN98AH;StringProperty [value: 144.241]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DO6NI;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
F8KGU;Didier;JN19BQ;StringProperty [value: 144.33]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK0AJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL3SBA/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL9UN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK8LQ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG5SP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DB2DY;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL9KDW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK5II;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH1WM/P;Mathias 2m;JN49AG;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL8FBX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DB5SM;Klaus-2m;JN59LE;StringProperty [value: 144.194]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL2MS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DJ3SN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH9ET;Tom 2m;JN57TS;StringProperty [value: 144.226]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DG4MNA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1ARS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL3BH;Holger;JN49SB;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
OK1KFH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK6M;2m-18el. M2,4x6;JN99CR;StringProperty [value: 144.235]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OK1KJP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1ADT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
SP7VVB;Maciek 2m;JO91VQ;StringProperty [value: 144.385]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
SP9KDA;KST4Contest1263;JO90PP;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
OK2KEA;Radioclub 2m;JN89EJ;StringProperty [value: 144.375]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
SP6KEP;RC 2m only;JO90CK;StringProperty [value: 144.18]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
F1PHB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO2AD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
G2D;Club 144.320;JO01JA;StringProperty [value: 144.319]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DB1PA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK0TUI;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
9A1O;RC Osijek;JN95IS;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF0BZ;Dave;JN05EL;StringProperty [value: 144.39]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF0N;Frank;JN21IY;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF0N;Frank;JN21IY;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL2V;Bob;JO82AM;StringProperty [value: 144.179]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL2V;Bob;JO82AM;StringProperty [value: 144.179]; wkd true; wkd144 true; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF0U;Sven;JO34DD;StringProperty [value: 1296.292]; wkd true; wkd144 false; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DF1C;Bob;JO43LS;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
OK8V;Alain;JN34TV;StringProperty [value: 432.115]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OV3T;Thomas;JO46CM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OZ1BEF;Dan;JO46OE;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OZ4VW;Arne;JO45UT;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OZ1DLD/P;Bent;JO45SK;StringProperty [value: 144.285]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OZ1FDH;Claus;JO55QX;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OZ7KJ;Skive Club;JO46ML;StringProperty [value: 144.225]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
SM7VUK;Bengt;JO66LI;StringProperty [value: 144.304]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OZ1HDF;Ken;JO55UN;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DO5SA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
9A2HM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
9A2HM;null;null;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
9A2HM;null;null;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240false; wkd2300true; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OV3T;Thomas;JO46CM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL2L;2m;JN68DT;StringProperty [value: 365 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
@@ -1,125 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const markdownIt = require("markdown-it");
|
||||
const markdownItAnchor = require("markdown-it-anchor");
|
||||
|
||||
function rewriteManualLinks(content, lang) {
|
||||
return (content || "")
|
||||
// GitHub-Wiki-Links im Stil [[de-Installation]] oder [[en-Installation]]
|
||||
.replace(/\[\[(en|de)-([^\]|]+)\]\]/g, (match, linkLang, slug) => {
|
||||
return `[${slug.replace(/-/g, " ")}](/manual/${linkLang}/${slug.toLowerCase()}/)`;
|
||||
})
|
||||
|
||||
// GitHub-Wiki-Links mit Label: [[Text|de-Installation]]
|
||||
.replace(/\[\[([^\]|]+)\|(en|de)-([^\]]+)\]\]/g, (match, label, linkLang, slug) => {
|
||||
return `[${label}](/manual/${linkLang}/${slug.toLowerCase()}/)`;
|
||||
})
|
||||
|
||||
// Markdown-Links auf de-/en-Dateien ohne .md
|
||||
.replace(/\]\((en|de)-([^)#]+)\)/g, (match, linkLang, slug) => {
|
||||
return `](/manual/${linkLang}/${slug.toLowerCase()}/)`;
|
||||
})
|
||||
|
||||
// Markdown-Links auf de-/en-Dateien mit .md
|
||||
.replace(/\]\((en|de)-([^)#]+)\.md\)/g, (match, linkLang, slug) => {
|
||||
return `](/manual/${linkLang}/${slug.toLowerCase()}/)`;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = function (eleventyConfig) {
|
||||
|
||||
eleventyConfig.addFilter("dateToIso", function (dateObj) {
|
||||
return new Date(dateObj).toISOString().split("T")[0];
|
||||
});
|
||||
|
||||
eleventyConfig.addFilter("dateToRfc822", function (dateObj) {
|
||||
return new Date(dateObj).toUTCString();
|
||||
});
|
||||
|
||||
eleventyConfig.addFilter("readableDate", function (dateObj) {
|
||||
return new Intl.DateTimeFormat("en", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric"
|
||||
}).format(dateObj);
|
||||
});
|
||||
|
||||
eleventyConfig.addCollection("sortedFeatures", function (collectionApi) {
|
||||
return collectionApi.getFilteredByTag("features").sort((a, b) => {
|
||||
return (a.data.order || 999) - (b.data.order || 999);
|
||||
});
|
||||
});
|
||||
|
||||
eleventyConfig.addCollection("featuredFeatures", function (collectionApi) {
|
||||
return collectionApi.getFilteredByTag("features")
|
||||
.filter(item => item.data.featured)
|
||||
.sort((a, b) => {
|
||||
return (a.data.order || 999) - (b.data.order || 999);
|
||||
});
|
||||
});
|
||||
|
||||
eleventyConfig.addFilter("whereTag", function(collection, tag) {
|
||||
return collection.filter(item => item.data.tags && item.data.tags.includes(tag));
|
||||
});
|
||||
|
||||
eleventyConfig.addPassthroughCopy({ "src/assets": "assets" });
|
||||
|
||||
const md = markdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true
|
||||
}).use(markdownItAnchor, {
|
||||
permalink: markdownItAnchor.permalink.headerLink()
|
||||
});
|
||||
|
||||
eleventyConfig.addFilter("markdown", function (value) {
|
||||
return md.render(value || "");
|
||||
});
|
||||
|
||||
eleventyConfig.addCollection("manualPages", function () {
|
||||
const docsDir = path.join(__dirname, "..", "github_docs");
|
||||
|
||||
if (!fs.existsSync(docsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(docsDir)
|
||||
.filter(file => /^(en|de)-.+\.md$/.test(file))
|
||||
.map(file => {
|
||||
const fullPath = path.join(docsDir, file);
|
||||
const raw = fs.readFileSync(fullPath, "utf8");
|
||||
|
||||
const lang = file.startsWith("de-") ? "de" : "en";
|
||||
const slug = file
|
||||
.replace(/^en-/, "")
|
||||
.replace(/^de-/, "")
|
||||
.replace(/\.md$/, "")
|
||||
.toLowerCase();
|
||||
|
||||
const title = file
|
||||
.replace(/^en-/, "")
|
||||
.replace(/^de-/, "")
|
||||
.replace(/\.md$/, "")
|
||||
.replace(/-/g, " ");
|
||||
|
||||
return {
|
||||
file,
|
||||
lang,
|
||||
slug,
|
||||
title,
|
||||
content: rewriteManualLinks(raw, lang)
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
dir: {
|
||||
input: "src",
|
||||
output: "_site",
|
||||
includes: "_includes",
|
||||
layouts: "_layouts"
|
||||
},
|
||||
markdownTemplateEngine: "njk",
|
||||
htmlTemplateEngine: "njk"
|
||||
};
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
# KST4Contest Website Architecture
|
||||
|
||||
## Goals
|
||||
|
||||
KST4Contest uses GitHub as the central source for code, documentation and releases.
|
||||
|
||||
The website is generated statically with Eleventy (11ty) and deployed to:
|
||||
|
||||
https://kst4contest.hamradioonline.de
|
||||
|
||||
## Documentation Source
|
||||
|
||||
The existing `github_docs/` directory remains the single source of truth for the manual.
|
||||
|
||||
It is used for:
|
||||
|
||||
- GitHub Wiki
|
||||
- PDF manual generation
|
||||
- Website manual pages
|
||||
|
||||
## Website Source
|
||||
|
||||
The `website/` directory contains the Eleventy project.
|
||||
|
||||
Manual content must not be duplicated inside `website/`.
|
||||
|
||||
Marketing, SEO, news, screenshots and download pages belong into `website/src/`.
|
||||
|
||||
## URL Structure
|
||||
|
||||
- `/`
|
||||
- `/features/`
|
||||
- `/download/`
|
||||
- `/screenshots/`
|
||||
- `/news/`
|
||||
- `/manual/`
|
||||
- `/manual/en/`
|
||||
- `/manual/de/`
|
||||
- `/faq/`
|
||||
|
||||
## Deployment
|
||||
|
||||
The generated static output is deployed to:
|
||||
|
||||
`/srv/www/kst4contest/current`
|
||||
|
||||
Nginx serves this directory.
|
||||
|
||||
## Build Strategy
|
||||
|
||||
GitHub Actions will later build the 11ty website and deploy the generated output to the server.
|
||||
@@ -1,122 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>About KST4Contest</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="About KST4Contest and its contest-oriented ON4KST workflow.">
|
||||
<link rel="canonical" href="https://kst4contest.hamradioonline.de/about/">
|
||||
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="About KST4Contest">
|
||||
<meta property="og:description" content="About KST4Contest and its contest-oriented ON4KST workflow.">
|
||||
<meta property="og:url" content="https://kst4contest.hamradioonline.de/about/">
|
||||
<meta property="og:site_name" content="KST4Contest">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="About KST4Contest">
|
||||
<meta name="twitter:description" content="About KST4Contest and its contest-oriented ON4KST workflow.">
|
||||
|
||||
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
|
||||
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
|
||||
|
||||
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta name="twitter:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="site-bg"></div>
|
||||
|
||||
<header class="site-header">
|
||||
<a class="brand" href="/">
|
||||
<span class="brand-mark">◎</span>
|
||||
<span>
|
||||
<strong>KST4Contest</strong>
|
||||
<small>ON4KST Contest Client</small>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<nav class="site-nav">
|
||||
|
||||
<a href="/">Home</a>
|
||||
|
||||
<a href="/features/">Features</a>
|
||||
|
||||
<a href="/screenshots/">Screenshots</a>
|
||||
|
||||
<a href="/manual/">Manual</a>
|
||||
|
||||
<a href="/news/">News</a>
|
||||
|
||||
<a href="/roadmap/">Roadmap</a>
|
||||
|
||||
<a href="/about/">About</a>
|
||||
|
||||
<a href="/faq/">FAQ</a>
|
||||
|
||||
<a class="nav-cta" href="/download/">Download</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
<section class="hero">
|
||||
<p class="badge">About</p>
|
||||
<h1>Built for real contest operation.</h1>
|
||||
<p class="lead">
|
||||
KST4Contest was created to turn ON4KST chat activity into actionable contest workflow.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="section narrow">
|
||||
<article class="card content-card">
|
||||
<h2>Why KST4Contest exists</h2>
|
||||
<p>
|
||||
Generic chat clients display messages. KST4Contest goes further:
|
||||
it helps operators identify candidates, manage skeds, use AirScout timing,
|
||||
synchronize with loggers and reduce workload during VHF/UHF/SHF contests.
|
||||
</p>
|
||||
|
||||
<h2>Open source and practical</h2>
|
||||
<p>
|
||||
The project is open source and focused on practical contest station workflows.
|
||||
Features are designed around real operating pressure, not theoretical UI concepts.
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="footer-grid">
|
||||
<div>
|
||||
<strong>KST4Contest</strong>
|
||||
<p>Contest-optimized ON4KST workflow for VHF/UHF/SHF operators.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>Project</strong>
|
||||
<p><a href="/features/">Features</a></p>
|
||||
<p><a href="/download/">Download</a></p>
|
||||
<p><a href="/manual/">Manual</a></p>
|
||||
<p><a href="/roadmap/">Roadmap</a></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>Community</strong>
|
||||
<p><a href="https://github.com/praktimarc/kst4contest">GitHub Repository</a></p>
|
||||
<p><a href="https://github.com/praktimarc/kst4contest/issues">Issues</a></p>
|
||||
<p><a href="/contact/">Contact</a></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>Legal</strong>
|
||||
<p><a href="/legal-notice/">Legal Notice</a></p>
|
||||
<p><a href="/privacy/">Privacy Policy</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,564 +0,0 @@
|
||||
:root {
|
||||
--bg: #050816;
|
||||
--bg2: #0b1224;
|
||||
--panel: rgba(15, 23, 42, 0.78);
|
||||
--panel2: rgba(30, 41, 59, 0.72);
|
||||
--border: rgba(148, 163, 184, 0.22);
|
||||
--text: #f8fafc;
|
||||
--muted: #aab6ca;
|
||||
--soft: #64748b;
|
||||
--accent: #38bdf8;
|
||||
--accent2: #a855f7;
|
||||
--accent3: #22c55e;
|
||||
--shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 14% 12%, rgba(168, 85, 247, 0.30), transparent 28%),
|
||||
radial-gradient(circle at 82% 16%, rgba(56, 189, 248, 0.24), transparent 30%),
|
||||
radial-gradient(circle at 50% 85%, rgba(34, 197, 94, 0.11), transparent 35%),
|
||||
linear-gradient(180deg, #050816 0%, #07111f 100%);
|
||||
color: var(--text);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
line-height: 1.65;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: -20%;
|
||||
z-index: -3;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(168, 85, 247, 0.22), transparent 28%),
|
||||
radial-gradient(circle at 78% 18%, rgba(56, 189, 248, 0.18), transparent 30%),
|
||||
radial-gradient(circle at 45% 80%, rgba(34, 197, 94, 0.10), transparent 26%);
|
||||
filter: blur(48px);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.038) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.038) 1px, transparent 1px);
|
||||
background-size: 54px 54px;
|
||||
-webkit-mask-image: radial-gradient(circle at center, black 0%, black 46%, transparent 78%);
|
||||
mask-image: radial-gradient(circle at center, black 0%, black 46%, transparent 78%);
|
||||
}
|
||||
|
||||
.site-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(5, 8, 22, 0.10), rgba(5, 8, 22, 0.84)),
|
||||
radial-gradient(circle at 8% 24%, rgba(168, 85, 247, 0.18), transparent 26%),
|
||||
radial-gradient(circle at 82% 20%, rgba(56, 189, 248, 0.16), transparent 30%);
|
||||
}
|
||||
|
||||
.site-bg::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at top, transparent 0%, rgba(5, 8, 22, 0.42) 72%),
|
||||
linear-gradient(90deg, rgba(5, 8, 22, 0.25), transparent 50%, rgba(5, 8, 22, 0.25));
|
||||
}
|
||||
|
||||
.site-bg::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
radial-gradient(circle at 1px 1px, rgba(255,255,255,0.10) 1px, transparent 0);
|
||||
background-size: 28px 28px;
|
||||
opacity: 0.11;
|
||||
-webkit-mask-image: radial-gradient(circle at 50% 22%, black 0%, transparent 68%);
|
||||
mask-image: radial-gradient(circle at 50% 22%, black 0%, transparent 68%);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
padding: 18px 6vw;
|
||||
background: rgba(5, 8, 22, 0.76);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
backdrop-filter: blur(18px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 14px;
|
||||
background:
|
||||
radial-gradient(circle at 30% 20%, white, transparent 22%),
|
||||
linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
box-shadow:
|
||||
0 0 26px rgba(168, 85, 247, 0.45),
|
||||
0 0 50px rgba(56, 189, 248, 0.18);
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
display: block;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.brand small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.site-nav a {
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.site-nav a:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-cta {
|
||||
padding: 9px 14px;
|
||||
border-radius: 999px;
|
||||
color: white !important;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
box-shadow: 0 0 22px rgba(56, 189, 248, 0.28);
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
padding: 92px 6vw 62px;
|
||||
max-width: 1180px;
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -160px -12vw auto -12vw;
|
||||
height: 420px;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(circle at 24% 40%, rgba(168, 85, 247, 0.20), transparent 36%),
|
||||
radial-gradient(circle at 62% 30%, rgba(56, 189, 248, 0.14), transparent 40%);
|
||||
filter: blur(74px);
|
||||
}
|
||||
|
||||
.hero-split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.9fr);
|
||||
gap: 42px;
|
||||
align-items: center;
|
||||
max-width: 1500px;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0 0 22px;
|
||||
font-size: clamp(3rem, 8vw, 6.5rem);
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.07em;
|
||||
background: linear-gradient(90deg, #ffffff 0%, #7dd3fc 40%, #a855f7 78%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.hero .lead,
|
||||
.hero > p,
|
||||
.section-heading p,
|
||||
.cta-panel p {
|
||||
color: var(--muted);
|
||||
font-size: 1.18rem;
|
||||
max-width: 850px;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #e9d5ff;
|
||||
background: rgba(168, 85, 247, 0.13);
|
||||
border: 1px solid rgba(168, 85, 247, 0.34);
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 0 22px rgba(168, 85, 247, 0.12);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin-top: 30px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 46px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 14px;
|
||||
color: #020617;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
font-weight: 800;
|
||||
box-shadow:
|
||||
0 16px 44px rgba(56, 189, 248, 0.25),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
color: white;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
color: white;
|
||||
background: rgba(30, 41, 59, 0.85);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.button.ghost {
|
||||
color: white;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.hero-panel,
|
||||
.card,
|
||||
.cta-panel {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,0.045), transparent 42%),
|
||||
var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 26px;
|
||||
box-shadow: var(--shadow);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transform: perspective(1200px) rotateY(-5deg) rotateX(2deg);
|
||||
}
|
||||
|
||||
.hero-panel::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(56, 189, 248, 0.45), transparent 35%, rgba(168, 85, 247, 0.38));
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
.terminal-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(2, 6, 23, 0.74);
|
||||
}
|
||||
|
||||
.terminal-bar span {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent2);
|
||||
}
|
||||
|
||||
.terminal-bar span:nth-child(2) {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.terminal-bar span:nth-child(3) {
|
||||
background: var(--accent3);
|
||||
}
|
||||
|
||||
.terminal-bar strong {
|
||||
margin-left: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.mock-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.mini-card {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(15, 23, 42, 0.88);
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
}
|
||||
|
||||
.mini-card strong,
|
||||
.mini-card small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mini-card small {
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 52px 6vw;
|
||||
}
|
||||
|
||||
.section.narrow {
|
||||
max-width: 1040px;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: clamp(2rem, 4vw, 3.4rem);
|
||||
line-height: 1.05;
|
||||
margin: 12px 0 14px;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 24px;
|
||||
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(56, 189, 248, 0.45);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,0.055), transparent 42%),
|
||||
rgba(15, 23, 42, 0.92);
|
||||
box-shadow:
|
||||
0 26px 90px rgba(0, 0, 0, 0.50),
|
||||
0 0 30px rgba(56, 189, 248, 0.08);
|
||||
}
|
||||
|
||||
.card h3,
|
||||
.card h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.card p {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.roadmap-version + .roadmap-version {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.roadmap-version ul {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.roadmap-version li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.roadmap-done a {
|
||||
color: var(--muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.feature-icon.large {
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
max-width: 980px;
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag-list span {
|
||||
padding: 7px 11px;
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.download-card {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.screenshot-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 180px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(56, 189, 248, 0.20), rgba(168, 85, 247, 0.20)),
|
||||
rgba(2, 6, 23, 0.7);
|
||||
border: 1px solid var(--border);
|
||||
color: white;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.cta-panel {
|
||||
padding: clamp(28px, 5vw, 60px);
|
||||
}
|
||||
|
||||
.manual-content {
|
||||
max-width: 980px;
|
||||
}
|
||||
|
||||
.manual-content h1,
|
||||
.manual-content h2,
|
||||
.manual-content h3 {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.manual-content pre {
|
||||
overflow-x: auto;
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
background: #020617;
|
||||
}
|
||||
|
||||
.manual-content code {
|
||||
color: #bae6fd;
|
||||
}
|
||||
|
||||
.manual-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.manual-content th,
|
||||
.manual-content td {
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.22);
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
padding: 42px 6vw;
|
||||
color: var(--muted);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero-split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.button,
|
||||
.card {
|
||||
transition: all 180ms ease;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.footer-grid strong {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.footer-grid p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.footer-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 MiB |
@@ -1,125 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Contact | KST4Contest</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="Contact options for KST4Contest.">
|
||||
<link rel="canonical" href="https://kst4contest.hamradioonline.de/contact/">
|
||||
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="Contact | KST4Contest">
|
||||
<meta property="og:description" content="Contact options for KST4Contest.">
|
||||
<meta property="og:url" content="https://kst4contest.hamradioonline.de/contact/">
|
||||
<meta property="og:site_name" content="KST4Contest">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="Contact | KST4Contest">
|
||||
<meta name="twitter:description" content="Contact options for KST4Contest.">
|
||||
|
||||
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
|
||||
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
|
||||
|
||||
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta name="twitter:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="site-bg"></div>
|
||||
|
||||
<header class="site-header">
|
||||
<a class="brand" href="/">
|
||||
<span class="brand-mark">◎</span>
|
||||
<span>
|
||||
<strong>KST4Contest</strong>
|
||||
<small>ON4KST Contest Client</small>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<nav class="site-nav">
|
||||
|
||||
<a href="/">Home</a>
|
||||
|
||||
<a href="/features/">Features</a>
|
||||
|
||||
<a href="/screenshots/">Screenshots</a>
|
||||
|
||||
<a href="/manual/">Manual</a>
|
||||
|
||||
<a href="/news/">News</a>
|
||||
|
||||
<a href="/roadmap/">Roadmap</a>
|
||||
|
||||
<a href="/about/">About</a>
|
||||
|
||||
<a href="/faq/">FAQ</a>
|
||||
|
||||
<a class="nav-cta" href="/download/">Download</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
<section class="hero">
|
||||
<p class="badge">Contact</p>
|
||||
<h1>Contact</h1>
|
||||
<p class="lead">Get in touch, report issues or contribute to KST4Contest.</p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="grid">
|
||||
<article class="card">
|
||||
<h3>GitHub Issues</h3>
|
||||
<p>Report bugs, request features or discuss technical problems.</p>
|
||||
<a href="https://github.com/praktimarc/kst4contest/issues">Open issues →</a>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h3>GitHub Repository</h3>
|
||||
<p>Browse source code, releases and documentation.</p>
|
||||
<a href="https://github.com/praktimarc/kst4contest">Open repository →</a>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h3>Email</h3>
|
||||
<p>For legal or website-related contact.</p>
|
||||
<a href="https://groups.google.com/g/kst4contest">Join the mailing list →</a>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="footer-grid">
|
||||
<div>
|
||||
<strong>KST4Contest</strong>
|
||||
<p>Contest-optimized ON4KST workflow for VHF/UHF/SHF operators.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>Project</strong>
|
||||
<p><a href="/features/">Features</a></p>
|
||||
<p><a href="/download/">Download</a></p>
|
||||
<p><a href="/manual/">Manual</a></p>
|
||||
<p><a href="/roadmap/">Roadmap</a></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>Community</strong>
|
||||
<p><a href="https://github.com/praktimarc/kst4contest">GitHub Repository</a></p>
|
||||
<p><a href="https://github.com/praktimarc/kst4contest/issues">Issues</a></p>
|
||||
<p><a href="/contact/">Contact</a></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>Legal</strong>
|
||||
<p><a href="/legal-notice/">Legal Notice</a></p>
|
||||
<p><a href="/privacy/">Privacy Policy</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user