From 8bd5d8877d0ca3217f2759f89bbd55c8e3c2e285 Mon Sep 17 00:00:00 2001 From: Marc Froehlich Date: Thu, 6 Aug 2026 01:52:59 +0200 Subject: [PATCH] Add persistent map analysis toggle and improve compact layout for smaller screens. Solves (#69) --- github_docs/de-Funktionen.md | 4 +- .../kst4contest/model/ChatPreferences.java | 28 +++- ...atPreferencesStationMapVisibilityTest.java | 73 +++++++++ .../kst4contest/view/map/StationMapView.java | 150 ++++++++++++++++-- 4 files changed, 241 insertions(+), 14 deletions(-) create mode 100644 src/main/java/kst4contest/test/ChatPreferencesStationMapVisibilityTest.java diff --git a/github_docs/de-Funktionen.md b/github_docs/de-Funktionen.md index 033536f..3f7ec1a 100644 --- a/github_docs/de-Funktionen.md +++ b/github_docs/de-Funktionen.md @@ -496,8 +496,10 @@ So kann der Contest-Operator auf einem Blick sehen, welche Stationen wann und ü ## Intervall-Beacon KST4Contest kann wiederkehrende CQ-Nachrichten in den öffentlichen Chat senden. Beide Chat-Kategorien verwenden ein gemeinsames Intervall, besitzen aber jeweils einen eigenen Aktivierungsschalter und Nachrichtentext. Globale Variablen wie `MYQRG`, `SECONDQRG` oder `MYLOCATOR` werden unmittelbar vor jeder Aussendung aktualisiert. + Der Beacon ist für längeres CQ-Rufen auf einer festen Frequenz gedacht. Beim Absuchen oder häufigen Wechseln der QRG sollte er ausgeschaltet werden, damit keine inzwischen falsche Frequenz verbreitet wird. Details: [Konfiguration – Beacon Settings](de-Konfiguration#beacon-settings-automatischer-beacon). + --- ## Simplelogfile @@ -510,7 +512,7 @@ Dateibasierte Log-Auswertung per Regex. Details: [Log-Synchronisation](Log-Synch Ein separates Fenster zeigt den QSO-Fluss zwischen anderen Stationen. Besonders interessant in ruhigeren Nacht-Stunden während des Contests, wenn weniger Verkehr herrscht. -Dieses Fenster kann miniaturisiert werden, wenn es nicht benötigt wird. Zukünftig geplant: Filterung auf Stationen im ausgewählten QTF. +Dieses Fenster kann minimiert werden, wenn es nicht benötigt wird. Zukünftig geplant: Filterung auf Stationen im ausgewählten QTF. --- diff --git a/src/main/java/kst4contest/model/ChatPreferences.java b/src/main/java/kst4contest/model/ChatPreferences.java index aca0a4b..c780f5a 100644 --- a/src/main/java/kst4contest/model/ChatPreferences.java +++ b/src/main/java/kst4contest/model/ChatPreferences.java @@ -50,7 +50,7 @@ public class ChatPreferences { * Reading must stay backwards compatible: missing/unknown tags should fall back to defaults. */ // private static final int CONFIG_VERSION = 2; - public static final int CONFIG_VERSION = 4; + public static final int CONFIG_VERSION = 5; // Prefer writing tag names that mirror variable names (human readable). Keep legacy tags for compatibility. private static final String TAG_CONFIG_VERSION = "configVersion"; @@ -339,6 +339,7 @@ public class ChatPreferences { private double[] GUIstationMapStageSceneSizeHW = new double[] { 1000, 800 }; private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN }; + private boolean GUIstationMapPathAnalysisVisible = true; /********************************************************************************* @@ -635,6 +636,14 @@ public class ChatPreferences { this.GUIstationMapStagePositionXY = GUIstationMapStagePositionXY; } + public boolean isGUIstationMapPathAnalysisVisible() { + return GUIstationMapPathAnalysisVisible; + } + + public void setGUIstationMapPathAnalysisVisible(boolean GUIstationMapPathAnalysisVisible) { + this.GUIstationMapPathAnalysisVisible = GUIstationMapPathAnalysisVisible; + } + public boolean isGuiOptions_defaultFilterNothing() { return guiOptions_defaultFilterNothing; } @@ -2048,6 +2057,12 @@ public class ChatPreferences { ); guiOptions.appendChild(GUIstationMapStagePositionXY); + Element GUIstationMapPathAnalysisVisible = doc.createElement("GUIstationMapPathAnalysisVisible"); + GUIstationMapPathAnalysisVisible.setTextContent( + String.valueOf(this.isGUIstationMapPathAnalysisVisible()) + ); + guiOptions.appendChild(GUIstationMapPathAnalysisVisible); + /**************************************************************************************** ****************************** now write this XML! ************************************* ****************************************************************************************/ @@ -2783,6 +2798,17 @@ public class ChatPreferences { this.getGUIstationMapStagePositionXY() ); + /* + * Files written before config version 5 do not contain this value. + * Keep the default true in that case so existing users discover the + * path-analysis feature before choosing to hide it themselves. + */ + this.setGUIstationMapPathAnalysisVisible(getBoolean( + element, + this.isGUIstationMapPathAnalysisVisible(), + "GUIstationMapPathAnalysisVisible" + )); + // Splitpane divider positions String s1 = getText(element, null, "GUIselectedCallSignSplitPane_dividerposition"); if (s1 != null) { diff --git a/src/main/java/kst4contest/test/ChatPreferencesStationMapVisibilityTest.java b/src/main/java/kst4contest/test/ChatPreferencesStationMapVisibilityTest.java new file mode 100644 index 0000000..23f8291 --- /dev/null +++ b/src/main/java/kst4contest/test/ChatPreferencesStationMapVisibilityTest.java @@ -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( + "false" + + "")); + + 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, """ + + + 4 + + 1000.0;800.0 + + + """); + + ChatPreferences restoredPreferences = new ChatPreferences(); + restoredPreferences.setStoreAndRestorePreferencesFileName( + legacyPreferencesFile.toString()); + restoredPreferences.readPreferencesFromXmlFile(); + + assertTrue(restoredPreferences.isGUIstationMapPathAnalysisVisible()); + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/StationMapView.java b/src/main/java/kst4contest/view/map/StationMapView.java index f42d37a..ab725dc 100644 --- a/src/main/java/kst4contest/view/map/StationMapView.java +++ b/src/main/java/kst4contest/view/map/StationMapView.java @@ -28,6 +28,8 @@ import javafx.scene.control.ScrollPane; import javafx.scene.layout.Priority; import javafx.scene.layout.Region; import javafx.scene.layout.ColumnConstraints; +import javafx.geometry.Pos; +import javafx.scene.layout.HBox; /** * Standalone station map window. @@ -49,6 +51,9 @@ public final class StationMapView { */ private static final boolean MAP_DEBUG_LOGGING = false; + private static final double MINIMUM_HEIGHT_WITH_PATH_ANALYSIS = 650.0; + private static final double MINIMUM_HEIGHT_WITHOUT_PATH_ANALYSIS = 420.0; + private final PathProfileChart detailPathProfileChart = new PathProfileChart(); private final Label detailPathModeValue = new Label("-"); @@ -76,6 +81,12 @@ public final class StationMapView { private VBox detailPane; private final Label statusLabel = new Label("Station map not initialized yet."); + + private final Label pathAnalysisHiddenHintLabel = new Label("Path analysis is hidden."); + private final Button pathAnalysisVisibilityButton = new Button(); + private final Tooltip pathAnalysisVisibilityTooltip = new Tooltip(); + + private final Label detailCallsignValue = new Label("-"); private final Label detailLocatorValue = new Label("-"); private final Label detailQrbValue = new Label("-"); @@ -138,8 +149,12 @@ public final class StationMapView { private PathAnalysisResult lastPathAnalysisResult = PathAnalysisResult.waitingForSelection(""); private VBox mapAndProfilePane; + private VBox profileSection; + private VBox pathAnalysisSection; private ScrollPane detailScrollPane; + + private final Label detailPathLosValue = new Label("-"); private final Label detailPathWorstClearanceValue = new Label("-"); @@ -287,6 +302,14 @@ public final class StationMapView { } }); + pathAnalysisVisibilityButton.setMinWidth(Region.USE_PREF_SIZE); + pathAnalysisVisibilityButton.setTooltip(pathAnalysisVisibilityTooltip); + pathAnalysisVisibilityButton.setOnAction(event -> + setPathAnalysisVisible(!profileSection.isVisible(), true)); + + pathAnalysisHiddenHintLabel.setMinWidth(Region.USE_PREF_SIZE); + pathAnalysisHiddenHintLabel.setStyle("-fx-font-style: italic; -fx-opacity: 0.85;"); + webView.setFocusTraversable(true); webView.setPickOnBounds(true); @@ -323,7 +346,8 @@ public final class StationMapView { webView.widthProperty().addListener((obs, oldValue, newValue) -> requestMapInvalidateSize()); webView.heightProperty().addListener((obs, oldValue, newValue) -> requestMapInvalidateSize()); - VBox profileSection = createProfileSection(); + profileSection = createProfileSection(); + mapAndProfilePane = new VBox(6, webView, profileSection); mapAndProfilePane.setPadding(new Insets(0)); @@ -349,10 +373,9 @@ public final class StationMapView { mapAndProfilePane.widthProperty().subtract(20) ); - detailPane = new VBox(10, - createSelectedStationSection(), - createPathAnalysisSection() - ); + pathAnalysisSection = createPathAnalysisSection(); + detailPane = new VBox(10, createSelectedStationSection(), pathAnalysisSection); + detailPane.setPadding(new Insets(10)); detailPane.setMinWidth(0); @@ -364,8 +387,14 @@ public final class StationMapView { detailScrollPane.setFitToWidth(true); detailScrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); detailScrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); - detailScrollPane.setMinWidth(300); + /* + * Allow the details pane to be reduced far enough to leave more room for the + * map. At its minimum width, a callsign with up to ten characters remains + * readable. + */ + detailScrollPane.setMinWidth(210); detailScrollPane.setPrefWidth(350); + detailScrollPane.setMaxWidth(Double.MAX_VALUE); detailScrollPane.setFitToWidth(true); @@ -378,8 +407,7 @@ public final class StationMapView { SplitPane.setResizableWithParent(detailScrollPane, true); rootPane = new BorderPane(); - rootPane.setTop(statusLabel); - BorderPane.setMargin(statusLabel, new Insets(8)); + rootPane.setTop(createMapHeader()); rootPane.setCenter(mainSplitPane); double[] size = chatPreferences.getGUIstationMapStageSceneSizeHW(); @@ -390,7 +418,12 @@ public final class StationMapView { scene = new Scene(rootPane, initialWidth, initialHeight); stage.setMinWidth(900); - stage.setMinHeight(650); + stage.setMinHeight(resolveMinimumStationMapHeight( + chatPreferences.isGUIstationMapPathAnalysisVisible())); + + stage.setScene(scene); + setPathAnalysisVisible(chatPreferences.isGUIstationMapPathAnalysisVisible(), false); + applyThemeFromPreferences(); stage.setScene(scene); applyThemeFromPreferences(); @@ -446,6 +479,91 @@ public final class StationMapView { } + /** + * Creates an always-visible header for the map status and analysis controls. + * + * Keeping the control outside the sections that it hides is important: users + * must always have an obvious way to restore a previously hidden analysis. + */ + private HBox createMapHeader() { + // The status may be shortened before the show/hide control is ever clipped. + statusLabel.setMinWidth(0); + statusLabel.setMaxWidth(Double.MAX_VALUE); + statusLabel.setTextOverrun(OverrunStyle.ELLIPSIS); + + HBox header = new HBox( + 10, + statusLabel, + pathAnalysisHiddenHintLabel, + pathAnalysisVisibilityButton + ); + header.setAlignment(Pos.CENTER_LEFT); + header.setPadding(new Insets(8)); + HBox.setHgrow(statusLabel, Priority.ALWAYS); + return header; + } + + /** + * Shows or hides both parts of the path analysis as one logical feature. + * + * Both visible and managed must be changed. A node that is merely invisible + * would still reserve layout space and the map would not grow into that area. + * The current analysis result remains attached to the controls and is + * immediately available again when the user restores the sections. + * + * @param visible true to show the profile and detailed analysis + * @param persist true when the change was explicitly requested by the user + */ + private void setPathAnalysisVisible(boolean visible, boolean persist) { + profileSection.setVisible(visible); + profileSection.setManaged(visible); + + pathAnalysisSection.setVisible(visible); + pathAnalysisSection.setManaged(visible); + + pathAnalysisHiddenHintLabel.setVisible(!visible); + pathAnalysisHiddenHintLabel.setManaged(!visible); + + pathAnalysisVisibilityButton.setText( + visible ? "Hide path analysis" : "Show path analysis"); + pathAnalysisVisibilityButton.setAccessibleText( + visible ? "Hide path analysis" : "Show path analysis"); + pathAnalysisVisibilityButton.setStyle( + visible ? "" : "-fx-font-weight: bold;"); + + pathAnalysisVisibilityTooltip.setText(visible + ? "Hide the path profile and detailed path analysis. You can show them again at any time." + : "Show the path profile and detailed path analysis."); + + pathAnalysisVisibilityButton.setAccessibleHelp( + pathAnalysisVisibilityTooltip.getText()); + + if (!visible) { + /* + * Do not leave a profile hover marker on the map after its chart was + * hidden. + */ + showProfileHoverPointOnMap(null); + } + + stage.setMinHeight(resolveMinimumStationMapHeight(visible)); + + if (persist) { + chatPreferences.setGUIstationMapPathAnalysisVisible(visible); + } + + if (rootPane != null) { + rootPane.requestLayout(); + Platform.runLater(this::requestMapInvalidateSize); + } + } + + private double resolveMinimumStationMapHeight(boolean pathAnalysisVisible) { + return pathAnalysisVisible + ? MINIMUM_HEIGHT_WITH_PATH_ANALYSIS + : MINIMUM_HEIGHT_WITHOUT_PATH_ANALYSIS; + } + private VBox createSelectedStationSection() { GridPane detailGrid = new GridPane(); detailGrid.setHgap(8); @@ -494,13 +612,18 @@ public final class StationMapView { gridPane.getColumnConstraints().clear(); ColumnConstraints labelColumn = new ColumnConstraints(); - labelColumn.setMinWidth(105); + labelColumn.setMinWidth(70); labelColumn.setPrefWidth(115); labelColumn.setMaxWidth(130); labelColumn.setHgrow(Priority.NEVER); ColumnConstraints valueColumn = new ColumnConstraints(); - valueColumn.setMinWidth(180); + + /* + * Reserve enough space for a callsign with up to ten characters, while still + * allowing the details pane to become considerably narrower. + */ + valueColumn.setMinWidth(85); valueColumn.setHgrow(Priority.ALWAYS); gridPane.getColumnConstraints().addAll(labelColumn, valueColumn); @@ -1530,8 +1653,11 @@ public final class StationMapView { return 768.0; } + double minimumHeight = resolveMinimumStationMapHeight( + chatPreferences.isGUIstationMapPathAnalysisVisible()); + // Avoid restoring very large old test sizes after the layout changed. - if (storedSize[1] < 650.0 || storedSize[1] > 1100.0) { + if (storedSize[1] < minimumHeight || storedSize[1] > 1100.0) { return 768.0; }