mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-08-24 11:06:59 +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
|
||||
@@ -1,155 +0,0 @@
|
||||
name: AUR Nightly (kst4contest-git)
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Nightly Runtime Artifacts"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
AUR_SSH_DIR: /tmp/aur-ssh
|
||||
|
||||
jobs:
|
||||
update-aur-git:
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
container:
|
||||
image: archlinux:latest
|
||||
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pacman -Sy --noconfirm git openssh base-devel
|
||||
useradd -m builder
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Mark workspace as safe Git directory
|
||||
run: |
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
|
||||
- name: Compute pkgver
|
||||
id: ver
|
||||
run: |
|
||||
BASE=$(grep -m1 '<version>' pom.xml \
|
||||
| sed 's/.*<version>\(.*\)<\/version>.*/\1/' \
|
||||
| sed 's/[-.]nightly//')
|
||||
|
||||
GIT_PKGVER="${BASE}.r$(git rev-list --count HEAD).g$(git rev-parse --short HEAD)"
|
||||
|
||||
echo "Computed pkgver: ${GIT_PKGVER}"
|
||||
echo "pkgver=${GIT_PKGVER}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Update PKGBUILD
|
||||
env:
|
||||
PKGVER: ${{ steps.ver.outputs.pkgver }}
|
||||
run: |
|
||||
sed -i "s/^pkgver=.*/pkgver=${PKGVER}/" \
|
||||
packaging/aur/kst4contest-git/PKGBUILD
|
||||
|
||||
- name: Generate .SRCINFO
|
||||
run: |
|
||||
cp -r packaging/aur/kst4contest-git /tmp/kst4contest-git
|
||||
chown -R builder:builder /tmp/kst4contest-git
|
||||
|
||||
su builder -c \
|
||||
"cd /tmp/kst4contest-git && makepkg --printsrcinfo > .SRCINFO"
|
||||
|
||||
cp /tmp/kst4contest-git/.SRCINFO \
|
||||
packaging/aur/kst4contest-git/.SRCINFO
|
||||
|
||||
echo "Generated .SRCINFO:"
|
||||
cat packaging/aur/kst4contest-git/.SRCINFO
|
||||
|
||||
- name: Set up and verify AUR SSH
|
||||
env:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
mkdir -p "${AUR_SSH_DIR}"
|
||||
|
||||
printf '%s\n' "${AUR_SSH_PRIVATE_KEY}" \
|
||||
> "${AUR_SSH_DIR}/aur_ed25519"
|
||||
|
||||
sed -i 's/\r$//' "${AUR_SSH_DIR}/aur_ed25519"
|
||||
chmod 600 "${AUR_SSH_DIR}/aur_ed25519"
|
||||
|
||||
ssh-keygen -y \
|
||||
-f "${AUR_SSH_DIR}/aur_ed25519" \
|
||||
> /dev/null
|
||||
|
||||
ssh-keyscan \
|
||||
-T 10 \
|
||||
-t ed25519 \
|
||||
aur.archlinux.org \
|
||||
> "${AUR_SSH_DIR}/known_hosts"
|
||||
|
||||
if [ ! -s "${AUR_SSH_DIR}/known_hosts" ]; then
|
||||
echo "::error::No SSH host key was received from aur.archlinux.org."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Received AUR host-key fingerprint:"
|
||||
ssh-keygen -lf "${AUR_SSH_DIR}/known_hosts"
|
||||
|
||||
if ! ssh-keygen -lf "${AUR_SSH_DIR}/known_hosts" \
|
||||
| grep -Fq "SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4"; then
|
||||
echo "::error::The AUR SSH host-key fingerprint does not match the official fingerprint."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s\n' \
|
||||
"Host aur.archlinux.org" \
|
||||
" HostName aur.archlinux.org" \
|
||||
" User aur" \
|
||||
" IdentityFile ${AUR_SSH_DIR}/aur_ed25519" \
|
||||
" IdentitiesOnly yes" \
|
||||
" StrictHostKeyChecking yes" \
|
||||
" UserKnownHostsFile ${AUR_SSH_DIR}/known_hosts" \
|
||||
> "${AUR_SSH_DIR}/config"
|
||||
|
||||
chmod 600 "${AUR_SSH_DIR}/config"
|
||||
chmod 600 "${AUR_SSH_DIR}/known_hosts"
|
||||
|
||||
- name: Push package metadata to AUR
|
||||
env:
|
||||
GIT_SSH_COMMAND: ssh -F /tmp/aur-ssh/config
|
||||
run: |
|
||||
git config --global user.email "philipp@wagnersnetz.de"
|
||||
git config --global user.name "Philipp Wagner"
|
||||
|
||||
AUR_DIR="/tmp/aur/kst4contest-git"
|
||||
|
||||
mkdir -p "$(dirname "${AUR_DIR}")"
|
||||
|
||||
git -c init.defaultBranch=master clone \
|
||||
"ssh://aur@aur.archlinux.org/kst4contest-git.git" \
|
||||
"${AUR_DIR}"
|
||||
|
||||
cp packaging/aur/kst4contest-git/PKGBUILD \
|
||||
"${AUR_DIR}/PKGBUILD"
|
||||
|
||||
cp packaging/aur/kst4contest-git/.SRCINFO \
|
||||
"${AUR_DIR}/.SRCINFO"
|
||||
|
||||
git -C "${AUR_DIR}" add PKGBUILD .SRCINFO
|
||||
|
||||
if git -C "${AUR_DIR}" diff --cached --quiet; then
|
||||
echo "kst4contest-git: package metadata is already current."
|
||||
else
|
||||
git -C "${AUR_DIR}" commit \
|
||||
-m "Update pkgver to ${{ steps.ver.outputs.pkgver }}"
|
||||
|
||||
git -C "${AUR_DIR}" push origin HEAD:master
|
||||
|
||||
echo "kst4contest-git: successfully pushed to AUR."
|
||||
fi
|
||||
@@ -1,177 +0,0 @@
|
||||
name: Publish AUR Packages
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Tagged Release Build"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release tag (e.g. v1.41.1) — defaults to latest stable release"
|
||||
required: false
|
||||
default: ""
|
||||
dry_run:
|
||||
description: "Dry run — skip AUR push and repo commit (for testing)"
|
||||
type: choice
|
||||
options: ["false", "true"]
|
||||
default: "false"
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
publish-aur:
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: archlinux:latest
|
||||
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pacman -Sy --noconfirm git openssh curl base-devel nodejs
|
||||
useradd -m builder
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.1.7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve release version
|
||||
id: ver
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [[ -n "${INPUT_VERSION}" ]]; then
|
||||
TAG="${INPUT_VERSION}"
|
||||
else
|
||||
TAG=$(curl -sf \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/releases/latest" \
|
||||
| grep '"tag_name"' | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/')
|
||||
fi
|
||||
PKGVER="${TAG#v}"
|
||||
echo "tag=${TAG} → pkgver=${PKGVER}"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "pkgver=${PKGVER}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Compute SHA256 checksums
|
||||
id: sha
|
||||
run: |
|
||||
TAG="${{ steps.ver.outputs.tag }}"
|
||||
REPO="${{ github.repository }}"
|
||||
|
||||
echo "Hashing pre-built Arch package..."
|
||||
SHA_BIN=$(curl -sfL \
|
||||
"https://github.com/${REPO}/releases/download/${TAG}/KST4Contest-${TAG}-archlinux-x86_64.pkg.tar.zst" \
|
||||
| sha256sum | awk '{print $1}')
|
||||
echo "bin: ${SHA_BIN}"
|
||||
|
||||
echo "Hashing source tarball..."
|
||||
SHA_SRC=$(curl -sfL \
|
||||
"https://github.com/${REPO}/archive/refs/tags/${TAG}.tar.gz" \
|
||||
| sha256sum | awk '{print $1}')
|
||||
echo "src: ${SHA_SRC}"
|
||||
|
||||
echo "bin=${SHA_BIN}" >> "$GITHUB_OUTPUT"
|
||||
echo "src=${SHA_SRC}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Update PKGBUILDs
|
||||
env:
|
||||
PKGVER: ${{ steps.ver.outputs.pkgver }}
|
||||
SHA_BIN: ${{ steps.sha.outputs.bin }}
|
||||
SHA_SRC: ${{ steps.sha.outputs.src }}
|
||||
run: |
|
||||
# kst4contest-bin
|
||||
sed -i "s/^pkgver=.*/pkgver=${PKGVER}/" packaging/aur/kst4contest-bin/PKGBUILD
|
||||
sed -i "s/^sha256sums=.*/sha256sums=('${SHA_BIN}')/" packaging/aur/kst4contest-bin/PKGBUILD
|
||||
|
||||
# kst4contest (source build)
|
||||
sed -i "s/^pkgver=.*/pkgver=${PKGVER}/" packaging/aur/kst4contest/PKGBUILD
|
||||
sed -i "s/^sha256sums=.*/sha256sums=('${SHA_SRC}')/" packaging/aur/kst4contest/PKGBUILD
|
||||
|
||||
# kst4contest-git: Basis aus pom.xml, Suffix aus git
|
||||
BASE_VER=$(grep -m1 '<version>' pom.xml \
|
||||
| sed 's/.*<version>\(.*\)<\/version>.*/\1/' | sed 's/[-.]nightly//')
|
||||
GIT_PKGVER="${BASE_VER}.r$(git rev-list --count HEAD).g$(git rev-parse --short HEAD)"
|
||||
sed -i "s/^pkgver=.*/pkgver=${GIT_PKGVER}/" packaging/aur/kst4contest-git/PKGBUILD
|
||||
|
||||
echo "=== Updated PKGBUILD versions ==="
|
||||
grep -H '^pkgver=\|^sha256sums=' packaging/aur/*/PKGBUILD
|
||||
|
||||
- name: Generate .SRCINFO files
|
||||
run: |
|
||||
for pkg in kst4contest-bin kst4contest kst4contest-git; do
|
||||
cp -r "packaging/aur/${pkg}" "/tmp/${pkg}"
|
||||
chown -R builder:builder "/tmp/${pkg}"
|
||||
su builder -c "cd /tmp/${pkg} && makepkg --printsrcinfo > .SRCINFO"
|
||||
cp "/tmp/${pkg}/.SRCINFO" "packaging/aur/${pkg}/.SRCINFO"
|
||||
echo "=== ${pkg}/.SRCINFO ==="
|
||||
cat "packaging/aur/${pkg}/.SRCINFO"
|
||||
done
|
||||
|
||||
- name: Commit updated PKGBUILDs to repo
|
||||
if: inputs.dry_run != 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git config user.name "github-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.git"
|
||||
git add packaging/aur/
|
||||
git diff --cached --quiet && echo "No PKGBUILD changes to commit." && exit 0
|
||||
git commit -m "chore: update AUR packages to ${{ steps.ver.outputs.tag }} [skip ci]"
|
||||
git push
|
||||
|
||||
- name: Set up AUR SSH
|
||||
if: inputs.dry_run != 'true'
|
||||
env:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "${AUR_SSH_PRIVATE_KEY}" > ~/.ssh/aur_ed25519
|
||||
chmod 600 ~/.ssh/aur_ed25519
|
||||
ssh-keyscan -t ed25519 aur.archlinux.org >> ~/.ssh/known_hosts
|
||||
cat >> ~/.ssh/config << 'EOF'
|
||||
Host aur.archlinux.org
|
||||
IdentityFile ~/.ssh/aur_ed25519
|
||||
User aur
|
||||
EOF
|
||||
|
||||
- name: Push to AUR
|
||||
if: inputs.dry_run != 'true'
|
||||
env:
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
run: |
|
||||
git config --global user.email "philipp@wagnersnetz.de"
|
||||
git config --global user.name "Philipp Wagner"
|
||||
|
||||
push_to_aur() {
|
||||
local pkg="$1"
|
||||
local msg="$2"
|
||||
local aur_dir="/tmp/aur/${pkg}"
|
||||
|
||||
git clone "ssh://aur@aur.archlinux.org/${pkg}.git" "${aur_dir}" 2>/dev/null || {
|
||||
mkdir -p "${aur_dir}"
|
||||
git -C "${aur_dir}" init
|
||||
git -C "${aur_dir}" remote add origin "ssh://aur@aur.archlinux.org/${pkg}.git"
|
||||
}
|
||||
|
||||
cp "packaging/aur/${pkg}/PKGBUILD" "${aur_dir}/"
|
||||
cp "packaging/aur/${pkg}/.SRCINFO" "${aur_dir}/"
|
||||
git -C "${aur_dir}" add PKGBUILD .SRCINFO
|
||||
git -C "${aur_dir}" diff --cached --quiet \
|
||||
&& echo "${pkg}: no changes, skipping push" && return 0
|
||||
git -C "${aur_dir}" commit -m "${msg}"
|
||||
git -C "${aur_dir}" push origin HEAD:master
|
||||
echo "${pkg}: pushed to AUR"
|
||||
}
|
||||
|
||||
push_to_aur kst4contest-bin "Update to ${TAG}"
|
||||
push_to_aur kst4contest "Update to ${TAG}"
|
||||
push_to_aur kst4contest-git \
|
||||
"Update pkgver to $(grep '^pkgver=' packaging/aur/kst4contest-git/PKGBUILD | cut -d= -f2)"
|
||||
@@ -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,350 +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 and flatpakref filename
|
||||
run: |
|
||||
if [[ "${{ github.ref_name }}" == beta-* ]]; then
|
||||
echo "OSTREE_BRANCH=beta" >> "$GITHUB_ENV"
|
||||
echo "FLATPAKREF_NAME=de.x08.KST4Contest.beta.flatpakref" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "OSTREE_BRANCH=stable" >> "$GITHUB_ENV"
|
||||
echo "FLATPAKREF_NAME=de.x08.KST4Contest.flatpakref" >> "$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/${FLATPAKREF_NAME}" << 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/*.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 }}
|
||||
@@ -490,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
|
||||
@@ -514,7 +169,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:
|
||||
@@ -612,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
|
||||
@@ -702,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:
|
||||
@@ -746,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/*.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
|
||||
@@ -35,9 +35,3 @@ dist/
|
||||
|
||||
#zip files for local backups
|
||||
*.zip
|
||||
|
||||
# node Modules in website
|
||||
website/node_modules/
|
||||
|
||||
# Local secrets for act testing
|
||||
.secrets
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
dr2x
|
||||
oe3cin
|
||||
+15832
File diff suppressed because it is too large
Load Diff
+42
-14
@@ -1,25 +1,53 @@
|
||||
# KST4Contest
|
||||
# KST4Contest – Wiki
|
||||
|
||||
KST4Contest is a desktop client for the [ON4KST Chat](https://www.on4kst.org/chat/login.php), developed for VHF, UHF and SHF contest operation. It combines chat, candidate selection, sked planning, aircraft scatter information and connections to logging and station software.
|
||||
**KST4Contest** (auch bekannt als *PraktiKST*) ist ein Java-basierter Chat-Client für den [ON4KST-Chat](http://www.on4kst.info/chat/), speziell entwickelt für den Contest-Betrieb auf den VHF/UHF/SHF-Bändern.
|
||||
|
||||
KST4Contest ist ein Desktop-Client für den [ON4KST-Chat](https://www.on4kst.org/chat/login.php), der für den Contest-Betrieb auf den VHF-, UHF- und SHF-Bändern entwickelt wurde. Er verbindet Chat, Stationsauswahl, Sked-Planung, Aircraft-Scatter-Daten sowie die Anbindung an Log- und Stationssoftware.
|
||||
|
||||
Developed by / Entwickelt von **DO5AMF (Marc Fröhlich)**, operator at / Operator bei DM5M.
|
||||
Entwickelt von **DO5AMF (Marc Fröhlich)**, Operator bei DM5M.
|
||||
|
||||
---
|
||||
|
||||
## Language / Sprache
|
||||
## 🌐 Sprache / Language
|
||||
|
||||
| Deutsch | English |
|
||||
| 🇩🇪 Deutsch | 🇬🇧 English |
|
||||
|---|---|
|
||||
| [Deutsches Handbuch](de-Home) | [English manual](en-Home) |
|
||||
| [Startseite (Deutsch)](de-Home) | [Home (English)](en-Home) |
|
||||
|
||||
---
|
||||
|
||||
## Project links / Projektlinks
|
||||
## 🇩🇪 Inhalt (Deutsch)
|
||||
|
||||
- [Project website / Projektwebseite](https://kst4contest.hamradioonline.de/)
|
||||
- [Download the current stable release / Aktuelle stabile Version herunterladen](https://github.com/praktimarc/kst4contest/releases/latest)
|
||||
- [Source code / Quellcode](https://github.com/praktimarc/kst4contest)
|
||||
- [Bug reports and feature requests / Fehler und Funktionswünsche](https://github.com/praktimarc/kst4contest/issues)
|
||||
- [Online manual / Online-Handbuch](https://kst4contest.hamradioonline.de/manual/)
|
||||
| Seite | Inhalt |
|
||||
|---|---|
|
||||
| [Installation](de-Installation) | Download, Java-Voraussetzungen, Update |
|
||||
| [Konfiguration](de-Konfiguration) | Alle Einstellungen im Detail |
|
||||
| [Log-Synchronisation](de-Log-Synchronisation) | UCXLog, N1MM+, QARTest, DXLog.net, WinTest |
|
||||
| [AirScout-Integration](de-AirScout-Integration) | Flugzeug-Scatter-Erkennung |
|
||||
| [DX-Cluster-Server](de-DX-Cluster-Server) | Integrierter DX-Cluster für das Log-Programm |
|
||||
| [Funktionen](de-Funktionen) | Alle Features im Überblick |
|
||||
| [Makros und Variablen](de-Makros-und-Variablen) | Text-Snippets, Shortcuts, Variablen |
|
||||
| [Benutzeroberfläche](de-Benutzeroberflaeche) | UI-Erklärung und Bedienung |
|
||||
| [Changelog](de-Changelog) | Versionsgeschichte |
|
||||
|
||||
---
|
||||
|
||||
## 🇬🇧 Contents (English)
|
||||
|
||||
| Page | Contents |
|
||||
|---|---|
|
||||
| [Installation](en-Installation) | Download, Java requirements, updates |
|
||||
| [Configuration](en-Configuration) | All settings in detail |
|
||||
| [Log Synchronisation](en-Log-Sync) | UCXLog, N1MM+, QARTest, DXLog.net, WinTest |
|
||||
| [AirScout Integration](en-AirScout-Integration) | Aircraft scatter detection |
|
||||
| [DX Cluster Server](en-DX-Cluster-Server) | Built-in DX cluster for your logging software |
|
||||
| [Features](en-Features) | All features at a glance |
|
||||
| [Macros and Variables](en-Macros-and-Variables) | Text snippets, shortcuts, variables |
|
||||
| [User Interface](en-User-Interface) | UI explained and how to operate it |
|
||||
| [Changelog](en-Changelog) | Version history |
|
||||
|
||||
---
|
||||
|
||||
## Schnellinfo / Quick Info
|
||||
|
||||
- **Download**: https://github.com/praktimarc/kst4contest/releases
|
||||
- **GitHub**: https://github.com/praktimarc/kst4contest
|
||||
- **Kontakt / Contact**: praktimarc+kst4contest@gmail.com
|
||||
|
||||
@@ -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.
|
||||
|
||||
+28
-94
@@ -1,64 +1,10 @@
|
||||
# KST4Contest – Handbuch
|
||||
# KST4Contest – Wiki
|
||||
|
||||
> [English version](en-Home) | Du liest gerade die deutsche Version
|
||||
> 🇬🇧 [English version](en-Home) | 🇩🇪 Du liest gerade die deutsche Version
|
||||
|
||||
KST4Contest ist ein Desktop-Client für den [ON4KST-Chat](https://www.on4kst.org/chat/login.php), der für den Contest-Betrieb auf den VHF-, UHF- und SHF-Bändern entwickelt wurde. Er verbindet Chat, Stationsauswahl, Sked-Planung, Aircraft-Scatter-Daten und die Anbindung an weitere Programme in einer gemeinsamen Arbeitsoberfläche.
|
||||
**KST4Contest** (auch bekannt als *PraktiKST*) ist ein Java-basierter Chat-Client für den [ON4KST-Chat](http://www.on4kst.info/chat/), der speziell für den Contest-Betrieb auf den VHF/UHF/SHF-Bändern (144 MHz und aufwärts) entwickelt wurde.
|
||||
|
||||
Entwickelt wird KST4Contest von **DO5AMF (Marc Fröhlich)**, Operator bei DM5M und (seit Mai 2025) **DN9APW (Philipp Wagner)**. Der Quellcode ist öffentlich auf [GitHub](https://github.com/praktimarc/kst4contest) verfügbar.
|
||||
|
||||
---
|
||||
|
||||
## Wozu braucht man einen eigenen ON4KST-Client?
|
||||
|
||||
Der ON4KST-Chat liefert während eines Contests eine große Menge an Informationen: aktive Stationen, Locator, Frequenzen, Sked-Anfragen und Hinweise auf aktuelle Aktivität. Das eigentliche Problem besteht nicht darin, diese Daten zu sehen. Man muss daraus rechtzeitig die nächste sinnvolle Verbindung ableiten.
|
||||
|
||||
KST4Contest wertet die verfügbaren Informationen aus, ordnet sie und stellt sie in einem contestgerechten Arbeitsablauf dar. Dabei werden unter anderem Antennenrichtung, Entfernung, bekannte Bänder und Frequenzen, bereits gearbeitete Stationen, Chat-Aktivität und Aircraft-Scatter-Zeiten berücksichtigt.
|
||||
|
||||
Das Programm entscheidet jedoch nicht, welches QSO tatsächlich möglich ist. Der Prioritätsscore, die AP-Timeline und die verschiedenen Hervorhebungen sind Entscheidungshilfen. Die endgültige Beurteilung bleibt beim Operator – schon deshalb, weil auch ein recht überzeugender Computerbildschirm noch keine Funkverbindung herstellt.
|
||||
|
||||
---
|
||||
|
||||
## Was KST4Contest im Contest unterstützt
|
||||
|
||||
- **Chat beobachten und einordnen:** KST4Contest kann zwei ON4KST-Chat-Kategorien gleichzeitig darstellen. Nachrichten, Frequenzangaben und bekannte Bandaktivitäten werden den jeweiligen Stationen zugeordnet.
|
||||
|
||||
- **Relevante Stationen herausfiltern:** Richtungs-, Entfernungs-, Worked- und NOT-QRV-Filter reduzieren die Benutzerliste auf die Stationen, die für den aktuellen Betriebszustand tatsächlich interessant sind.
|
||||
|
||||
- **Kandidaten priorisieren:** Das Score-System bewertet aktive Chatmember anhand mehrerer bekannter Kriterien. Die derzeit relevantesten Kandidaten erscheinen zusätzlich in einer eigenen Prioritätsliste.
|
||||
|
||||
- **Skeds vorbereiten und im Blick behalten:** Sked-Erinnerungen, automatische Vorwarnungen und die AP-Timeline helfen dabei, vereinbarte Verbindungen nicht zwischen Chat, Log und laufendem CQ-Betrieb zu verlieren.
|
||||
|
||||
- **Aircraft Scatter einbeziehen:** Über die AirScout-Schnittstelle werden geeignete Flugzeuge und erwartete Reflexionszeiten in die Stationsbewertung und Zeitplanung übernommen.
|
||||
|
||||
- **Log und Funkstation anbinden:** KST4Contest synchronisiert gearbeitete Stationen und Frequenzinformationen mit unterstützten Logprogrammen. Zusätzlich stehen Schnittstellen zu Win-Test, PSTRotator und ein integrierter DX-Cluster-Server zur Verfügung.
|
||||
|
||||
- **Stationen und Funkwege darstellen:** Die Stationskarte zeigt aktive Chatmember, Locator-Felder, Antennenrichtungen und den Weg zur ausgewählten Station. Für ausgewählte Verbindungen kann außerdem ein Geländeprofil berechnet werden.
|
||||
|
||||
Die Funktionen greifen ineinander. Eine erkannte Frequenz kann beispielsweise einem aktiven Band zugeordnet werden, der Worked-Status stammt aus dem Log, Aircraft-Scatter-Daten ergänzen die zeitliche Bewertung und der daraus berechnete Score beeinflusst die Prioritätsliste. Fehlende oder veraltete Eingangsdaten können deshalb auch das Ergebnis beeinflussen.
|
||||
|
||||
---
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
Für die Anmeldung ist ein registrierter ON4KST-Account erforderlich. Registrierung und Login sind über die [offizielle ON4KST-Anmeldeseite](https://www.on4kst.org/chat/login.php) erreichbar.
|
||||
|
||||
Die offizielle Sprache im ON4KST-Chat ist Englisch. Das gilt auch für Nachrichten an Stationen aus dem eigenen Land. Übliche Amateurfunk-Abkürzungen wie `pse`, `agn`, `qrg`, `dir`, `rrr`, `tnx` oder `73` sind dabei normal und meistens erheblich schneller als ausformulierte Prosa.
|
||||
|
||||
Download, unterstützte Betriebssysteme und Installationswege sind im Kapitel [Installation](de-Installation) beschrieben.
|
||||
|
||||
---
|
||||
|
||||
## Versionsstand dieses Handbuchs
|
||||
|
||||
Dieses Handbuch beschreibt die stabile Version **v1.41.1**.
|
||||
|
||||
Funktionen oder Änderungen, die nur im aktuellen Entwicklungsstand enthalten sind, werden ausdrücklich als **Nightly / v1.42** gekennzeichnet. Fehlt eine solche Kennzeichnung, bezieht sich die Beschreibung auf die stabile Version.
|
||||
|
||||
- [Aktuelle stabile Version herunterladen](https://github.com/praktimarc/kst4contest/releases/latest)
|
||||
- [Nightly-Builds und automatisierte Builds](https://github.com/praktimarc/kst4contest/actions)
|
||||
- [Versionsgeschichte](de-Changelog)
|
||||
|
||||
Für einen Contest ist grundsätzlich die stabile Version zu empfehlen. Nightly-Builds enthalten neuere Korrekturen und Funktionen, können sich aber zwischen zwei Builds verändern. Sie sind sinnvoll, wenn eine bestimmte Änderung getestet werden soll – weniger sinnvoll ist der erste Versuch zehn Minuten vor Contestbeginn.
|
||||
Entwickelt von **DO5AMF (Marc Fröhlich)**, Operator bei DM5M.
|
||||
|
||||
---
|
||||
|
||||
@@ -66,52 +12,40 @@ Für einen Contest ist grundsätzlich die stabile Version zu empfehlen. Nightly-
|
||||
|
||||
| Seite | Inhalt |
|
||||
|---|---|
|
||||
| [Installation](de-Installation) | ON4KST-Account, Download, Installation und Updates |
|
||||
| [Konfiguration](de-Konfiguration) | Login, Station, Bänder, Benutzeroberfläche und externe Schnittstellen |
|
||||
| [Log-Synchronisation](de-Log-Synchronisation) | Simplelogfile, UCXLog, N1MM+, QARTest, DXLog.net und Win-Test |
|
||||
| [AirScout-Integration](de-AirScout-Integration) | Verbindung zu AirScout und Auswertung von Aircraft-Scatter-Zeiten |
|
||||
| [DX-Cluster-Server](de-DX-Cluster-Server) | Übergabe erkannter Möglichkeiten an das Logprogramm |
|
||||
| [Funktionen](de-Funktionen) | Arbeitsweise, Herleitung und Grenzen der einzelnen Funktionen |
|
||||
| [Makros und Variablen](de-Makros-und-Variablen) | Wiederverwendbare Texte, Shortcuts und automatisch ersetzte Werte |
|
||||
| [Benutzeroberfläche](de-Benutzeroberflaeche) | Aufbau der Oberfläche und Bedienung im Contest |
|
||||
| [Changelog](de-Changelog) | Releases, Nightly-Änderungen und behobene Fehler |
|
||||
| [Installation](de-Installation) | Download, Java-Voraussetzungen, Update |
|
||||
| [Konfiguration](de-Konfiguration) | Alle Einstellungen im Detail |
|
||||
| [Log-Synchronisation](de-Log-Synchronisation) | UCXLog, N1MM+, QARTest, DXLog.net, WinTest |
|
||||
| [AirScout-Integration](de-AirScout-Integration) | Flugzeug-Scatter-Erkennung |
|
||||
| [DX-Cluster-Server](de-DX-Cluster-Server) | Integrierter DX-Cluster für das Log-Programm |
|
||||
| [Funktionen](de-Funktionen) | Alle Features im Überblick |
|
||||
| [Makros und Variablen](de-Makros-und-Variablen) | Text-Snippets, Shortcuts, Variablen |
|
||||
| [Benutzeroberfläche](de-Benutzeroberflaeche) | UI-Erklärung und Bedienung |
|
||||
| [Changelog](de-Changelog) | Versionsgeschichte |
|
||||
|
||||
---
|
||||
|
||||
## Kontakt und Support
|
||||
## Was ist KST4Contest?
|
||||
|
||||
- **Download:** [Aktuelle stabile Version](https://github.com/praktimarc/kst4contest/releases/latest)
|
||||
- **Quellcode:** [praktimarc/kst4contest](https://github.com/praktimarc/kst4contest)
|
||||
- **Fehler und Funktionswünsche:** [GitHub Issues](https://github.com/praktimarc/kst4contest/issues)
|
||||
- **E-Mail:** praktimarc+kst4contest@gmail.com
|
||||
Bitte nur für Themen verwenden, die KST4Contest betreffen.
|
||||
Der ON4KST-Chat ist der De-facto-Standard für Skeds auf den 144-MHz-und-höher-Bändern. KST4Contest erweitert die Chat-Nutzung um contest-spezifische Funktionen:
|
||||
|
||||
### Einen Fehler melden
|
||||
- **Worked-Markierung**: Bereits gearbeitete Stationen werden farblich markiert, direkt aus dem Logprogramm via UDP synchronisiert.
|
||||
- **Sked-Richtungs-Erkennung**: Wenn eine Station eine andere aus deiner Richtung anruft, wird sie grün und fett hervorgehoben.
|
||||
- **QRG-Erkennung**: KST4Contest liest Frequenzen automatisch aus dem Chat-Verkehr und zeigt sie in der Benutzerliste an.
|
||||
- **AirScout-Interface**: Anzeige reflektierbarer Flugzeuge direkt in der Benutzerliste.
|
||||
- **Integrierter DX-Cluster-Server**: Spots werden direkt an das Logprogramm gesendet.
|
||||
- **Dark Mode** (ab v1.26): Schont die Augen in der Nacht.
|
||||
- **Multi-Channel-Login** (ab v1.26): Gleichzeitig in zwei Chat-Kategorien einloggen.
|
||||
|
||||
Ein Fehler lässt sich wesentlich schneller nachvollziehen, wenn die Meldung mindestens folgende Angaben enthält:
|
||||
---
|
||||
|
||||
1. verwendete KST4Contest-Version,
|
||||
2. Betriebssystem und Installationsart,
|
||||
3. genaue Schritte bis zum Fehler,
|
||||
4. erwartetes und tatsächlich beobachtetes Verhalten,
|
||||
5. gegebenenfalls einen Screenshot,
|
||||
6. die Fehler-Logdatei.
|
||||
## Kontakt & Support
|
||||
|
||||
Die Fehler-Logdatei wird hier gespeichert:
|
||||
|
||||
| Betriebssystem | Pfad |
|
||||
|---|---|
|
||||
| Linux / macOS | `~/.praktiKST/kst4contest-errors.log` |
|
||||
| Windows | `C:\Users\<Benutzername>\.praktiKST\kst4contest-errors.log` |
|
||||
|
||||
Vor dem Hochladen sollte die Datei kurz geprüft werden. Ein Fehlerprotokoll enthält überwiegend technische Informationen, kann abhängig vom Fehler aber beispielsweise lokale Dateipfade oder weitere Kontextdaten enthalten.
|
||||
|
||||
In Datei- und Verzeichnisnamen wird teilweise noch der technische Name `praktiKST` verwendet. Gemeint ist dasselbe Programm.
|
||||
- **E-Mail**: praktimarc+kst4contest@gmail.com *(nur für kst4contest-Themen)*
|
||||
- **GitHub**: https://github.com/praktimarc/kst4contest
|
||||
- **Download**: https://github.com/praktimarc/kst4contest/releases/latest
|
||||
|
||||
---
|
||||
|
||||
## Danksagungen
|
||||
|
||||
KST4Contest wurde durch Rückmeldungen aus dem praktischen Contest-Betrieb wesentlich verbessert.
|
||||
|
||||
Besonderer Dank gilt Gianluca Costantino (IU3OAR), Alessandro Murador (IZ3VTH), Reczetár István (HA1FV), Viliam Petrik (OM0AAO, Idee zum DX-Cluster), Konrad Neitzel (DC9DJ, Projektstruktur), Andreas (DO5ALF, Webmaster von funkerportal.de), Franz van Velzen (PE0WGA, Tester), DN9APW als neuem Entwickler im Team und Master über die CI/CD pipelines sowie allen weiteren Testern und Ideengebern.
|
||||
Besonderer Dank gilt: Gianluca Costantino (IU3OAR), Alessandro Murador (IZ3VTH), Reczetár István (HA1FV), OM0AAO (Viliam Petrik, DX-Cluster-Idee), DC9DJ (Konrad Neitzel, Projektstruktur), DO5ALF (Andreas, Webmaster funkerportal.de), PE0WGA (Franz van Velzen, Tester) sowie allen weiteren Testern und Ideengebern.
|
||||
|
||||
+84
-366
@@ -2,422 +2,140 @@
|
||||
|
||||
> 🇬🇧 [English version](en-Installation) | 🇩🇪 Du liest gerade die deutsche Version
|
||||
|
||||
KST4Contest wird für Windows, Linux und macOS als fertiges Programmpaket bereitgestellt. Für die offiziellen Release-Pakete muss Java nicht separat installiert werden: Die benötigte Java-Laufzeitumgebung ist bereits enthalten.
|
||||
|
||||
Benötigt werden damit im Wesentlichen ein unterstütztes Betriebssystem, eine Internetverbindung und ein ON4KST-Account. Das klingt zunächst überschaubar – und ist es normalerweise auch.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
Es wird eine Mindestauflösung von 1200px mal 720px empfohlen
|
||||
|
||||
### ON4KST-Account
|
||||
|
||||
KST4Contest ist ein eigenständiger Client für den ON4KST-Chat, ersetzt aber nicht den dazugehörigen Benutzeraccount.
|
||||
Um den Chat zu nutzen, ist ein registrierter Account beim ON4KST-Chat-Dienst erforderlich:
|
||||
|
||||
Falls noch kein Account vorhanden ist, kann er über die offizielle ON4KST-Seite angelegt werden:
|
||||
- Registrierung unter: http://www.on4kst.info/chat/register.php
|
||||
|
||||
- [ON4KST-Anmeldung und Registrierung](https://www.on4kst.org/chat/login.php)
|
||||
### Verhaltensregeln im Chat
|
||||
|
||||
ON4KST gibt Englisch als gemeinsame Sprache im Chat vor. Das gilt auch dann, wenn beide beteiligten Stationen dieselbe Muttersprache sprechen. Im Contestbetrieb werden häufig die üblichen Amateurfunk-Abkürzungen wie `pse`, `agn`, `qrg`, `dir`, `rrr`, `tnx` oder `73` verwendet.
|
||||
Die offizielle Sprache im ON4KST-Chat ist **Englisch**. Auch bei Kommunikation mit Stationen aus dem eigenen Land bitte Englisch verwenden. Übliche HAM-Abkürzungen (agn, dir, pse, rrr, tnx, 73 …) sind gang und gäbe.
|
||||
|
||||
Die eigentliche Bedienung des Chats und das Senden persönlicher Nachrichten werden im Kapitel zur Benutzeroberfläche beschrieben. Für die Installation ist zunächst nur wichtig, dass der Account funktioniert.
|
||||
### Persönliche Nachrichten
|
||||
|
||||
### Bildschirmgröße
|
||||
Um eine Privatnachricht an eine andere Station zu senden, immer folgendes Format verwenden:
|
||||
|
||||
Eine nutzbare Bildschirmfläche von ungefähr **1200 × 720 Pixeln** oder mehr wird empfohlen.
|
||||
```
|
||||
/CQ RUFZEICHEN Nachrichtentext
|
||||
```
|
||||
|
||||
KST4Contest passt das Hauptfenster automatisch an die verfügbare Fläche des primären Bildschirms an. Das Programm kann deshalb auch auf kleineren Bildschirmen gestartet werden. Weniger Platz bleibt allerdings weniger Platz: In diesem Fall können nicht alle Tabellen, Filter und Zusatzinformationen gleichzeitig in sinnvoller Größe dargestellt werden.
|
||||
Beispiel: `/CQ DL5ASG pse sked 144.205?`
|
||||
|
||||
### Java
|
||||
|
||||
Für die fertigen Pakete aus den [GitHub Releases](https://github.com/praktimarc/kst4contest/releases/latest) ist keine separate Java-Installation erforderlich. Die benötigte Laufzeitumgebung wird zusammen mit KST4Contest ausgeliefert.
|
||||
|
||||
Eine Java-Entwicklungsumgebung wird nur benötigt, wenn KST4Contest selbst aus dem Quellcode gebaut werden soll. Bei den AUR-Paketen `kst4contest` und `kst4contest-git` werden Java 21 und Maven als Build-Abhängigkeiten durch den Paketmanager berücksichtigt.
|
||||
|
||||
---
|
||||
|
||||
## Stable, Beta oder Nightly?
|
||||
|
||||
KST4Contest wird in drei Entwicklungsständen bereitgestellt:
|
||||
|
||||
| Kanal | Geeignet für | Einordnung |
|
||||
|---|---|---|
|
||||
| **Stable** | Normaler Contestbetrieb | Veröffentlichte und für den regulären Einsatz vorgesehene Version |
|
||||
| **Beta** | Gezielte Tests vor einem Stable-Release | Vorabversion mit weitgehend festgelegtem Funktionsumfang |
|
||||
| **Nightly** | Frühe Tests neuer Funktionen | Aktueller Entwicklungsstand aus dem `main`-Branch |
|
||||
|
||||
Für den normalen Einsatz wird die **Stable-Version** empfohlen.
|
||||
|
||||
Beta- und Nightly-Versionen können Funktionen enthalten, die im Stable-Release noch nicht verfügbar sind. Sie können aber ebenso unfertige Bedienabläufe, geänderte Einstellungen oder neue Fehler enthalten. Das ist kein ungewöhnlicher Defekt im Veröffentlichungsverfahren, sondern der Zweck eines Entwicklungskanals.
|
||||
|
||||
Wenn in diesem Manual eine Funktion ausdrücklich mit **Nightly** gekennzeichnet ist, gehört sie noch nicht zwingend zum aktuellen Stable-Release.
|
||||
Bei starkem Chat-Verkehr (5–6 Nachrichten pro Sekunde im Contest) gehen öffentliche Nachrichten, die an ein bestimmtes Rufzeichen gerichtet sind, leicht unter. KST4Contest fängt solche Nachrichten aber auch dann ab, wenn sie fälschlicherweise öffentlich gepostet werden (siehe [Funktionen – PM-Abfang](Funktionen#catching-personal-messages)).
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
Die aktuelle Stable-Version ist hier verfügbar:
|
||||
### Windows
|
||||
|
||||
- [Aktuelles KST4Contest-Release](https://github.com/praktimarc/kst4contest/releases/latest)
|
||||
- [Alle veröffentlichten Versionen](https://github.com/praktimarc/kst4contest/releases)
|
||||
Die aktuelle Version kann als ZIP-Datei heruntergeladen werden:
|
||||
|
||||
Die Release-Seite enthält die Programmpakete sowie die deutschen und englischen PDF-Handbücher.
|
||||
**https://github.com/praktimarc/kst4contest/releases/latest**
|
||||
|
||||
### Welches Paket wird benötigt?
|
||||
Der Dateiname hat das Format `praktiKST-v<Versionsnummer>-windows-x64.zip `.
|
||||
|
||||
| Betriebssystem | Paket | Typischer Dateiname |
|
||||
|---|---|---|
|
||||
| Windows x64 | ZIP-Paket | `praktiKST-v<Version>-windows-x64.zip` |
|
||||
| Linux x86_64 | AppImage | `KST4Contest-v<Version>-linux-x86_64.AppImage` |
|
||||
| Debian/Ubuntu amd64 | DEB-Paket | `KST4Contest-v<Version>-debian-amd64.deb` |
|
||||
| Fedora/RPM x86_64 | RPM-Paket | `KST4Contest-v<Version>-fedora-x86_64.rpm` |
|
||||
| Arch Linux x86_64 | Arch-Paket | `KST4Contest-v<Version>-archlinux-x86_64.pkg.tar.zst` |
|
||||
| Linux mit Flatpak | Flatpak-Referenz | `de.x08.KST4Contest.flatpakref` |
|
||||
| macOS Apple Silicon | DMG für ARM64 | `KST4Contest-v<Version>-macos-arm64.dmg` |
|
||||
| macOS Intel | DMG für x86_64 | `KST4Contest-v<Version>-macos-x86_64.dmg` |
|
||||
### Linux
|
||||
|
||||
Die aktuelle Version kann als AppImage heruntergeladen werden:
|
||||
|
||||
**https://github.com/praktimarc/kst4contest/releases/latest**
|
||||
|
||||
Der Dateiname hat das Format `KST4Contest-v<Versionsnummer>-linux-x86_64.AppImage`.
|
||||
|
||||
### macOS
|
||||
|
||||
> ⚠️ **Best-Effort-Support:** macOS-Builds werden als zusätzliche Option bereitgestellt, sind aber **nicht umfassend getestet**. Wir bauen und veröffentlichen macOS-Binaries mit jedem Release, können allerdings nicht alle Szenarien unter macOS testen. Bei Problemen freuen wir uns über eine Rückmeldung – wir versuchen unser Bestes, können aber nicht den gleichen Support-Umfang wie für Windows und Linux garantieren.
|
||||
|
||||
Die aktuelle Version kann als DMG-Disk-Image heruntergeladen werden (für Apple-Silicon- und Intel-Macs verfügbar):
|
||||
|
||||
**https://github.com/praktimarc/kst4contest/releases/latest**
|
||||
|
||||
Der Dateiname hat das Format `KST4Contest-v<Versionsnummer>-macos-<Architektur>.dmg`, wobei `<Architektur>` entweder `arm64` (Apple Silicon) oder `x86_64` (Intel) ist.
|
||||
|
||||
Lade Programmpakete nur aus den offiziellen GitHub Releases, dem KST4Contest-Flatpak-Repository oder den verlinkten AUR-Paketen herunter. Dateien aus anderen Quellen können anders gebaut, veraltet oder verändert worden sein.
|
||||
|
||||
---
|
||||
|
||||
## Installation unter Windows
|
||||
## Installation
|
||||
|
||||
KST4Contest wird unter Windows als ZIP-Paket bereitgestellt. Ein klassischer Installer ist nicht erforderlich.
|
||||
### Windows
|
||||
|
||||
1. Lade `praktiKST-v<Version>-windows-x64.zip` aus dem aktuellen Release herunter.
|
||||
2. Entpacke die ZIP-Datei vollständig in einen eigenen Ordner.
|
||||
3. Öffne den entpackten Ordner.
|
||||
4. Starte `praktiKST.exe`.
|
||||
1. ZIP-Datei herunterladen.
|
||||
2. ZIP-Datei in einen gewünschten Ordner entpacken.
|
||||
3. `praktiKST.exe` ausführen.
|
||||
|
||||
Starte das Programm nicht direkt aus der noch komprimierten ZIP-Datei. KST4Contest besteht aus mehreren Dateien und einer mitgelieferten Laufzeitumgebung. Windows kann diese Struktur nur zuverlässig verwenden, wenn das Archiv vorher vollständig entpackt wurde.
|
||||
Die Einstellungen werden unter `%USERPROFILE%\.praktikst\preferences.xml` gespeichert.
|
||||
|
||||
Die Programmeinstellungen befinden sich nicht im entpackten Programmordner, sondern im Benutzerverzeichnis:
|
||||
### Linux
|
||||
1. AppImage herunterladen.
|
||||
2. AppImage in gewünschten Ordner entpacken.
|
||||
3. AppImage ausführbar machen (geht im Terminal mit `chmod +x KST4Contest-v<Versionsnummer>-linux-x86_64.AppImage`)
|
||||
4. AppImage ausführen.
|
||||
|
||||
```text
|
||||
%USERPROFILE%\.praktiKST\preferences.xml
|
||||
```
|
||||
Die Einstellungen werden unter `~/.praktikst/preferences.xml` gespeichert.
|
||||
|
||||
Dadurch können neue Programmversionen in einen anderen Ordner entpackt werden, ohne dass die vorhandenen Einstellungen verloren gehen.
|
||||
### macOS
|
||||
1. DMG-Datei für die eigene Architektur herunterladen (Apple Silicon oder Intel).
|
||||
2. DMG-Datei öffnen.
|
||||
3. `KST4Contest.app` in den **Programme**-Ordner ziehen.
|
||||
4. Beim ersten Start zeigt macOS ggf. eine Warnung, da die App nicht notarisiert ist. Zum Öffnen:
|
||||
- Rechtsklick (oder Ctrl-Klick) auf `KST4Contest.app` im Finder → **Öffnen** wählen.
|
||||
- Alternativ: **Systemeinstellungen → Datenschutz & Sicherheit** → **Trotzdem öffnen** klicken.
|
||||
5. KST4Contest aus dem Programme-Ordner oder dem Launchpad starten.
|
||||
|
||||
Die Einstellungen werden unter `~/.praktikst/preferences.xml` gespeichert.
|
||||
|
||||
---
|
||||
|
||||
## Installation unter Linux
|
||||
## Update
|
||||
|
||||
Unter Linux stehen mehrere Paketformate zur Verfügung. Welches davon sinnvoll ist, hängt weniger von KST4Contest als von der verwendeten Distribution und der gewünschten Update-Methode ab.
|
||||
KST4Contest enthält einen **automatischen Update-Hinweis-Dienst**: Sobald eine neue Version verfügbar ist, erscheint beim Start ein Fenster mit:
|
||||
- der Information, dass eine neue Version vorliegt,
|
||||
- einem Changelog,
|
||||
- dem Download-Link zur neuen Version.
|
||||
|
||||
| Installationsart | Sinnvoll, wenn … |
|
||||
|---|---|
|
||||
| **Flatpak** | Updates zentral verwaltet werden sollen und Flatpak bereits verwendet wird |
|
||||
| **AppImage** | KST4Contest ohne Installation als einzelne portable Datei gestartet werden soll |
|
||||
| **DEB/RPM** | die Paketverwaltung der Distribution verwendet werden soll |
|
||||
| **Arch-Paket/AUR** | Arch Linux, Manjaro oder EndeavourOS eingesetzt wird |
|
||||

|
||||
|
||||
### Flatpak
|
||||
### Update-Prozess
|
||||
|
||||
Flatpak ist für die meisten Linux-Anwender der einfachste Weg, KST4Contest installiert und aktualisierbar zu halten. Das KST4Contest-Repository ist GPG-signiert und enthält die Kanäle Stable, Beta und Nightly.
|
||||
#### Windows
|
||||
|
||||
#### Stable über die Release-Datei installieren
|
||||
Derzeit gibt es nur einen Weg zum Aktualisieren:
|
||||
|
||||
Lade `de.x08.KST4Contest.flatpakref` aus dem aktuellen Release herunter und öffne die Datei mit der Softwareverwaltung der verwendeten Desktop-Umgebung.
|
||||
1. Den alten Ordner löschen.
|
||||
2. Das neue ZIP entpacken.
|
||||
|
||||
Alternativ kann sie im Terminal installiert werden:
|
||||
Die Einstellungsdatei (`preferences.xml`) bleibt erhalten, da sie im Benutzerordner gespeichert ist – nicht im Programmordner.
|
||||
|
||||
```bash
|
||||
flatpak install ./de.x08.KST4Contest.flatpakref
|
||||
```
|
||||
#### Linux
|
||||
|
||||
#### KST4Contest-Repository hinzufügen
|
||||
Derzeit folgendermaßen:
|
||||
1. neues AppImage herunterladen
|
||||
2. neues AppImage ausführbar makieren
|
||||
3. (optional) altes AppImage löschen.
|
||||
|
||||
Das Repository muss nur einmal hinzugefügt werden:
|
||||
#### macOS
|
||||
|
||||
```bash
|
||||
flatpak remote-add --if-not-exists kst4contest \
|
||||
https://praktimarc.github.io/kst4contest/kst4contest.flatpakrepo
|
||||
```
|
||||
1. Neue DMG-Datei herunterladen.
|
||||
2. DMG öffnen.
|
||||
3. Die neue `KST4Contest.app` in den **Programme**-Ordner ziehen und die alte Version ersetzen.
|
||||
|
||||
Anschließend kann die Stable-Version installiert werden:
|
||||
|
||||
```bash
|
||||
flatpak install kst4contest de.x08.KST4Contest//stable
|
||||
```
|
||||
|
||||
#### Beta installieren
|
||||
|
||||
```bash
|
||||
flatpak install kst4contest de.x08.KST4Contest//beta
|
||||
```
|
||||
|
||||
#### Nightly installieren
|
||||
|
||||
```bash
|
||||
flatpak install kst4contest de.x08.KST4Contest//nightly
|
||||
```
|
||||
|
||||
KST4Contest verwendet für alle drei Kanäle dieselbe App-ID. Eine parallele Installation mehrerer KST4Contest-Kanäle ist deshalb nicht vorgesehen.
|
||||
|
||||
Zum Wechsel von Stable auf Nightly beispielsweise:
|
||||
|
||||
```bash
|
||||
flatpak uninstall de.x08.KST4Contest//stable
|
||||
flatpak install kst4contest de.x08.KST4Contest//nightly
|
||||
```
|
||||
|
||||
Die persönlichen Einstellungen im Verzeichnis `~/.praktiKST` werden dabei nicht automatisch gelöscht.
|
||||
|
||||
Installierte Flatpak-Anwendungen können mit folgendem Befehl aktualisiert werden:
|
||||
|
||||
```bash
|
||||
flatpak update de.x08.KST4Contest
|
||||
```
|
||||
|
||||
Je nach Desktop-Umgebung kann die grafische Softwareverwaltung verfügbare Flatpak-Updates ebenfalls anzeigen oder automatisch installieren. Der Befehl `flatpak update` selbst führt das Update aus; er verspricht nicht, irgendwann von allein vorbeizukommen.
|
||||
|
||||
### AppImage
|
||||
|
||||
Das AppImage benötigt keine klassische Installation.
|
||||
|
||||
1. Lade `KST4Contest-v<Version>-linux-x86_64.AppImage` herunter.
|
||||
2. Öffne ein Terminal im Download-Verzeichnis.
|
||||
3. Mache die Datei ausführbar:
|
||||
|
||||
```bash
|
||||
chmod +x KST4Contest-v<Version>-linux-x86_64.AppImage
|
||||
```
|
||||
|
||||
4. Starte KST4Contest:
|
||||
|
||||
```bash
|
||||
./KST4Contest-v<Version>-linux-x86_64.AppImage
|
||||
```
|
||||
|
||||
Das AppImage kann anschließend an einen anderen Ort verschoben werden, beispielsweise nach `~/Applications`.
|
||||
|
||||
### Debian und Ubuntu
|
||||
|
||||
Installiere das DEB-Paket mit:
|
||||
|
||||
```bash
|
||||
sudo apt install ./KST4Contest-v<Version>-debian-amd64.deb
|
||||
```
|
||||
|
||||
Alternativ kann die Datei in einer grafischen Paketverwaltung geöffnet werden.
|
||||
|
||||
### Fedora und kompatible RPM-Systeme
|
||||
|
||||
Installiere das RPM-Paket mit:
|
||||
|
||||
```bash
|
||||
sudo dnf install ./KST4Contest-v<Version>-fedora-x86_64.rpm
|
||||
```
|
||||
|
||||
### Arch Linux: fertiges Release-Paket
|
||||
|
||||
Das aus dem GitHub Release heruntergeladene Arch-Paket kann direkt installiert werden:
|
||||
|
||||
```bash
|
||||
sudo pacman -U KST4Contest-v<Version>-archlinux-x86_64.pkg.tar.zst
|
||||
```
|
||||
|
||||
### Arch Linux: Installation über den AUR
|
||||
|
||||
Im AUR stehen drei Varianten bereit:
|
||||
|
||||
| Paket | Inhalt |
|
||||
|---|---|
|
||||
| [`kst4contest-bin`](https://aur.archlinux.org/packages/kst4contest-bin) | Vorgefertigtes Paket des aktuellen Stable-Releases |
|
||||
| [`kst4contest`](https://aur.archlinux.org/packages/kst4contest) | Stable-Release, das lokal aus dem Quellcode gebaut wird |
|
||||
| [`kst4contest-git`](https://aur.archlinux.org/packages/kst4contest-git) | Aktueller Entwicklungsstand aus dem `main`-Branch |
|
||||
|
||||
Für die meisten Anwender ist `kst4contest-bin` die naheliegende Variante:
|
||||
|
||||
```bash
|
||||
yay -S kst4contest-bin
|
||||
```
|
||||
|
||||
Stable aus dem Quellcode bauen:
|
||||
|
||||
```bash
|
||||
yay -S kst4contest
|
||||
```
|
||||
|
||||
Aktuellen Entwicklungsstand bauen:
|
||||
|
||||
```bash
|
||||
yay -S kst4contest-git
|
||||
```
|
||||
|
||||
Die drei Pakete stellen dieselbe Anwendung bereit und sind deshalb als gegenseitige Konflikte definiert. Installiere nur eine Variante gleichzeitig.
|
||||
|
||||
AUR-Updates werden berücksichtigt, wenn der verwendete AUR-Helper nach Paketaktualisierungen sucht, beispielsweise mit:
|
||||
|
||||
```bash
|
||||
yay -Syu
|
||||
```
|
||||
|
||||
Sie erfolgen nicht allein deshalb automatisch, weil das Paket aus dem AUR stammt.
|
||||
|
||||
---
|
||||
|
||||
## Installation unter macOS
|
||||
## Bekannte Probleme beim Start
|
||||
|
||||
> **Best-Effort-Support:** Die macOS-Pakete werden zusammen mit den übrigen Releases gebaut, aber nicht in demselben Umfang getestet wie die Windows- und Linux-Versionen. Rückmeldungen sind willkommen; eine vollständig geprüfte Unterstützung aller macOS-Versionen und Hardwarevarianten kann derzeit jedoch nicht zugesagt werden.
|
||||
### Norton 360
|
||||
|
||||
Für Apple-Silicon-Macs wird das Paket mit `arm64` benötigt. Für Intel-Macs ist das Paket mit `x86_64` vorgesehen.
|
||||
Norton 360 stuft `praktiKST.exe` als gefährlich ein (Fehlalarm). Es muss eine Ausnahme für die Datei eingerichtet werden:
|
||||
|
||||
1. Lade die passende DMG-Datei herunter.
|
||||
2. Öffne die DMG-Datei.
|
||||
3. Ziehe `KST4Contest.app` in den Ordner **Programme**.
|
||||
4. Starte KST4Contest aus dem Programme-Ordner oder über das Launchpad.
|
||||
1. Norton 360 öffnen.
|
||||
2. Sicherheit → Verlauf → Das entsprechende Ereignis suchen.
|
||||
3. „Wiederherstellen & Ausnahme hinzufügen" wählen.
|
||||
|
||||
Die Anwendung ist derzeit nicht von Apple notarisiert. macOS kann den ersten Start deshalb blockieren.
|
||||
|
||||
Falls die Anwendung aus dem offiziellen GitHub Release stammt:
|
||||
|
||||
1. Öffne den Programme-Ordner im Finder.
|
||||
2. Klicke mit der rechten Maustaste oder mit gedrückter Ctrl-Taste auf `KST4Contest.app`.
|
||||
3. Wähle **Öffnen**.
|
||||
4. Bestätige den Start im angezeigten Dialog.
|
||||
|
||||
Alternativ kann macOS unter **Systemeinstellungen → Datenschutz & Sicherheit** die Schaltfläche **Trotzdem öffnen** anbieten.
|
||||
|
||||
---
|
||||
|
||||
## Wo werden die Einstellungen gespeichert?
|
||||
|
||||
KST4Contest speichert seine Einstellungen und weitere lokale Arbeitsdateien im Benutzerverzeichnis. Der Programmordner und das Datenverzeichnis sind voneinander getrennt.
|
||||
|
||||
| Betriebssystem | Datenverzeichnis | Einstellungsdatei |
|
||||
|---|---|---|
|
||||
| Windows | `%USERPROFILE%\.praktiKST\` | `%USERPROFILE%\.praktiKST\preferences.xml` |
|
||||
| Linux | `~/.praktiKST/` | `~/.praktiKST/preferences.xml` |
|
||||
| macOS | `~/.praktiKST/` | `~/.praktiKST/preferences.xml` |
|
||||
|
||||
Beachte die Schreibweise `.praktiKST` mit großem `KST`. Unter Linux und macOS wird zwischen Groß- und Kleinschreibung unterschieden. `.praktikst` wäre dort schlicht ein anderes Verzeichnis.
|
||||
|
||||
Ein Programmupdate entfernt dieses Verzeichnis nicht. Trotzdem ist es sinnvoll, vor größeren Versionswechseln oder umfangreichen Änderungen an der Konfiguration eine Sicherung davon anzulegen.
|
||||
|
||||
---
|
||||
|
||||
## Updates
|
||||
|
||||
KST4Contest prüft beim Start, ob ein neueres Stable-Release verfügbar ist. Wenn eine neuere Version gefunden wird, erscheint ein Informationsfenster mit:
|
||||
|
||||
- der installierten Version,
|
||||
- der aktuellen Stable-Version,
|
||||
- einer kurzen Übersicht wesentlicher Änderungen,
|
||||
- dem Changelog,
|
||||
- bekannten Problemen,
|
||||
- einem Link zur passenden GitHub-Release-Seite.
|
||||
|
||||

|
||||
|
||||
Der Update-Checker installiert nichts selbst. Er informiert über die neue Version und öffnet die plattformneutrale Release-Seite. Dort muss das passende Paket für Windows, Linux oder macOS ausgewählt werden.
|
||||
|
||||
### Windows aktualisieren
|
||||
|
||||
1. Beende KST4Contest.
|
||||
2. Lade das neue Windows-ZIP herunter.
|
||||
3. Entpacke es in einen neuen oder leeren Ordner.
|
||||
4. Starte die neue Version.
|
||||
5. Prüfe, ob die bisherigen Einstellungen geladen wurden.
|
||||
6. Entferne den alten Programmordner erst danach.
|
||||
|
||||
Die Einstellungen bleiben erhalten, weil sie unter `%USERPROFILE%\.praktiKST` und nicht im Programmordner gespeichert werden.
|
||||
|
||||
### AppImage aktualisieren
|
||||
|
||||
1. Lade das neue AppImage herunter.
|
||||
2. Mache es ausführbar.
|
||||
3. Starte die neue Datei.
|
||||
4. Entferne das bisherige AppImage erst, wenn die neue Version funktioniert.
|
||||
|
||||
### Debian und Ubuntu aktualisieren
|
||||
|
||||
```bash
|
||||
sudo apt install ./KST4Contest-v<Version>-debian-amd64.deb
|
||||
```
|
||||
|
||||
### Fedora aktualisieren
|
||||
|
||||
```bash
|
||||
sudo dnf upgrade ./KST4Contest-v<Version>-fedora-x86_64.rpm
|
||||
```
|
||||
|
||||
### Arch-Paket aktualisieren
|
||||
|
||||
```bash
|
||||
sudo pacman -U KST4Contest-v<Version>-archlinux-x86_64.pkg.tar.zst
|
||||
```
|
||||
|
||||
### AUR-Paket aktualisieren
|
||||
|
||||
Aktualisiere das installierte Paket über den verwendeten AUR-Helper, beispielsweise:
|
||||
|
||||
```bash
|
||||
yay -Syu
|
||||
```
|
||||
|
||||
### Flatpak aktualisieren
|
||||
|
||||
```bash
|
||||
flatpak update de.x08.KST4Contest
|
||||
```
|
||||
|
||||
### macOS aktualisieren
|
||||
|
||||
1. Lade die neue DMG-Datei für die vorhandene Architektur herunter.
|
||||
2. Beende KST4Contest.
|
||||
3. Öffne die DMG-Datei.
|
||||
4. Ersetze `KST4Contest.app` im Programme-Ordner.
|
||||
5. Starte die neue Version und prüfe die vorhandenen Einstellungen.
|
||||
|
||||
Die Konfiguration unter `~/.praktiKST` bleibt davon unberührt.
|
||||
|
||||
---
|
||||
|
||||
## Probleme beim ersten Start
|
||||
|
||||
### Windows meldet eine unbekannte Anwendung
|
||||
|
||||
KST4Contest ist derzeit nicht mit einem kommerziellen Windows-Code-Signing-Zertifikat signiert. Windows oder ein zusätzlich installiertes Sicherheitsprodukt kann deshalb vor einer unbekannten oder selten heruntergeladenen Anwendung warnen.
|
||||
|
||||
Prüfe in diesem Fall zuerst:
|
||||
|
||||
- Stammt die Datei aus dem [offiziellen KST4Contest-Release](https://github.com/praktimarc/kst4contest/releases/latest)?
|
||||
- Passt der Dateiname zum veröffentlichten Release?
|
||||
- Wurde die Datei vollständig heruntergeladen?
|
||||
- Nennt das Sicherheitsprogramm einen konkreten Erkennungsnamen oder nur eine allgemeine Reputationswarnung?
|
||||
|
||||
Ein Warnhinweis ist allein weder ein sicherer Beweis für Schadsoftware noch automatisch ein Fehlalarm. Wenn die Herkunft der Datei unklar ist, sollte sie nicht gestartet oder aus der Quarantäne wiederhergestellt werden.
|
||||
|
||||
Einige Anwender haben insbesondere Quarantäne-Meldungen von Norton 360 gemeldet. Wenn sich eine Meldung reproduzieren lässt, erstelle bitte einen [GitHub-Issue](https://github.com/praktimarc/kst4contest/issues) und nenne:
|
||||
|
||||
- die KST4Contest-Version,
|
||||
- den vollständigen Dateinamen,
|
||||
- das verwendete Sicherheitsprodukt und dessen Version,
|
||||
- den angezeigten Erkennungsnamen,
|
||||
- möglichst einen Screenshot der Meldung.
|
||||
|
||||
### Das AppImage startet nicht
|
||||
|
||||
Prüfe zuerst das Ausführungsrecht:
|
||||
|
||||
```bash
|
||||
chmod +x KST4Contest-v<Version>-linux-x86_64.AppImage
|
||||
```
|
||||
|
||||
Starte die Datei anschließend aus einem Terminal. Fehlermeldungen sind dort meist aussagekräftiger als ein Doppelklick, der lediglich nichts Sichtbares tut.
|
||||
|
||||
### macOS blockiert die Anwendung
|
||||
|
||||
Verwende die unter [Installation unter macOS](#installation-unter-macos) beschriebene Funktion **Öffnen** im Kontextmenü. Prüfe vorher, ob die DMG-Datei aus dem offiziellen GitHub Release stammt.
|
||||
|
||||
### Das Problem bleibt bestehen
|
||||
|
||||
Prüfe zunächst, ob das Problem bereits unter [GitHub Issues](https://github.com/praktimarc/kst4contest/issues) beschrieben wurde. Falls nicht, erstelle einen neuen Issue mit:
|
||||
|
||||
- Betriebssystem und Version,
|
||||
- verwendeter KST4Contest-Version,
|
||||
- Installationsart,
|
||||
- genauer Fehlermeldung,
|
||||
- den Schritten, mit denen sich das Problem reproduzieren lässt.
|
||||
|
||||
„Geht nicht“ beschreibt den Zustand meistens korrekt, hilft bei der Fehlersuche aber nur begrenzt.
|
||||
*(Gemeldet von PE0WGA, Franz van Velzen – danke!)*
|
||||
|
||||
@@ -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.
|
||||
|
||||
+29
-95
@@ -1,117 +1,51 @@
|
||||
# KST4Contest – User Manual
|
||||
# KST4Contest – Wiki
|
||||
|
||||
> You are reading the English version | [Deutsche Version](de-Home)
|
||||
> 🇬🇧 You are reading the English version | 🇩🇪 [Deutsche Version](de-Home)
|
||||
|
||||
KST4Contest is a desktop client for the [ON4KST Chat](https://www.on4kst.org/chat/login.php), developed for VHF, UHF and SHF contest operation. It brings chat, candidate selection, sked planning, aircraft scatter data and external station software together in a single operating interface.
|
||||
**KST4Contest** (also known as *PraktiKST*) is a Java-based chat client for the [ON4KST Chat](http://www.on4kst.info/chat/), specifically designed for contest operation on the VHF/UHF/SHF bands (144 MHz and above).
|
||||
|
||||
KST4Contest is developed by **DO5AMF (Marc Fröhlich)**, operator at DM5M and (since May 2025) **DN9APW (Philipp Wagner)**. The source code is publicly available on [GitHub](https://github.com/praktimarc/kst4contest).
|
||||
Developed by **DO5AMF (Marc Fröhlich)**, operator at DM5M.
|
||||
|
||||
---
|
||||
|
||||
## Why use a dedicated ON4KST client?
|
||||
|
||||
During a contest, the ON4KST Chat provides a considerable amount of information: active stations, locators, frequencies, sked requests and indications of current activity. The actual problem is not seeing this data. It is turning it into the next useful contact in time.
|
||||
|
||||
KST4Contest evaluates the available information, puts it into context and presents it as part of a contest-oriented workflow. This includes antenna direction, distance, known bands and frequencies, worked status, chat activity and aircraft scatter timing.
|
||||
|
||||
The program does not decide which QSO is actually possible. Priority scores, the AP timeline and visual highlights are decision aids. The final judgement remains with the operator – not least because even a very convincing computer screen cannot complete a radio contact.
|
||||
|
||||
---
|
||||
|
||||
## How KST4Contest supports contest operation
|
||||
|
||||
- **Observe and organise chat activity:** KST4Contest can display two ON4KST chat categories at the same time. Messages, frequency information and known band activity are assigned to the corresponding stations.
|
||||
|
||||
- **Reduce the station list:** Direction, distance, worked and NOT-QRV filters help limit the user list to stations that are relevant to the current operating situation.
|
||||
|
||||
- **Prioritise candidates:** The score system evaluates active chat members using several known criteria. The currently most relevant candidates are also shown in a separate priority list.
|
||||
|
||||
- **Prepare skeds and keep them visible:** Sked reminders, automatic advance messages and the AP timeline help prevent scheduled contacts from disappearing somewhere between chat, logging and ongoing CQ operation.
|
||||
|
||||
- **Include aircraft scatter information:** The AirScout interface brings suitable aircraft and expected reflection times into candidate evaluation and sked planning.
|
||||
|
||||
- **Connect the logger and station equipment:** KST4Contest synchronises worked stations and frequency information with supported logging software. It also provides interfaces for Win-Test, PSTRotator and a built-in DX Cluster server.
|
||||
|
||||
- **Display stations and radio paths:** The station map shows active chat members, locator squares, antenna directions and the path to the selected station. A terrain profile can also be calculated for selected paths.
|
||||
|
||||
These functions share information. A detected frequency can indicate an active band, worked status comes from the logger, aircraft scatter data adds timing information and the resulting score affects the priority list. Missing or outdated input data can therefore affect the result as well.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
A registered ON4KST account is required. Registration and login are available from the [official ON4KST login page](https://www.on4kst.org/chat/login.php).
|
||||
|
||||
English is the official language of the ON4KST Chat. This also applies when communicating with stations from your own country. Common amateur radio abbreviations such as `pse`, `agn`, `qrg`, `dir`, `rrr`, `tnx` and `73` are normal and usually considerably faster than carefully written prose.
|
||||
|
||||
Downloads, supported operating systems and installation methods are described in [Installation](en-Installation).
|
||||
|
||||
---
|
||||
|
||||
## Manual version
|
||||
|
||||
This manual describes the stable release **v1.41.1**.
|
||||
|
||||
Features or changes that are only available in the current development version are explicitly marked as **Nightly / v1.42**. If no such label is present, the description refers to the stable release.
|
||||
|
||||
- [Download the current stable release](https://github.com/praktimarc/kst4contest/releases/latest)
|
||||
- [Nightly and automated builds](https://github.com/praktimarc/kst4contest/actions)
|
||||
- [Version history](en-Changelog)
|
||||
|
||||
For contest operation, the stable release is generally the appropriate choice. Nightly builds contain newer fixes and features, but may change between builds. They are useful when a particular change needs testing. Trying one for the first time ten minutes before a contest is less useful.
|
||||
|
||||
---
|
||||
|
||||
## Quick navigation
|
||||
## Quick Navigation
|
||||
|
||||
| Page | Contents |
|
||||
|---|---|
|
||||
| [Installation](en-Installation) | ON4KST account, downloads, installation and updates |
|
||||
| [Configuration](en-Configuration) | Login, station, bands, user interface and external connections |
|
||||
| [Log Synchronisation](en-Log-Sync) | Simplelogfile, UCXLog, N1MM+, QARTest, DXLog.net and Win-Test |
|
||||
| [AirScout Integration](en-AirScout-Integration) | Connecting AirScout and evaluating aircraft scatter timing |
|
||||
| [DX Cluster Server](en-DX-Cluster-Server) | Passing detected opportunities to logging software |
|
||||
| [Features](en-Features) | Operation, reasoning and limitations of individual functions |
|
||||
| [Macros and Variables](en-Macros-and-Variables) | Reusable messages, shortcuts and automatically substituted values |
|
||||
| [User Interface](en-User-Interface) | Interface layout and operation during a contest |
|
||||
| [Changelog](en-Changelog) | Releases, Nightly changes and resolved issues |
|
||||
| [Installation](en-Installation) | Download, Java requirements, update |
|
||||
| [Configuration](en-Configuration) | All settings in detail |
|
||||
| [Log Synchronisation](en-Log-Sync) | UCXLog, N1MM+, QARTest, DXLog.net, WinTest |
|
||||
| [AirScout Integration](en-AirScout-Integration) | Aircraft scatter detection |
|
||||
| [DX Cluster Server](en-DX-Cluster-Server) | Built-in DX cluster for your logging software |
|
||||
| [Features](en-Features) | All features at a glance |
|
||||
| [Macros and Variables](en-Macros-and-Variables) | Text snippets, shortcuts, variables |
|
||||
| [User Interface](en-User-Interface) | UI explanation and operation |
|
||||
| [Changelog](en-Changelog) | Version history |
|
||||
|
||||
---
|
||||
|
||||
## Contact and support
|
||||
## What is KST4Contest?
|
||||
|
||||
- **Download:** [Current stable release](https://github.com/praktimarc/kst4contest/releases/latest)
|
||||
- **Source code:** [praktimarc/kst4contest](https://github.com/praktimarc/kst4contest)
|
||||
- **Bug reports and feature requests:** [GitHub Issues](https://github.com/praktimarc/kst4contest/issues)
|
||||
- **Email:** praktimarc+kst4contest@gmail.com
|
||||
Please use this address only for KST4Contest-related topics.
|
||||
The ON4KST Chat is the de-facto standard for skeds on the 144 MHz and higher bands. KST4Contest enhances the chat experience with contest-specific features:
|
||||
|
||||
### Reporting a bug
|
||||
- **Worked marking**: Stations already worked are highlighted visually, synchronised directly from your logging software via UDP.
|
||||
- **Sked direction detection**: When a station calls another one from your direction, it is highlighted green and bold.
|
||||
- **QRG detection**: KST4Contest automatically reads frequencies from the chat traffic and shows them in the user list.
|
||||
- **AirScout interface**: Reflectable aircraft are shown directly in the user list.
|
||||
- **Built-in DX cluster server**: Spots are sent directly to your logging software.
|
||||
- **Dark mode** (from v1.26): Easy on the eyes during night-time operation.
|
||||
- **Multi-channel login** (from v1.26): Simultaneously logged into two chat categories.
|
||||
|
||||
A problem is considerably easier to reproduce when the report contains at least:
|
||||
---
|
||||
|
||||
1. the KST4Contest version,
|
||||
2. the operating system and installation method,
|
||||
3. the exact steps leading to the problem,
|
||||
4. the expected and observed behaviour,
|
||||
5. a screenshot where appropriate,
|
||||
6. the error log file.
|
||||
## Contact & Support
|
||||
|
||||
The error log is stored at:
|
||||
|
||||
| Operating system | Path |
|
||||
|---|---|
|
||||
| Linux / macOS | `~/.praktiKST/kst4contest-errors.log` |
|
||||
| Windows | `C:\Users\<YourName>\.praktiKST\kst4contest-errors.log` |
|
||||
|
||||
Please inspect the file briefly before uploading it. An error log mainly contains technical information, but depending on the problem it may also include local file paths or other contextual data.
|
||||
|
||||
Some file and directory names still use the technical name `praktiKST`. They refer to the same program.
|
||||
- **Email**: praktimarc+kst4contest@gmail.com *(for kst4contest topics only)*
|
||||
- **GitHub**: https://github.com/praktimarc/kst4contest
|
||||
- **Download**: https://github.com/praktimarc/kst4contest/releases/latest
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
KST4Contest has benefited considerably from feedback gathered during actual contest operation.
|
||||
|
||||
Special thanks go to Gianluca Costantino (IU3OAR), Alessandro Murador (IZ3VTH), Reczetár István (HA1FV), Viliam Petrik (OM0AAO, DX Cluster idea), Konrad Neitzel (DC9DJ, project structure), Andreas (DO5ALF, webmaster of funkerportal.de), Franz van Velzen (PE0WGA, testing), and all other testers and contributors.
|
||||
Special thanks to: Gianluca Costantino (IU3OAR), Alessandro Murador (IZ3VTH), Reczetár István (HA1FV), OM0AAO (Viliam Petrik, DX cluster idea), DC9DJ (Konrad Neitzel, project structure), DO5ALF (Andreas, webmaster funkerportal.de), PE0WGA (Franz van Velzen, tester) and all other testers and contributors.
|
||||
|
||||
+91
-373
@@ -2,422 +2,140 @@
|
||||
|
||||
> 🇬🇧 You are reading the English version | 🇩🇪 [Deutsche Version](de-Installation)
|
||||
|
||||
KST4Contest is distributed as a ready-to-run application package for Windows, Linux and macOS. The official release packages do not require a separate Java installation: the required Java runtime is already included.
|
||||
## Prerequisites
|
||||
|
||||
In practical terms, you need a supported operating system, an internet connection and an ON4KST account. That sounds manageable—and usually it is.
|
||||
An resolution of 1200px by 720px is recommended
|
||||
|
||||
## Requirements
|
||||
### ON4KST Account
|
||||
|
||||
### ON4KST account
|
||||
To use the chat, a registered account with the ON4KST chat service is required:
|
||||
|
||||
KST4Contest is an independent client for the ON4KST chat, but it does not replace the corresponding user account.
|
||||
- Register at: http://www.on4kst.info/chat/register.php
|
||||
|
||||
If you do not have an account yet, you can create one on the official ON4KST website:
|
||||
### Chat Etiquette
|
||||
|
||||
- [ON4KST login and registration](https://www.on4kst.org/chat/login.php)
|
||||
The official language in the ON4KST Chat is **English**. Please use English even when communicating with stations from your own country. Common HAM abbreviations (agn, dir, pse, rrr, tnx, 73 …) are widely used and understood.
|
||||
|
||||
ON4KST specifies English as the common language for the chat. This also applies when both stations happen to share another language. During contests, the usual amateur radio abbreviations such as `pse`, `agn`, `qrg`, `dir`, `rrr`, `tnx` and `73` are widely used.
|
||||
### Personal Messages
|
||||
|
||||
Using the chat and sending personal messages are covered in the user interface chapter. For the installation, the only thing that matters at this point is that the account works.
|
||||
To send a private message to another station, always use the following format:
|
||||
|
||||
### Screen size
|
||||
```
|
||||
/CQ CALLSIGN message text
|
||||
```
|
||||
|
||||
A usable screen area of approximately **1200 × 720 pixels** or more is recommended.
|
||||
Example: `/CQ DL5ASG pse sked 144.205?`
|
||||
|
||||
KST4Contest automatically adapts the main window to the available area of the primary screen. The application can therefore also be started on smaller displays. Less space is still less space, however: not all tables, filters and additional information can then be displayed at a useful size at the same time.
|
||||
|
||||
### Java
|
||||
|
||||
The ready-made packages from the [GitHub Releases](https://github.com/praktimarc/kst4contest/releases/latest) do not require a separate Java installation. The required runtime is distributed with KST4Contest.
|
||||
|
||||
A Java development environment is only required if you want to build KST4Contest from source. For the AUR packages `kst4contest` and `kst4contest-git`, Java 21 and Maven are handled as build dependencies by the package manager.
|
||||
|
||||
---
|
||||
|
||||
## Stable, Beta or Nightly?
|
||||
|
||||
KST4Contest is distributed through three development channels:
|
||||
|
||||
| Channel | Intended use | Classification |
|
||||
|---|---|---|
|
||||
| **Stable** | Normal contest operation | Published version intended for regular use |
|
||||
| **Beta** | Focused testing before a Stable release | Pre-release version with a largely defined feature set |
|
||||
| **Nightly** | Early testing of new features | Current development state from the `main` branch |
|
||||
|
||||
The **Stable version** is recommended for normal operation.
|
||||
|
||||
Beta and Nightly builds may contain features that are not yet available in the Stable release. They may also contain unfinished workflows, changed settings or new defects. That is not an unusual failure of the release process; it is the purpose of a development channel.
|
||||
|
||||
If a function in this manual is explicitly marked as **Nightly**, it is not necessarily part of the current Stable release yet.
|
||||
During heavy chat traffic (5–6 messages per second in a contest), public messages directed at a specific callsign are easily missed. However, KST4Contest also catches such messages if they are accidentally posted publicly (see [Features – PM Catching](Features#catching-personal-messages)).
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
The current Stable version is available here:
|
||||
### Windows
|
||||
|
||||
- [Latest KST4Contest release](https://github.com/praktimarc/kst4contest/releases/latest)
|
||||
- [All published releases](https://github.com/praktimarc/kst4contest/releases)
|
||||
The latest version can be downloaded as a ZIP file:
|
||||
|
||||
The release page contains the application packages as well as the English and German PDF manuals.
|
||||
**https://github.com/praktimarc/kst4contest/releases/latest**
|
||||
|
||||
### Which package do I need?
|
||||
The filename has the format `praktiKST-v<version_number>-windows-x64.zip`.
|
||||
|
||||
| Operating system | Package | Typical filename |
|
||||
|---|---|---|
|
||||
| Windows x64 | ZIP package | `praktiKST-v<version>-windows-x64.zip` |
|
||||
| Linux x86_64 | AppImage | `KST4Contest-v<version>-linux-x86_64.AppImage` |
|
||||
| Debian/Ubuntu amd64 | DEB package | `KST4Contest-v<version>-debian-amd64.deb` |
|
||||
| Fedora/RPM x86_64 | RPM package | `KST4Contest-v<version>-fedora-x86_64.rpm` |
|
||||
| Arch Linux x86_64 | Arch package | `KST4Contest-v<version>-archlinux-x86_64.pkg.tar.zst` |
|
||||
| Linux with Flatpak | Flatpak reference | `de.x08.KST4Contest.flatpakref` |
|
||||
| macOS Apple Silicon | DMG for ARM64 | `KST4Contest-v<version>-macos-arm64.dmg` |
|
||||
| macOS Intel | DMG for x86_64 | `KST4Contest-v<version>-macos-x86_64.dmg` |
|
||||
### Linux
|
||||
|
||||
The latest version can be downloaded as an AppImage:
|
||||
|
||||
**https://github.com/praktimarc/kst4contest/releases/latest**
|
||||
|
||||
The filename has the format `KST4Contest-v<version_number>-linux-x86_64.AppImage`.
|
||||
|
||||
### macOS
|
||||
|
||||
> ⚠️ **Best-Effort Support:** macOS builds are provided as a convenience but are **not fully tested**. We build and release macOS binaries with every release, but we cannot test every scenario on macOS. If you encounter issues, please report them – we will do our best to address them, but cannot guarantee the same level of support as for Windows and Linux.
|
||||
|
||||
The latest version can be downloaded as a DMG disk image (available for both Apple Silicon and Intel Macs):
|
||||
|
||||
**https://github.com/praktimarc/kst4contest/releases/latest**
|
||||
|
||||
The filename has the format `KST4Contest-v<version_number>-macos-<arch>.dmg`, where `<arch>` is `arm64` (Apple Silicon) or `x86_64` (Intel).
|
||||
|
||||
Only download application packages from the official GitHub Releases, the KST4Contest Flatpak repository or the linked AUR packages. Files from other sources may have been built differently, may be outdated or may have been modified.
|
||||
|
||||
---
|
||||
|
||||
## Installing on Windows
|
||||
## Installation
|
||||
|
||||
KST4Contest is distributed as a ZIP package for Windows. A conventional installer is not required.
|
||||
### Windows
|
||||
|
||||
1. Download `praktiKST-v<version>-windows-x64.zip` from the latest release.
|
||||
2. Extract the ZIP file completely into a dedicated folder.
|
||||
3. Open the extracted folder.
|
||||
4. Start `praktiKST.exe`.
|
||||
1. Download the ZIP file.
|
||||
2. Unzip the ZIP file into a folder of your choice.
|
||||
3. Run `praktiKST.exe`.
|
||||
|
||||
Do not start the application directly from the compressed ZIP file. KST4Contest consists of several files and a bundled runtime. Windows can only use this structure reliably after the archive has been extracted completely.
|
||||
Settings are stored at `%USERPROFILE%\.praktikst\preferences.xml`.
|
||||
|
||||
The application settings are not stored in the extracted program directory. They are stored in your user profile:
|
||||
### Linux
|
||||
1. Download the AppImage.
|
||||
2. Unzip the AppImage into a folder of your choice.
|
||||
3. Make the AppImage executable (in the terminal with `chmod +x KST4Contest-v<version_number>-linux-x86_64.AppImage`)
|
||||
4. Run the AppImage.
|
||||
|
||||
```text
|
||||
%USERPROFILE%\.praktiKST\preferences.xml
|
||||
```
|
||||
Settings are stored at `~/.praktikst/preferences.xml`.
|
||||
|
||||
This allows a new application version to be extracted into a different directory without losing the existing settings.
|
||||
|
||||
---
|
||||
|
||||
## Installing on Linux
|
||||
|
||||
Several Linux package formats are available. The appropriate choice depends less on KST4Contest itself than on your distribution and the way you prefer to manage updates.
|
||||
|
||||
| Installation method | Useful when … |
|
||||
|---|---|
|
||||
| **Flatpak** | updates should be managed centrally and Flatpak is already in use |
|
||||
| **AppImage** | KST4Contest should run as a portable file without package installation |
|
||||
| **DEB/RPM** | the distribution’s native package manager should be used |
|
||||
| **Arch package/AUR** | Arch Linux, Manjaro or EndeavourOS is being used |
|
||||
|
||||
### Flatpak
|
||||
|
||||
For most Linux users, Flatpak is the simplest way to keep KST4Contest installed and manageable through the system’s update tools. The KST4Contest repository is GPG-signed and contains the Stable, Beta and Nightly channels.
|
||||
|
||||
#### Installing Stable from the release file
|
||||
|
||||
Download `de.x08.KST4Contest.flatpakref` from the latest release and open it with the software manager provided by your desktop environment.
|
||||
|
||||
Alternatively, install it from a terminal:
|
||||
|
||||
```bash
|
||||
flatpak install ./de.x08.KST4Contest.flatpakref
|
||||
```
|
||||
|
||||
#### Adding the KST4Contest repository
|
||||
|
||||
The repository only needs to be added once:
|
||||
|
||||
```bash
|
||||
flatpak remote-add --if-not-exists kst4contest \
|
||||
https://praktimarc.github.io/kst4contest/kst4contest.flatpakrepo
|
||||
```
|
||||
|
||||
You can then install the Stable version:
|
||||
|
||||
```bash
|
||||
flatpak install kst4contest de.x08.KST4Contest//stable
|
||||
```
|
||||
|
||||
#### Installing Beta
|
||||
|
||||
```bash
|
||||
flatpak install kst4contest de.x08.KST4Contest//beta
|
||||
```
|
||||
|
||||
#### Installing Nightly
|
||||
|
||||
```bash
|
||||
flatpak install kst4contest de.x08.KST4Contest//nightly
|
||||
```
|
||||
|
||||
All three KST4Contest channels use the same application ID. Installing multiple KST4Contest channels in parallel is therefore not supported.
|
||||
|
||||
For example, to switch from Stable to Nightly:
|
||||
|
||||
```bash
|
||||
flatpak uninstall de.x08.KST4Contest//stable
|
||||
flatpak install kst4contest de.x08.KST4Contest//nightly
|
||||
```
|
||||
|
||||
This does not automatically remove the personal settings stored in `~/.praktiKST`.
|
||||
|
||||
Installed Flatpak applications can be updated with:
|
||||
|
||||
```bash
|
||||
flatpak update de.x08.KST4Contest
|
||||
```
|
||||
|
||||
Depending on the desktop environment, the graphical software manager may also display or automatically install available Flatpak updates. The `flatpak update` command itself performs an update; it does not promise to turn up and run by itself one day.
|
||||
|
||||
### AppImage
|
||||
|
||||
The AppImage does not require a conventional installation.
|
||||
|
||||
1. Download `KST4Contest-v<version>-linux-x86_64.AppImage`.
|
||||
2. Open a terminal in the download directory.
|
||||
3. Make the file executable:
|
||||
|
||||
```bash
|
||||
chmod +x KST4Contest-v<version>-linux-x86_64.AppImage
|
||||
```
|
||||
|
||||
4. Start KST4Contest:
|
||||
|
||||
```bash
|
||||
./KST4Contest-v<version>-linux-x86_64.AppImage
|
||||
```
|
||||
|
||||
The AppImage can then be moved to another location, such as `~/Applications`.
|
||||
|
||||
### Debian and Ubuntu
|
||||
|
||||
Install the DEB package with:
|
||||
|
||||
```bash
|
||||
sudo apt install ./KST4Contest-v<version>-debian-amd64.deb
|
||||
```
|
||||
|
||||
Alternatively, open the file with a graphical package manager.
|
||||
|
||||
### Fedora and compatible RPM systems
|
||||
|
||||
Install the RPM package with:
|
||||
|
||||
```bash
|
||||
sudo dnf install ./KST4Contest-v<version>-fedora-x86_64.rpm
|
||||
```
|
||||
|
||||
### Arch Linux: installing the release package
|
||||
|
||||
The Arch package downloaded from the GitHub Release can be installed directly:
|
||||
|
||||
```bash
|
||||
sudo pacman -U KST4Contest-v<version>-archlinux-x86_64.pkg.tar.zst
|
||||
```
|
||||
|
||||
### Arch Linux: installing from the AUR
|
||||
|
||||
Three variants are available in the AUR:
|
||||
|
||||
| Package | Content |
|
||||
|---|---|
|
||||
| [`kst4contest-bin`](https://aur.archlinux.org/packages/kst4contest-bin) | Pre-built package from the current Stable release |
|
||||
| [`kst4contest`](https://aur.archlinux.org/packages/kst4contest) | Stable release built locally from source |
|
||||
| [`kst4contest-git`](https://aur.archlinux.org/packages/kst4contest-git) | Current development state from the `main` branch |
|
||||
|
||||
For most users, `kst4contest-bin` is the straightforward option:
|
||||
|
||||
```bash
|
||||
yay -S kst4contest-bin
|
||||
```
|
||||
|
||||
To build the Stable release from source:
|
||||
|
||||
```bash
|
||||
yay -S kst4contest
|
||||
```
|
||||
|
||||
To build the current development state:
|
||||
|
||||
```bash
|
||||
yay -S kst4contest-git
|
||||
```
|
||||
|
||||
All three packages provide the same application and are therefore defined as conflicting with one another. Install only one variant at a time.
|
||||
|
||||
AUR updates are included when the selected AUR helper checks for package updates, for example:
|
||||
|
||||
```bash
|
||||
yay -Syu
|
||||
```
|
||||
|
||||
They do not happen automatically merely because the package came from the AUR.
|
||||
|
||||
---
|
||||
|
||||
## Installing on macOS
|
||||
|
||||
> **Best-effort support:** The macOS packages are built together with the other releases, but they are not tested to the same extent as the Windows and Linux versions. Feedback is welcome; fully tested support for every macOS version and hardware variant cannot currently be guaranteed.
|
||||
|
||||
Apple Silicon Macs require the package marked `arm64`. Intel Macs require the package marked `x86_64`.
|
||||
|
||||
1. Download the appropriate DMG file.
|
||||
### macOS
|
||||
1. Download the DMG file for your architecture (Apple Silicon or Intel).
|
||||
2. Open the DMG file.
|
||||
3. Drag `KST4Contest.app` into the **Applications** folder.
|
||||
4. Start KST4Contest from the Applications folder or Launchpad.
|
||||
3. Drag `KST4Contest.app` into your **Applications** folder.
|
||||
4. On first launch, macOS may show a warning because the app is not notarised. To open it:
|
||||
- Right-click (or Control-click) on `KST4Contest.app` in Finder and choose **Open**.
|
||||
- Alternatively, go to **System Settings → Privacy & Security** and click **Open Anyway**.
|
||||
5. Run KST4Contest from your Applications folder or Launchpad.
|
||||
|
||||
The application is not currently notarized by Apple. macOS may therefore block the first launch.
|
||||
|
||||
If the application came from the official GitHub Release:
|
||||
|
||||
1. Open the Applications folder in Finder.
|
||||
2. Right-click or Control-click `KST4Contest.app`.
|
||||
3. Select **Open**.
|
||||
4. Confirm the launch in the dialog that appears.
|
||||
|
||||
Alternatively, macOS may provide an **Open Anyway** button under **System Settings → Privacy & Security**.
|
||||
Settings are stored at `~/.praktikst/preferences.xml`.
|
||||
|
||||
---
|
||||
|
||||
## Where are the settings stored?
|
||||
## Updating
|
||||
|
||||
KST4Contest stores its settings and other local working files in the user’s home directory. The application directory and the data directory are separate.
|
||||
KST4Contest includes an **automatic update notification service**: as soon as a new version is available, a window will appear at startup with:
|
||||
- information that a new version is available,
|
||||
- a changelog,
|
||||
- the download link for the new version.
|
||||
|
||||
| Operating system | Data directory | Settings file |
|
||||
|---|---|---|
|
||||
| Windows | `%USERPROFILE%\.praktiKST\` | `%USERPROFILE%\.praktiKST\preferences.xml` |
|
||||
| Linux | `~/.praktiKST/` | `~/.praktiKST/preferences.xml` |
|
||||
| macOS | `~/.praktiKST/` | `~/.praktiKST/preferences.xml` |
|
||||

|
||||
|
||||
Note the spelling `.praktiKST` with an uppercase `KST`. Linux and macOS distinguish between uppercase and lowercase letters. `.praktikst` would simply be a different directory.
|
||||
### Update Process
|
||||
|
||||
#### Windows
|
||||
|
||||
Currently, there is only one way to update:
|
||||
|
||||
1. Delete the old folder.
|
||||
2. Unzip the new ZIP file.
|
||||
|
||||
The settings file (`preferences.xml`) is preserved because it is stored in the user folder, not the program folder.
|
||||
|
||||
#### Linux
|
||||
|
||||
Currently as follows:
|
||||
1. Download the new AppImage
|
||||
2. Mark the new AppImage as executable
|
||||
3. (optional) Delete the old AppImage.
|
||||
|
||||
#### macOS
|
||||
|
||||
1. Download the new DMG file.
|
||||
2. Open the DMG.
|
||||
3. Drag the new `KST4Contest.app` into your **Applications** folder, replacing the old version.
|
||||
|
||||
An application update does not remove this directory. Even so, creating a backup before a major version change or extensive configuration work is sensible.
|
||||
|
||||
---
|
||||
|
||||
## Updates
|
||||
## Known Issues at Startup
|
||||
|
||||
When KST4Contest starts, it checks whether a newer Stable release is available. If it finds one, it displays an information window containing:
|
||||
### Norton 360
|
||||
|
||||
- the installed version,
|
||||
- the latest Stable version,
|
||||
- a short overview of the main changes,
|
||||
- the changelog,
|
||||
- known issues,
|
||||
- a link to the corresponding GitHub Release page.
|
||||
Norton 360 classifies `praktiKST.exe` as dangerous (false positive). An exception must be created for the file:
|
||||
|
||||

|
||||
1. Open Norton 360.
|
||||
2. Security → History → Find the corresponding event.
|
||||
3. Select "Restore & Add Exception".
|
||||
|
||||
The update checker does not install anything. It reports the new version and opens the platform-neutral release page. From there, you must select the appropriate package for Windows, Linux or macOS.
|
||||
|
||||
### Updating Windows
|
||||
|
||||
1. Close KST4Contest.
|
||||
2. Download the new Windows ZIP package.
|
||||
3. Extract it into a new or empty directory.
|
||||
4. Start the new version.
|
||||
5. Check that the existing settings have been loaded.
|
||||
6. Remove the old application directory only after that.
|
||||
|
||||
The settings are preserved because they are stored under `%USERPROFILE%\.praktiKST`, not in the application directory.
|
||||
|
||||
### Updating an AppImage
|
||||
|
||||
1. Download the new AppImage.
|
||||
2. Make it executable.
|
||||
3. Start the new file.
|
||||
4. Remove the previous AppImage only after the new version works.
|
||||
|
||||
### Updating Debian and Ubuntu
|
||||
|
||||
```bash
|
||||
sudo apt install ./KST4Contest-v<version>-debian-amd64.deb
|
||||
```
|
||||
|
||||
### Updating Fedora
|
||||
|
||||
```bash
|
||||
sudo dnf upgrade ./KST4Contest-v<version>-fedora-x86_64.rpm
|
||||
```
|
||||
|
||||
### Updating the Arch package
|
||||
|
||||
```bash
|
||||
sudo pacman -U KST4Contest-v<version>-archlinux-x86_64.pkg.tar.zst
|
||||
```
|
||||
|
||||
### Updating an AUR package
|
||||
|
||||
Update the installed package through the selected AUR helper, for example:
|
||||
|
||||
```bash
|
||||
yay -Syu
|
||||
```
|
||||
|
||||
### Updating Flatpak
|
||||
|
||||
```bash
|
||||
flatpak update de.x08.KST4Contest
|
||||
```
|
||||
|
||||
### Updating macOS
|
||||
|
||||
1. Download the new DMG file for the appropriate architecture.
|
||||
2. Close KST4Contest.
|
||||
3. Open the DMG file.
|
||||
4. Replace `KST4Contest.app` in the Applications folder.
|
||||
5. Start the new version and check the existing settings.
|
||||
|
||||
The configuration under `~/.praktiKST` is not affected.
|
||||
|
||||
---
|
||||
|
||||
## Problems during the first launch
|
||||
|
||||
### Windows reports an unknown application
|
||||
|
||||
KST4Contest is not currently signed with a commercial Windows code-signing certificate. Windows or an additional security product may therefore warn about an unknown or rarely downloaded application.
|
||||
|
||||
Check the following first:
|
||||
|
||||
- Did the file come from the [official KST4Contest release](https://github.com/praktimarc/kst4contest/releases/latest)?
|
||||
- Does the filename match the published release?
|
||||
- Was the file downloaded completely?
|
||||
- Does the security product report a specific detection name or only a general reputation warning?
|
||||
|
||||
A warning alone is neither reliable proof of malware nor automatically a false positive. If the origin of the file is unclear, do not run it or restore it from quarantine.
|
||||
|
||||
Some users have reported quarantine messages from Norton 360 in particular. If the warning can be reproduced, please create a [GitHub issue](https://github.com/praktimarc/kst4contest/issues) and include:
|
||||
|
||||
- the KST4Contest version,
|
||||
- the complete filename,
|
||||
- the security product and its version,
|
||||
- the reported detection name,
|
||||
- a screenshot of the warning, if possible.
|
||||
|
||||
### The AppImage does not start
|
||||
|
||||
Check the executable permission first:
|
||||
|
||||
```bash
|
||||
chmod +x KST4Contest-v<version>-linux-x86_64.AppImage
|
||||
```
|
||||
|
||||
Then start the file from a terminal. Error messages shown there are usually more useful than a double-click that simply produces no visible result.
|
||||
|
||||
### macOS blocks the application
|
||||
|
||||
Use the **Open** function described under [Installing on macOS](#installing-on-macos). Before doing so, verify that the DMG file came from the official GitHub Release.
|
||||
|
||||
### The problem remains
|
||||
|
||||
First check whether the problem has already been reported under [GitHub Issues](https://github.com/praktimarc/kst4contest/issues). If it has not, create a new issue containing:
|
||||
|
||||
- the operating system and version,
|
||||
- the KST4Contest version,
|
||||
- the installation method,
|
||||
- the exact error message,
|
||||
- the steps required to reproduce the problem.
|
||||
|
||||
“It does not work” usually describes the situation accurately, but it is of limited value during diagnosis.
|
||||
*(Reported by PE0WGA, Franz van Velzen – thank you!)*
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 63 KiB |
@@ -1,16 +0,0 @@
|
||||
pkgbase = kst4contest-bin
|
||||
pkgdesc = ON4KST Chat Client for VHF/UHF contest operation (pre-built)
|
||||
pkgver = 1.41.1
|
||||
pkgrel = 1
|
||||
url = https://github.com/praktimarc/kst4contest
|
||||
arch = x86_64
|
||||
license = GPL-3.0-only
|
||||
depends = gst-plugins-base
|
||||
depends = gst-plugins-good
|
||||
provides = kst4contest
|
||||
conflicts = kst4contest
|
||||
conflicts = kst4contest-git
|
||||
source = KST4Contest-v1.41.1-archlinux-x86_64.pkg.tar.zst::https://github.com/praktimarc/kst4contest/releases/download/v1.41.1/KST4Contest-v1.41.1-archlinux-x86_64.pkg.tar.zst
|
||||
sha256sums = 8e9a53ff832920c9ef2733635b90c5a4ffcd57a2958aaa251e92bd031142c614
|
||||
|
||||
pkgname = kst4contest-bin
|
||||
@@ -1,17 +0,0 @@
|
||||
# Maintainer: Philipp Wagner <philipp@wagnersnetz.de>
|
||||
pkgname=kst4contest-bin
|
||||
pkgver=1.41.1
|
||||
pkgrel=1
|
||||
pkgdesc="ON4KST Chat Client for VHF/UHF contest operation (pre-built)"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/praktimarc/kst4contest"
|
||||
license=('GPL-3.0-only')
|
||||
depends=('gst-plugins-base' 'gst-plugins-good')
|
||||
provides=('kst4contest')
|
||||
conflicts=('kst4contest' 'kst4contest-git')
|
||||
source=("KST4Contest-v${pkgver}-archlinux-${CARCH}.pkg.tar.zst::https://github.com/praktimarc/kst4contest/releases/download/v${pkgver}/KST4Contest-v${pkgver}-archlinux-${CARCH}.pkg.tar.zst")
|
||||
sha256sums=('8e9a53ff832920c9ef2733635b90c5a4ffcd57a2958aaa251e92bd031142c614')
|
||||
|
||||
package() {
|
||||
cp -a "${srcdir}/usr" "${pkgdir}/"
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
pkgbase = kst4contest-git
|
||||
pkgdesc = ON4KST Chat Client for VHF/UHF contest operation (git)
|
||||
pkgver = 1.42.0.r145.gd885924
|
||||
pkgrel = 1
|
||||
url = https://github.com/praktimarc/kst4contest
|
||||
arch = x86_64
|
||||
license = GPL-3.0-only
|
||||
makedepends = java-environment=21
|
||||
makedepends = maven
|
||||
makedepends = git
|
||||
depends = gst-plugins-base
|
||||
depends = gst-plugins-good
|
||||
provides = kst4contest
|
||||
conflicts = kst4contest
|
||||
conflicts = kst4contest-bin
|
||||
source = kst4contest::git+https://github.com/praktimarc/kst4contest.git
|
||||
sha256sums = SKIP
|
||||
|
||||
pkgname = kst4contest-git
|
||||
@@ -1,76 +0,0 @@
|
||||
# Maintainer: Philipp Wagner <philipp@wagnersnetz.de>
|
||||
pkgname=kst4contest-git
|
||||
pkgver=1.42.0.r145.gd885924
|
||||
pkgrel=1
|
||||
pkgdesc="ON4KST Chat Client for VHF/UHF contest operation (git)"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/praktimarc/kst4contest"
|
||||
license=('GPL-3.0-only')
|
||||
depends=('gst-plugins-base' 'gst-plugins-good')
|
||||
makedepends=('java-environment=21' 'maven' 'git')
|
||||
provides=('kst4contest')
|
||||
conflicts=('kst4contest' 'kst4contest-bin')
|
||||
source=("kst4contest::git+https://github.com/praktimarc/kst4contest.git")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
pkgver() {
|
||||
cd "${srcdir}/kst4contest"
|
||||
BASE=$(grep -m1 '<version>' pom.xml \
|
||||
| sed 's/.*<version>\(.*\)<\/version>.*/\1/' | sed 's/[-.]nightly//')
|
||||
printf '%s.r%s.g%s' "${BASE}" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "${srcdir}/kst4contest"
|
||||
|
||||
export JAVA_HOME=$(find /usr/lib/jvm -maxdepth 1 -name 'java-21-*' -type d | head -n 1)
|
||||
export PATH="${JAVA_HOME}/bin:${PATH}"
|
||||
|
||||
mvn -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 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
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "${srcdir}/kst4contest"
|
||||
|
||||
install -dm755 "${pkgdir}/usr/lib/KST4Contest"
|
||||
cp -a dist/KST4Contest/. "${pkgdir}/usr/lib/KST4Contest/"
|
||||
|
||||
install -dm755 "${pkgdir}/usr/bin"
|
||||
printf '#!/bin/sh\nexec /usr/lib/KST4Contest/bin/KST4Contest "$@"\n' \
|
||||
> "${pkgdir}/usr/bin/KST4Contest"
|
||||
chmod 755 "${pkgdir}/usr/bin/KST4Contest"
|
||||
|
||||
install -dm755 "${pkgdir}/usr/share/applications"
|
||||
cat > "${pkgdir}/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 "${pkgdir}/usr/lib/KST4Contest/lib/KST4Contest.png" ]]; then
|
||||
install -Dm644 "${pkgdir}/usr/lib/KST4Contest/lib/KST4Contest.png" \
|
||||
"${pkgdir}/usr/share/icons/hicolor/256x256/apps/KST4Contest.png"
|
||||
fi
|
||||
|
||||
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
pkgbase = kst4contest
|
||||
pkgdesc = ON4KST Chat Client for VHF/UHF contest operation
|
||||
pkgver = 1.41.1
|
||||
pkgrel = 1
|
||||
url = https://github.com/praktimarc/kst4contest
|
||||
arch = x86_64
|
||||
license = GPL-3.0-only
|
||||
makedepends = java-environment=21
|
||||
makedepends = maven
|
||||
depends = gst-plugins-base
|
||||
depends = gst-plugins-good
|
||||
provides = kst4contest
|
||||
conflicts = kst4contest-bin
|
||||
conflicts = kst4contest-git
|
||||
source = kst4contest-1.41.1.tar.gz::https://github.com/praktimarc/kst4contest/archive/refs/tags/v1.41.1.tar.gz
|
||||
sha256sums = e96207a2d3fee19d35e34717f5312beb28bb087c040164e352337e749ce53b8d
|
||||
|
||||
pkgname = kst4contest
|
||||
@@ -1,69 +0,0 @@
|
||||
# Maintainer: Philipp Wagner <philipp@wagnersnetz.de>
|
||||
pkgname=kst4contest
|
||||
pkgver=1.41.1
|
||||
pkgrel=1
|
||||
pkgdesc="ON4KST Chat Client for VHF/UHF contest operation"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/praktimarc/kst4contest"
|
||||
license=('GPL-3.0-only')
|
||||
depends=('gst-plugins-base' 'gst-plugins-good')
|
||||
makedepends=('java-environment=21' 'maven')
|
||||
provides=('kst4contest')
|
||||
conflicts=('kst4contest-bin' 'kst4contest-git')
|
||||
source=("${pkgname}-${pkgver}.tar.gz::https://github.com/praktimarc/kst4contest/archive/refs/tags/v${pkgver}.tar.gz")
|
||||
sha256sums=('e96207a2d3fee19d35e34717f5312beb28bb087c040164e352337e749ce53b8d')
|
||||
|
||||
build() {
|
||||
cd "${srcdir}/kst4contest-${pkgver}"
|
||||
|
||||
export JAVA_HOME=$(find /usr/lib/jvm -maxdepth 1 -name 'java-21-*' -type d | head -n 1)
|
||||
export PATH="${JAVA_HOME}/bin:${PATH}"
|
||||
|
||||
mvn -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 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
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "${srcdir}/kst4contest-${pkgver}"
|
||||
|
||||
install -dm755 "${pkgdir}/usr/lib/KST4Contest"
|
||||
cp -a dist/KST4Contest/. "${pkgdir}/usr/lib/KST4Contest/"
|
||||
|
||||
install -dm755 "${pkgdir}/usr/bin"
|
||||
printf '#!/bin/sh\nexec /usr/lib/KST4Contest/bin/KST4Contest "$@"\n' \
|
||||
> "${pkgdir}/usr/bin/KST4Contest"
|
||||
chmod 755 "${pkgdir}/usr/bin/KST4Contest"
|
||||
|
||||
install -dm755 "${pkgdir}/usr/share/applications"
|
||||
cat > "${pkgdir}/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 "${pkgdir}/usr/lib/KST4Contest/lib/KST4Contest.png" ]]; then
|
||||
install -Dm644 "${pkgdir}/usr/lib/KST4Contest/lib/KST4Contest.png" \
|
||||
"${pkgdir}/usr/share/icons/hicolor/256x256/apps/KST4Contest.png"
|
||||
fi
|
||||
|
||||
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -18,18 +18,11 @@ public class ApplicationConstants {
|
||||
public static final String APPLICATION_NAME = "praktiKST";
|
||||
|
||||
/**
|
||||
* Version shown to the user and used for semantic version comparison.
|
||||
* Name of file to store preferences in.
|
||||
*/
|
||||
public static final String APPLICATION_CURRENT_VERSION = "1.42";
|
||||
public static final double APPLICATION_CURRENTVERSIONNUMBER = 1.41;
|
||||
|
||||
/**
|
||||
* Legacy numeric representation used only while older update feeds and
|
||||
* application versions still exist.
|
||||
*/
|
||||
@Deprecated
|
||||
public static final double APPLICATION_CURRENTVERSIONNUMBER = 1.42;
|
||||
|
||||
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";
|
||||
@@ -45,24 +38,7 @@ public class ApplicationConstants {
|
||||
|
||||
public static final String AUTOANSWER_PREFIX = "[KST4C Automsg] "; // hard-coded marker (user can't remove it)
|
||||
|
||||
/**
|
||||
* UI message retention limits.
|
||||
*
|
||||
* The global chat message list is the backing list for several FilteredLists
|
||||
* and TableViews. It must not grow without limit during long contest runs.
|
||||
*
|
||||
* The list is kept in newest-first order:
|
||||
* index 0 = newest message
|
||||
* last index = oldest message
|
||||
*/
|
||||
public static final int CHAT_MESSAGE_STORE_MAX_SIZE = 30000;
|
||||
public static final int CHAT_MESSAGE_STORE_TRIM_TO_SIZE = 25000;
|
||||
|
||||
/**
|
||||
* DXCluster table retention limits.
|
||||
*/
|
||||
public static final int CLUSTER_MESSAGE_STORE_MAX_SIZE = 10000;
|
||||
public static final int CLUSTER_MESSAGE_STORE_TRIM_TO_SIZE = 8000;
|
||||
|
||||
/**
|
||||
* generates a unique runtime id per session. Its used to feed the poison pill in order to kill only this one and
|
||||
|
||||
+6
-9
@@ -9,8 +9,6 @@ import java.net.NoRouteToHostException;
|
||||
import java.net.SocketException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.TimerTask;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import javafx.collections.ObservableList;
|
||||
import kst4contest.locatorUtils.Location;
|
||||
@@ -19,7 +17,6 @@ import kst4contest.model.ChatMember;
|
||||
|
||||
public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(AirScoutPeriodicalAPReflectionInquirerTask.class.getName());
|
||||
private ChatController client;
|
||||
|
||||
public AirScoutPeriodicalAPReflectionInquirerTask(ChatController client) {
|
||||
@@ -58,7 +55,7 @@ public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
|
||||
ownCallSign = this.client.getChatPreferences().getStn_loginCallSign();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] Error parsing callsign", e);
|
||||
System.out.println("[ASPERIODICAL, Error]: " + e.getMessage());
|
||||
}
|
||||
String myCallAndMyLocString = ownCallSign + "," + this.client.getChatPreferences().getStn_loginLocatorMainCat(); //bugfix, Airscout do not process 9A1W-2 but 9A1W like formatted calls
|
||||
|
||||
@@ -108,13 +105,13 @@ public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
|
||||
dsocket.send(packet);
|
||||
dsocket.close();
|
||||
} catch (UnknownHostException e1) {
|
||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] Unknown host", e1);
|
||||
e1.printStackTrace();
|
||||
} catch (NoRouteToHostException e) {
|
||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] No route to host", e);
|
||||
e.printStackTrace();
|
||||
} catch (SocketException e) {
|
||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] Socket error", e);
|
||||
e.printStackTrace();
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] IO error sending query", e);
|
||||
e.printStackTrace();
|
||||
}
|
||||
// System.out.println("[ASUDPTask, info:] sent query " + queryStringToAirScout);
|
||||
|
||||
@@ -139,7 +136,7 @@ public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
|
||||
dsocket.send(packet);
|
||||
dsocket.close();
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] IO error sending watchlist", e);
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
// System.out.println("[ASUDPTask, info:] set watchlist: " + asWatchListStringSuffix);
|
||||
|
||||
@@ -48,8 +48,7 @@ public class BeaconTask extends TimerTask {
|
||||
replaceVariables = replaceVariables.replaceAll("MYCALL", this.chatController.getChatPreferences().getStn_loginCallSign());
|
||||
replaceVariables = replaceVariables.replaceAll("MYLOCATOR", this.chatController.getChatPreferences().getStn_loginLocatorMainCat());
|
||||
replaceVariables = replaceVariables.replaceAll("MYQTF", this.chatController.getChatPreferences().getActualQTF().getValue() + "");
|
||||
replaceVariables = replaceVariables.replaceAll("SECONDQRG", this.chatController.getChatPreferences().getMYQRGSecondCat().getValue() + "");
|
||||
|
||||
replaceVariables = replaceVariables.replaceAll("SECONDQRG", this.chatController.getChatPreferences().getActualQTF().getValue() + "");
|
||||
|
||||
|
||||
beaconMSG.setMessageText(
|
||||
|
||||
@@ -24,7 +24,6 @@ import kst4contest.logic.PriorityCalculator;
|
||||
import kst4contest.model.*;
|
||||
import kst4contest.test.MockKstServer;
|
||||
import kst4contest.utils.PlayAudioUtils;
|
||||
import kst4contest.locatorUtils.Location;
|
||||
import kst4contest.view.Kst4ContestApplication;
|
||||
|
||||
import java.io.*;
|
||||
@@ -33,9 +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.
|
||||
@@ -163,16 +159,14 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
|
||||
|
||||
public void initRotor() {
|
||||
rotatorClient = new PstRotatorClient(
|
||||
chatPreferences.getStn_pstRotatorHost(),
|
||||
chatPreferences.getStn_pstRotatorPort(),
|
||||
this,
|
||||
this
|
||||
);
|
||||
public void initRotor() {
|
||||
// Beispiel: PSTRotator läuft lokal auf Port 12060
|
||||
// Der Client wird automatisch auf 12061 hören.
|
||||
rotatorClient = new PstRotatorClient("127.0.0.1", 12000, this, this); //TODO: IP anpassen, Port auch aus den prefs holen, default 12000
|
||||
|
||||
rotatorClient.start();
|
||||
}
|
||||
// Startet den Thread und das Polling
|
||||
rotatorClient.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* sets rotator to "AZ DEGREE" by button click <br/><br/>
|
||||
@@ -322,29 +316,33 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
EnumSet<Band> offered = EnumSet.noneOf(Band.class);
|
||||
|
||||
for (ChatMember cm : findActiveChatMembersByRawCall(callRaw)) {
|
||||
if (cm == null || cm.getCallSignRaw() == null) continue;
|
||||
synchronized (getLst_chatMemberList()) {
|
||||
for (ChatMember cm : getLst_chatMemberList()) {
|
||||
if (cm == null || cm.getCallSignRaw() == null) continue;
|
||||
if (!normalizeCallRaw(cm.getCallSignRaw()).equals(callRaw)) continue;
|
||||
|
||||
Map<Band, ChatMember.ActiveFrequencyInfo> map = cm.getKnownActiveBands();
|
||||
if (map == null || map.isEmpty()) continue;
|
||||
Map<Band, ChatMember.ActiveFrequencyInfo> map = cm.getKnownActiveBands();
|
||||
if (map == null || map.isEmpty()) continue;
|
||||
|
||||
for (Map.Entry<Band, ChatMember.ActiveFrequencyInfo> e : map.entrySet()) {
|
||||
if (e.getKey() == null || e.getValue() == null) continue;
|
||||
for (Map.Entry<Band, ChatMember.ActiveFrequencyInfo> e : map.entrySet()) {
|
||||
if (e.getKey() == null || e.getValue() == null) continue;
|
||||
|
||||
long age = nowMs - e.getValue().timestampEpoch;
|
||||
if (age >= 0 && age <= maxAgeMs) {
|
||||
offered.add(e.getKey());
|
||||
}
|
||||
|
||||
if (DEBUG_BAND_UPGRADE_HINT) {
|
||||
System.out.println("[BandUpgradeHint] history call=" + callRaw
|
||||
+ " band=" + e.getKey()
|
||||
+ " freq=" + e.getValue().frequency
|
||||
+ " ageMs=" + age);
|
||||
}
|
||||
|
||||
long age = nowMs - e.getValue().timestampEpoch;
|
||||
if (age >= 0 && age <= maxAgeMs) {
|
||||
offered.add(e.getKey());
|
||||
}
|
||||
|
||||
if (DEBUG_BAND_UPGRADE_HINT) {
|
||||
System.out.println("[BandUpgradeHint] history call=" + callRaw
|
||||
+ " band=" + e.getKey()
|
||||
+ " freq=" + e.getValue().frequency
|
||||
+ " ageMs=" + age);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return offered;
|
||||
}
|
||||
|
||||
@@ -356,19 +354,21 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
EnumSet<Band> worked = EnumSet.noneOf(Band.class);
|
||||
|
||||
for (ChatMember cm : findActiveChatMembersByRawCall(callRaw)) {
|
||||
if (cm == null || cm.getCallSignRaw() == null) continue;
|
||||
synchronized (getLst_chatMemberList()) {
|
||||
for (ChatMember cm : getLst_chatMemberList()) {
|
||||
if (cm == null || cm.getCallSignRaw() == null) continue;
|
||||
if (!normalizeCallRaw(cm.getCallSignRaw()).equals(callRaw)) continue;
|
||||
|
||||
if (cm.isWorked144()) worked.add(Band.B_144);
|
||||
if (cm.isWorked432()) worked.add(Band.B_432);
|
||||
if (cm.isWorked1240()) worked.add(Band.B_1296);
|
||||
if (cm.isWorked2300()) worked.add(Band.B_2320);
|
||||
if (cm.isWorked3400()) worked.add(Band.B_3400);
|
||||
if (cm.isWorked5600()) worked.add(Band.B_5760);
|
||||
if (cm.isWorked10G()) worked.add(Band.B_10G);
|
||||
if (cm.isWorked24G()) worked.add(Band.B_24G);
|
||||
if (cm.isWorked144()) worked.add(Band.B_144);
|
||||
if (cm.isWorked432()) worked.add(Band.B_432);
|
||||
if (cm.isWorked1240()) worked.add(Band.B_1296);
|
||||
if (cm.isWorked2300()) worked.add(Band.B_2320);
|
||||
if (cm.isWorked3400()) worked.add(Band.B_3400);
|
||||
if (cm.isWorked5600()) worked.add(Band.B_5760);
|
||||
if (cm.isWorked10G()) worked.add(Band.B_10G);
|
||||
if (cm.isWorked24G()) worked.add(Band.B_24G); // optional, only if your Band enum supports it
|
||||
}
|
||||
}
|
||||
|
||||
return worked;
|
||||
}
|
||||
|
||||
@@ -643,8 +643,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
// rotatorClient.
|
||||
|
||||
// this.lst_chatMemberList.clear();;
|
||||
this.clearActiveChatMembers();
|
||||
this.lst_chatMemberList.clear();;
|
||||
this.lst_clusterMemberList.clear();
|
||||
|
||||
this.setDisconnected(true);
|
||||
@@ -712,7 +711,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
}
|
||||
} else if (action.equals(ApplicationConstants.DISCSTRING_DISCONNECTONLY)){
|
||||
|
||||
this.clearActiveChatMembers();
|
||||
this.lst_chatMemberList.clear();;
|
||||
this.lst_clusterMemberList.clear();
|
||||
|
||||
|
||||
@@ -958,9 +957,16 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<ChatMember> matchingMembers = findActiveChatMembersByRawCall(targetCallsignRaw);
|
||||
return matchingMembers.isEmpty() ? null : matchingMembers.get(0);
|
||||
String normalizedTargetCall = normalizeCallRaw(targetCallsignRaw);
|
||||
synchronized (getLst_chatMemberList()) {
|
||||
for (ChatMember member : getLst_chatMemberList()) {
|
||||
if (member == null || member.getCallSignRaw() == null) continue;
|
||||
if (normalizeCallRaw(member.getCallSignRaw()).equals(normalizedTargetCall)) {
|
||||
return member;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String resolveSkedTargetLocator(String targetCallsignRaw) {
|
||||
@@ -968,43 +974,22 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
return null;
|
||||
}
|
||||
|
||||
for (ChatMember member : findActiveChatMembersByRawCall(targetCallsignRaw)) {
|
||||
String locator = member.getQra();
|
||||
if (locator != null && !locator.isBlank()) {
|
||||
return locator.trim().toUpperCase(Locale.ROOT);
|
||||
String normalizedTargetCall = normalizeCallRaw(targetCallsignRaw);
|
||||
synchronized (getLst_chatMemberList()) {
|
||||
for (ChatMember member : getLst_chatMemberList()) {
|
||||
if (member == null || member.getCallSignRaw() == null) continue;
|
||||
if (!normalizeCallRaw(member.getCallSignRaw()).equals(normalizedTargetCall)) continue;
|
||||
|
||||
String locator = member.getQra();
|
||||
if (locator != null && !locator.isBlank()) {
|
||||
return locator.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// private ChatMember resolveSkedTargetMember(String targetCallsignRaw) {
|
||||
// if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) {
|
||||
// return null;
|
||||
// }
|
||||
//
|
||||
// List<ChatMember> matchingMembers = findActiveChatMembersByRawCall(targetCallsignRaw);
|
||||
// return matchingMembers.isEmpty() ? null : matchingMembers.get(0);
|
||||
//
|
||||
// }
|
||||
//
|
||||
// private String resolveSkedTargetLocator(String targetCallsignRaw) {
|
||||
// if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) {
|
||||
// return null;
|
||||
// }
|
||||
//
|
||||
// String normalizedTargetCall = normalizeCallRaw(targetCallsignRaw);
|
||||
//
|
||||
// for (ChatMember member : findActiveChatMembersByRawCall(targetCallsignRaw)) {
|
||||
// String locator = member.getQra();
|
||||
// if (locator != null && !locator.isBlank()) {
|
||||
// return locator.trim().toUpperCase(Locale.ROOT);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return null;
|
||||
// }
|
||||
|
||||
public StationMetricsService getStationMetricsService() {
|
||||
return stationMetricsService;
|
||||
}
|
||||
@@ -1027,14 +1012,10 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
return new HashMap<>(lastInboundCategoryByCallSignRaw);
|
||||
}
|
||||
|
||||
// public List<ChatMember> snapshotChatMembers() {
|
||||
// synchronized (getLst_chatMemberList()) {
|
||||
// return new ArrayList<>(getLst_chatMemberList());
|
||||
// }
|
||||
// }
|
||||
|
||||
public List<ChatMember> snapshotChatMembers() {
|
||||
return new ArrayList<>(activeChatMembersByCallAndCategory.values());
|
||||
synchronized (getLst_chatMemberList()) {
|
||||
return new ArrayList<>(getLst_chatMemberList());
|
||||
}
|
||||
}
|
||||
|
||||
public List<ContestSked> snapshotActiveSkeds() {
|
||||
@@ -1094,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;
|
||||
@@ -1143,48 +1120,13 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
private ObservableList<ChatMember> chatMemberList = FXCollections.observableArrayList(); // List of active stations
|
||||
|
||||
|
||||
private ObservableList<ChatMember> lst_chatMemberList = FXCollections.synchronizedObservableList(chatMemberList); // List
|
||||
// of active stn in chat
|
||||
private FilteredList<ChatMember> lst_chatMemberListFiltered = new FilteredList<ChatMember>(chatMemberList);
|
||||
private SortedList<ChatMember> lst_chatMemberSortedFilteredList = new SortedList<ChatMember>(lst_chatMemberListFiltered);
|
||||
|
||||
private ObservableList<ChatMember> lst_chatMemberList = FXCollections.synchronizedObservableList(chatMemberList); // List
|
||||
private ObservableList<Predicate<ChatMember>> lst_chatMemberListFilterPredicates = FXCollections.observableArrayList();
|
||||
private ObservableList<ClusterMessage> lst_clusterMemberList = FXCollections.observableArrayList();
|
||||
|
||||
/*
|
||||
* Thread-safe active-member model.
|
||||
*
|
||||
* MessageBusManagementThread must not use the JavaFX TableView backing list as
|
||||
* its primary data model. Otherwise every add/remove fires FilteredList,
|
||||
* SortedList and TableView selection listeners on the MessageBus thread and JavaFX
|
||||
* can throw "Not on FX application thread" or internal listener NPEs.
|
||||
*
|
||||
* This map is the worker-thread safe source of truth. The ObservableList below
|
||||
* remains a UI mirror and is mutated only on the JavaFX application thread.
|
||||
*/
|
||||
private final java.util.concurrent.ConcurrentMap<String, ChatMember> activeChatMembersByCallAndCategory =
|
||||
new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
/*
|
||||
* 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
|
||||
@@ -1192,254 +1134,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
// ******************************************************************************************************************************************
|
||||
|
||||
|
||||
/**
|
||||
* Executes UI-bound work on the JavaFX application thread.
|
||||
*
|
||||
* Worker threads may call this safely. If the caller is already on the FX
|
||||
* thread, the action is executed immediately to preserve ordering.
|
||||
*/
|
||||
private void runOnFxThread(Runnable action) {
|
||||
if (action == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
action.run();
|
||||
} else {
|
||||
Platform.runLater(action);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the active-member key. The raw/base callsign alone is not enough
|
||||
* because the same station can be logged into multiple ON4KST categories at
|
||||
* the same time. Therefore the category number is part of the key.
|
||||
*/
|
||||
private String buildActiveChatMemberKey(String callSign, ChatCategory category) {
|
||||
String normalizedCallsign = ChatMember.normalizeCallSignToBaseCallSign(callSign);
|
||||
if (normalizedCallsign == null || normalizedCallsign.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int categoryNumber = category == null ? -1 : category.getCategoryNumber();
|
||||
return normalizedCallsign.trim().toUpperCase(Locale.ROOT) + "|" + categoryNumber;
|
||||
}
|
||||
|
||||
private String buildActiveChatMemberKey(ChatMember member) {
|
||||
if (member == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String callSign = member.getCallSignRaw() != null ? member.getCallSignRaw() : member.getCallSign();
|
||||
return buildActiveChatMemberKey(callSign, member.getChatCategory());
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or replaces an active chat member in the worker-thread model and mirrors
|
||||
* that change to the JavaFX list. This is the only supported path for ON4KST
|
||||
* user-enter events.
|
||||
*/
|
||||
public void addOrUpdateActiveChatMember(ChatMember member) {
|
||||
String key = buildActiveChatMemberKey(member);
|
||||
if (key == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeChatMembersByCallAndCategory.put(key, member);
|
||||
|
||||
runOnFxThread(() -> {
|
||||
int existingIndex = findChatMemberIndexInUiListByKey(key);
|
||||
if (existingIndex >= 0) {
|
||||
lst_chatMemberList.set(existingIndex, member);
|
||||
} else {
|
||||
lst_chatMemberList.add(member);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an active chat member from the worker-thread model and from the UI
|
||||
* mirror. Removal from the ObservableList is always performed on the FX thread
|
||||
* because the list is bound to FilteredList/SortedList/TableView.
|
||||
*/
|
||||
public boolean removeActiveChatMember(ChatMember member) {
|
||||
String key = buildActiveChatMemberKey(member);
|
||||
if (key == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ChatMember removedMember = activeChatMembersByCallAndCategory.remove(key);
|
||||
runOnFxThread(() -> lst_chatMemberList.removeIf(currentMember -> key.equals(buildActiveChatMemberKey(currentMember))));
|
||||
return removedMember != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the active-member model and the JavaFX UI mirror. Use this instead of
|
||||
* getLst_chatMemberList().clear() from timers or worker threads.
|
||||
*/
|
||||
public void clearActiveChatMembers() {
|
||||
activeChatMembersByCallAndCategory.clear();
|
||||
runOnFxThread(() -> lst_chatMemberList.clear());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a member from the thread-safe active model. This avoids reading the
|
||||
* TableView backing list from MessageBusManagementThread.
|
||||
*/
|
||||
public ChatMember findActiveChatMember(ChatMember lookForThis) {
|
||||
String key = buildActiveChatMemberKey(lookForThis);
|
||||
return key == null ? null : activeChatMembersByCallAndCategory.get(key);
|
||||
}
|
||||
|
||||
public ChatMember findActiveChatMember(String callSign, ChatCategory category) {
|
||||
String key = buildActiveChatMemberKey(callSign, category);
|
||||
return key == null ? null : activeChatMembersByCallAndCategory.get(key);
|
||||
}
|
||||
|
||||
public int getActiveChatMemberCount() {
|
||||
return activeChatMembersByCallAndCategory.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all active category variants of a callsign. This is used when a QRG or
|
||||
* band hint should be propagated from one category instance to the same station
|
||||
* in another category.
|
||||
*/
|
||||
public List<ChatMember> findActiveChatMembersByRawCall(String callSignRaw) {
|
||||
String normalizedCallsign = ChatMember.normalizeCallSignToBaseCallSign(callSignRaw);
|
||||
if (normalizedCallsign == null || normalizedCallsign.isBlank()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
String normalizedKeyPart = normalizedCallsign.trim().toUpperCase(Locale.ROOT);
|
||||
List<ChatMember> matchingMembers = new ArrayList<>();
|
||||
|
||||
for (ChatMember member : activeChatMembersByCallAndCategory.values()) {
|
||||
if (member == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String memberCall = member.getCallSignRaw() != null ? member.getCallSignRaw() : member.getCallSign();
|
||||
String normalizedMemberCall = ChatMember.normalizeCallSignToBaseCallSign(memberCall);
|
||||
if (normalizedMemberCall != null && normalizedMemberCall.equalsIgnoreCase(normalizedKeyPart)) {
|
||||
matchingMembers.add(member);
|
||||
}
|
||||
}
|
||||
|
||||
return matchingMembers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates locator and derived direction values in the active model. The UI list
|
||||
* contains the same member object, but the user-list refresh is still triggered
|
||||
* on the JavaFX thread so TableView/map derived displays can repaint safely.
|
||||
*/
|
||||
public boolean updateActiveChatMemberLocator(ChatMember lookupMember, String newLocator) {
|
||||
ChatMember activeMember = findActiveChatMember(lookupMember);
|
||||
if (activeMember == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
activeMember.setQra(newLocator);
|
||||
activeMember.setQrb(new Location().getDistanceKmByTwoLocatorStrings(chatPreferences.getStn_loginLocatorMainCat(), newLocator));
|
||||
activeMember.setQTFdirection(new Location(chatPreferences.getStn_loginLocatorMainCat()).getBearing(new Location(newLocator)));
|
||||
fireUserListUpdate("Locator changed");
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean updateActiveChatMemberState(ChatMember lookupMember, int newState) {
|
||||
ChatMember activeMember = findActiveChatMember(lookupMember);
|
||||
if (activeMember == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
activeMember.setState(newState);
|
||||
fireUserListUpdate("User state changed");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a UM3-style user-info update only to an already active chat member.
|
||||
*
|
||||
* ON4KST may send UM3 messages for stations that are not logged into the
|
||||
* current channel, for example when a user changes locator/profile data on the
|
||||
* website. Such stations must not be added to the visible chat member list,
|
||||
* because they cannot be addressed in the channel. Their full information will
|
||||
* be delivered again with UA0/UA5/UA2 when they actually join.
|
||||
*
|
||||
* @return true if an active member was found and updated, false if the UM3
|
||||
* information was intentionally ignored.
|
||||
*/
|
||||
public boolean updateActiveChatMemberInfoIfPresent(ChatMember updatedMember) {
|
||||
String key = buildActiveChatMemberKey(updatedMember);
|
||||
if (key == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ChatMember activeMember = activeChatMembersByCallAndCategory.get(key);
|
||||
if (activeMember == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
activeMember.setName(updatedMember.getName());
|
||||
activeMember.setQra(updatedMember.getQra());
|
||||
activeMember.setState(updatedMember.getState());
|
||||
activeMember.setLastActivity(updatedMember.getLastActivity());
|
||||
activeMember.setActivityTimeLastInEpoch(updatedMember.getActivityTimeLastInEpoch());
|
||||
activeMember.setQrb(updatedMember.getQrb());
|
||||
activeMember.setQTFdirection(updatedMember.getQTFdirection());
|
||||
fireUserListUpdate("User info updated");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible wrapper for older call sites. Despite the historic name,
|
||||
* this method no longer adds unknown UM3 users to the active member model.
|
||||
*/
|
||||
public boolean updateOrAddActiveChatMemberInfo(ChatMember updatedMember) {
|
||||
return updateActiveChatMemberInfoIfPresent(updatedMember);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a newly detected QRG/band on all active category variants of a station.
|
||||
* The old StringProperty is still set for compatibility with existing TableView
|
||||
* columns and DXCluster code.
|
||||
*/
|
||||
public void applyDetectedFrequencyToActiveMembers(ChatMember sender, Band detectedBand, double detectedFrequencyMhz) {
|
||||
if (sender == null || detectedBand == null || detectedFrequencyMhz <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
String displayFrequency = String.valueOf(detectedFrequencyMhz);
|
||||
String rawCall = sender.getCallSignRaw() != null ? sender.getCallSignRaw() : sender.getCallSign();
|
||||
|
||||
for (ChatMember member : findActiveChatMembersByRawCall(rawCall)) {
|
||||
if (member == null) {
|
||||
continue;
|
||||
}
|
||||
member.addKnownFrequency(detectedBand, detectedFrequencyMhz);
|
||||
member.setFrequency(new SimpleStringProperty(displayFrequency));
|
||||
}
|
||||
|
||||
// Also cover dummy senders that are not in the active model, e.g. [n/a] senders.
|
||||
sender.addKnownFrequency(detectedBand, detectedFrequencyMhz);
|
||||
sender.setFrequency(new SimpleStringProperty(displayFrequency));
|
||||
fireUserListUpdate("Frequency detected");
|
||||
}
|
||||
|
||||
private int findChatMemberIndexInUiListByKey(String key) {
|
||||
if (key == null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < lst_chatMemberList.size(); i++) {
|
||||
if (key.equals(buildActiveChatMemberKey(lst_chatMemberList.get(i)))) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1484,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
|
||||
@@ -1562,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;
|
||||
}
|
||||
@@ -1921,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
|
||||
@@ -1940,7 +1479,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
}
|
||||
|
||||
|
||||
private void initLst_toMeMessageList() {
|
||||
// ObservableList<String> sniffedList = chatPreferences.getLstNotify_QSOSniffer_sniffedCallSignList();
|
||||
|
||||
@@ -1957,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 ---
|
||||
|
||||
@@ -2195,11 +1729,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
messageTXBus = new LinkedBlockingQueue<ChatMessage>();
|
||||
|
||||
// socket = new Socket(hostname, port);//socket for the on4kst chat server
|
||||
socket = new Socket(
|
||||
chatPreferences.getStn_on4kstServersDns(),
|
||||
chatPreferences.getStn_on4kstServersPort()
|
||||
);//socket for the on4kst chat server
|
||||
|
||||
socket = new Socket(chatController.chatPreferences.getStn_on4kstServersDns(), port);//socket for the on4kst chat server
|
||||
System.out.println("Connected to the chat server: " + socket.isConnected());
|
||||
|
||||
ByteBuffer buffer = ByteBuffer.allocate(1024);
|
||||
@@ -2294,7 +1824,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
socket.close();
|
||||
|
||||
chatController.setConnectedAndLoggedIn(false);
|
||||
chatController.clearActiveChatMembers();
|
||||
chatController.getLst_chatMemberList().clear();
|
||||
|
||||
System.out.println("[Chatcontroller, Warning: ] Socket closed or disconnected");
|
||||
|
||||
@@ -2393,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);
|
||||
@@ -2539,7 +1965,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
String loginString = "";
|
||||
loginString = "LOGINC|" + chatPreferences.getStn_loginCallSign() + "|" + chatPreferences.getStn_loginPassword()
|
||||
+ "|" + chatPreferences.getLoginChatCategoryMain().getCategoryNumber() + "|praktiKST v" + ApplicationConstants.APPLICATION_CURRENT_VERSION
|
||||
+ "|" + chatPreferences.getLoginChatCategoryMain().getCategoryNumber() + "|praktiKST v" + ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER
|
||||
+ "|25|0|1|" + "0" + "|0|";
|
||||
|
||||
// System.out.println(loginString);
|
||||
@@ -2775,8 +2201,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
return;
|
||||
}
|
||||
|
||||
rebuildWorkedGrossFieldCacheFromDatabase();
|
||||
|
||||
HashMap<String, ChatMember> finalWorkedDataFromDatabase = workedDataFromDatabase;
|
||||
|
||||
Platform.runLater(() -> {
|
||||
@@ -2961,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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -67,110 +64,6 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips binary logger framing bytes before the XML payload. Some UCXLog packets
|
||||
* contain transport bytes before the XML declaration.
|
||||
*
|
||||
* @param rawPacket raw UDP payload
|
||||
* @return cleaned XML string or trimmed original text
|
||||
*/
|
||||
private String helper_extractXmlPayload(String rawPacket) {
|
||||
if (rawPacket == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
int xmlStart = rawPacket.indexOf("<?xml");
|
||||
if (xmlStart < 0) {
|
||||
xmlStart = rawPacket.indexOf("<contactinfo");
|
||||
}
|
||||
if (xmlStart < 0) {
|
||||
xmlStart = rawPacket.indexOf("<RadioInfo");
|
||||
}
|
||||
|
||||
return xmlStart >= 0
|
||||
? rawPacket.substring(xmlStart).trim()
|
||||
: rawPacket.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an optional XML child node.
|
||||
*
|
||||
* @param element parent element
|
||||
* @param tagName tag to read
|
||||
* @return trimmed value or empty string
|
||||
*/
|
||||
private String helper_getOptionalElementText(Element element, String tagName) {
|
||||
if (element == null || tagName == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
NodeList nodeList = element.getElementsByTagName(tagName);
|
||||
if (nodeList == null || nodeList.getLength() == 0 || nodeList.item(0) == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String textContent = nodeList.item(0).getTextContent();
|
||||
return textContent == null ? "" : textContent.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the QSO locator from UCXLog contactinfo. gridsquare is preferred,
|
||||
* rcvnr is used as fallback for exchanges such as 001JO41HK.
|
||||
*
|
||||
* @param element contactinfo XML element
|
||||
* @return normalized six-character locator or null
|
||||
*/
|
||||
private String helper_resolveLocatorFromContactInfo(Element element) {
|
||||
String gridSquare = WorkedGrossFieldCache.extractLocator6(helper_getOptionalElementText(element, "gridsquare"));
|
||||
if (gridSquare != null) {
|
||||
return gridSquare;
|
||||
}
|
||||
|
||||
return WorkedGrossFieldCache.extractLocator6(helper_getOptionalElementText(element, "rcvnr"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the project Band enum from logger band values.
|
||||
*
|
||||
* @param band logger band text
|
||||
* @return matching Band or null
|
||||
*/
|
||||
private Band helper_resolveBandFromLoggerBand(String band) {
|
||||
if (band == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (band.trim()) {
|
||||
case "144":
|
||||
case "2m":
|
||||
return Band.B_144;
|
||||
case "432":
|
||||
case "70cm":
|
||||
return Band.B_432;
|
||||
case "1240":
|
||||
case "1296":
|
||||
case "23cm":
|
||||
return Band.B_1296;
|
||||
case "2300":
|
||||
case "2320":
|
||||
case "13cm":
|
||||
return Band.B_2320;
|
||||
case "3400":
|
||||
case "9cm":
|
||||
return Band.B_3400;
|
||||
case "5600":
|
||||
case "5760":
|
||||
case "6cm":
|
||||
return Band.B_5760;
|
||||
case "10G":
|
||||
case "10368":
|
||||
case "3cm":
|
||||
return Band.B_10G;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void run() {
|
||||
|
||||
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;
|
||||
|
||||
@@ -319,14 +212,6 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
NodeList list = doc.getElementsByTagName("contactinfo");
|
||||
if (list.getLength() != 0) {
|
||||
|
||||
/*
|
||||
* QSO and TRX information share the same UDP receiver. The log-sync
|
||||
* preference therefore controls processing, not ownership of the socket.
|
||||
*/
|
||||
if (!client.getChatPreferences().isLogsynch_ucxUDPWkdCallListenerEnabled()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
for (int temp = 0; temp < list.getLength(); temp++) {
|
||||
|
||||
Node node = list.item(temp);
|
||||
@@ -336,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");
|
||||
@@ -349,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
|
||||
@@ -546,10 +452,6 @@ 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
|
||||
@@ -557,11 +459,7 @@ public class ReadUDPbyUCXMessageThread 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);
|
||||
}
|
||||
@@ -599,15 +497,6 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
} else {
|
||||
list = doc.getElementsByTagName("RadioInfo");
|
||||
|
||||
/*
|
||||
* RadioInfo packets may arrive on the shared UDP port even when automatic
|
||||
* QRG synchronization is disabled. Ignore them unless TRX sync is enabled.
|
||||
*/
|
||||
if (list.getLength() != 0
|
||||
&& !client.getChatPreferences().isTrxSynch_ucxLogUDPListenerEnabled()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
for (int temp = 0; temp < list.getLength(); temp++) {
|
||||
|
||||
Node node = list.item(temp);
|
||||
@@ -661,12 +550,7 @@ 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);
|
||||
}
|
||||
@@ -720,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) {
|
||||
|
||||
@@ -108,16 +108,6 @@ public class UpdateChecker {
|
||||
|
||||
Element element = (Element) node;
|
||||
updateInfos.setLatestVersionNumberOnServer(Double.parseDouble(element.getElementsByTagName("versionNumber").item(0).getTextContent()));
|
||||
|
||||
NodeList semanticVersionNodes =
|
||||
element.getElementsByTagName("semanticVersion");
|
||||
|
||||
if (semanticVersionNodes.getLength() > 0) {
|
||||
updateInfos.setLatestSemanticVersionOnServer(
|
||||
semanticVersionNodes.item(0).getTextContent()
|
||||
);
|
||||
}
|
||||
|
||||
updateInfos.setAdminMessage(element.getElementsByTagName("adminMessage").item(0).getTextContent());
|
||||
updateInfos.setMajorChanges(element.getElementsByTagName("majorChanges").item(0)
|
||||
.getTextContent());
|
||||
|
||||
@@ -26,14 +26,6 @@ public class UserActualizationTask extends TimerTask {
|
||||
|
||||
Thread.currentThread().setName("UserActualizationTask");
|
||||
|
||||
/*
|
||||
* File-based log synchronization is optional. Do not create, open or parse the
|
||||
* configured file while the feature is disabled.
|
||||
*/
|
||||
if (!client.getChatPreferences().isLogsynch_fileBasedWkdCallInterpreterEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// System.out.println("[Useract: ] Thread runned now");
|
||||
|
||||
// System.out.println("***********************Useract started");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -159,8 +159,6 @@ public class ChatPreferences {
|
||||
int stn_on4kstServersPort = 23001;
|
||||
|
||||
boolean stn_pstRotatorEnabled = false;
|
||||
String stn_pstRotatorHost = "127.0.0.1";
|
||||
int stn_pstRotatorPort = 12000;
|
||||
|
||||
boolean stn_loginAFKState = false; //always start as here
|
||||
String stn_loginCallSign = "do5amf";
|
||||
@@ -175,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;
|
||||
@@ -309,8 +290,8 @@ public class ChatPreferences {
|
||||
String messageHandling_unworkedStnRequesterBeaconsText;
|
||||
String messageHandling_beaconUnworkedstationsPrefix;
|
||||
|
||||
String messageHandling_autoAnswerTextMainCat = "Hi, sry I am not qrv, just testing new features of KST4Contest " + ApplicationConstants.APPLICATION_CURRENT_VERSION;
|
||||
String messageHandling_autoAnswerTextSecondCat = "Hi, sry I am not qrv, just testing new features of KST4Contest " + ApplicationConstants.APPLICATION_CURRENT_VERSION;
|
||||
String messageHandling_autoAnswerTextMainCat = "Hi, sry I am not qrv, just testing new features of KST4Contest " + ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER;
|
||||
String messageHandling_autoAnswerTextSecondCat = "Hi, sry I am not qrv, just testing new features of KST4Contest " + ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER;
|
||||
|
||||
boolean messageHandling_autoAnswerEnabled = false;
|
||||
boolean messageHandling_autoAnswerEnabledSecondCat = false;
|
||||
@@ -328,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 };
|
||||
|
||||
|
||||
/*********************************************************************************
|
||||
*
|
||||
@@ -393,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;
|
||||
}
|
||||
@@ -605,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;
|
||||
}
|
||||
@@ -763,30 +689,6 @@ public class ChatPreferences {
|
||||
this.stn_pstRotatorEnabled = stn_pstRotatorEnabled;
|
||||
}
|
||||
|
||||
public String getStn_pstRotatorHost() {
|
||||
return stn_pstRotatorHost;
|
||||
}
|
||||
|
||||
public void setStn_pstRotatorHost(String stn_pstRotatorHost) {
|
||||
if (stn_pstRotatorHost == null || stn_pstRotatorHost.isBlank()) {
|
||||
this.stn_pstRotatorHost = "127.0.0.1";
|
||||
} else {
|
||||
this.stn_pstRotatorHost = stn_pstRotatorHost.trim();
|
||||
}
|
||||
}
|
||||
|
||||
public int getStn_pstRotatorPort() {
|
||||
return stn_pstRotatorPort;
|
||||
}
|
||||
|
||||
public void setStn_pstRotatorPort(int stn_pstRotatorPort) {
|
||||
if (stn_pstRotatorPort < 1 || stn_pstRotatorPort > 65535) {
|
||||
this.stn_pstRotatorPort = 12000;
|
||||
} else {
|
||||
this.stn_pstRotatorPort = stn_pstRotatorPort;
|
||||
}
|
||||
}
|
||||
|
||||
public SimpleStringProperty getNotify_optionalFrequencyPrefix() {
|
||||
return notify_optionalFrequencyPrefix;
|
||||
}
|
||||
@@ -1343,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);
|
||||
@@ -1444,13 +1289,6 @@ public class ChatPreferences {
|
||||
stn_pstRotatorEnabled.setTextContent(this.stn_pstRotatorEnabled + "");
|
||||
station.appendChild(stn_pstRotatorEnabled);
|
||||
|
||||
Element stn_pstRotatorHost = doc.createElement("stn_pstRotatorHost");
|
||||
stn_pstRotatorHost.setTextContent(this.stn_pstRotatorHost);
|
||||
station.appendChild(stn_pstRotatorHost);
|
||||
|
||||
Element stn_pstRotatorPort = doc.createElement("stn_pstRotatorPort");
|
||||
stn_pstRotatorPort.setTextContent(Integer.toString(this.stn_pstRotatorPort));
|
||||
station.appendChild(stn_pstRotatorPort);
|
||||
|
||||
|
||||
/**
|
||||
@@ -1902,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! *************************************
|
||||
****************************************************************************************/
|
||||
@@ -2030,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");
|
||||
@@ -2125,22 +1867,6 @@ public class ChatPreferences {
|
||||
|
||||
stn_pstRotatorEnabled = getBoolean(stationEl, stn_pstRotatorEnabled, "stn_pstRotatorEnabled");
|
||||
|
||||
setStn_pstRotatorHost(
|
||||
getText(
|
||||
stationEl,
|
||||
stn_pstRotatorHost,
|
||||
"stn_pstRotatorHost"
|
||||
)
|
||||
);
|
||||
|
||||
setStn_pstRotatorPort(
|
||||
getInt(
|
||||
stationEl,
|
||||
stn_pstRotatorPort,
|
||||
"stn_pstRotatorPort"
|
||||
)
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2538,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) {
|
||||
@@ -2944,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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,10 +3,7 @@ package kst4contest.model;
|
||||
import java.util.ArrayList;
|
||||
|
||||
public class UpdateInformation {
|
||||
// double latestVersionNumberOnServer = 1.26; //dummy value to prevent nullpointerexc
|
||||
double latestVersionNumberOnServer = 0.0;
|
||||
String latestSemanticVersionOnServer = "";
|
||||
|
||||
double latestVersionNumberOnServer = 1.26; //dummy value to prevent nullpointerexc
|
||||
String adminMessage ="";
|
||||
String majorChanges ="";
|
||||
String latestVersionPathOnWebserver="";
|
||||
@@ -39,27 +36,6 @@ public class UpdateInformation {
|
||||
public void setLatestVersionNumberOnServer(double latestVersionNumberOnServer) {
|
||||
this.latestVersionNumberOnServer = latestVersionNumberOnServer;
|
||||
}
|
||||
public String getLatestSemanticVersionOnServer() {
|
||||
return latestSemanticVersionOnServer;
|
||||
}
|
||||
|
||||
public void setLatestSemanticVersionOnServer(String latestSemanticVersionOnServer) {
|
||||
this.latestSemanticVersionOnServer =
|
||||
latestSemanticVersionOnServer == null ? "" : latestSemanticVersionOnServer.trim();
|
||||
}
|
||||
|
||||
public boolean hasSemanticVersion() {
|
||||
return !latestSemanticVersionOnServer.isBlank();
|
||||
}
|
||||
|
||||
public String getLatestVersionForDisplay() {
|
||||
if (hasSemanticVersion()) {
|
||||
return latestSemanticVersionOnServer;
|
||||
}
|
||||
|
||||
return Double.toString(latestVersionNumberOnServer);
|
||||
}
|
||||
|
||||
|
||||
public String getAdminMessage() {
|
||||
return adminMessage;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
package kst4contest.utils;
|
||||
|
||||
public final class VersionUtils {
|
||||
|
||||
private VersionUtils() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares numeric release versions such as 1.41, 1.41.1 and 1.41.10.
|
||||
*
|
||||
* Pre-release and build suffixes are ignored because the update feed
|
||||
* currently publishes stable releases only.
|
||||
*/
|
||||
public static int compareStableVersions(String left, String right) {
|
||||
int[] leftParts = parseVersion(left);
|
||||
int[] rightParts = parseVersion(right);
|
||||
int partCount = Math.max(leftParts.length, rightParts.length);
|
||||
|
||||
for (int index = 0; index < partCount; index++) {
|
||||
int leftPart = index < leftParts.length ? leftParts[index] : 0;
|
||||
int rightPart = index < rightParts.length ? rightParts[index] : 0;
|
||||
|
||||
int comparison = Integer.compare(leftPart, rightPart);
|
||||
if (comparison != 0) {
|
||||
return comparison;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int[] parseVersion(String version) {
|
||||
if (version == null || version.isBlank()) {
|
||||
throw new IllegalArgumentException("Version must not be empty");
|
||||
}
|
||||
|
||||
String normalized = version.trim();
|
||||
|
||||
if (normalized.startsWith("v") || normalized.startsWith("V")) {
|
||||
normalized = normalized.substring(1);
|
||||
}
|
||||
|
||||
int hyphenIndex = normalized.indexOf('-');
|
||||
int plusIndex = normalized.indexOf('+');
|
||||
int suffixIndex;
|
||||
|
||||
if (hyphenIndex < 0) {
|
||||
suffixIndex = plusIndex;
|
||||
} else if (plusIndex < 0) {
|
||||
suffixIndex = hyphenIndex;
|
||||
} else {
|
||||
suffixIndex = Math.min(hyphenIndex, plusIndex);
|
||||
}
|
||||
|
||||
if (suffixIndex >= 0) {
|
||||
normalized = normalized.substring(0, suffixIndex);
|
||||
}
|
||||
|
||||
String[] textParts = normalized.split("\\.");
|
||||
int[] numericParts = new int[textParts.length];
|
||||
|
||||
for (int index = 0; index < textParts.length; index++) {
|
||||
numericParts[index] = Integer.parseInt(textParts[index]);
|
||||
}
|
||||
|
||||
return numericParts;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user