Build with Gradle in two modules, and free core of JavaFX

Etappen 1 and 2 of the Compose migration (43 commits).

The Maven build, the single source tree and the JPMS module-info went together.
core now holds the domain and app-desktop the user interface, both built by
Gradle, packaged through jpackage from Gradle, with SpotBugs and PMD wired in.
JavaFX moves to the classpath, which is why the launcher is Main and not the
Application subclass. The PR check, the nightly artifacts, the release and the
AUR packages all build from Gradle; the macOS DMG points at its output.
Verified on Linux, macOS and Windows.

core then loses JavaFX entirely. It observes through kst4contest.observe --
ObservableValue, ObservableRoster, and UiDispatcher as the one boundary a
worker thread crosses to touch the interface. The controller collections become
rosters and the visible station list a pure derivation rather than a
FilteredList/SortedList chain, which is also what fixed two focus defects only
a load test had exposed: the keyboard focus stays where the operator put it,
and the send field keeps it after a row click.

The rule that outlives this commit: ObservableRoster is the store and the list
a view is bound to is a mirror of it, never the other way round.
This commit is contained in:
Rsclub2_2 committed 2026-10-05 22:31:56 +02:00
1 parent bcbeadae95
commit a6fba12f01
360 files changed
+6304 -2422

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
plugins {
java
application
alias(libs.plugins.javafx)
}
javafx {
version = libs.versions.javafx.get()
modules = listOf("javafx.controls", "javafx.fxml", "javafx.web", "javafx.media")
}
dependencies {
// Mirrors the compile-scope entries in pom.xml: 11 test classes live under
// src/main/java and need JUnit and Mockito on the main compile classpath.
implementation(libs.junit.jupiter.api)
implementation(libs.mockito.core.compile)
implementation(libs.jetbrains.annotations)
implementation(project(":core"))
implementation(libs.sqlite.jdbc)
implementation(libs.jlayer)
testImplementation(libs.junit.jupiter)
testImplementation(libs.mockito.core)
testImplementation(libs.mockito.junit.jupiter)
// Gradle 9 no longer adds the launcher implicitly.
testRuntimeOnly(libs.junit.platform.launcher)
}
application {
mainClass.set("kst4contest.view.Kst4ContestApplication")
}
// Collects the application jar and its runtime dependencies into the layout the
// packaging scripts expect: a flat directory with the main jar named app.jar.
val collectRuntime by tasks.registering(Sync::class) {
dependsOn(tasks.jar)
from(tasks.jar) { rename { "app.jar" } }
from(configurations.runtimeClasspath)
into(layout.buildDirectory.dir("dist-libs"))
}
val packageImage by tasks.registering(Exec::class) {
dependsOn(collectRuntime)
// jpackage must come from the project toolchain, not from PATH: jdk.jsobject
// was removed after JDK 21, and jlink then cannot resolve the module list.
val launcher = javaToolchains.launcherFor {
languageVersion.set(JavaLanguageVersion.of(21))
}.get()
val jpackageTool = launcher.metadata.installationPath.file("bin/jpackage").asFile.absolutePath
val input = layout.buildDirectory.dir("dist-libs").get().asFile
val output = layout.buildDirectory.dir("jpackage").get().asFile
val os = org.gradle.internal.os.OperatingSystem.current()
val icon = when {
os.isWindows -> "packaging/icons/kst4contest.ico"
os.isMacOsX -> "packaging/icons/kst4contest.icns"
else -> "packaging/icons/kst4contest.png"
}
doFirst { output.deleteRecursively() }
commandLine(
jpackageTool,
"--icon", rootProject.file(icon).absolutePath,
"--type", "app-image",
"--name", "praktiKST",
"--input", input.absolutePath,
"--dest", output.absolutePath,
"--main-jar", "app.jar",
"--add-modules", providers.gradleProperty("jpackageAddModules").get(),
"--main-class", "kst4contest.view.Main",
"--java-options", "-Dfile.encoding=UTF-8"
)
}
@@ -0,0 +1,13 @@
package kst4contest.controller;
import kst4contest.view.Kst4ContestApplication;
public class AppRunner {
public static void main(String[] args) {
Kst4ContestApplication.main(args);
}
}
@@ -0,0 +1,73 @@
package kst4contest.test;
import kst4contest.model.ChatPreferences;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ChatPreferencesStationMapVisibilityTest {
@TempDir
Path temporaryDirectory;
@Test
void pathAnalysisIsVisibleByDefault() {
ChatPreferences preferences = new ChatPreferences();
assertTrue(preferences.isGUIstationMapPathAnalysisVisible());
}
@Test
void hiddenPathAnalysisStateSurvivesXmlRoundTrip() throws IOException {
Path preferencesFile = temporaryDirectory.resolve("preferences.xml");
ChatPreferences writtenPreferences = new ChatPreferences();
writtenPreferences.setStoreAndRestorePreferencesFileName(
preferencesFile.toString());
writtenPreferences.setGUIstationMapPathAnalysisVisible(false);
writtenPreferences.writePreferencesToXmlFile();
String writtenXml = Files.readString(preferencesFile);
assertTrue(writtenXml.contains(
"<GUIstationMapPathAnalysisVisible>false"
+ "</GUIstationMapPathAnalysisVisible>"));
ChatPreferences restoredPreferences = new ChatPreferences();
restoredPreferences.setStoreAndRestorePreferencesFileName(
preferencesFile.toString());
restoredPreferences.readPreferencesFromXmlFile();
assertFalse(restoredPreferences.isGUIstationMapPathAnalysisVisible());
}
@Test
void legacyXmlWithoutVisibilitySettingKeepsAnalysisDiscoverable()
throws IOException {
Path legacyPreferencesFile =
temporaryDirectory.resolve("legacy-preferences.xml");
Files.writeString(legacyPreferencesFile, """
<?xml version="1.0" encoding="UTF-8"?>
<praktiKST>
<configVersion>4</configVersion>
<guiOptions>
<GUIstationMapStageSceneSizeHW>1000.0;800.0</GUIstationMapStageSceneSizeHW>
</guiOptions>
</praktiKST>
""");
ChatPreferences restoredPreferences = new ChatPreferences();
restoredPreferences.setStoreAndRestorePreferencesFileName(
legacyPreferencesFile.toString());
restoredPreferences.readPreferencesFromXmlFile();
assertTrue(restoredPreferences.isGUIstationMapPathAnalysisVisible());
}
}
@@ -0,0 +1,123 @@
package kst4contest.test;
import kst4contest.logic.FrequencyTextParser;
import kst4contest.model.Band;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FrequencyTextParserTest {
@Test
void detectsExplicitStationNameFrequencies() {
List<FrequencyTextParser.DetectedFrequency> detected =
FrequencyTextParser.findExplicitFrequencies(
"Phil 432.357"
);
assertEquals(1, detected.size());
assertEquals(
Band.B_432,
detected.get(0).getBand()
);
assertEquals(
432.357,
detected.get(0).getFrequencyMHz(),
0.000_001
);
}
@Test
void detectsCommaAndMicrowaveFrequencies() {
List<FrequencyTextParser.DetectedFrequency> detected =
FrequencyTextParser.findExplicitFrequencies(
"QRV 1296,210 / 10368.100"
);
assertEquals(2, detected.size());
assertEquals(
Band.B_1296,
detected.get(0).getBand()
);
assertEquals(
Band.B_10G,
detected.get(1).getBand()
);
}
@Test
void detectsFiftyAndSeventyMhzFrequencies() {
assertEquals(
Band.B_50,
FrequencyTextParser
.findExplicitFrequencies("50.150")
.get(0)
.getBand()
);
assertEquals(
Band.B_70,
FrequencyTextParser
.findExplicitFrequencies("70.200")
.get(0)
.getBand()
);
}
@Test
void ignoresRelativeAndAmbiguousValues() {
assertTrue(
FrequencyTextParser
.findExplicitFrequencies(
"Mike .180"
)
.isEmpty()
);
assertTrue(
FrequencyTextParser
.findExplicitFrequencies(
"Mike 180"
)
.isEmpty()
);
assertTrue(
FrequencyTextParser
.findExplicitFrequencies(
"David 1.2"
)
.isEmpty()
);
assertTrue(
FrequencyTextParser
.findExplicitFrequencies(
"RST 599"
)
.isEmpty()
);
}
@Test
void normalizesSubKhzNotation() {
FrequencyTextParser.DetectedFrequency detected =
FrequencyTextParser
.findExplicitFrequencies(
"144.300.03"
)
.get(0);
assertEquals(Band.B_144, detected.getBand());
assertEquals(
144.30003,
detected.getFrequencyMHz(),
0.000_001
);
}
}
@@ -0,0 +1,111 @@
package kst4contest.test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import kst4contest.observe.SimpleValue;
import javafx.collections.FXCollections;
import kst4contest.controller.MessageVariableResolver;
import kst4contest.model.AirPlane;
import kst4contest.model.AirPlaneReflectionInfo;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
class MessageVariableResolverTest {
private ChatPreferences chatPreferences;
private MessageVariableResolver resolver;
@BeforeEach
void setUp() {
chatPreferences = mock(ChatPreferences.class);
when(chatPreferences.getMYQRGFirstCat()).thenReturn(new SimpleValue<>("144.388.03"));
when(chatPreferences.getMYQRGSecondCat()).thenReturn(new SimpleValue<>("432.088.00"));
when(chatPreferences.getStn_loginLocatorMainCat()).thenReturn("JO51IJ");
when(chatPreferences.getStn_loginCallSign()).thenReturn("DO5AMF");
when(chatPreferences.getActualQTF()).thenReturn(new SimpleValue<>(135.0));
resolver = new MessageVariableResolver(chatPreferences);
}
@Test
void resolvesAllGlobalVariablesInEveryMessageContext() {
String template = "MYCALL MYQRGSHORT MYQRG SECONDQRG MYLOCATORSHORT MYLOCATOR MYQTF";
assertEquals(
"DO5AMF 144.388 144.388.03 432.088.00 JO51 JO51IJ 135",
resolver.resolveGlobalVariables(template)
);
}
@Test
void shortValuesRemainSafeWhenTheConfiguredValueIsShorterThanExpected() {
when(chatPreferences.getMYQRGFirstCat()).thenReturn(new SimpleValue<>("144"));
when(chatPreferences.getStn_loginLocatorMainCat()).thenReturn("JO5");
assertEquals("144 JO5", resolver.resolveGlobalVariables("MYQRGSHORT MYLOCATORSHORT"));
}
@Test
void resolvesSelectedStationNameAndTheFirstTwoAirPlanes() {
ChatMember selectedStation = new ChatMember();
selectedStation.setCallSign("DL0TEST");
selectedStation.setName("Test Operator");
AirPlane firstAirPlane = new AirPlane();
firstAirPlane.setPotential(100);
firstAirPlane.setArrivingDurationMinutes(1);
AirPlane secondAirPlane = new AirPlane();
secondAirPlane.setPotential(75);
secondAirPlane.setArrivingDurationMinutes(9);
AirPlaneReflectionInfo reflectionInfo = new AirPlaneReflectionInfo();
reflectionInfo.setRisingAirplanes(FXCollections.observableArrayList(firstAirPlane, secondAirPlane));
selectedStation.setAirPlaneReflectInfo(reflectionInfo);
assertEquals(
"Hi Test Operator, a very big AP in 1 min; Next big AP in 9 min",
resolver.resolveForSelectedStation(
"Hi QRZNAME, FIRSTAP; SECONDAP",
selectedStation
)
);
}
@Test
void usesCallsignWhenTheSelectedStationHasNoName() {
ChatMember selectedStation = new ChatMember();
selectedStation.setCallSign("DL0TEST");
selectedStation.setName(" ");
assertEquals(
"Hi DL0TEST",
resolver.resolveForSelectedStation("Hi QRZNAME", selectedStation)
);
}
@Test
void keepsStationVariablesVisibleWhenNoStationIsSelected() {
assertEquals(
"QRZNAME FIRSTAP SECONDAP",
resolver.resolveForSelectedStation("QRZNAME FIRSTAP SECONDAP", null)
);
}
@Test
void returnsUsefulFallbacksWhenNoAirPlaneIsAvailable() {
ChatMember selectedStation = new ChatMember();
selectedStation.setCallSign("DL0TEST");
assertEquals(
"no ap available ",
resolver.resolveForSelectedStation("FIRSTAP SECONDAP", selectedStation)
);
}
}
@@ -0,0 +1,131 @@
package kst4contest.test;
import kst4contest.observe.SimpleValue;
import kst4contest.controller.Utils4KST;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class PatternMatcherTest {
/**
* Tests if pattern matches with the given String.
*
* @param testString
* @param regExPattern
* @return true if match, else false
*/
private static boolean testPattern(String testString, String regExPattern) {
Pattern pattern = Pattern.compile(regExPattern);
Matcher matcher = pattern.matcher(testString);
return matcher.find();
}
/**
* Normalizes a chatmembers frequency-string for cluster usage<br/>
* <b>returns a frequency String in KHz like = "144300" or "144300.0" to match DXC protocol needs</b>
*
* @param optionalPrefix: if there is a value like ".300", it have to be decided, wich ".300": 144.300, 432.300, 1296.300 .... prefix means for example "144."
*/
private static String normalizeFrequencyString(String qrgString, String optionalPrefix) {
// final String PTRN_QRG_CAT2 = "(([0-9]{3,4}[\\.|,| ]?[0-9]{3})([\\.|,][\\d]{1,2})?)|(([a-zA-Z][0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)|((\\b[0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)";
final String PTRN_QRG_CAT2_wholeQRGMHz4Digits = "(([0-9]{4}[\\.|,| ]?[0-9]{3})([\\.|,][\\d]{1,2})?)"; //1296.300.3 etc
final String PTRN_QRG_CAT2_wholeQRGMHz3Digits = "(([0-9]{3}[\\.|,| ]?[0-9]{3})([\\.][\\d]{1,2})?)"; //144.300.3 etc
final String PTRN_QRG_CAT2_QRGwithoutPrefix = "((\\b[0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)"; //144.300.3 etc
String predefinedPrefixInMHz = optionalPrefix;
String stringAggregation = "";
if (testPattern(qrgString, PTRN_QRG_CAT2_wholeQRGMHz4Digits)) {
System.out.print("yep: ");
stringAggregation = qrgString;
stringAggregation = stringAggregation.replace(".","");
stringAggregation = stringAggregation.replace(",","");
if (stringAggregation.length() == 8) {
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length()-1) + "." + stringAggregation.substring(stringAggregation.length()-1, stringAggregation.length());
stringAggregation = stringAggregationNew;
} else if (stringAggregation.length() == 9) {
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length()-2) + "." + stringAggregation.substring(stringAggregation.length()-2, stringAggregation.length());
stringAggregation = stringAggregationNew;
}
} else
if (testPattern(qrgString, PTRN_QRG_CAT2_wholeQRGMHz3Digits)) {
System.out.print("yep: ");
stringAggregation = qrgString;
stringAggregation = stringAggregation.replace(".","");
stringAggregation = stringAggregation.replace(",","");
if (stringAggregation.length() == 7) {
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length()-1) + "." + stringAggregation.substring(stringAggregation.length()-1, stringAggregation.length());
stringAggregation = stringAggregationNew;
} else if (stringAggregation.length() == 8) {
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length()-2) + "." + stringAggregation.substring(stringAggregation.length()-2, stringAggregation.length());
stringAggregation = stringAggregationNew;
}
}
else
if (testPattern(qrgString, PTRN_QRG_CAT2_QRGwithoutPrefix)) { //case ".050 or .300 or something like that"
System.out.print("yep: ");
stringAggregation = qrgString;
stringAggregation = stringAggregation.replace(".", "");
stringAggregation = stringAggregation.replace(",", "");
if (stringAggregation.length() == 3) { // like 050 or 300
String stringAggregationNew = optionalPrefix + stringAggregation;
stringAggregation = stringAggregationNew;
return stringAggregation;
} else if (stringAggregation.length() == 4) { //like 050.2 --> 0502
stringAggregation = optionalPrefix + stringAggregation;
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length() - 1) + "." + stringAggregation.substring(stringAggregation.length() - 1, stringAggregation.length());
stringAggregation = stringAggregationNew;
return stringAggregation;
} else if (stringAggregation.length() == 5) { //like 050.20 --> 05020
stringAggregation = optionalPrefix + stringAggregation;
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length() - 2) + "." + stringAggregation.substring(stringAggregation.length() - 2, stringAggregation.length());
stringAggregation = stringAggregationNew;
return stringAggregation;
}
}
return qrgString;
}
public static void main(String[] args) {
int i = 0;
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.775", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300.2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144,300.2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144300.2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144300,2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300.20", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("300", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300.2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300.20", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300.2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300.20", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300,2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300,20", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300,2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300.2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("q305", new SimpleValue<>("144")));
}
}
@@ -0,0 +1,63 @@
package kst4contest.test;
import kst4contest.observe.SimpleValue;
import kst4contest.controller.Utils4KST;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class Utils4KSTFrequencyTest {
@Test
void convertsCompleteFrequenciesAcrossAllSupportedPrefixLengths() {
assertEquals("50200.0", convert("50.200", "144"));
assertEquals("70250.0", convert("70,250", "144"));
assertEquals("144205.0", convert("144.205", "432"));
assertEquals("432088.0", convert("432.088", "144"));
assertEquals("1296338.0", convert("1296.338", "144"));
assertEquals("10368100.0", convert("10368.100", "144"));
assertEquals("24048100.0", convert("24048.100", "144"));
}
@Test
void retainsSubKhzPrecision() {
assertEquals("144205.2", convert("144.205.2", "432"));
assertEquals("1296338.25", convert("1296.338.25", "144"));
}
@Test
void convertsCompactLegacyFormats() {
assertEquals("432088.0", convert("432088", "144"));
assertEquals("10368100.0", convert("10368100", "144"));
assertEquals("432088.2", convert("432088.2", "144"));
assertEquals("432088.0", convert("432 088", "144"));
}
@Test
void appliesFallbackOnlyToRelativeFrequencies() {
assertEquals("432205.0", convert(".205", "432"));
assertEquals("144205.0", convert("205", "144"));
assertEquals("10368300.0", convert(".300", "10368"));
}
@Test
void rejectsMissingOrUnsupportedValues() {
assertEquals("", convert(null, "144"));
assertEquals("", convert("", "144"));
assertEquals("", convert("not-a-frequency", "144"));
assertEquals("", convert("599", "144"));
assertEquals("", convert(".205", null));
assertEquals("", convert(".205", "invalid"));
}
private String convert(String frequency, String fallbackPrefix) {
SimpleValue<String> fallback = fallbackPrefix == null
? null
: new SimpleValue<>(fallbackPrefix);
return Utils4KST.normalizeFrequencyString(
frequency,
fallback
);
}
}
@@ -0,0 +1,106 @@
package kst4contest.view;
import kst4contest.controller.ActiveOperatorProfile;
import kst4contest.controller.OperatorProfilePaths;
import kst4contest.controller.OperatorProfileStore;
import kst4contest.model.OperatorProfile;
import javafx.application.Platform;
import javafx.stage.Stage;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Owns the lifecycle of the running application.
*
* <p>Switching the operator profile tears the current runtime down completely and builds
* a fresh one in the same process. Rebinding the existing windows is not an option: the
* user interface is built from the chat controller outwards, with several hundred
* references to the active preferences, and many controls are instance fields created
* once. A new {@link Kst4ContestApplication} instance gets fresh controls, which is safe
* here because the class keeps no mutable static state.</p>
*/
public final class ApplicationRuntimeLauncher {
private static final Logger LOGGER = Logger.getLogger(ApplicationRuntimeLauncher.class.getName());
private static Kst4ContestApplication currentRuntime;
private ApplicationRuntimeLauncher() {
// Utility class.
}
/**
* Registers the runtime that is currently live.
*
* @param runtime the running application instance
*/
public static void setCurrent(final Kst4ContestApplication runtime) {
currentRuntime = runtime;
}
/**
* Returns the runtime that is currently live.
*
* @return the running application instance, or null before the first startup
*/
public static Kst4ContestApplication getCurrent() {
return currentRuntime;
}
/**
* Shuts the application down.
*
* <p>JavaFX only calls {@code stop()} on the instance it launched itself, so an exit
* after a profile switch has to release the resources explicitly.</p>
*/
public static void exitApplication() {
if (currentRuntime != null) {
currentRuntime.shutdownRuntime();
}
Platform.exit();
System.exit(0);
}
/**
* Replaces the running runtime with one bound to another operator profile.
*
* @param targetProfile profile to activate
* @return true if the new runtime was built
*/
public static boolean switchProfile(final OperatorProfile targetProfile) {
if (targetProfile == null) {
return false;
}
new OperatorProfileStore().recordLastUsed(targetProfile.getProfileId());
if (currentRuntime != null) {
currentRuntime.shutdownRuntime();
}
ActiveOperatorProfile.set(OperatorProfilePaths.resolve(targetProfile));
Kst4ContestApplication nextRuntime = new Kst4ContestApplication();
try {
nextRuntime.start(new Stage());
} catch (Exception e) {
// The previous runtime is already gone, so there is nothing left to return to.
LOGGER.log(Level.SEVERE, "Could not start the selected operator profile", e);
Kst4ContestApplication.alertWindowEvent(
"The operator profile could not be started: " + e.getMessage()
+ "\n\nKST4Contest has to be closed.");
Platform.exit();
System.exit(1);
return false;
}
setCurrent(nextRuntime);
return true;
}
}
@@ -0,0 +1,102 @@
package kst4contest.view;
import java.util.List;
/**
* Command line options of the application.
*
* <p>The parsed value is additionally kept in a process wide holder. JavaFX only knows
* the parameters of the {@code Application} instance it launched itself, so an instance
* created during a profile switch would see no parameters at all. Parsing once at
* startup and remembering the result avoids that entirely.</p>
*/
public class CommandLineOptions {
/**
* Command line switch selecting the operator profile to start with.
*/
public static final String PROFILE_ARGUMENT = "--profile";
/**
* System property used as an alternative to the command line switch.
*/
public static final String PROFILE_SYSTEM_PROPERTY = "kst4contest.profile";
private static volatile CommandLineOptions rememberedOptions = new CommandLineOptions(null);
private final String requestedProfileName;
public CommandLineOptions(final String requestedProfileName) {
this.requestedProfileName = requestedProfileName;
}
/**
* Parses the raw application arguments.
*
* <p>Unknown arguments are ignored on purpose. A typo in a command line must never
* keep an operator out of the application shortly before a contest.</p>
*
* @param rawArguments raw arguments, may be null
* @return the parsed options
*/
public static CommandLineOptions parse(final List<String> rawArguments) {
String requestedProfileName = null;
if (rawArguments != null) {
for (int argumentIndex = 0; argumentIndex < rawArguments.size(); argumentIndex++) {
String currentArgument = rawArguments.get(argumentIndex);
if (currentArgument == null) {
continue;
}
if (currentArgument.startsWith(PROFILE_ARGUMENT + "=")) {
requestedProfileName = currentArgument.substring(PROFILE_ARGUMENT.length() + 1);
} else if (PROFILE_ARGUMENT.equals(currentArgument)
&& argumentIndex + 1 < rawArguments.size()) {
requestedProfileName = rawArguments.get(argumentIndex + 1);
argumentIndex++;
}
}
}
if (requestedProfileName == null || requestedProfileName.isBlank()) {
requestedProfileName = System.getProperty(PROFILE_SYSTEM_PROPERTY);
}
if (requestedProfileName != null && requestedProfileName.isBlank()) {
requestedProfileName = null;
}
return new CommandLineOptions(
requestedProfileName == null ? null : requestedProfileName.trim());
}
/**
* Stores the parsed options for the lifetime of the process.
*
* @param options options to remember
*/
public static void remember(final CommandLineOptions options) {
rememberedOptions = options == null ? new CommandLineOptions(null) : options;
}
/**
* Returns the options parsed at application startup.
*
* @return the remembered options, never null
*/
public static CommandLineOptions remembered() {
return rememberedOptions;
}
/**
* Returns the operator profile requested on the command line.
*
* @return the requested profile name, or null when none was given
*/
public String getRequestedProfileName() {
return requestedProfileName;
}
}
@@ -0,0 +1,129 @@
package kst4contest.view;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import kst4contest.observe.ObservableRoster;
import kst4contest.observe.UiDispatcher;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Supplier;
/**
* Mirrors an {@link ObservableRoster} into an {@link ObservableList} so the
* existing {@code TableView} bindings keep working.
*
* <p>The mirror instance never changes: a {@code TableView} holds on to the
* list it was handed. {@link #dispose()} unregisters the listener, which is
* what keeps a discarded runtime from being held alive across a profile switch.
*
* <p>Besides the plain one-to-one mirror there is a derived form: a supplier
* computes the visible content (filtered, ordered) and one or more rosters act
* as the change sources. That is what replaces the former
* {@code FilteredList}/{@code SortedList} chains, which recomputed themselves
* whenever their source list changed.
*/
public final class FxRosterBinding<T> {
private final ObservableList<T> mirror = FXCollections.observableArrayList();
private final List<Runnable> unsubscribes = new ArrayList<>();
private final Supplier<List<T>> derivation;
private final UiDispatcher dispatcher;
private volatile boolean disposed;
private FxRosterBinding(Supplier<List<T>> derivation, UiDispatcher dispatcher) {
this.derivation = derivation;
this.dispatcher = dispatcher;
}
/** Mirrors the roster one to one. */
public static <T> FxRosterBinding<T> mirror(ObservableRoster<T> source, UiDispatcher dispatcher) {
FxRosterBinding<T> binding = new FxRosterBinding<>(source::snapshot, dispatcher);
binding.subscribe(source);
binding.applyNow();
return binding;
}
/**
* Mirrors whatever {@code derivation} computes and recomputes it whenever one
* of the given rosters changes.
*
* @param dispatcher hands the mirror update to the user-interface thread
* @param derivation computes the visible content; called on the UI thread
* @param sources rosters whose changes trigger a recomputation
*/
public static <T> FxRosterBinding<T> derived(UiDispatcher dispatcher,
Supplier<List<T>> derivation,
ObservableRoster<?>... sources) {
FxRosterBinding<T> binding = new FxRosterBinding<>(derivation, dispatcher);
for (ObservableRoster<?> source : sources) {
binding.subscribe(source);
}
binding.applyNow();
return binding;
}
/**
* Recomputes the mirror. Needed when something other than a source roster
* changed the result, for example a newly set filter predicate.
*/
public void refresh() {
if (disposed) {
return;
}
dispatcher.runOnUi(this::applyNow);
}
/** The list to hand to a {@code TableView}. Always the same instance. */
public ObservableList<T> list() {
return mirror;
}
/** Unregisters from every source and empties the mirror. Idempotent. */
public void dispose() {
if (disposed) {
return;
}
disposed = true;
for (Runnable unsubscribe : unsubscribes) {
unsubscribe.run();
}
unsubscribes.clear();
mirror.clear();
}
private <S> void subscribe(ObservableRoster<S> source) {
Consumer<List<S>> listener = snapshot -> refresh();
source.addListener(listener);
unsubscribes.add(() -> source.removeListener(listener));
}
private void applyNow() {
if (disposed) {
return;
}
List<T> computed = derivation.get();
/*
* An unchanged result must not produce a list change event. A TableView
* reacts to setAll with selection and scroll churn, and the periodic
* station-list refresh would otherwise fight the operator for the
* selected row on every tick.
*
* This comparison also breaks a cycle: the table's sort policy calls
* refresh(), which lands here, and setAll would ask the table to sort
* again. Once the content is equal the chain stops.
*
* It rests on element identity. ChatMember declares
* equals(ChatMember) — an overload, not an override of
* equals(Object) — so List.equals falls back to reference comparison.
* Turning that overload into a real override would make two different
* rows with the same callsign compare equal here, and a genuine change
* could be dropped. See ChatMember.
*/
if (mirror.equals(computed)) {
return;
}
mirror.setAll(computed);
}
}
@@ -0,0 +1,123 @@
package kst4contest.view;
import kst4contest.controller.ChatController;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javafx.scene.image.Image;
import javafx.stage.Stage;
import java.net.URL;
public class GuiUtils {
private static final String APPLICATION_ICON_RESOURCE = "/icons/kst4contest.png";
private static Image applicationIcon;
/**
* Applies the common KST4Contest application icon to a JavaFX stage.
*
* <p>The icon is loaded only once and reused for all application windows.
* A missing icon resource must never prevent a window from opening.</p>
*
* @param stage stage that should receive the application icon
*/
public static void applyApplicationIcon(Stage stage) {
if (stage == null) {
return;
}
Image icon = getApplicationIcon();
if (icon != null && !stage.getIcons().contains(icon)) {
stage.getIcons().add(icon);
}
}
/**
* Loads and caches the common KST4Contest application icon.
*
* @return application icon or null if the resource is unavailable
*/
private static Image getApplicationIcon() {
if (applicationIcon != null) {
return applicationIcon;
}
URL iconUrl = GuiUtils.class.getResource(APPLICATION_ICON_RESOURCE);
if (iconUrl == null) {
System.err.println(
"Application icon resource not found: "
+ APPLICATION_ICON_RESOURCE
);
return null;
}
applicationIcon = new Image(iconUrl.toExternalForm());
return applicationIcon;
}
private static final String PTRN_CALLSIGNSYNTAX = "^(?:[A-Z]{1,2}[0-9]|[0-9][A-Z])[0-9A-Z]{1,3}$";
/**
* Checks wheter the input value of the String is numeric or not, true if yes
* TODO: Move to a utils class for checking input values by user...
* @param str
* @return
*/
static boolean isNumeric(String str){
return str != null && str.matches("[0-9.]+");
}
/**
* Checks wheter the given String has a HAM radio callsign syntax or not
* @param maybeCallSignValue
* @return true if yes
*/
static boolean isCallSignSyntax(String maybeCallSignValue) {
Pattern pattern = Pattern.compile(PTRN_CALLSIGNSYNTAX, Pattern.CASE_INSENSITIVE);
Matcher matcher = pattern.matcher(maybeCallSignValue);
try {
if (matcher.find()) {
return true;
}
else return false;
} catch (Exception exc) {
return false;
}
}
public static void triggerGUIFilteredChatMemberListChange(ChatController chatController) {
if (javafx.application.Platform.isFxApplicationThread()) {
triggerUpdate(chatController);
} else{
javafx.application.Platform.runLater(() -> triggerUpdate(chatController));
}
}
/**
* 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;
}
chatController.fireUserListUpdate("Forced filtered ChatMember refresh");
}
}
@@ -0,0 +1,31 @@
package kst4contest.view;
import javafx.application.Platform;
import kst4contest.observe.UiDispatcher;
/** The {@link UiDispatcher} backed by the JavaFX application thread. */
public class JavaFxUiDispatcher implements UiDispatcher {
@Override
public void runOnUi(Runnable task) {
if (Platform.isFxApplicationThread()) {
runGuarded(task);
} else {
Platform.runLater(() -> runGuarded(task));
}
}
@Override
public boolean isUiThread() {
return Platform.isFxApplicationThread();
}
private static void runGuarded(Runnable task) {
try {
task.run();
} catch (RuntimeException e) {
// Must not tear down the JavaFX application thread.
System.err.println("[observe] UI task failed: " + e);
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,50 @@
package kst4contest.view;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.util.Duration;
import kst4contest.model.ChatPreferences;
import java.util.Objects;
/**
* Coalesces JavaFX layout changes into selective preferences writes.
*/
public final class LayoutAutosave {
private static final Duration SAVE_DELAY = Duration.millis(750);
private final ChatPreferences preferences;
private final PauseTransition saveDelay = new PauseTransition(SAVE_DELAY);
private boolean pending;
public LayoutAutosave(ChatPreferences preferences) {
this.preferences = Objects.requireNonNull(preferences, "preferences");
saveDelay.setOnFinished(event -> flushPending());
}
public void requestSave() {
if (!Platform.isFxApplicationThread()) {
Platform.runLater(this::requestSave);
return;
}
pending = true;
saveDelay.playFromStart();
}
public void flushPending() {
if (!pending) {
return;
}
saveDelay.stop();
pending = false;
preferences.writeLayoutPreferencesToXmlFile();
}
public void cancelPending() {
saveDelay.stop();
pending = false;
}
}
@@ -0,0 +1,15 @@
package kst4contest.view;
/**
* Main class with simple main method.
* Workaround to avoid Error that JavaFX is missing.
*/
public class Main {
/**
* Main method that simply calls the main method inside our application class.
* @param args Commandline Arguments.
*/
public static void main(String[] args) {
Kst4ContestApplication.main(args);
}
}
@@ -0,0 +1,327 @@
package kst4contest.view;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
import javafx.scene.control.TableCell;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import javafx.scene.shape.Rectangle;
import javafx.util.Duration;
import java.net.URI;
import java.util.Locale;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Displays a single-line message text in a TableView.
*
* <p>The cell provides two functions that JavaFX's default text cell cannot
* combine:
* <ul>
* <li>a tooltip containing the complete text, but only while the visible
* cell is too narrow for that text;</li>
* <li>clickable HTTP, HTTPS and www links inside otherwise normal text.</li>
* </ul>
*
* <p>The row type is generic because the same implementation is used by the
* ChatMessage tables and the DX-cluster table.</p>
*
* @param <S> row type of the surrounding TableView
*/
public final class MessageTextTableCell<S> extends TableCell<S, String> {
private static final Pattern WEB_LINK_PATTERN = Pattern.compile(
"(?i)\\b(?:https?://|www\\.)[^\\s<>\"']+"
);
private static final String TRAILING_LINK_PUNCTUATION = ".,;:!?)]}";
private final Consumer<String> linkOpener;
private final Predicate<String> highlightedTextPredicate;
private final String normalStyleClass;
private final String highlightedStyleClass;
private final HBox contentBox = new HBox(0);
private final Rectangle contentClip = new Rectangle();
private final Tooltip fullTextTooltip = new Tooltip();
private String displayedText = "";
private boolean fullTextTooltipInstalled = false;
/**
* Creates a normal message cell without additional text highlighting.
*
* @param linkOpener callback which opens a validated HTTP or HTTPS URL
*/
public MessageTextTableCell(Consumer<String> linkOpener) {
this(linkOpener, text -> false, null, null);
}
/**
* Creates a message cell with optional CSS highlighting.
*
* <p>KST4Contest uses this variant for public messages which contain the
* operator's own callsign. Only the supplied CSS classes are added or
* removed. The standard {@code table-cell} class and all other JavaFX
* state remain untouched.</p>
*
* @param linkOpener callback which opens a validated URL
* @param highlightedTextPredicate identifies highlighted messages
* @param normalStyleClass CSS class for normal messages, may be null
* @param highlightedStyleClass CSS class for highlighted messages, may be null
*/
public MessageTextTableCell(
Consumer<String> linkOpener,
Predicate<String> highlightedTextPredicate,
String normalStyleClass,
String highlightedStyleClass
) {
this.linkOpener = Objects.requireNonNull(linkOpener, "linkOpener");
this.highlightedTextPredicate = highlightedTextPredicate == null
? text -> false
: highlightedTextPredicate;
this.normalStyleClass = normalStyleClass;
this.highlightedStyleClass = highlightedStyleClass;
contentBox.setAlignment(Pos.CENTER_LEFT);
contentBox.setFillHeight(false);
contentBox.setClip(contentClip);
fullTextTooltip.setWrapText(true);
fullTextTooltip.setMaxWidth(800);
fullTextTooltip.setShowDelay(Duration.millis(250));
fullTextTooltip.setShowDuration(Duration.seconds(30));
setContentDisplay(javafx.scene.control.ContentDisplay.GRAPHIC_ONLY);
}
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
removeOptionalStyleClasses();
contentBox.getChildren().clear();
if (empty || item == null || item.isEmpty()) {
displayedText = "";
fullTextTooltip.setText("");
setText(null);
setGraphic(null);
setClippedTooltipActive(false);
return;
}
displayedText = item;
fullTextTooltip.setText(item);
applyOptionalStyleClass(item);
buildContent(item);
setText(null);
setGraphic(contentBox);
updateClipAndTooltip();
}
@Override
protected void layoutChildren() {
super.layoutChildren();
updateClipAndTooltip();
}
/**
* Splits the displayed text into ordinary labels and clickable links.
*/
private void buildContent(String text) {
Matcher matcher = WEB_LINK_PATTERN.matcher(text);
int nextPlainTextStart = 0;
while (matcher.find()) {
String rawMatch = matcher.group();
String linkText = removeTrailingPunctuation(rawMatch);
if (linkText.isEmpty()) {
continue;
}
appendPlainText(text.substring(nextPlainTextStart, matcher.start()));
appendLink(linkText);
/*
* Punctuation removed from the URL remains ordinary message text.
* Starting here ensures it is included by the next substring.
*/
nextPlainTextStart = matcher.start() + linkText.length();
}
appendPlainText(text.substring(nextPlainTextStart));
}
private void appendPlainText(String text) {
if (text == null || text.isEmpty()) {
return;
}
Label textFragment = new Label(text);
textFragment.setPadding(Insets.EMPTY);
textFragment.setMinWidth(Region.USE_PREF_SIZE);
textFragment.setMaxWidth(Region.USE_PREF_SIZE);
textFragment.setMouseTransparent(true);
textFragment.textFillProperty().bind(textFillProperty());
textFragment.getStyleClass().add("message-text-fragment");
contentBox.getChildren().add(textFragment);
}
private void appendLink(String linkText) {
Hyperlink hyperlink = new Hyperlink(linkText);
hyperlink.setPadding(Insets.EMPTY);
hyperlink.setMinWidth(Region.USE_PREF_SIZE);
hyperlink.setMaxWidth(Region.USE_PREF_SIZE);
hyperlink.setFocusTraversable(false);
hyperlink.getStyleClass().add("message-table-link");
hyperlink.setOnAction(event -> {
openLink(linkText);
event.consume();
});
contentBox.getChildren().add(hyperlink);
}
/**
* Opens only HTTP and HTTPS targets. A visible www address receives an
* HTTPS scheme before it is passed to the application.
*/
private void openLink(String linkText) {
try {
String normalizedLink = linkText.toLowerCase(Locale.ROOT).startsWith("www.")
? "https://" + linkText
: linkText;
URI uri = URI.create(normalizedLink);
String scheme = uri.getScheme();
if (scheme == null
|| (!scheme.equalsIgnoreCase("http")
&& !scheme.equalsIgnoreCase("https"))) {
return;
}
linkOpener.accept(uri.toASCIIString());
} catch (RuntimeException exception) {
System.out.println(
"[MessageTextTableCell] Cannot open malformed link: "
+ linkText
+ " / "
+ exception.getMessage()
);
}
}
/**
* Removes punctuation which commonly follows a link in normal prose.
*/
private String removeTrailingPunctuation(String rawLink) {
String result = rawLink;
while (!result.isEmpty()
&& TRAILING_LINK_PUNCTUATION.indexOf(
result.charAt(result.length() - 1)
) >= 0) {
result = result.substring(0, result.length() - 1);
}
return result;
}
/**
* Clips the one-line content and installs the full-text tooltip only if
* the rendered nodes are wider than the usable cell area.
*/
private void updateClipAndTooltip() {
if (displayedText.isEmpty() || getGraphic() == null) {
setClippedTooltipActive(false);
return;
}
double availableWidth = Math.max(
0,
getWidth() - snappedLeftInset() - snappedRightInset()
);
double availableHeight = Math.max(
0,
getHeight() - snappedTopInset() - snappedBottomInset()
);
contentClip.setWidth(availableWidth);
contentClip.setHeight(availableHeight);
boolean textIsClipped = contentBox.prefWidth(-1) > availableWidth + 1;
setClippedTooltipActive(textIsClipped);
}
/**
* Installs the tooltip on the actual graphic node below the mouse pointer.
*
* <p>Installing it on the TableCell itself is not reliable when the cell
* displays an HBox containing labels and hyperlinks. The graphic node is
* the effective mouse target. Installation is tracked explicitly so
* repeated layout passes neither add duplicate handlers nor leave a
* tooltip attached to a reused empty cell.</p>
*/
private void setClippedTooltipActive(boolean active) {
/*
* The tooltip is handled exclusively by the graphic node. Keeping a
* second tooltip on the TableCell would allow two competing tooltip
* targets for the same visible content.
*/
setTooltip(null);
if (active == fullTextTooltipInstalled) {
return;
}
if (active) {
Tooltip.install(contentBox, fullTextTooltip);
} else {
fullTextTooltip.hide();
Tooltip.uninstall(contentBox, fullTextTooltip);
}
fullTextTooltipInstalled = active;
}
private void applyOptionalStyleClass(String item) {
boolean highlighted;
try {
highlighted = highlightedTextPredicate.test(item);
} catch (RuntimeException exception) {
highlighted = false;
}
String styleClass = highlighted
? highlightedStyleClass
: normalStyleClass;
if (styleClass != null
&& !styleClass.isBlank()
&& !getStyleClass().contains(styleClass)) {
getStyleClass().add(styleClass);
}
}
private void removeOptionalStyleClasses() {
if (normalStyleClass != null) {
getStyleClass().remove(normalStyleClass);
}
if (highlightedStyleClass != null) {
getStyleClass().remove(highlightedStyleClass);
}
}
}
@@ -0,0 +1,107 @@
package kst4contest.view;
import kst4contest.controller.OperatorProfilePaths;
import kst4contest.controller.OperatorProfileStore;
import kst4contest.model.OperatorProfile;
import kst4contest.model.OperatorProfileSelection;
import java.util.List;
import java.util.Optional;
/**
* Decides which operator profile the application starts with.
*
* <p>The class contains no user interface code so the decision can be tested headless.
* Asking the operator is delegated to an {@link OperatorProfileChoiceRequester}, and a
* problem worth telling the operator about is reported through
* {@link #getStartupWarning()} instead of being shown here.</p>
*
* <p>The most important property of this logic is what it does <em>not</em> do: an
* installation with no or exactly one profile is resolved without asking anything and
* without touching a single file, so a single operator start stays exactly as fast and
* as quiet as it was before profiles existed.</p>
*/
public class OperatorProfileBootstrap {
private String startupWarning;
/**
* Resolves the operator profile to start with.
*
* @param store registry to read the profiles from
* @param commandLineOptions parsed command line options
* @param choiceRequester requester used when the operator has to choose
* @return the resolved selection, or null when the operator chose to quit
*/
public OperatorProfileSelection resolveAtStartup(final OperatorProfileStore store,
final CommandLineOptions commandLineOptions,
final OperatorProfileChoiceRequester choiceRequester) {
startupWarning = null;
List<OperatorProfile> availableProfiles = store.loadProfiles();
String requestedProfileName = commandLineOptions == null
? null
: commandLineOptions.getRequestedProfileName();
if (requestedProfileName != null) {
OperatorProfile requestedProfile = findProfile(availableProfiles, requestedProfileName);
if (requestedProfile != null) {
return OperatorProfilePaths.resolve(requestedProfile);
}
startupWarning = "The operator profile \"" + requestedProfileName
+ "\" is unknown. KST4Contest continues with the normal profile selection.";
}
if (availableProfiles.isEmpty()) {
// No registry at all: the historic flat installation is the only profile.
return OperatorProfilePaths.resolve(store.buildImplicitRootProfile());
}
if (availableProfiles.size() == 1) {
return OperatorProfilePaths.resolve(availableProfiles.get(0));
}
String preselectedProfileId = store.loadLastUsedProfileId().orElse(null);
Optional<OperatorProfile> chosenProfile =
choiceRequester.requestProfileChoice(availableProfiles, preselectedProfileId);
return chosenProfile.map(OperatorProfilePaths::resolve).orElse(null);
}
/**
* Returns a message that should be shown to the operator after startup.
*
* @return the warning text, or null when startup was unremarkable
*/
public String getStartupWarning() {
return startupWarning;
}
/**
* Finds a profile by identifier or display name, ignoring case.
*
* @param availableProfiles profiles to search
* @param requestedName identifier or display name entered by the operator
* @return the matching profile, or null
*/
private static OperatorProfile findProfile(final List<OperatorProfile> availableProfiles,
final String requestedName) {
for (OperatorProfile currentProfile : availableProfiles) {
if (requestedName.equalsIgnoreCase(currentProfile.getProfileId())) {
return currentProfile;
}
}
for (OperatorProfile currentProfile : availableProfiles) {
if (requestedName.equalsIgnoreCase(currentProfile.getDisplayName())) {
return currentProfile;
}
}
return null;
}
}
@@ -0,0 +1,26 @@
package kst4contest.view;
import kst4contest.model.OperatorProfile;
import java.util.List;
import java.util.Optional;
/**
* Asks the operator which profile to start with.
*
* <p>The startup logic depends on this interface rather than on a dialog, so the
* decision which profile to use can be tested without a JavaFX runtime.</p>
*/
@FunctionalInterface
public interface OperatorProfileChoiceRequester {
/**
* Requests a profile choice.
*
* @param selectableProfiles profiles to choose from, never empty
* @param preselectedProfileId identifier to preselect, may be null
* @return the chosen profile, or empty when the operator wants to quit
*/
Optional<OperatorProfile> requestProfileChoice(List<OperatorProfile> selectableProfiles,
String preselectedProfileId);
}
@@ -0,0 +1,145 @@
package kst4contest.view;
import kst4contest.model.OperatorProfile;
import javafx.geometry.Insets;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.input.KeyCode;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.stage.Modality;
import javafx.stage.Stage;
import java.util.List;
import java.util.Optional;
/**
* Asks the operator which profile to start with.
*
* <p>The dialog is shown only when more than one profile exists. It is intentionally
* minimal, because it stands between the operator and a contest: the last used profile
* is preselected, the list has the focus, and Enter or a double click start immediately.</p>
*/
public final class OperatorProfilePickerDialog {
private OperatorProfilePickerDialog() {
// Utility class.
}
/**
* Shows the picker and waits for the operator's choice.
*
* @param selectableProfiles profiles to choose from
* @param preselectedProfileId identifier of the profile to preselect, may be null
* @return the chosen profile, or empty when the operator wants to quit
*/
public static Optional<OperatorProfile> showAndSelect(final List<OperatorProfile> selectableProfiles,
final String preselectedProfileId) {
Stage dialogStage = new Stage();
GuiUtils.applyApplicationIcon(dialogStage);
dialogStage.initModality(Modality.APPLICATION_MODAL);
dialogStage.setTitle("Select operator profile");
ListView<OperatorProfile> profileListView = new ListView<>();
profileListView.getItems().addAll(selectableProfiles);
profileListView.setCellFactory(listView -> new OperatorProfileListCell());
VBox.setVgrow(profileListView, Priority.ALWAYS);
selectPreselectedProfile(profileListView, selectableProfiles, preselectedProfileId);
OperatorProfile[] chosenProfile = new OperatorProfile[1];
Button startButton = new Button("Start");
startButton.setDefaultButton(true);
startButton.setOnAction(event -> {
chosenProfile[0] = profileListView.getSelectionModel().getSelectedItem();
dialogStage.close();
});
Button quitButton = new Button("Quit");
quitButton.setCancelButton(true);
quitButton.setOnAction(event -> {
chosenProfile[0] = null;
dialogStage.close();
});
profileListView.setOnMouseClicked(event -> {
if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) {
startButton.fire();
}
});
profileListView.setOnKeyPressed(event -> {
if (event.getCode() == KeyCode.ENTER) {
startButton.fire();
}
});
HBox buttonRow = new HBox(10, startButton, quitButton);
buttonRow.setPadding(new Insets(10, 0, 0, 0));
VBox dialogContent = new VBox(8,
new Label("More than one operator profile is configured."),
profileListView,
buttonRow);
dialogContent.setPadding(new Insets(15));
// No application stylesheet yet: use the same base font size as the main windows.
dialogContent.setStyle("-fx-font-size: 12px;");
dialogStage.setScene(new Scene(dialogContent, 380, 280));
profileListView.requestFocus();
dialogStage.showAndWait();
return Optional.ofNullable(chosenProfile[0]);
}
private static void selectPreselectedProfile(final ListView<OperatorProfile> profileListView,
final List<OperatorProfile> selectableProfiles,
final String preselectedProfileId) {
int profileIndexToSelect = 0;
if (preselectedProfileId != null) {
for (int profileIndex = 0; profileIndex < selectableProfiles.size(); profileIndex++) {
if (preselectedProfileId.equalsIgnoreCase(
selectableProfiles.get(profileIndex).getProfileId())) {
profileIndexToSelect = profileIndex;
break;
}
}
}
profileListView.getSelectionModel().select(profileIndexToSelect);
profileListView.scrollTo(profileIndexToSelect);
}
/**
* Renders a profile with its name and the kind of worked data it uses.
*/
private static final class OperatorProfileListCell extends ListCell<OperatorProfile> {
@Override
protected void updateItem(final OperatorProfile profile, final boolean empty) {
super.updateItem(profile, empty);
if (empty || profile == null) {
setText(null);
return;
}
String workedDataDescription = profile.isRootProfile() || profile.isSharedWorkedDatabase()
? "shared station worked database"
: "own worked database";
setText(profile.getDisplayName() + "\n" + workedDataDescription);
}
}
}
@@ -0,0 +1,488 @@
package kst4contest.view;
import kst4contest.controller.ActiveOperatorProfile;
import kst4contest.controller.OperatorProfileManagementService;
import kst4contest.controller.OperatorProfilePaths;
import kst4contest.model.OperatorProfile;
import kst4contest.model.OperatorProfileSelection;
import javafx.beans.property.SimpleStringProperty;
import javafx.geometry.Insets;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonBar;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Dialog;
import javafx.scene.control.Label;
import javafx.scene.control.RadioButton;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.control.TextInputDialog;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.util.Pair;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
/**
* Settings tab that manages the operator profiles.
*
* <p>Every profile keeps its own settings and window layout. Whether it also keeps its
* own worked stations is chosen per profile, because a multi operator contest station
* shares one log while two operators on a private computer usually do not.</p>
*/
public class OperatorProfileSettingsPane extends VBox {
private static final DateTimeFormatter LAST_USED_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault());
private final OperatorProfileManagementService managementService;
private final Consumer<OperatorProfile> profileActivationRequest;
private final TableView<OperatorProfile> profileTable = new TableView<>();
private final Label activeProfileLabel = new Label();
private final Label preferencesPathLabel = new Label();
private final Label workedDatabasePathLabel = new Label();
public OperatorProfileSettingsPane(final OperatorProfileManagementService managementService,
final Consumer<OperatorProfile> profileActivationRequest) {
this.managementService = managementService;
this.profileActivationRequest = profileActivationRequest;
setSpacing(10);
setPadding(new Insets(15));
getChildren().addAll(
buildActiveProfileHeader(),
buildProfileTable(),
buildButtonRows(),
buildExplanationLabel());
refreshActiveProfileHeader();
refreshProfileTable();
}
private GridPane buildActiveProfileHeader() {
GridPane headerGrid = new GridPane();
headerGrid.setHgap(10);
headerGrid.setVgap(4);
headerGrid.add(new Label("Active profile:"), 0, 0);
headerGrid.add(activeProfileLabel, 1, 0);
headerGrid.add(new Label("Settings file:"), 0, 1);
headerGrid.add(preferencesPathLabel, 1, 1);
headerGrid.add(new Label("Worked stations:"), 0, 2);
headerGrid.add(workedDatabasePathLabel, 1, 2);
return headerGrid;
}
private TableView<OperatorProfile> buildProfileTable() {
TableColumn<OperatorProfile, String> nameColumn = new TableColumn<>("Profile");
nameColumn.setCellValueFactory(cellData ->
new SimpleStringProperty(cellData.getValue().getDisplayName()));
nameColumn.setPrefWidth(200);
TableColumn<OperatorProfile, String> workedDataColumn = new TableColumn<>("Worked stations");
workedDataColumn.setCellValueFactory(cellData ->
new SimpleStringProperty(describeWorkedDataMode(cellData.getValue())));
workedDataColumn.setPrefWidth(200);
TableColumn<OperatorProfile, String> lastUsedColumn = new TableColumn<>("Last used");
lastUsedColumn.setCellValueFactory(cellData ->
new SimpleStringProperty(describeLastUsed(cellData.getValue())));
lastUsedColumn.setPrefWidth(140);
profileTable.getColumns().add(nameColumn);
profileTable.getColumns().add(workedDataColumn);
profileTable.getColumns().add(lastUsedColumn);
profileTable.setPlaceholder(new Label("No operator profile configured."));
VBox.setVgrow(profileTable, Priority.ALWAYS);
return profileTable;
}
private VBox buildButtonRows() {
Button newProfileButton = new Button("New profile...");
newProfileButton.setOnAction(event -> createProfile());
Button duplicateProfileButton = new Button("Duplicate...");
duplicateProfileButton.setOnAction(event -> duplicateSelectedProfile());
Button renameProfileButton = new Button("Rename...");
renameProfileButton.setOnAction(event -> renameSelectedProfile());
Button deleteProfileButton = new Button("Delete...");
deleteProfileButton.setOnAction(event -> deleteSelectedProfile());
Button changeWorkedDataButton = new Button("Change worked stations...");
changeWorkedDataButton.setOnAction(event -> changeWorkedDataModeOfSelectedProfile());
Button switchProfileButton = new Button("Switch to selected profile...");
switchProfileButton.setOnAction(event -> activateSelectedProfile());
HBox managementRow = new HBox(8,
newProfileButton, duplicateProfileButton, renameProfileButton, deleteProfileButton);
HBox activationRow = new HBox(8, changeWorkedDataButton, switchProfileButton);
return new VBox(8, managementRow, activationRow);
}
private Label buildExplanationLabel() {
Label explanation = new Label(
"Each profile has its own settings and window layout. A profile can either share the "
+ "common station worked stations, which is what a multi operator station wants, "
+ "or keep its own. Duplicating a profile copies everything except callsign and "
+ "password, and never copies worked stations.");
explanation.setWrapText(true);
return explanation;
}
private void refreshActiveProfileHeader() {
OperatorProfileSelection activeProfile = ActiveOperatorProfile.get();
if (activeProfile == null) {
activeProfileLabel.setText("unknown");
return;
}
activeProfileLabel.setText(activeProfile.getProfile().getDisplayName());
preferencesPathLabel.setText(activeProfile.getPreferencesAbsolutePath());
workedDatabasePathLabel.setText(activeProfile.getWorkedDatabaseAbsolutePath());
}
private void refreshProfileTable() {
List<OperatorProfile> knownProfiles = managementService.listProfiles();
OperatorProfile previouslySelected = profileTable.getSelectionModel().getSelectedItem();
profileTable.getItems().setAll(knownProfiles);
if (previouslySelected != null && knownProfiles.contains(previouslySelected)) {
profileTable.getSelectionModel().select(previouslySelected);
} else if (!knownProfiles.isEmpty()) {
profileTable.getSelectionModel().select(0);
}
}
private void createProfile() {
Optional<Pair<String, Boolean>> enteredProfile =
showProfileCreationDialog("New operator profile", "");
if (enteredProfile.isEmpty()) {
return;
}
OperatorProfile createdProfile = managementService.createProfile(
enteredProfile.get().getKey(), enteredProfile.get().getValue());
if (createdProfile == null) {
showError("The profile could not be created. The profile registry could not be written.");
return;
}
refreshProfileTable();
profileTable.getSelectionModel().select(createdProfile);
showInformation("The profile \"" + createdProfile.getDisplayName() + "\" was created without "
+ "callsign and password. Enter them on the Station tab after switching to it.");
}
private void duplicateSelectedProfile() {
OperatorProfile selectedProfile = requireSelectedProfile();
if (selectedProfile == null) {
return;
}
TextInputDialog nameDialog =
new TextInputDialog("Copy of " + selectedProfile.getDisplayName());
nameDialog.setTitle("Duplicate operator profile");
nameDialog.setHeaderText("Name of the new profile");
nameDialog.setContentText(
"Everything is copied except callsign and password. Worked stations are never copied.");
Optional<String> enteredName = nameDialog.showAndWait();
if (enteredName.isEmpty() || enteredName.get().isBlank()) {
return;
}
OperatorProfile duplicatedProfile =
managementService.duplicateProfile(selectedProfile, enteredName.get());
if (duplicatedProfile == null) {
showError("The profile could not be duplicated.");
return;
}
refreshProfileTable();
profileTable.getSelectionModel().select(duplicatedProfile);
}
private void renameSelectedProfile() {
OperatorProfile selectedProfile = requireSelectedProfile();
if (selectedProfile == null) {
return;
}
TextInputDialog nameDialog = new TextInputDialog(selectedProfile.getDisplayName());
nameDialog.setTitle("Rename operator profile");
nameDialog.setHeaderText("New name of the profile");
nameDialog.setContentText("Files and folders of the profile are not touched.");
Optional<String> enteredName = nameDialog.showAndWait();
if (enteredName.isEmpty() || enteredName.get().isBlank()) {
return;
}
managementService.renameProfile(selectedProfile, enteredName.get());
refreshProfileTable();
refreshActiveProfileHeader();
}
private void deleteSelectedProfile() {
OperatorProfile selectedProfile = requireSelectedProfile();
if (selectedProfile == null) {
return;
}
if (selectedProfile.isRootProfile()) {
showError("The default profile uses the files of the installation itself "
+ "and cannot be deleted.");
return;
}
if (isActiveProfile(selectedProfile)) {
showError("The profile currently in use cannot be deleted. Switch to another "
+ "profile first.");
return;
}
Alert confirmation = new Alert(AlertType.CONFIRMATION);
confirmation.setTitle("Delete operator profile");
confirmation.setHeaderText("Delete the profile \"" + selectedProfile.getDisplayName() + "\"?");
confirmation.setContentText(
"The following folder is removed permanently:\n"
+ managementService.getProfileDirectory(selectedProfile)
+ "\n\n"
+ (selectedProfile.isSharedWorkedDatabase()
? "The common station worked stations are not touched."
: "The worked stations of this profile are deleted as well."));
ButtonType deleteButton = new ButtonType("Delete profile", ButtonBar.ButtonData.OK_DONE);
ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
confirmation.getButtonTypes().setAll(deleteButton, cancelButton);
if (confirmation.showAndWait().orElse(cancelButton) != deleteButton) {
return;
}
if (!managementService.deleteProfile(selectedProfile)) {
showError("The profile could not be deleted.");
}
refreshProfileTable();
}
private void changeWorkedDataModeOfSelectedProfile() {
OperatorProfile selectedProfile = requireSelectedProfile();
if (selectedProfile == null) {
return;
}
if (selectedProfile.isRootProfile()) {
showError("The default profile always uses the common station worked stations, "
+ "because that database is the one of the installation itself.");
return;
}
Optional<Boolean> chosenMode = showWorkedDataModeDialog(selectedProfile);
if (chosenMode.isEmpty() || chosenMode.get() == selectedProfile.isSharedWorkedDatabase()) {
return;
}
managementService.setSharedWorkedDatabase(selectedProfile, chosenMode.get());
refreshProfileTable();
if (isActiveProfile(selectedProfile)) {
showInformation("The change takes effect after switching to this profile again.");
}
}
private void activateSelectedProfile() {
OperatorProfile selectedProfile = requireSelectedProfile();
if (selectedProfile == null) {
return;
}
if (isActiveProfile(selectedProfile)) {
showInformation("This profile is already active.");
return;
}
profileActivationRequest.accept(selectedProfile);
}
private Optional<Pair<String, Boolean>> showProfileCreationDialog(final String title,
final String initialName) {
Dialog<Pair<String, Boolean>> creationDialog = new Dialog<>();
creationDialog.setTitle(title);
creationDialog.setHeaderText("Name and worked stations of the new profile");
ButtonType createButton = new ButtonType("Create profile", ButtonBar.ButtonData.OK_DONE);
ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
creationDialog.getDialogPane().getButtonTypes().setAll(createButton, cancelButton);
TextField nameField = new TextField(initialName);
nameField.setPromptText("for example DN9APW");
ToggleGroup workedDataGroup = new ToggleGroup();
RadioButton ownDatabaseOption = new RadioButton("Own worked stations for this profile");
ownDatabaseOption.setToggleGroup(workedDataGroup);
ownDatabaseOption.setSelected(true);
RadioButton sharedDatabaseOption =
new RadioButton("Share the common station worked stations (multi operator station)");
sharedDatabaseOption.setToggleGroup(workedDataGroup);
VBox dialogContent = new VBox(8,
new Label("Profile name"),
nameField,
new Label("Worked stations"),
ownDatabaseOption,
sharedDatabaseOption);
dialogContent.setPadding(new Insets(10));
creationDialog.getDialogPane().setContent(dialogContent);
creationDialog.setResultConverter(pressedButton -> {
if (pressedButton != createButton || nameField.getText().isBlank()) {
return null;
}
return new Pair<>(nameField.getText().trim(), sharedDatabaseOption.isSelected());
});
return creationDialog.showAndWait();
}
private Optional<Boolean> showWorkedDataModeDialog(final OperatorProfile profile) {
Dialog<Boolean> modeDialog = new Dialog<>();
modeDialog.setTitle("Worked stations");
modeDialog.setHeaderText("Worked stations of \"" + profile.getDisplayName() + "\"");
ButtonType applyButton = new ButtonType("Apply", ButtonBar.ButtonData.OK_DONE);
ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
modeDialog.getDialogPane().getButtonTypes().setAll(applyButton, cancelButton);
ToggleGroup workedDataGroup = new ToggleGroup();
RadioButton ownDatabaseOption = new RadioButton("Own worked stations for this profile");
ownDatabaseOption.setToggleGroup(workedDataGroup);
RadioButton sharedDatabaseOption =
new RadioButton("Share the common station worked stations (multi operator station)");
sharedDatabaseOption.setToggleGroup(workedDataGroup);
sharedDatabaseOption.setSelected(profile.isSharedWorkedDatabase());
ownDatabaseOption.setSelected(!profile.isSharedWorkedDatabase());
Label pathHint = new Label("Switching does not move any data. Worked stations already "
+ "collected under the other setting stay where they are.");
pathHint.setWrapText(true);
VBox dialogContent = new VBox(8, ownDatabaseOption, sharedDatabaseOption, pathHint);
dialogContent.setPadding(new Insets(10));
modeDialog.getDialogPane().setContent(dialogContent);
modeDialog.setResultConverter(pressedButton ->
pressedButton == applyButton ? sharedDatabaseOption.isSelected() : null);
return modeDialog.showAndWait();
}
private OperatorProfile requireSelectedProfile() {
OperatorProfile selectedProfile = profileTable.getSelectionModel().getSelectedItem();
if (selectedProfile == null) {
showInformation("Select a profile in the table first.");
}
return selectedProfile;
}
private static boolean isActiveProfile(final OperatorProfile profile) {
OperatorProfileSelection activeProfile = ActiveOperatorProfile.get();
return activeProfile != null
&& activeProfile.getProfile().getProfileId().equals(profile.getProfileId());
}
private static String describeWorkedDataMode(final OperatorProfile profile) {
if (profile.isRootProfile() || profile.isSharedWorkedDatabase()) {
return "common station database";
}
return "own database";
}
private static String describeLastUsed(final OperatorProfile profile) {
if (profile.getLastUsedEpochMs() <= 0L) {
return "";
}
return LAST_USED_FORMATTER.format(Instant.ofEpochMilli(profile.getLastUsedEpochMs()));
}
private static void showInformation(final String message) {
Alert information = new Alert(AlertType.INFORMATION);
information.setTitle("Operator profiles");
information.setContentText(message);
information.showAndWait();
}
private static void showError(final String message) {
Alert error = new Alert(AlertType.ERROR);
error.setTitle("Operator profiles");
error.setContentText(message);
error.showAndWait();
}
}
@@ -0,0 +1,83 @@
package kst4contest.view;
import java.util.List;
/**
* Selects the CSS style class used for a private-message table row.
*/
public final class PrivateMessageRowStyleResolver {
/** Style used for messages sent by the local station. */
public static final String OWN_STYLE_CLASS =
"messageHighlightOwn-column";
/** Upper inclusive age bounds for the private-message color levels. */
private static final List<Long> AGE_LIMITS = List.of(
30L,
60L,
90L,
120L,
180L,
300L
);
/** Style classes corresponding to the configured age bounds. */
private static final List<String> AGE_STYLES = List.of(
"messageHighlight30-column",
"messageHighlight60-column",
"messageHighlight90-column",
"messageHighlight120-column",
"messageHighlight180-column",
"messageHighlight300-column"
);
/** All private-message row classes managed by the row factory. */
private static final List<String> MANAGED_STYLES = List.of(
OWN_STYLE_CLASS,
AGE_STYLES.get(0),
AGE_STYLES.get(1),
AGE_STYLES.get(2),
AGE_STYLES.get(3),
AGE_STYLES.get(4),
AGE_STYLES.get(5)
);
private PrivateMessageRowStyleResolver() {
}
/**
* Returns the complete set of private-message row classes managed by the
* row factory.
*
* @return immutable list of managed style classes
*/
public static List<String> knownStyleClasses() {
return MANAGED_STYLES;
}
/**
* Selects the private-message row class for the supplied message age.
*
* @param ownMessage whether the message was sent by the local station
* @param ageSeconds message age in seconds
* @return managed CSS class, or {@code null} after the five-minute window
*/
public static String resolveStyleClass(
final boolean ownMessage,
final long ageSeconds
) {
String styleClass = null;
if (ownMessage) {
styleClass = OWN_STYLE_CLASS;
} else {
for (int index = 0; index < AGE_LIMITS.size(); index++) {
if (ageSeconds <= AGE_LIMITS.get(index)) {
styleClass = AGE_STYLES.get(index);
break;
}
}
}
return styleClass;
}
}
@@ -0,0 +1,280 @@
package kst4contest.view;
import javafx.application.Platform;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.scene.Node;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.input.MouseEvent;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import kst4contest.model.ChatPreferences;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.OptionalDouble;
/**
* Applies persisted leaf-column widths and performs one content-based initial
* sizing pass when no width has been stored yet.
*/
public final class TableLayoutManager {
private static final double CELL_HORIZONTAL_PADDING = 16.0;
private static final double DEFAULT_MINIMUM_WIDTH = 24.0;
private TableLayoutManager() {
}
public static ColumnSpec column(String id, TableColumn<?, String> column) {
return new ColumnSpec(id, column);
}
public static <S> void install(
TableView<S> table,
String tableId,
ChatPreferences preferences,
LayoutAutosave autosave,
ColumnSpec... columnSpecs
) {
Objects.requireNonNull(table, "table");
Objects.requireNonNull(tableId, "tableId");
Objects.requireNonNull(preferences, "preferences");
Objects.requireNonNull(autosave, "autosave");
Map<TableColumn<?, String>, ColumnState> states = new IdentityHashMap<>();
for (ColumnSpec spec : columnSpecs) {
if (spec == null || !spec.column.getColumns().isEmpty()) {
continue;
}
if (spec.column.prefWidthProperty().isBound()) {
spec.column.prefWidthProperty().unbind();
}
spec.column.setId(tableId + "." + spec.id);
OptionalDouble storedWidth = preferences.getTableColumnWidth(tableId, spec.id);
ColumnState state = new ColumnState(spec, storedWidth.isEmpty());
states.put(spec.column, state);
if (storedWidth.isPresent()) {
setWidth(state, storedWidth.getAsDouble());
state.initialized = true;
}
spec.column.widthProperty().addListener((observable, oldWidth, newWidth) -> {
if (state.adjusting || !state.initialized || newWidth == null) {
return;
}
storeWidth(preferences, autosave, tableId, state.spec.id, newWidth.doubleValue());
});
}
installEarlyManualResizeDetection(table, tableId, preferences, autosave, states);
scheduleInitialSizingWhenUsable(table, tableId, preferences, autosave, states);
}
private static <S> void scheduleInitialSizingWhenUsable(
TableView<S> table,
String tableId,
ChatPreferences preferences,
LayoutAutosave autosave,
Map<TableColumn<?, String>, ColumnState> states
) {
if (states.values().stream().noneMatch(state -> !state.initialized)) {
return;
}
if (table.getItems() != null && !table.getItems().isEmpty()) {
Platform.runLater(() -> sizePendingColumns(table, tableId, preferences, autosave, states));
return;
}
@SuppressWarnings("unchecked")
final ListChangeListener<S>[] holder = new ListChangeListener[1];
holder[0] = change -> {
if (table.getItems() == null || table.getItems().isEmpty()) {
return;
}
table.getItems().removeListener(holder[0]);
Platform.runLater(() -> sizePendingColumns(table, tableId, preferences, autosave, states));
};
table.getItems().addListener(holder[0]);
}
private static <S> void sizePendingColumns(
TableView<S> table,
String tableId,
ChatPreferences preferences,
LayoutAutosave autosave,
Map<TableColumn<?, String>, ColumnState> states
) {
for (ColumnState state : states.values()) {
if (state.initialized) {
continue;
}
double width = state.spec.flexible
? flexibleInitialWidth(table, state.spec)
: contentInitialWidth(table, state.spec);
setWidth(state, width);
state.initialized = true;
storeWidth(preferences, autosave, tableId, state.spec.id, width);
}
}
private static <S> double contentInitialWidth(TableView<S> table, ColumnSpec spec) {
Text measurement = new Text();
measurement.setFont(Font.getDefault());
double requiredWidth = measure(spec.column.getText(), measurement);
for (int rowIndex = 0; rowIndex < table.getItems().size(); rowIndex++) {
ObservableValue<?> value = spec.column.getCellObservableValue(rowIndex);
if (value == null || value.getValue() == null) {
continue;
}
requiredWidth = Math.max(
requiredWidth,
measure(String.valueOf(value.getValue()), measurement)
);
}
return calculateInitialContentWidth(
requiredWidth,
spec.minimumWidth,
spec.maximumInitialWidth
);
}
private static <S> double flexibleInitialWidth(TableView<S> table, ColumnSpec spec) {
double tableShare = table.getWidth() > 1.0 ? table.getWidth() * 0.42 : spec.flexibleFallbackWidth;
Text measurement = new Text();
measurement.setFont(Font.getDefault());
double headerWidth = measure(spec.column.getText(), measurement) + CELL_HORIZONTAL_PADDING;
return clamp(Math.max(headerWidth, tableShare), spec.minimumWidth, spec.maximumInitialWidth);
}
private static <S> void installEarlyManualResizeDetection(
TableView<S> table,
String tableId,
ChatPreferences preferences,
LayoutAutosave autosave,
Map<TableColumn<?, String>, ColumnState> states
) {
Map<TableColumn<?, String>, Double> widthsAtHeaderPress = new IdentityHashMap<>();
table.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> {
if (!isColumnHeaderEvent(event)) {
return;
}
widthsAtHeaderPress.clear();
states.forEach((column, state) -> widthsAtHeaderPress.put(column, column.getWidth()));
});
table.addEventFilter(MouseEvent.MOUSE_RELEASED, event -> {
if (widthsAtHeaderPress.isEmpty()) {
return;
}
states.forEach((column, state) -> {
Double oldWidth = widthsAtHeaderPress.get(column);
if (oldWidth == null || Math.abs(oldWidth - column.getWidth()) <= 0.5) {
return;
}
state.initialized = true;
storeWidth(preferences, autosave, tableId, state.spec.id, column.getWidth());
});
widthsAtHeaderPress.clear();
});
}
private static boolean isColumnHeaderEvent(MouseEvent event) {
Object target = event.getTarget();
Node node = target instanceof Node ? (Node) target : null;
while (node != null && node.getParent() != null) {
if (node.getStyleClass().contains("column-header")
|| node.getStyleClass().contains("nested-column-header")) {
return true;
}
node = node.getParent();
}
return false;
}
private static void setWidth(ColumnState state, double width) {
state.adjusting = true;
try {
state.spec.column.setPrefWidth(width);
} finally {
state.adjusting = false;
}
}
private static void storeWidth(
ChatPreferences preferences,
LayoutAutosave autosave,
String tableId,
String columnId,
double width
) {
preferences.setTableColumnWidth(tableId, columnId, width);
autosave.requestSave();
}
private static double measure(String value, Text measurement) {
measurement.setText(value == null ? "" : value);
return measurement.getLayoutBounds().getWidth();
}
private static double clamp(double value, double minimum, double maximum) {
return Math.max(minimum, Math.min(value, maximum));
}
/**
* Calculates a compact content width. Package-private for focused sizing tests.
*/
@SuppressWarnings("PMD.CommentDefaultAccessModifier")
static double calculateInitialContentWidth(
final double measuredWidth,
final double minimum,
final double maximum
) {
return clamp(measuredWidth + CELL_HORIZONTAL_PADDING, minimum, maximum);
}
public static final class ColumnSpec {
private final String id;
private final TableColumn<?, String> column;
private double minimumWidth = DEFAULT_MINIMUM_WIDTH;
private double maximumInitialWidth = Double.MAX_VALUE;
private double flexibleFallbackWidth = 320.0;
private boolean flexible;
private ColumnSpec(String id, TableColumn<?, String> column) {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("Column id must not be blank");
}
this.id = id;
this.column = Objects.requireNonNull(column, "column");
}
public ColumnSpec maximumInitialWidth(double maximumInitialWidth) {
this.maximumInitialWidth = maximumInitialWidth;
return this;
}
public ColumnSpec flexible(double fallbackWidth) {
flexible = true;
flexibleFallbackWidth = fallbackWidth;
return this;
}
}
private static final class ColumnState {
private final ColumnSpec spec;
private boolean adjusting;
private boolean initialized;
private ColumnState(ColumnSpec spec, boolean awaitingInitialSizing) {
this.spec = spec;
initialized = !awaitingInitialSizing;
}
}
}
@@ -0,0 +1,376 @@
package kst4contest.view;
import javafx.scene.layout.Pane;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Line;
import javafx.scene.shape.Polygon;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.paint.Color;
import javafx.scene.effect.DropShadow;
import javafx.scene.Node;
import javafx.scene.Group;
import javafx.scene.text.Font;
import kst4contest.model.ContestSked;
import kst4contest.model.ChatCategory;
import java.util.Collections;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* A custom UI Component that visualizes future events (Skeds/AP).
* It changes opacity based on the current antenna direction.
*
* Extended:
* - Can also render "priority candidates" (ScoreService top list) with time base = next AirScout airplane minute.
* - Clicking a candidate triggers a callback (selection + /cq preparation happens in Kst4ContestApplication).
*/
public class TimelineView extends Pane {
private double currentAntennaAzimuth = 0;
private double beamWidth = 50.0; // TODO: from Prefs (later)
private final long PREVIEW_TIME_MS = 30 * 60 * 1000; // 30 Minutes Preview
double margin = 30; // enough space for the callsign label
private Function<ContestSked, String> skedTooltipExtraTextProvider; //used for further info in sked tooltip
private Consumer<CandidateEvent> onCandidateClicked;
public TimelineView() {
this.setPrefHeight(40);
this.setStyle("-fx-background-color: #2b2b2b;");
// weitere init defaults, falls du welche hattest
}
/**
* Backward compatibility: if some code still calls the old ctor.
* Potential/tooltip are not view-wide properties; they belong to markers/events.
*/
@Deprecated
public TimelineView(int opportunityPotentialPercent, String tooltipText) {
this(); // ignore args on purpose
}
//
// public int getOpportunityPotentialPercent() {
// return opportunityPotentialPercent;
// }
public void setCurrentAntennaAzimuth(double az) {
this.currentAntennaAzimuth = az;
}
public long getPreviewTimeMs() {
return PREVIEW_TIME_MS;
}
public void setOnCandidateClicked(Consumer<CandidateEvent> handler) {
this.onCandidateClicked = handler;
}
/** Backward compatible call (Skeds only) */
public void updateVisuals(List<ContestSked> skeds) {
updateVisuals(skeds, Collections.emptyList());
}
/**
* Redraws the timeline based on the list of active skeds AND priority candidates.
*/
public void updateVisuals(List<ContestSked> skeds, List<CandidateEvent> candidates) {
this.getChildren().clear();
double width = this.getWidth();
if (width <= 5) {
// Layout not ready yet; will be updated by caller later (uiPulse/list change)
return;
}
// Draw Axis
double axisY = 30;
Line axis = new Line(0, axisY, width, axisY);
axis.setStroke(Color.GRAY);
this.getChildren().add(axis);
long now = System.currentTimeMillis();
// 1) Draw Priority Candidates (upper lanes)
for (CandidateEvent ev : candidates) {
long timeDiff = ev.getTimeUntilMs();
if (timeDiff < 0 || timeDiff > PREVIEW_TIME_MS) continue;
double percent = (double) timeDiff / PREVIEW_TIME_MS;
double xPos = percent * width;
xPos = Math.max(margin, Math.min(this.getWidth() - margin, xPos)); //starting point of the diagram
Node marker = createCandidateMarker(ev);
applyAntennaEffect(marker, ev.getTargetAzimuth());
// Upper lanes so they don't overlap skeds
double laneBaseY = 2;
double laneOffsetY = 14.0 * ev.getLaneIndex();
marker.setLayoutX(xPos);
marker.setLayoutY(laneBaseY + laneOffsetY);
this.getChildren().add(marker);
}
// 2) Draw Skeds (lower lane)
for (ContestSked sked : skeds) {
long timeDiff = sked.getSkedTimeEpoch() - now;
// Only draw if within the next 30 mins
if (timeDiff >= 0 && timeDiff <= PREVIEW_TIME_MS) {
double percent = (double) timeDiff / PREVIEW_TIME_MS;
double xPos = percent * width;
xPos = clamp(xPos, 10, width - 10);
Node marker = createSkedMarker(sked);
applyAntennaEffect(marker, sked.getTargetAzimuth());
marker.setLayoutX(xPos);
marker.setLayoutY(axisY - 18); // below candidate lanes, near axis
this.getChildren().add(marker);
}
}
}
private double clamp(double v, double min, double max) {
return Math.max(min, Math.min(max, v));
}
/**
* Logic:
* If Antenna is ON TARGET -> Bright & Glowing.
* If Antenna is OFF TARGET -> Transparent (Ghost).
*/
private void applyAntennaEffect(Node marker, double targetAz) {
// invalid azimuth -> keep readable
if (!Double.isFinite(targetAz) || targetAz < 0) {
return;
}
double delta = Math.abs(currentAntennaAzimuth - targetAz);
if (delta > 180) delta = 360 - delta;
final boolean onTarget = delta <= (beamWidth / 2.0);
final boolean inBeam = delta <= beamWidth;
// Rule: only fade when we are clearly NOT pointing there
final double iconOpacity = inBeam ? 1.0 : 0.30;
if (marker instanceof Group g) {
// Never fade the whole group -> text stays readable
g.setOpacity(1.0);
for (Node child : g.getChildren()) {
if (child instanceof Label) {
child.setOpacity(1.0);
} else {
child.setOpacity(iconOpacity);
}
}
// Add glow only if well centered (optional)
if (onTarget) {
g.setEffect(new DropShadow(10, Color.LIMEGREEN));
g.setScaleX(1.10);
g.setScaleY(1.10);
} else {
g.setEffect(null);
g.setScaleX(1.0);
g.setScaleY(1.0);
}
return;
}
// fallback
marker.setOpacity(iconOpacity);
marker.setEffect(onTarget ? new DropShadow(10, Color.LIMEGREEN) : null);
}
public void setSkedTooltipExtraTextProvider(Function<ContestSked, String> provider) {
this.skedTooltipExtraTextProvider = provider;
}
/** Existing marker for Skeds (diamond + label) */
private Node createSkedMarker(ContestSked sked) {
Polygon diamond = new Polygon(0.0, 0.0, 6.0, 6.0, 0.0, 12.0, -6.0, 6.0);
diamond.setFill(colorForPotential(sked.getOpportunityPotentialPercent()));
String baseToolTipFallBack =
sked.getTargetChatCallsign()
+ " ("
+ sked.getBand()
+ ")\nAz: "
+ sked.getTargetAzimuth();
if (skedTooltipExtraTextProvider != null) {
String extra = skedTooltipExtraTextProvider.apply(sked);
if (extra != null && !extra.isBlank()) {
baseToolTipFallBack += "\n" + extra;
}
}
Tooltip t = new Tooltip(baseToolTipFallBack);
Tooltip.install(diamond, t);
Label lbl = new Label(
"SKED: " + sked.getTargetChatCallsign()
);
// lbl.setFont(new Font(9));
// lbl.setTextFill(Color.WHITE);
lbl.setLayoutY(14);
lbl.setLayoutX(-10);
lbl.setStyle(
"-fx-text-fill: white;" +
"-fx-font-weight: bold;" +
"-fx-background-color: rgba(0,0,0,0.65);" +
"-fx-background-radius: 6;" +
"-fx-padding: 1 4 1 4;"
);
lbl.setEffect(new DropShadow(2, Color.BLACK));
return new Group(diamond, lbl);
}
/**
* Give me a color for a given potencial of a AP reflection
*
* @param p AS potencial
* @return
*/
private Color colorForPotential(int p) {
if (p >= 95) return Color.MAGENTA; // ~100%
if (p >= 75) return Color.RED;
if (p >= 50) return Color.YELLOW;
return Color.DEEPSKYBLUE; // low potential
}
/** New marker for Priority Candidates (triangle + label) */
private Node createCandidateMarker(CandidateEvent ev) {
// Color derived from airplane potential (not urgency)
Color markerColor = colorForPotential(ev.getOpportunityPotentialPercent());
// small triangle marker (points downwards)
Polygon tri = new Polygon(-6.0, 0.0, 6.0, 0.0, 0.0, 10.0);
tri.setFill(markerColor);
// Optional: small dot behind triangle (makes it easier to see)
Circle dot = new Circle(4, markerColor);
dot.setLayoutY(5); // center behind triangle
Label lbl = new Label(ev.getDisplayCallSign());
lbl.setFont(new Font(9));
lbl.setTextFill(Color.WHITE);
lbl.setLayoutY(10);
lbl.setLayoutX(-12);
lbl.setStyle(
"-fx-text-fill: white;" +
"-fx-font-weight: bold;" +
"-fx-background-color: rgba(0,0,0,0.65);" +
"-fx-background-radius: 6;" +
"-fx-padding: 1 4 1 4;"
);
lbl.setEffect(new DropShadow(8, Color.BLACK));
// IMPORTANT: include dot + triangle + label in the Group
Group g = new Group(dot, tri, lbl);
if (ev.getTooltipText() != null && !ev.getTooltipText().isBlank()) {
Tooltip.install(g, new Tooltip(ev.getTooltipText()));
}
g.setOnMouseClicked(e -> {
if (onCandidateClicked != null) {
onCandidateClicked.accept(ev);
}
});
return g;
}
/**
* Data object rendered by the Timeline ("priority candidate").
* Created in Kst4ContestApplication from ScoreService TopCandidates + AirScout next-AP minute.
*/
public static class CandidateEvent {
private final String callSignRaw;
private final String displayCallSign;
private final ChatCategory preferredChatCategory;
private final long timeUntilMs;
private final int minuteBucket;
private final int laneIndex;
private final double targetAzimuth;
private final double score;
private final String tooltipText;
private final int opportunityPotentialPercent;
public CandidateEvent(
String callSignRaw,
String displayCallSign,
ChatCategory preferredChatCategory,
long timeUntilMs,
int minuteBucket,
int laneIndex,
double targetAzimuth,
double score,
int opportunityPotentialPercent,
String tooltipText
) {
this.callSignRaw = callSignRaw;
this.displayCallSign = displayCallSign;
this.preferredChatCategory = preferredChatCategory;
this.timeUntilMs = timeUntilMs;
this.minuteBucket = minuteBucket;
this.laneIndex = laneIndex;
this.targetAzimuth = targetAzimuth;
this.score = score;
this.opportunityPotentialPercent = opportunityPotentialPercent;
this.tooltipText = tooltipText;
}
public String getCallSignRaw() { return callSignRaw; }
public String getDisplayCallSign() { return displayCallSign; }
public ChatCategory getPreferredChatCategory() { return preferredChatCategory; }
public long getTimeUntilMs() { return timeUntilMs; }
public int getMinuteBucket() { return minuteBucket; }
public int getLaneIndex() { return laneIndex; }
public double getTargetAzimuth() { return targetAzimuth; }
public double getScore() { return score; }
public String getTooltipText() { return tooltipText; }
public int getOpportunityPotentialPercent() { return opportunityPotentialPercent; }
}
public void setBeamWidthDeg(double beamWidthDeg) {
if (beamWidthDeg > 0 && Double.isFinite(beamWidthDeg)) {
this.beamWidth = beamWidthDeg;
}
}
}
@@ -0,0 +1,109 @@
package kst4contest.view;
import javafx.scene.control.TableCell;
import javafx.scene.control.Tooltip;
import javafx.scene.text.Text;
import javafx.util.Duration;
import java.util.function.Function;
import java.util.function.BiFunction;
/**
* Displays text normally and exposes the full value only when it is clipped.
* An optional functional explanation remains available and is combined with
* the full value when both are needed.
*/
public class TruncatedTextTableCell<S> extends TableCell<S, String> {
private final Function<String, String> formatter;
private final BiFunction<S, String, String> functionalTooltipProvider;
private final Tooltip tooltip = new Tooltip();
private final Text textMeasurement = new Text();
private String fullText = "";
public TruncatedTextTableCell() {
this(Function.identity(), null);
}
public TruncatedTextTableCell(Function<String, String> formatter) {
this(formatter, null);
}
public TruncatedTextTableCell(
Function<String, String> formatter,
BiFunction<S, String, String> functionalTooltipProvider
) {
this.formatter = formatter == null ? Function.identity() : formatter;
this.functionalTooltipProvider = functionalTooltipProvider;
tooltip.setWrapText(true);
tooltip.setMaxWidth(800);
tooltip.setShowDelay(Duration.millis(250));
tooltip.setShowDuration(Duration.seconds(30));
}
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
fullText = "";
setText(null);
setGraphic(null);
setTooltip(null);
return;
}
String formatted = formatter.apply(item);
fullText = formatted == null ? "" : formatted;
setText(fullText);
setGraphic(null);
updateTooltip();
}
@Override
protected void layoutChildren() {
super.layoutChildren();
updateTooltip();
}
private void updateTooltip() {
if (isEmpty()) {
setTooltip(null);
return;
}
String functionalText = resolveFunctionalTooltip();
boolean clipped = isTextClipped();
String tooltipText = TruncatedTextTooltipSupport.buildTooltipText(
fullText,
clipped,
functionalText
);
if (tooltipText == null) {
setTooltip(null);
return;
}
tooltip.setText(tooltipText);
setTooltip(tooltip);
}
private String resolveFunctionalTooltip() {
if (functionalTooltipProvider == null || getTableRow() == null) {
return null;
}
return functionalTooltipProvider.apply(getTableRow().getItem(), fullText);
}
private boolean isTextClipped() {
if (fullText.isEmpty()) {
return false;
}
textMeasurement.setText(fullText);
textMeasurement.setFont(getFont());
double requiredWidth = textMeasurement.getLayoutBounds().getWidth();
double availableWidth = Math.max(0.0,
getWidth() - snappedLeftInset() - snappedRightInset() - 2.0);
return TruncatedTextTooltipSupport.isTextClipped(requiredWidth, availableWidth);
}
}
@@ -0,0 +1,28 @@
package kst4contest.view;
/**
* Pure tooltip decisions kept separate from JavaFX controls for unit testing.
*/
final class TruncatedTextTooltipSupport {
private TruncatedTextTooltipSupport() {
}
static boolean isTextClipped(double requiredWidth, double availableWidth) {
return requiredWidth > availableWidth + 1.0;
}
static String buildTooltipText(String fullText, boolean clipped, String functionalText) {
boolean hasFunctionalText = functionalText != null && !functionalText.isBlank();
if (!clipped && !hasFunctionalText) {
return null;
}
if (!clipped) {
return functionalText;
}
if (!hasFunctionalText) {
return fullText;
}
return fullText + "\n\n" + functionalText;
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,481 @@
package kst4contest.view.map;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
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 java.util.function.Supplier;
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 Supplier<Band> reachabilityBandOverrideSupplier;
private final MapCallsignRawSnapshotBuilder snapshotBuilder = new MapCallsignRawSnapshotBuilder();
private String lastPathAnalysisRequestSignature = "";
private final PauseTransition refreshCoalescer = new PauseTransition(Duration.seconds(1.0));
/*
* The listeners are kept so install() can be undone. Without that, the coalescing
* animation and the registered listeners would keep a discarded runtime reachable
* after an operator profile switch.
*/
private Consumer<List<ChatMember>> chatMemberListListener;
private java.util.function.Consumer<ChatMember> selectedChatMemberListener;
private java.util.function.Consumer<Double> antennaDirectionListener;
private Consumer<List<Predicate<ChatMember>>> filterPredicateListener;
public StationMapBridge(ChatController chatController,
TableView<ChatMember> chatMemberTable,
StationMapView stationMapView,
Consumer<ChatMember> focusChatMemberConsumer,
Supplier<Band> reachabilityBandOverrideSupplier) {
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.reachabilityBandOverrideSupplier = Objects.requireNonNull(
reachabilityBandOverrideSupplier,
"reachabilityBandOverrideSupplier"
);
this.refreshCoalescer.setOnFinished(event -> refreshNow());
}
public void install() {
stationMapView.setOnCallsignRawSelected(this::handleMapCallsignSelection);
stationMapView.setOnTriggerClusterSpot(this::handleExplicitClusterSpot);
stationMapView.setOnResetView(this::handleMapReset);
chatMemberListListener = members -> scheduleRefresh();
chatController.getLst_chatMemberList().addListener(chatMemberListListener);
selectedChatMemberListener = newValue -> requestImmediateRefresh();
chatController.getScoreService().selectedChatMember()
.addListener(selectedChatMemberListener);
antennaDirectionListener = newValue -> scheduleRefresh();
chatController.getChatPreferences().getActualQTF().addListener(antennaDirectionListener);
filterPredicateListener = predicates -> requestImmediateRefresh();
chatController.getLst_chatMemberListFilterPredicates().addListener(filterPredicateListener);
requestImmediateRefresh();
}
/**
* Removes everything {@link #install()} registered and stops the coalescing timer.
*
* <p>Needed when the runtime owning this bridge is discarded, for example during an
* operator profile switch. A running {@link PauseTransition} would otherwise keep
* firing into a dead user interface.</p>
*/
public void uninstall() {
refreshCoalescer.stop();
stationMapView.setOnCallsignRawSelected(null);
stationMapView.setOnTriggerClusterSpot(null);
stationMapView.setOnResetView(null);
if (chatMemberListListener != null) {
chatController.getLst_chatMemberList().removeListener(chatMemberListListener);
chatMemberListListener = null;
}
if (selectedChatMemberListener != null) {
chatController.getScoreService().selectedChatMember()
.removeListener(selectedChatMemberListener);
selectedChatMemberListener = null;
}
if (antennaDirectionListener != null) {
chatController.getChatPreferences().getActualQTF().removeListener(antennaDirectionListener);
antennaDirectionListener = null;
}
if (filterPredicateListener != null) {
chatController.getLst_chatMemberListFilterPredicates().removeListener(filterPredicateListener);
filterPredicateListener = null;
}
}
private void handleMapReset() {
Runnable resetAction = () -> {
/*
* Ignore an analysis result that may still arrive for the
* previously selected station.
*/
pathAnalysisGeneration.incrementAndGet();
lastPathAnalysisRequestSignature = "";
/*
* Clear the application's central station selection.
*/
chatController.getScoreService().setSelectedChatMember(null);
/*
* Keep the main station table synchronized with the central selection.
*/
chatMemberTable.getSelectionModel().clearSelection();
requestImmediateRefresh();
};
if (Platform.isFxApplicationThread()) {
resetAction.run();
} else {
Platform.runLater(resetAction);
}
}
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);
}
}
/**
* Forces the currently selected map path to be requested again.
*
* <p>This is used by the explicit "Calc selected" action after the operator
* changed the reachability band. Merely changing the ComboBox still does not
* trigger terrain analysis.</p>
*/
public void requestSelectedPathAnalysisRefresh() {
lastPathAnalysisRequestSignature = "";
requestImmediateRefresh();
}
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.visibleChatMembers());
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);
Band requestedBandOverride = reachabilityBandOverrideSupplier.get();
chatController.getReachabilityService().requestPathAnalysisForMap(
selectedMember,
selectedSnapshot,
requestedBandOverride,
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);
/*
* A map click is an explicit operator action. Clear the signature so a
* newly selected reachability band is honored even when the same station
* is clicked again.
*/
lastPathAnalysisRequestSignature = "";
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;
}
return chatController.getLst_chatMemberList().snapshot().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 Double.NaN;
}
ChatMember selectedMember = resolveBestChatMember(selectedSnapshot.callSignRaw());
var resolution = chatController.getReachabilityService()
.resolveAutomaticPropagationFrequency(selectedMember);
return resolution == null
? Double.NaN
: resolution.getAnalysisFrequencyMHz();
}
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)
+ "|"
+ selectedSnapshot.bandSummary()
+ "|"
+ 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
@@ -0,0 +1,222 @@
.root {
/* Same base font size on all platforms: macOS/Linux default to 13px, Windows to 12px. */
-fx-font-size: 12px;
}
.button:pressed {
-fx-border-color: #ff0000;
}
.button:hover {
-fx-border-color: #ff7777;
}
.btn-showstate-enabled-default {
/*-fx-background-color:linear-gradient(#f0ff35, #b8ee36),*/
/*radial-gradient(center 50% -40%, radius 200%, #b8ee36 45%, #80c800 50%);*/
-fx-background-radius: 6, 5;
-fx-background-insets: 0, 1;
-fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 );
-fx-text-fill: black;
}
.btn-showstate-enabled-default:hover {
/*-fx-background-color:linear-gradient(#f0ff35, #b8ee36),*/
/*radial-gradient(center 50% -40%, radius 200%, #b8ee36 45%, #80c800 50%);*/
-fx-background-radius: 6, 5;
-fx-background-insets: 0, 1;
-fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 );
-fx-text-fill: black;
}
.btn-showstate-enabled-furtherInfo {
/*-fx-background-color:linear-gradient(#f0ff35, #b8ee36),*/
/* radial-gradient(center 50% -40%, radius 200%, #b8ee36 45%, #80c800 50%);*/
-fx-background-radius: 6, 5;
-fx-background-insets: 0, 1;
-fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 );
-fx-text-fill: green;
}
.btn-showstate-enabled-furtherInfo:hover {
/*-fx-background-color:linear-gradient(#f0ff35, #b8ee36),*/
/* radial-gradient(center 50% -40%, radius 200%, #b8ee36 45%, #80c800 50%);*/
-fx-background-radius: 6, 5;
-fx-background-insets: 0, 1;
-fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 );
-fx-text-fill: green;
}
.btn-showstate-disabled {
-fx-background-color:linear-gradient(#f0ff35, #111111),
radial-gradient(center 50% -40%, radius 200%, #b8ee36 45%, #80c800 50%);
-fx-background-radius: 6, 5;
-fx-background-insets: 0, 1;
-fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 );
-fx-text-fill: red;
}
.toggle-button:selected {
-fx-background-color:linear-gradient(#f0ff35, #a9ff00),
radial-gradient(center 50% -40%, radius 200%, #b8ee36 45%, #80c800 50%);
-fx-background-radius: 6, 5;
-fx-background-insets: 0, 1;
-fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 );
-fx-text-fill: #395306;
}
.text-field {
-fx-prompt-text-fill: black;
}
.text-field .text {
-fx-fill: linear-gradient(from 0% 0% to 100% 200%, green 0%, lightgreen 100%);
-fx-stroke: green;
-fx-stroke-width: 0.2;
-fx-font-size: 25px;
}
.text-input-MYQRG1 {
-fx-text-fill: linear-gradient(from 0% 0% to 100% 200%, orange 0%, red 100%);
-fx-font-weight: 300;
-fx-padding: 1,1,1,1;
}
.button{
-fx-focus-traversable: false;
}
.button:hover{
-fx-text-fill: linear-gradient(from 0% 0% to 100% 200%, blue 0%, red 100%);
}
.button:hover {
-fx-background-color:linear-gradient(#f0ff35, #a9ff00),
radial-gradient(center 50% -40%, radius 200%, lightblue 45%, orange 50%);
-fx-background-radius: 6, 5;
-fx-background-insets: 0, 1;
-fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 );
-fx-text-fill: #395306;
}
.buttonMyQrg1 {
-fx-background-color: linear-gradient(from 0% 0% to 100% 200%, #00ffff 0%, #ff99ff 100%);
-fx-background-radius: 6, 5;
-fx-background-insets: 0, 1;
-fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 );
-fx-text-fill: #395306;
}
.toggle-button:selected {
-fx-background-color: linear-gradient(from 0% 0% to 100% 200%, #00ffff 0%, #ff99ff 100%);
-fx-background-radius: 6, 5;
-fx-background-insets: 0, 1;
-fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 );
-fx-text-fill: #395306;
}
.table-view .column-header .text {
-fx-fill: linear-gradient(from 0% 0% to 100% 200%, repeat, black 0%, red 50%);
-fx-stroke: black;
-fx-stroke-width: 0.3;
}
.table-view .column-header .label{
-fx-alignment: CENTER_LEFT;
-fx-font-weight: none;
}
.table-row-cell > .defaultText-column {
-fx-text-fill: black;
-fx-background-insets: 0 0 1 0px;
-fx-selection-bar-non-focused: derive(-fx-base, 50%);
}
.table-row-cell > .messageToMe-column {
-fx-text-fill: green;
-fx-background-insets: 0 0 1 0px;
-fx-selection-bar-non-focused: derive(-fx-base, 50%);
}
.messageHighlightOwn-column { /*PM own message*/
-fx-background-color: #00ffff;
-fx-background-insets: 0 0 1 0px;
-fx-text-fill: black;
-fx-selection-bar-non-focused: derive(-fx-base, 50%);
}
.messageHighlight30-column { /*PM for 30 sec: works*/
-fx-text-fill: black;
-fx-background-color: #33cc33;
-fx-selection-bar-non-focused: derive(-fx-base, 50%);
}
.messageHighlight60-column { /*PM for 30 sec: works*/
-fx-text-fill: black;
-fx-background-color: #40bf40;
-fx-selection-bar-non-focused: derive(-fx-base, 50%);
}
.messageHighlight90-column { /*PM for 30 sec: works*/
-fx-text-fill: black;
-fx-background-color: #4db34d;
-fx-selection-bar-non-focused: derive(-fx-base, 50%);
}
.messageHighlight120-column { /*PM for 30 sec: works*/
-fx-text-fill: black;
-fx-background-color: #59a659;
-fx-selection-bar-non-focused: derive(-fx-base, 50%);
}
.messageHighlight180-column { /*PM for 30 sec: works*/
-fx-text-fill: black;
-fx-background-color: #669966;
-fx-selection-bar-non-focused: derive(-fx-base, 50%);
}
.messageHighlight300-column { /*PM for 30 sec: works*/
-fx-text-fill: black;
-fx-background-color: #738c73;
-fx-selection-bar-non-focused: derive(-fx-base, 50%);
}
.table-cell-bold {
-fx-font-weight: bold;
}
.table-cell-inAngleAndRange {
-fx-text-fill: green;
-fx-font-weight: bold;
}
.table-cell-100PercentAP { /*GEHT*/
-fx-text-fill: linear-gradient(from 0% 0% to 100% 200%, #f98aff 0%, #f98aff 100%); /*purple*/;
-fx-font-weight: bold;
}
.table-cell-75PercentAP { /*GEHT*/
-fx-text-fill: #fa6666;
-fx-font-weight: bold;
}
.table-cell-50PercentAP {
-fx-text-fill: #fa9f66;
-fx-font-weight: bold;
}
/*
* Clickable web links inside message-table cells.
* Text fragments keep the current table-cell color; links remain visible.
*/
.message-table-link {
-fx-padding: 0;
-fx-border-color: transparent;
-fx-underline: true;
-fx-text-fill: #005eb8;
}
.message-table-link:visited {
-fx-text-fill: #6b3fa0;
}
@@ -0,0 +1,260 @@
.root {
/* Same base font size on all platforms: macOS/Linux default to 13px, Windows to 12px. */
-fx-font-size: 12px;
-fx-accent: #1e74c6;
-fx-focus-color: -fx-accent;
-fx-base: #373e43;
-fx-control-inner-background: derive(-fx-base, 35%);
-fx-control-inner-background-alt: -fx-control-inner-background ;
}
.label{
-fx-text-fill: lightgray;
}
.label-callSignChatCatDescriptor {
-fx-font-family: "Arial";
-fx-font-size: 18px;
-fx-text-fill: linear-gradient(from 0% 0% to 100% 200%, green 0%, lightgreen 100%);
-fx-alignment: center;
}
.text-field {
-fx-prompt-text-fill: gray;
}
.text-field .text {
-fx-fill: linear-gradient(from 0% 0% to 100% 200%, green 0%, lightgreen 100%);
-fx-stroke: green;
-fx-stroke-width: 0.2;
-fx-font-size: 25px;
}
.text-input-MYQRG1 {
-fx-text-fill: linear-gradient(from 0% 0% to 100% 200%, #f98aff 0%, #f98aff 100%); /*purple*/
-fx-font-weight: 300;
-fx-padding: 1,1,1,1;
}
.titulo{
-fx-font-weight: bold;
-fx-font-size: 18px;
}
.button{
-fx-focus-traversable: false;
}
.button:hover{
-fx-text-fill: white;
-fx-border-color: #ff7777;
}
.separator *.line {
-fx-background-color: #3C3C3C;
-fx-border-style: solid;
-fx-border-width: 1px;
}
.scroll-bar{
-fx-background-color: derive(-fx-base,45%)
}
.button:default {
-fx-base: -fx-accent ;
-fx-font-weight: bold;
}
.button:pressed {
-fx-border-color: #ff0000;
}
.button:hover {
-fx-background-color:linear-gradient(#f0ff35, #a9ff00),
radial-gradient(center 50% -40%, radius 200%, #b8ee36 45%, #80c800 50%);
-fx-background-radius: 6, 5;
-fx-background-insets: 0, 1;
-fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 );
-fx-text-fill: #395306;
}
.buttonMyQrg1 {
-fx-background-color: linear-gradient(from 0% 0% to 100% 200%, green 0%, lightgreen 100%);
-fx-background-radius: 6, 5;
-fx-background-insets: 0, 1;
-fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 );
-fx-text-fill: #395306;
}
.btn-showstate-enabled-default {
-fx-base: #373e43;
-fx-text-fill: lightgray;
-fx-background-radius: 6, 5;
-fx-background-insets: 0, 1;
-fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 );
}
.btn-showstate-enabled-default:hover {
-fx-base: #373e43;
-fx-text-fill: black;
-fx-background-radius: 6, 5;
-fx-background-insets: 0, 1;
-fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 );
}
.btn-showstate-enabled-furtherInfo {
-fx-base: #373e43;
-fx-text-fill: linear-gradient(from 0% 0% to 100% 200%, green 0%, lightgreen 100%);
-fx-background-radius: 6, 5;
-fx-background-insets: 0, 1;
-fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 );
}
.btn-showstate-enabled-furtherInfo:hover {
-fx-base: #373e43;
-fx-text-fill: linear-gradient(from 0% 0% to 100% 200%, green 0%, lightgreen 100%);
-fx-background-radius: 6, 5;
-fx-background-insets: 0, 1;
-fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 );
}
.btn-showstate-disabled {
-fx-base: #373e43 ;
-fx-font-weight: bold;
-fx-text-fill: red;
-fx-background-radius: 6, 5;
-fx-background-insets: 0, 1;
-fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 );
}
.toggle-button:selected {
-fx-background-color:linear-gradient(#f0ff35, #a9ff00),
radial-gradient(center 50% -40%, radius 200%, #b8ee36 45%, #80c800 50%);
-fx-background-radius: 6, 5;
-fx-background-insets: 0, 1;
-fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 );
-fx-text-fill: #395306;
}
.table-view .column-header {
-fx-background-color: linear-gradient(to right, #373838, #373838);
}
.table-view .column-header .text {
-fx-fill: linear-gradient(from 0% 0% to 100% 200%, repeat, green 0%, lightgreen 50%);
-fx-stroke: green;
-fx-stroke-width: 0.2;
}
.table-view{
/*-fx-background-color: derive(-fx-base, 10%);*/
-fx-selection-bar-non-focused: derive(-fx-base, 50%);
}
.table-view .column-header .label{
-fx-alignment: CENTER_LEFT;
-fx-font-weight: none;
}
.table-row-cell > .defaultText-column {
-fx-text-fill: white;
-fx-background-insets: 0 0 1 0px;
-fx-selection-bar-non-focused: derive(-fx-base, 50%);
}
.table-row-cell > .messageToMe-column {
-fx-text-fill: lightgreen;
-fx-background-insets: 0 0 1 0px;
-fx-selection-bar-non-focused: derive(-fx-base, 50%);
}
.messageHighlightOwn-column { /*PM own message*/
-fx-background-color: #4674b9;
-fx-background-insets: 0 0 1 0px;
-fx-text-fill: white;
-fx-selection-bar-non-focused: derive(-fx-base, 50%);
}
.messageHighlight30-column { /*PM for 30 sec: works*/
-fx-text-fill: white;
-fx-background-color: #33cc33;
-fx-selection-bar-non-focused: derive(-fx-base, 50%);
}
.messageHighlight60-column { /*PM for 30 sec: works*/
-fx-text-fill: white;
-fx-background-color: #40bf40;
-fx-selection-bar-non-focused: derive(-fx-base, 50%);
}
.messageHighlight90-column { /*PM for 30 sec: works*/
-fx-text-fill: white;
-fx-background-color: #4db34d;
-fx-selection-bar-non-focused: derive(-fx-base, 50%);
}
.messageHighlight120-column { /*PM for 30 sec: works*/
-fx-text-fill: white;
-fx-background-color: #59a659;
-fx-selection-bar-non-focused: derive(-fx-base, 50%);
}
.messageHighlight180-column { /*PM for 30 sec: works*/
-fx-text-fill: white;
-fx-background-color: #669966;
-fx-selection-bar-non-focused: derive(-fx-base, 50%);
}
.messageHighlight300-column { /*PM for 30 sec: works*/
-fx-text-fill: white;
-fx-background-color: #738c73;
-fx-selection-bar-non-focused: derive(-fx-base, 50%);
}
.table-cell-bold {
-fx-font-weight: bold;
}
.table-cell-inAngleAndRange {
-fx-text-fill: lightgreen;
-fx-font-weight: bold;
}
.table-cell-100PercentAP { /*GEHT*/
-fx-text-fill: linear-gradient(from 0% 0% to 100% 200%, #f98aff 0%, #f98aff 100%); /*purple*/;
-fx-font-weight: bold;
}
.table-cell-75PercentAP { /*GEHT*/
-fx-text-fill: #fa6666;
-fx-font-weight: bold;
}
.table-cell-50PercentAP {
-fx-text-fill: #fa9f66;
-fx-font-weight: bold;
}
/*
* Clickable web links inside message-table cells.
* Brighter colors preserve contrast on the dark table background.
*/
.message-table-link {
-fx-padding: 0;
-fx-border-color: transparent;
-fx-underline: true;
-fx-text-fill: #73b7ff;
}
.message-table-link:visited {
-fx-text-fill: #c59cff;
}
@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
@@ -0,0 +1,6 @@
/* @preserve
* Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com
* (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n<o;n++)for(e in i=arguments[n])t[e]=i[e];return t}var R=Object.create||function(t){return N.prototype=t,new N};function N(){}function a(t,e){var i,n=Array.prototype.slice;return t.bind?t.bind.apply(t,n.call(arguments,1)):(i=n.call(arguments,2),function(){return t.apply(e,i.length?i.concat(n.call(arguments)):arguments)})}var D=0;function h(t){return"_leaflet_id"in t||(t._leaflet_id=++D),t._leaflet_id}function j(t,e,i){var n,o,s=function(){n=!1,o&&(r.apply(i,o),o=!1)},r=function(){n?o=arguments:(t.apply(i,arguments),setTimeout(s,e),n=!0)};return r}function H(t,e,i){var n=e[1],e=e[0],o=n-e;return t===n&&i?t:((t-e)%o+o)%o+e}function u(){return!1}function i(t,e){return!1===e?t:(e=Math.pow(10,void 0===e?6:e),Math.round(t*e)/e)}function W(t){return t.trim?t.trim():t.replace(/^\s+|\s+$/g,"")}function F(t){return W(t).split(/\s+/)}function c(t,e){for(var i in Object.prototype.hasOwnProperty.call(t,"options")||(t.options=t.options?R(t.options):{}),e)t.options[i]=e[i];return t.options}function U(t,e,i){var n,o=[];for(n in t)o.push(encodeURIComponent(i?n.toUpperCase():n)+"="+encodeURIComponent(t[n]));return(e&&-1!==e.indexOf("?")?"&":"?")+o.join("&")}var V=/\{ *([\w_ -]+) *\}/g;function q(t,i){return t.replace(V,function(t,e){e=i[e];if(void 0===e)throw new Error("No value provided for variable "+t);return e="function"==typeof e?e(i):e})}var d=Array.isArray||function(t){return"[object Array]"===Object.prototype.toString.call(t)};function G(t,e){for(var i=0;i<t.length;i++)if(t[i]===e)return i;return-1}var K="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";function Y(t){return window["webkit"+t]||window["moz"+t]||window["ms"+t]}var X=0;function J(t){var e=+new Date,i=Math.max(0,16-(e-X));return X=e+i,window.setTimeout(t,i)}var $=window.requestAnimationFrame||Y("RequestAnimationFrame")||J,Q=window.cancelAnimationFrame||Y("CancelAnimationFrame")||Y("CancelRequestAnimationFrame")||function(t){window.clearTimeout(t)};function x(t,e,i){if(!i||$!==J)return $.call(window,a(t,e));t.call(e)}function r(t){t&&Q.call(window,t)}var tt={__proto__:null,extend:l,create:R,bind:a,get lastId(){return D},stamp:h,throttle:j,wrapNum:H,falseFn:u,formatNum:i,trim:W,splitWords:F,setOptions:c,getParamString:U,template:q,isArray:d,indexOf:G,emptyImageUrl:K,requestFn:$,cancelFn:Q,requestAnimFrame:x,cancelAnimFrame:r};function et(){}et.extend=function(t){function e(){c(this),this.initialize&&this.initialize.apply(this,arguments),this.callInitHooks()}var i,n=e.__super__=this.prototype,o=R(n);for(i in(o.constructor=e).prototype=o,this)Object.prototype.hasOwnProperty.call(this,i)&&"prototype"!==i&&"__super__"!==i&&(e[i]=this[i]);if(t.statics&&l(e,t.statics),t.includes){var s=t.includes;if("undefined"!=typeof L&&L&&L.Mixin){s=d(s)?s:[s];for(var r=0;r<s.length;r++)s[r]===L.Mixin.Events&&console.warn("Deprecated include of L.Mixin.Events: this property will be removed in future releases, please inherit from L.Evented instead.",(new Error).stack)}l.apply(null,[o].concat(t.includes))}return l(o,t),delete o.statics,delete o.includes,o.options&&(o.options=n.options?R(n.options):{},l(o.options,t.options)),o._initHooks=[],o.callInitHooks=function(){if(!this._initHooksCalled){n.callInitHooks&&n.callInitHooks.call(this),this._initHooksCalled=!0;for(var t=0,e=o._initHooks.length;t<e;t++)o._initHooks[t].call(this)}},e},et.include=function(t){var e=this.prototype.options;return l(this.prototype,t),t.options&&(this.prototype.options=e,this.mergeOptions(t.options)),this},et.mergeOptions=function(t){return l(this.prototype.options,t),this},et.addInitHook=function(t){var e=Array.prototype.slice.call(arguments,1),i="function"==typeof t?t:function(){this[t].apply(this,e)};return this.prototype._initHooks=this.prototype._initHooks||[],this.prototype._initHooks.push(i),this};var e={on:function(t,e,i){if("object"==typeof t)for(var n in t)this._on(n,t[n],e);else for(var o=0,s=(t=F(t)).length;o<s;o++)this._on(t[o],e,i);return this},off:function(t,e,i){if(arguments.length)if("object"==typeof t)for(var n in t)this._off(n,t[n],e);else{t=F(t);for(var o=1===arguments.length,s=0,r=t.length;s<r;s++)o?this._off(t[s]):this._off(t[s],e,i)}else delete this._events;return this},_on:function(t,e,i,n){"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1===this._listens(t,e,i)&&(e={fn:e,ctx:i=i===this?void 0:i},n&&(e.once=!0),this._events=this._events||{},this._events[t]=this._events[t]||[],this._events[t].push(e))},_off:function(t,e,i){var n,o,s;if(this._events&&(n=this._events[t]))if(1===arguments.length){if(this._firingCount)for(o=0,s=n.length;o<s;o++)n[o].fn=u;delete this._events[t]}else"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1!==(e=this._listens(Line truncated
//# sourceMappingURL=leaflet.js.map
@@ -0,0 +1,663 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
@@ -0,0 +1,6 @@
/* @preserve
* Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com
* (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n<o;n++)for(e in i=arguments[n])t[e]=i[e];return t}var R=Object.create||function(t){return N.prototype=t,new N};function N(){}function a(t,e){var i,n=Array.prototype.slice;return t.bind?t.bind.apply(t,n.call(arguments,1)):(i=n.call(arguments,2),function(){return t.apply(e,i.length?i.concat(n.call(arguments)):arguments)})}var D=0;function h(t){return"_leaflet_id"in t||(t._leaflet_id=++D),t._leaflet_id}function j(t,e,i){var n,o,s=function(){n=!1,o&&(r.apply(i,o),o=!1)},r=function(){n?o=arguments:(t.apply(i,arguments),setTimeout(s,e),n=!0)};return r}function H(t,e,i){var n=e[1],e=e[0],o=n-e;return t===n&&i?t:((t-e)%o+o)%o+e}function u(){return!1}function i(t,e){return!1===e?t:(e=Math.pow(10,void 0===e?6:e),Math.round(t*e)/e)}function W(t){return t.trim?t.trim():t.replace(/^\s+|\s+$/g,"")}function F(t){return W(t).split(/\s+/)}function c(t,e){for(var i in Object.prototype.hasOwnProperty.call(t,"options")||(t.options=t.options?R(t.options):{}),e)t.options[i]=e[i];return t.options}function U(t,e,i){var n,o=[];for(n in t)o.push(encodeURIComponent(i?n.toUpperCase():n)+"="+encodeURIComponent(t[n]));return(e&&-1!==e.indexOf("?")?"&":"?")+o.join("&")}var V=/\{ *([\w_ -]+) *\}/g;function q(t,i){return t.replace(V,function(t,e){e=i[e];if(void 0===e)throw new Error("No value provided for variable "+t);return e="function"==typeof e?e(i):e})}var d=Array.isArray||function(t){return"[object Array]"===Object.prototype.toString.call(t)};function G(t,e){for(var i=0;i<t.length;i++)if(t[i]===e)return i;return-1}var K="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";function Y(t){return window["webkit"+t]||window["moz"+t]||window["ms"+t]}var X=0;function J(t){var e=+new Date,i=Math.max(0,16-(e-X));return X=e+i,window.setTimeout(t,i)}var $=window.requestAnimationFrame||Y("RequestAnimationFrame")||J,Q=window.cancelAnimationFrame||Y("CancelAnimationFrame")||Y("CancelRequestAnimationFrame")||function(t){window.clearTimeout(t)};function x(t,e,i){if(!i||$!==J)return $.call(window,a(t,e));t.call(e)}function r(t){t&&Q.call(window,t)}var tt={__proto__:null,extend:l,create:R,bind:a,get lastId(){return D},stamp:h,throttle:j,wrapNum:H,falseFn:u,formatNum:i,trim:W,splitWords:F,setOptions:c,getParamString:U,template:q,isArray:d,indexOf:G,emptyImageUrl:K,requestFn:$,cancelFn:Q,requestAnimFrame:x,cancelAnimFrame:r};function et(){}et.extend=function(t){function e(){c(this),this.initialize&&this.initialize.apply(this,arguments),this.callInitHooks()}var i,n=e.__super__=this.prototype,o=R(n);for(i in(o.constructor=e).prototype=o,this)Object.prototype.hasOwnProperty.call(this,i)&&"prototype"!==i&&"__super__"!==i&&(e[i]=this[i]);if(t.statics&&l(e,t.statics),t.includes){var s=t.includes;if("undefined"!=typeof L&&L&&L.Mixin){s=d(s)?s:[s];for(var r=0;r<s.length;r++)s[r]===L.Mixin.Events&&console.warn("Deprecated include of L.Mixin.Events: this property will be removed in future releases, please inherit from L.Evented instead.",(new Error).stack)}l.apply(null,[o].concat(t.includes))}return l(o,t),delete o.statics,delete o.includes,o.options&&(o.options=n.options?R(n.options):{},l(o.options,t.options)),o._initHooks=[],o.callInitHooks=function(){if(!this._initHooksCalled){n.callInitHooks&&n.callInitHooks.call(this),this._initHooksCalled=!0;for(var t=0,e=o._initHooks.length;t<e;t++)o._initHooks[t].call(this)}},e},et.include=function(t){var e=this.prototype.options;return l(this.prototype,t),t.options&&(this.prototype.options=e,this.mergeOptions(t.options)),this},et.mergeOptions=function(t){return l(this.prototype.options,t),this},et.addInitHook=function(t){var e=Array.prototype.slice.call(arguments,1),i="function"==typeof t?t:function(){this[t].apply(this,e)};return this.prototype._initHooks=this.prototype._initHooks||[],this.prototype._initHooks.push(i),this};var e={on:function(t,e,i){if("object"==typeof t)for(var n in t)this._on(n,t[n],e);else for(var o=0,s=(t=F(t)).length;o<s;o++)this._on(t[o],e,i);return this},off:function(t,e,i){if(arguments.length)if("object"==typeof t)for(var n in t)this._off(n,t[n],e);else{t=F(t);for(var o=1===arguments.length,s=0,r=t.length;s<r;s++)o?this._off(t[s]):this._off(t[s],e,i)}else delete this._events;return this},_on:function(t,e,i,n){"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1===this._listens(t,e,i)&&(e={fn:e,ctx:i=i===this?void 0:i},n&&(e.once=!0),this._events=this._events||{},this._events[t]=this._events[t]||[],this._events[t].push(e))},_off:function(t,e,i){var n,o,s;if(this._events&&(n=this._events[t]))if(1===arguments.length){if(this._firingCount)for(o=0,s=n.length;o<s;o++)n[o].fn=u;delete this._events[t]}else"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1!==(e=this._listens(Line truncated
//# sourceMappingURL=leaflet.js.map
@@ -0,0 +1,110 @@
package kst4contest.test;
import kst4contest.view.map.MapHtmlResources;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MapHtmlResourcesContractTest {
@Test
void moveEndUpdatesViewportWithoutRebuildingStationMarkers() {
String html = MapHtmlResources.createStationMapHtml(12345);
String handler = eventHandler(html, "moveend");
assertTrue(handler.contains("notifyViewport();"));
assertFalse(handler.contains("renderStationMarkers();"));
}
@Test
void zoomEndStillRebuildsStationMarkersBeforeUpdatingViewport() {
String html = MapHtmlResources.createStationMapHtml(12345);
String handler = eventHandler(html, "zoomend");
int markerRender = handler.indexOf("renderStationMarkers();");
int viewportNotification = handler.indexOf("notifyViewport();");
assertTrue(markerRender >= 0);
assertTrue(viewportNotification > markerRender);
}
@Test
void stationDataChangesStillRebuildStationMarkers() {
String html = MapHtmlResources.createStationMapHtml(12345);
int setterStart = html.indexOf("function setStations(stationsJson)");
int nextFunction = html.indexOf("function setBeam(beamJson)", setterStart);
String setterBody = html.substring(setterStart, nextFunction);
assertTrue(setterBody.contains("stationData = JSON.parse(stationsJson);"));
assertTrue(setterBody.contains("renderStationMarkers();"));
}
@Test
void viewportNotificationForwardsBoundsAndZoomToJava() {
String html = MapHtmlResources.createStationMapHtml(12345);
String functionBody = functionBody(html, "function notifyViewport()", "function gridLineColor()");
assertTrue(functionBody.contains("const bounds = map.getBounds();"));
assertTrue(functionBody.contains("const zoom = map.getZoom();"));
assertTrue(functionBody.contains("window.javaMapBridge.onViewportChanged("));
assertTrue(functionBody.contains("bounds.getSouth(),"));
assertTrue(functionBody.contains("bounds.getWest(),"));
assertTrue(functionBody.contains("bounds.getNorth(),"));
assertTrue(functionBody.contains("bounds.getEast(),"));
}
@Test
void stationHitTestingKeepsRawCallsignSelectionContract() {
String html = MapHtmlResources.createStationMapHtml(12345);
String functionBody = functionBody(html, "function inspectPoint(x, y)", "function setHome(lat, lon, zoom)");
assertTrue(functionBody.contains("el.closest('.station-marker-root')"));
assertTrue(functionBody.contains("stationRoot.getAttribute('data-callsignraw')"));
assertTrue(functionBody.contains("return 'station|' + callSignRaw"));
}
@Test
void stationClusteringCanBeToggledWithoutReplacingStationData() {
String html = MapHtmlResources.createStationMapHtml(12345);
assertTrue(html.contains("let stationClusteringEnabled = true;"));
assertTrue(html.contains("if (!stationClusteringEnabled"));
assertTrue(html.contains("|| Number(map.getZoom()) >= KST_CLUSTER_DISABLE_ZOOM)"));
assertTrue(html.contains("function setStationClusteringEnabled(enabled)"));
int setterStart = html.indexOf("function setStationClusteringEnabled(enabled)");
int setterEnd = html.indexOf('}', setterStart);
String setterBody = html.substring(setterStart, setterEnd);
int stateUpdate = setterBody.indexOf("stationClusteringEnabled = Boolean(enabled);");
int markerRender = setterBody.indexOf("renderStationMarkers();");
assertTrue(stateUpdate >= 0);
assertTrue(markerRender > stateUpdate);
assertFalse(setterBody.contains("stationData ="));
assertTrue(html.contains("setStationClusteringEnabled: setStationClusteringEnabled"));
}
private static String eventHandler(String html, String eventName) {
String signature = "map.on('" + eventName + "', function () {";
int handlerStart = html.indexOf(signature);
int handlerEnd = html.indexOf("});", handlerStart);
assertTrue(handlerStart >= 0);
assertTrue(handlerEnd > handlerStart);
return html.substring(handlerStart, handlerEnd);
}
private static String functionBody(String html, String signature, String nextSignature) {
int functionStart = html.indexOf(signature);
int functionEnd = html.indexOf(nextSignature, functionStart);
assertTrue(functionStart >= 0);
assertTrue(functionEnd > functionStart);
return html.substring(functionStart, functionEnd);
}
}
@@ -0,0 +1,61 @@
package kst4contest.test;
import kst4contest.view.PrivateMessageRowStyleResolver;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class PrivateMessageRowStyleResolverTest {
@ParameterizedTest
@CsvSource({
"0, messageHighlight30-column",
"30, messageHighlight30-column",
"31, messageHighlight60-column",
"60, messageHighlight60-column",
"61, messageHighlight90-column",
"90, messageHighlight90-column",
"91, messageHighlight120-column",
"120, messageHighlight120-column",
"121, messageHighlight180-column",
"180, messageHighlight180-column",
"181, messageHighlight300-column",
"300, messageHighlight300-column"
})
void selectsAgeStyleClassAtEveryBoundary(
long ageSeconds,
String expectedStyleClass
) {
assertEquals(
expectedStyleClass,
PrivateMessageRowStyleResolver.resolveStyleClass(
false,
ageSeconds
)
);
}
@Test
void returnsNoAgeStyleClassAfterFiveMinutes() {
assertNull(
PrivateMessageRowStyleResolver.resolveStyleClass(
false,
301
)
);
}
@Test
void keepsOwnMessageStyleAfterFiveMinutes() {
assertEquals(
PrivateMessageRowStyleResolver.OWN_STYLE_CLASS,
PrivateMessageRowStyleResolver.resolveStyleClass(
true,
301
)
);
}
}
@@ -0,0 +1,46 @@
package kst4contest.view;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class CommandLineOptionsTest {
@Test
void profileArgumentIsAcceptedInBothSpellings() {
assertEquals("OP2", CommandLineOptions.parse(List.of("--profile=OP2")).getRequestedProfileName());
assertEquals("OP2", CommandLineOptions.parse(List.of("--profile", "OP2")).getRequestedProfileName());
assertEquals("DM5M Contest",
CommandLineOptions.parse(List.of("--profile", "DM5M Contest")).getRequestedProfileName());
}
@Test
void missingOrEmptyProfileArgumentsAreTreatedAsAbsent() {
assertNull(CommandLineOptions.parse(List.of()).getRequestedProfileName());
assertNull(CommandLineOptions.parse(null).getRequestedProfileName());
assertNull(CommandLineOptions.parse(List.of("--profile=")).getRequestedProfileName());
assertNull(CommandLineOptions.parse(List.of("--profile")).getRequestedProfileName());
}
@Test
void unrelatedArgumentsAreIgnoredInsteadOfFailing() {
assertEquals("OP2",
CommandLineOptions.parse(List.of("--verbose", "--profile=OP2", "somefile.adi"))
.getRequestedProfileName());
assertNull(CommandLineOptions.parse(List.of("--verbose", "-x")).getRequestedProfileName());
}
@Test
void rememberedOptionsDefaultToEmptyInsteadOfNull() {
CommandLineOptions.remember(null);
assertNull(CommandLineOptions.remembered().getRequestedProfileName());
CommandLineOptions.remember(new CommandLineOptions("OP2"));
assertEquals("OP2", CommandLineOptions.remembered().getRequestedProfileName());
CommandLineOptions.remember(null);
}
}
@@ -0,0 +1,137 @@
package kst4contest.view;
import javafx.collections.ObservableList;
import kst4contest.observe.DirectUiDispatcher;
import kst4contest.observe.SimpleRoster;
import org.junit.jupiter.api.Test;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FxRosterBindingTest {
@Test
void theMirrorStartsWithTheCurrentContent() {
SimpleRoster<String> roster = new SimpleRoster<>();
roster.add("DN9APW");
FxRosterBinding<String> binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher());
assertEquals(List.of("DN9APW"), List.copyOf(binding.list()));
}
@Test
void theMirrorFollowsLaterChanges() {
SimpleRoster<String> roster = new SimpleRoster<>();
FxRosterBinding<String> binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher());
roster.add("DN9APW");
roster.add("DO5AMF");
assertEquals(List.of("DN9APW", "DO5AMF"), List.copyOf(binding.list()));
}
@Test
void theMirrorIsTheSameInstanceThroughout() {
SimpleRoster<String> roster = new SimpleRoster<>();
FxRosterBinding<String> binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher());
ObservableList<String> list = binding.list();
roster.add("DN9APW");
assertTrue(list == binding.list(),
"a TableView holds on to the instance it was given; it must not be replaced");
}
@Test
void disposeStopsFollowingAndReleasesTheListener() {
SimpleRoster<String> roster = new SimpleRoster<>();
FxRosterBinding<String> binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher());
binding.dispose();
roster.add("DN9APW");
assertTrue(binding.list().isEmpty(),
"a disposed binding must not keep the old runtime alive across a profile switch");
}
@Test
void anUnchangedResultProducesNoListChangeEvent() {
SimpleRoster<String> roster = new SimpleRoster<>();
roster.add("DN9APW");
FxRosterBinding<String> binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher());
AtomicInteger changes = new AtomicInteger();
binding.list().addListener((javafx.collections.ListChangeListener<String>) change -> changes.incrementAndGet());
binding.refresh();
assertEquals(0, changes.get(),
"an unchanged station list must not make the TableView drop its selection");
}
@Test
void aDerivedBindingAppliesTheDerivationAndFollowsItsSources() {
SimpleRoster<String> members = new SimpleRoster<>();
SimpleRoster<String> filters = new SimpleRoster<>();
members.add("DO5AMF");
members.add("DN9APW");
FxRosterBinding<String> binding = FxRosterBinding.derived(
new DirectUiDispatcher(),
() -> members.snapshot().stream()
.filter(call -> filters.snapshot().isEmpty() || filters.snapshot().contains(call))
.sorted(Comparator.naturalOrder())
.toList(),
members, filters);
assertEquals(List.of("DN9APW", "DO5AMF"), List.copyOf(binding.list()));
filters.add("DO5AMF");
assertEquals(List.of("DO5AMF"), List.copyOf(binding.list()),
"a change of a source roster must recompute the derived content");
members.add("DL1ABC");
assertEquals(List.of("DO5AMF"), List.copyOf(binding.list()));
}
@Test
void refreshRecomputesWithoutASourceChange() {
SimpleRoster<String> members = new SimpleRoster<>();
members.add("DN9APW");
members.add("DO5AMF");
String[] wanted = { "DN9APW" };
FxRosterBinding<String> binding = FxRosterBinding.derived(
new DirectUiDispatcher(),
() -> members.snapshot().stream().filter(call -> call.equals(wanted[0])).toList(),
members);
assertEquals(List.of("DN9APW"), List.copyOf(binding.list()));
wanted[0] = "DO5AMF";
binding.refresh();
assertEquals(List.of("DO5AMF"), List.copyOf(binding.list()),
"a newly set filter must be visible without waiting for the next roster change");
}
@Test
void aDisposedDerivedBindingDetachesFromEverySource() {
SimpleRoster<String> members = new SimpleRoster<>();
SimpleRoster<String> filters = new SimpleRoster<>();
FxRosterBinding<String> binding = FxRosterBinding.derived(
new DirectUiDispatcher(), members::snapshot, members, filters);
binding.dispose();
members.add("DN9APW");
filters.add("DN9APW");
assertTrue(binding.list().isEmpty());
}
}
@@ -0,0 +1,34 @@
package kst4contest.view;
import kst4contest.controller.On4KstConnectionState;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class MacOsConnectionStateMenuTitleTest {
@Test
void everyStateHasTheAgreedTitle() {
assertEquals("🟢 LINK: Connected",
Kst4ContestApplication.macOsConnectionStateMenuTitle(On4KstConnectionState.ONLINE));
for (On4KstConnectionState state : new On4KstConnectionState[]{
On4KstConnectionState.CONNECTING,
On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT,
On4KstConnectionState.AUTHENTICATING,
On4KstConnectionState.SYNCING_MAIN_CHAT,
On4KstConnectionState.SYNCING_SECOND_CHAT}) {
assertEquals("🟡 LINK: Connecting…", Kst4ContestApplication.macOsConnectionStateMenuTitle(state));
}
assertEquals("🟡 LINK: Disconnecting…",
Kst4ContestApplication.macOsConnectionStateMenuTitle(On4KstConnectionState.STOPPING));
assertEquals("🔴 LINK: Reconnecting…",
Kst4ContestApplication.macOsConnectionStateMenuTitle(On4KstConnectionState.RECONNECT_WAIT));
assertEquals("🔴 LINK: Disconnected",
Kst4ContestApplication.macOsConnectionStateMenuTitle(On4KstConnectionState.DISCONNECTED));
}
@Test
void missingStateIsShownAsDisconnected() {
assertEquals("🔴 LINK: Disconnected", Kst4ContestApplication.macOsConnectionStateMenuTitle(null));
}
}
@@ -0,0 +1,161 @@
package kst4contest.view;
import kst4contest.controller.OperatorProfilePaths;
import kst4contest.controller.OperatorProfileStore;
import kst4contest.model.OperatorProfile;
import kst4contest.model.OperatorProfileSelection;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OperatorProfileBootstrapTest {
@TempDir
Path temporaryDirectory;
@Test
void installationWithoutRegistryStartsSilentlyOnTheHistoricLayout() {
AtomicInteger pickerInvocations = new AtomicInteger();
OperatorProfileStore store = storeAt();
OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup(
store, new CommandLineOptions(null), countingPicker(pickerInvocations, null));
assertNotNull(resolved);
assertEquals("preferences.xml", resolved.getPreferencesRelativeFileName());
assertEquals("praktiKST.db", resolved.getWorkedDatabaseRelativeFileName());
// Nothing may be asked, and nothing may be written.
assertEquals(0, pickerInvocations.get());
assertTrue(store.loadProfiles().isEmpty());
}
@Test
void singleProfileStartsWithoutAskingAnything() {
AtomicInteger pickerInvocations = new AtomicInteger();
OperatorProfileStore store = storeAt();
store.saveProfiles(List.of(OperatorProfilePaths.buildRootProfile("Default")), "default");
OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup(
store, new CommandLineOptions(null), countingPicker(pickerInvocations, null));
assertNotNull(resolved);
assertEquals(0, pickerInvocations.get());
}
@Test
void twoProfilesAskTheOperatorAndPreselectTheLastUsedOne() {
OperatorProfileStore store = storeAt();
OperatorProfile secondProfile = new OperatorProfile("OP2", "DN9APW", false, false);
store.saveProfiles(
List.of(OperatorProfilePaths.buildRootProfile("Default"), secondProfile), "OP2");
AtomicInteger pickerInvocations = new AtomicInteger();
String[] observedPreselection = new String[1];
OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup(
store,
new CommandLineOptions(null),
(profiles, preselectedProfileId) -> {
pickerInvocations.incrementAndGet();
observedPreselection[0] = preselectedProfileId;
return Optional.of(profiles.get(1));
});
assertEquals(1, pickerInvocations.get());
assertEquals("OP2", observedPreselection[0]);
assertNotNull(resolved);
assertEquals("profiles/OP2/praktiKST.db", resolved.getWorkedDatabaseRelativeFileName());
}
@Test
void aValidProfileArgumentSkipsThePicker() {
OperatorProfileStore store = storeAt();
store.saveProfiles(
List.of(OperatorProfilePaths.buildRootProfile("Default"),
new OperatorProfile("OP2", "DN9APW", false, false)),
"default");
AtomicInteger pickerInvocations = new AtomicInteger();
OperatorProfileBootstrap bootstrap = new OperatorProfileBootstrap();
OperatorProfileSelection byId = bootstrap.resolveAtStartup(
store, new CommandLineOptions("op2"), countingPicker(pickerInvocations, null));
assertEquals(0, pickerInvocations.get());
assertEquals("profiles/OP2/preferences.xml", byId.getPreferencesRelativeFileName());
assertNull(bootstrap.getStartupWarning());
OperatorProfileSelection byDisplayName = bootstrap.resolveAtStartup(
store, new CommandLineOptions("DN9APW"), countingPicker(pickerInvocations, null));
assertEquals(0, pickerInvocations.get());
assertEquals("profiles/OP2/preferences.xml", byDisplayName.getPreferencesRelativeFileName());
}
@Test
void anUnknownProfileArgumentWarnsAndFallsBackToTheNormalSelection() {
OperatorProfileStore store = storeAt();
store.saveProfiles(
List.of(OperatorProfilePaths.buildRootProfile("Default"),
new OperatorProfile("OP2", "DN9APW", false, false)),
"default");
AtomicInteger pickerInvocations = new AtomicInteger();
OperatorProfileBootstrap bootstrap = new OperatorProfileBootstrap();
OperatorProfileSelection resolved = bootstrap.resolveAtStartup(
store, new CommandLineOptions("NOPE"), countingPicker(pickerInvocations, 0));
assertEquals(1, pickerInvocations.get());
assertNotNull(resolved);
assertNotNull(bootstrap.getStartupWarning());
assertTrue(bootstrap.getStartupWarning().contains("NOPE"));
}
@Test
void quittingInThePickerYieldsNoSelection() {
OperatorProfileStore store = storeAt();
store.saveProfiles(
List.of(OperatorProfilePaths.buildRootProfile("Default"),
new OperatorProfile("OP2", "DN9APW", false, false)),
"default");
OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup(
store, new CommandLineOptions(null), (profiles, preselected) -> Optional.empty());
assertNull(resolved);
}
private OperatorProfileChoiceRequester countingPicker(final AtomicInteger invocationCounter,
final Integer profileIndexToChoose) {
return (profiles, preselectedProfileId) -> {
invocationCounter.incrementAndGet();
if (profileIndexToChoose == null) {
return Optional.of(profiles.get(0));
}
return Optional.of(profiles.get(profileIndexToChoose));
};
}
private OperatorProfileStore storeAt() {
return new OperatorProfileStore(temporaryDirectory.resolve("profiles.xml").toString());
}
}
@@ -0,0 +1,19 @@
package kst4contest.view;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class TableLayoutManagerTest {
@Test
void contentWidthUsesCompactPadding() {
assertEquals(116.0, TableLayoutManager.calculateInitialContentWidth(100.0, 24.0, 200.0));
}
@Test
void contentWidthStillHonorsMinimumAndMaximum() {
assertEquals(24.0, TableLayoutManager.calculateInitialContentWidth(0.0, 24.0, 200.0));
assertEquals(200.0, TableLayoutManager.calculateInitialContentWidth(250.0, 24.0, 200.0));
}
}
@@ -0,0 +1,32 @@
package kst4contest.view;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TruncatedTextTableCellTest {
@Test
void plainFullTextTooltipIsShownOnlyForClippedText() {
assertNull(TruncatedTextTooltipSupport.buildTooltipText("complete", false, null));
assertEquals("complete",
TruncatedTextTooltipSupport.buildTooltipText("complete", true, null));
}
@Test
void functionalTooltipRemainsAndCombinesWithClippedValue() {
assertEquals("Worked status",
TruncatedTextTooltipSupport.buildTooltipText("X", false, "Worked status"));
assertEquals("Long value\n\nWorked status",
TruncatedTextTooltipSupport.buildTooltipText("Long value", true, "Worked status"));
}
@Test
void clippingComparisonUsesAvailableRenderedWidth() {
assertFalse(TruncatedTextTooltipSupport.isTextClipped(100, 100));
assertTrue(TruncatedTextTooltipSupport.isTextClipped(102, 100));
}
}
@@ -0,0 +1,111 @@
package kst4contest.view.map;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Guards the property that made the terrain cache useless with several operator
* profiles: it used to drop every cached profile whenever the configured callsign or
* locator changed.
*/
class TerrainProfileCacheRepositoryTest {
private static final String USER_HOME_PROPERTY = "user.home";
private static final String PROVIDER_ID = "test-provider";
private static final int SAMPLE_COUNT = 3;
@TempDir
Path temporaryHomeDirectory;
private String originalUserHome;
@BeforeEach
void redirectUserHomeToTemporaryDirectory() {
originalUserHome = System.getProperty(USER_HOME_PROPERTY);
System.setProperty(USER_HOME_PROPERTY, temporaryHomeDirectory.toString());
}
@AfterEach
void restoreUserHome() {
if (originalUserHome == null) {
System.clearProperty(USER_HOME_PROPERTY);
} else {
System.setProperty(USER_HOME_PROPERTY, originalUserHome);
}
}
@Test
void profilesOfDifferentOwnersCoexistAndSurviveSwitchingBackAndForth() {
TerrainProfileCacheRepository repository = new TerrainProfileCacheRepository();
repository.save("DM5M", "JO51IJ", "DL0ABC", "JN49FK",
SAMPLE_COUNT, PROVIDER_ID, profileData("station-a"));
// Another operator profile with a different callsign and locator.
repository.save("DN9APW", "JN59AA", "DL0ABC", "JN49FK",
SAMPLE_COUNT, PROVIDER_ID, profileData("station-b"));
Optional<TerrainProfileData> firstOwnerEntry = repository.load(
"DM5M", "JO51IJ", "DL0ABC", "JN49FK", SAMPLE_COUNT, PROVIDER_ID);
Optional<TerrainProfileData> secondOwnerEntry = repository.load(
"DN9APW", "JN59AA", "DL0ABC", "JN49FK", SAMPLE_COUNT, PROVIDER_ID);
assertTrue(firstOwnerEntry.isPresent(),
"Working under a second operator identity must not discard the first one's cache");
assertTrue(secondOwnerEntry.isPresent());
assertEquals("station-a", firstOwnerEntry.get().sourceName());
assertEquals("station-b", secondOwnerEntry.get().sourceName());
}
@Test
void anUnknownOwnerSimplyMissesTheCacheInsteadOfClearingIt() {
TerrainProfileCacheRepository repository = new TerrainProfileCacheRepository();
repository.save("DM5M", "JO51IJ", "DL0ABC", "JN49FK",
SAMPLE_COUNT, PROVIDER_ID, profileData("station-a"));
assertTrue(repository.load("DL0XYZ", "JO60AA", "DL0ABC", "JN49FK",
SAMPLE_COUNT, PROVIDER_ID).isEmpty());
assertTrue(repository.load("DM5M", "JO51IJ", "DL0ABC", "JN49FK",
SAMPLE_COUNT, PROVIDER_ID).isPresent(),
"A cache miss of one owner must not remove the entries of another");
}
@Test
void theCacheLivesInItsOwnGlobalFileAndNotInTheWorkedStationDatabase() {
TerrainProfileCacheRepository repository = new TerrainProfileCacheRepository();
repository.save("DM5M", "JO51IJ", "DL0ABC", "JN49FK",
SAMPLE_COUNT, PROVIDER_ID, profileData("station-a"));
Path applicationDirectory = temporaryHomeDirectory.resolve(".praktiKST");
assertTrue(Files.exists(applicationDirectory.resolve("terrainprofilecache.db")));
assertFalse(Files.exists(applicationDirectory.resolve("praktiKST.db")),
"The terrain cache must not pull in the worked station database");
}
private static TerrainProfileData profileData(final String sourceName) {
return new TerrainProfileData(
List.of(
new PathProfilePoint(0.0, 51.0, 10.0, 100.0),
new PathProfilePoint(10.0, 51.1, 10.1, 220.0),
new PathProfilePoint(20.0, 51.2, 10.2, 150.0)),
sourceName,
false);
}
}