Add optional station map clustering toggle (solves #79) and added documentation

This commit is contained in:
Marc Froehlich
2026-09-03 01:26:19 +02:00
parent 595fb84362
commit 53555cbe69
16 changed files with 250 additions and 16 deletions
@@ -55,7 +55,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 = 6;
public static final int CONFIG_VERSION = 7;
// Prefer writing tag names that mirror variable names (human readable). Keep legacy tags for compatibility.
private static final String TAG_CONFIG_VERSION = "configVersion";
@@ -346,6 +346,7 @@ public class ChatPreferences {
private double[] GUIstationMapStageSceneSizeHW = new double[] { 1000, 800 };
private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN };
private boolean GUIstationMapPathAnalysisVisible = true;
private boolean GUIstationMapClusteringEnabled = true;
private final Map<String, Double> tableColumnWidths = new LinkedHashMap<>();
private static final String TAG_TABLE_COLUMN_WIDTH = "tableColumnWidth";
@@ -655,6 +656,14 @@ public class ChatPreferences {
this.GUIstationMapPathAnalysisVisible = GUIstationMapPathAnalysisVisible;
}
public boolean isGUIstationMapClusteringEnabled() {
return GUIstationMapClusteringEnabled;
}
public void setGUIstationMapClusteringEnabled(boolean GUIstationMapClusteringEnabled) {
this.GUIstationMapClusteringEnabled = GUIstationMapClusteringEnabled;
}
/**
* Returns a stored width for one stable table/leaf-column identity.
*
@@ -2120,6 +2129,12 @@ public class ChatPreferences {
);
guiOptions.appendChild(GUIstationMapPathAnalysisVisible);
Element GUIstationMapClusteringEnabled = doc.createElement("GUIstationMapClusteringEnabled");
GUIstationMapClusteringEnabled.setTextContent(
String.valueOf(this.isGUIstationMapClusteringEnabled())
);
guiOptions.appendChild(GUIstationMapClusteringEnabled);
appendTableColumnWidths(doc, guiOptions);
/****************************************************************************************
@@ -2204,6 +2219,8 @@ public class ChatPreferences {
getGUIstationMapStageSceneSizeHW()[0] + ";" + getGUIstationMapStageSceneSizeHW()[1]);
upsertDirectChildText(document, guiOptions, "GUIstationMapStagePositionXY",
getGUIstationMapStagePositionXY()[0] + ";" + getGUIstationMapStagePositionXY()[1]);
upsertDirectChildText(document, guiOptions, "GUIstationMapClusteringEnabled",
String.valueOf(isGUIstationMapClusteringEnabled()));
}
private void appendTableColumnWidths(Document document, Element guiOptions) {
@@ -2956,6 +2973,7 @@ public class ChatPreferences {
* case read GUI options
*
***********************************************/
this.setGUIstationMapClusteringEnabled(true);
list = doc.getElementsByTagName("guiOptions");
if (list.getLength() != 0) {
@@ -2994,6 +3012,17 @@ public class ChatPreferences {
"GUIstationMapPathAnalysisVisible"
));
/*
* Files written before config version 7 do not contain this value.
* Missing or malformed values keep clustering enabled so existing
* installations retain the established map behaviour.
*/
this.setGUIstationMapClusteringEnabled(getBooleanOrDefault(
element,
true,
"GUIstationMapClusteringEnabled"
));
// Splitpane divider positions
String s1 = getText(element, null, "GUIselectedCallSignSplitPane_dividerposition");
if (s1 != null) {
@@ -3344,6 +3373,20 @@ public class ChatPreferences {
return "true".equalsIgnoreCase(v) || "1".equals(v) || "yes".equalsIgnoreCase(v);
}
private static boolean getBooleanOrDefault(Element parent, boolean defaultValue, String... tagNames) {
String value = getText(parent, null, tagNames);
if (value == null) {
return defaultValue;
}
if ("true".equalsIgnoreCase(value) || "1".equals(value) || "yes".equalsIgnoreCase(value)) {
return true;
}
if ("false".equalsIgnoreCase(value) || "0".equals(value) || "no".equalsIgnoreCase(value)) {
return false;
}
return defaultValue;
}
private static int getInt(Element parent, int defaultValue, String... tagNames) {
String v = getText(parent, null, tagNames);
if (v == null) {
@@ -15,6 +15,7 @@ import java.nio.charset.StandardCharsets;
* - grid / beam / connection use non-interactive panes
* - JavaScript errors are forwarded to Java through javaMapBridge
* - setTheme(light|dark) aligns the map with the JavaFX application theme
* - setStationClusteringEnabled(boolean) re-renders the existing station data
*
* Important:
* This version intentionally uses integer Leaflet zoom levels again.
@@ -356,6 +357,7 @@ public final class MapHtmlResources {
*/
let stationData = [];
let stationsByCallsignRaw = {};
let stationClusteringEnabled = true;
let clustersById = {};
let clusterSequence = 0;
@@ -810,13 +812,19 @@ public final class MapHtmlResources {
return;
}
if (Number(map.getZoom()) >= KST_CLUSTER_DISABLE_ZOOM) {
if (!stationClusteringEnabled
|| Number(map.getZoom()) >= KST_CLUSTER_DISABLE_ZOOM) {
renderAllStationsIndividually();
} else {
renderClusteredStations();
}
}
function setStationClusteringEnabled(enabled) {
stationClusteringEnabled = Boolean(enabled);
renderStationMarkers();
}
/**
* Zooms into a cluster.
*
@@ -1271,6 +1279,7 @@ public final class MapHtmlResources {
getViewportState: getViewportState,
setHome: setHome,
setStations: setStations,
setStationClusteringEnabled: setStationClusteringEnabled,
setBeam: setBeam,
setConnection: setConnection,
setProfileHoverPoint: setProfileHoverPoint,
@@ -1284,4 +1293,4 @@ public final class MapHtmlResources {
</html>
""".replace("__TILE_PROXY_PORT__", String.valueOf(tileProxyPort));
}
}
}
@@ -90,6 +90,9 @@ public final class StationMapView {
private final Button resetViewButton = new Button("Reset view");
private final Tooltip statusTooltip = new Tooltip();
private final CheckBox stationClusteringCheckBox = new CheckBox("Group nearby stations");
private final Tooltip stationClusteringTooltip = new Tooltip(
"Group nearby stations into clusters at lower zoom levels.");
private Runnable onResetView;
@@ -333,6 +336,21 @@ public final class StationMapView {
}
});
stationClusteringCheckBox.setMinWidth(Region.USE_PREF_SIZE);
stationClusteringCheckBox.setTooltip(stationClusteringTooltip);
stationClusteringCheckBox.setAccessibleText("Group nearby stations");
stationClusteringCheckBox.setAccessibleHelp(stationClusteringTooltip.getText());
stationClusteringCheckBox.setSelected(chatPreferences.isGUIstationMapClusteringEnabled());
stationClusteringCheckBox.selectedProperty().addListener((obs, oldValue, newValue) -> {
boolean enabled = newValue;
chatPreferences.setGUIstationMapClusteringEnabled(enabled);
if (mapReady) {
executeMapScriptSafely(
"window.kstMapApi.setStationClusteringEnabled(" + enabled + ");");
}
layoutSaveRequester.run();
});
pathAnalysisVisibilityButton.setMinWidth(Region.USE_PREF_SIZE);
pathAnalysisVisibilityButton.setTooltip(pathAnalysisVisibilityTooltip);
pathAnalysisVisibilityButton.setOnAction(event ->
@@ -535,6 +553,7 @@ public final class StationMapView {
statusLabel,
triggerClusterSpotButton,
resetViewButton,
stationClusteringCheckBox,
pathAnalysisHiddenHintLabel,
pathAnalysisVisibilityButton
);
@@ -808,6 +827,9 @@ public final class StationMapView {
window.setMember("javaMapBridge", javaMapBridge);
executeMapScriptSafely("window.kstMapApi.init();");
executeMapScriptSafely(
"window.kstMapApi.setStationClusteringEnabled("
+ chatPreferences.isGUIstationMapClusteringEnabled() + ");");
mapReady = true;
applyMapThemeToWebView(chatPreferences.isGUI_darkModeActive());
@@ -103,6 +103,7 @@ class ChatPreferencesLayoutPersistenceTest {
preferences.setStn_loginCallSign("UNSAVED-CALL");
preferences.getGUIscn_ChatwindowMainSceneSizeHW()[0] = 812;
preferences.getGUIscn_ChatwindowMainSceneSizeHW()[1] = 1340;
preferences.setGUIstationMapClusteringEnabled(false);
preferences.setTableColumnWidth("qso-other-monitor", "call-tx", 123.75);
assertTrue(preferences.writeLayoutPreferencesToXmlFile());
@@ -112,12 +113,15 @@ class ChatPreferencesLayoutPersistenceTest {
assertFalse(writtenXml.contains("UNSAVED-CALL"));
assertTrue(writtenXml.contains("<futureExtension mode=\"keep-me\">"));
assertTrue(writtenXml.contains("<futureLayoutValue>untouched</futureLayoutValue>"));
assertTrue(writtenXml.contains("<configVersion>6</configVersion>"));
assertTrue(writtenXml.contains("<configVersion>7</configVersion>"));
assertTrue(writtenXml.contains("<GUIscn_ChatwindowMainSceneSizeHW>812.0;1340.0"));
assertTrue(writtenXml.contains("<GUIstationMapClusteringEnabled>false"
+ "</GUIstationMapClusteringEnabled>"));
ChatPreferences restored = preferencesAt(preferencesFile);
assertTrue(restored.readPreferencesFromXmlFile());
assertEquals("SAVED-CALL", restored.getStn_loginCallSign());
assertFalse(restored.isGUIstationMapClusteringEnabled());
assertEquals(123.75,
restored.getTableColumnWidth("qso-other-monitor", "call-tx").orElseThrow());
}
@@ -0,0 +1,101 @@
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 ChatPreferencesStationMapClusteringTest {
@TempDir
Path temporaryDirectory;
@Test
void clusteringIsEnabledByDefault() {
assertTrue(new ChatPreferences().isGUIstationMapClusteringEnabled());
}
@Test
void disabledClusteringSurvivesFullXmlRoundTrip() throws IOException {
Path preferencesFile = temporaryDirectory.resolve("preferences.xml");
ChatPreferences written = preferencesAt(preferencesFile);
written.setGUIstationMapClusteringEnabled(false);
assertTrue(written.writePreferencesToXmlFile());
String writtenXml = Files.readString(preferencesFile);
assertTrue(writtenXml.contains("<configVersion>7</configVersion>"));
assertTrue(writtenXml.contains("<GUIstationMapClusteringEnabled>false"
+ "</GUIstationMapClusteringEnabled>"));
ChatPreferences restored = preferencesAt(preferencesFile);
assertTrue(restored.readPreferencesFromXmlFile());
assertFalse(restored.isGUIstationMapClusteringEnabled());
}
@Test
void versionSixWithoutClusteringSettingKeepsClusteringEnabled() throws IOException {
Path preferencesFile = temporaryDirectory.resolve("version-six.xml");
Files.writeString(preferencesFile, """
<?xml version="1.0" encoding="UTF-8"?>
<praktiKST>
<configVersion>6</configVersion>
<guiOptions>
<GUIstationMapStageSceneSizeHW>1000.0;800.0</GUIstationMapStageSceneSizeHW>
</guiOptions>
</praktiKST>
""");
ChatPreferences restored = preferencesAt(preferencesFile);
restored.setGUIstationMapClusteringEnabled(false);
assertTrue(restored.readPreferencesFromXmlFile());
assertTrue(restored.isGUIstationMapClusteringEnabled());
}
@Test
void missingGuiOptionsKeepsClusteringEnabled() throws IOException {
Path preferencesFile = temporaryDirectory.resolve("missing.xml");
Files.writeString(preferencesFile, """
<?xml version="1.0" encoding="UTF-8"?>
<praktiKST>
<configVersion>6</configVersion>
</praktiKST>
""");
ChatPreferences restored = preferencesAt(preferencesFile);
restored.setGUIstationMapClusteringEnabled(false);
assertTrue(restored.readPreferencesFromXmlFile());
assertTrue(restored.isGUIstationMapClusteringEnabled());
}
@Test
void invalidClusteringValueKeepsClusteringEnabled() throws IOException {
Path preferencesFile = temporaryDirectory.resolve("invalid.xml");
Files.writeString(preferencesFile, """
<?xml version="1.0" encoding="UTF-8"?>
<praktiKST>
<configVersion>7</configVersion>
<guiOptions>
<GUIstationMapClusteringEnabled>sometimes</GUIstationMapClusteringEnabled>
</guiOptions>
</praktiKST>
""");
ChatPreferences restored = preferencesAt(preferencesFile);
restored.setGUIstationMapClusteringEnabled(false);
assertTrue(restored.readPreferencesFromXmlFile());
assertTrue(restored.isGUIstationMapClusteringEnabled());
}
private ChatPreferences preferencesAt(Path preferencesFile) {
ChatPreferences preferences = new ChatPreferences();
preferences.setStoreAndRestorePreferencesFileName(preferencesFile.toString());
return preferences;
}
}
@@ -0,0 +1,31 @@
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 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"));
}
}