mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-11-08 14:38:36 +01:00
Apply live beacon settings and improve station map (#164)
Apply beacon checkbox changes at runtime and keep the aggregate status chip synchronized. Improve explicit station-map navigation, spatial filter overlays, overlay legend, selected-path contrast, and the documented map screenshot.
This commit is contained in:
1 parent
4d96822c0c
commit
d925c83412
61 files changed
+2686
-299
No files matched your search
@@ -0,0 +1,75 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import kst4contest.model.ChatPreferences;
|
||||
import kst4contest.model.ThreadStateMessage;
|
||||
|
||||
/** Keeps category failures and builds the single status shown for both beacons. */
|
||||
final class BeaconStatus {
|
||||
|
||||
static final String SOURCE_NAME = ChatController.BEACON_STATUS_SOURCE;
|
||||
|
||||
private boolean mainInvalid;
|
||||
private boolean secondInvalid;
|
||||
private String activity = "Beacon configuration loaded.";
|
||||
|
||||
/** Records the result of one complete timer run. */
|
||||
synchronized void recordRun(
|
||||
boolean mainInvalid,
|
||||
boolean secondInvalid,
|
||||
String activity
|
||||
) {
|
||||
this.mainInvalid = mainInvalid;
|
||||
this.secondInvalid = secondInvalid;
|
||||
this.activity = activity;
|
||||
}
|
||||
|
||||
/** Updates the explanation without hiding a still-relevant category failure. */
|
||||
synchronized void recordActivity(String activity) {
|
||||
this.activity = activity;
|
||||
}
|
||||
|
||||
/** Builds a fresh aggregate from the current preferences and remembered failures. */
|
||||
synchronized ThreadStateMessage message(ChatPreferences preferences) {
|
||||
boolean secondAvailable = preferences.isLoginToSecondChatEnabled();
|
||||
boolean mainEnabled = preferences.isBcn_beaconsEnabledMainCat();
|
||||
boolean secondEnabled = secondAvailable
|
||||
&& preferences.isBcn_beaconsEnabledSecondCat();
|
||||
int available = secondAvailable ? 2 : 1;
|
||||
int enabled = (mainEnabled ? 1 : 0) + (secondEnabled ? 1 : 0);
|
||||
boolean critical = (mainInvalid && mainEnabled)
|
||||
|| (secondInvalid && secondEnabled);
|
||||
|
||||
String description;
|
||||
if (critical) {
|
||||
description = "FAILED";
|
||||
} else if (enabled == 0) {
|
||||
description = "off";
|
||||
} else if (enabled == available) {
|
||||
description = "on";
|
||||
} else {
|
||||
description = enabled + "/" + available;
|
||||
}
|
||||
|
||||
String details = "Main category beacon: " + onOff(mainEnabled)
|
||||
+ "; second category beacon: "
|
||||
+ (secondAvailable
|
||||
? onOff(secondEnabled)
|
||||
: "unavailable");
|
||||
String information = activity == null || activity.isBlank()
|
||||
? details
|
||||
: activity + "\n" + details;
|
||||
|
||||
ThreadStateMessage message = new ThreadStateMessage(
|
||||
SOURCE_NAME,
|
||||
enabled > 0,
|
||||
information,
|
||||
critical
|
||||
);
|
||||
message.setRunningInformationTextDescription(description);
|
||||
return message;
|
||||
}
|
||||
|
||||
private static String onOff(boolean enabled) {
|
||||
return enabled ? "on" : "off";
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import java.util.TimerTask;
|
||||
|
||||
import kst4contest.model.ChatCategory;
|
||||
import kst4contest.model.ChatMessage;
|
||||
import kst4contest.model.ThreadStateMessage;
|
||||
|
||||
/**
|
||||
* Sends the configured public-chat beacons for both active chat categories.
|
||||
@@ -20,10 +19,9 @@ import kst4contest.model.ThreadStateMessage;
|
||||
*/
|
||||
public class BeaconTask extends TimerTask {
|
||||
|
||||
private static final String THREAD_NICKNAME = "MyBeacon";
|
||||
|
||||
private final ChatController chatController;
|
||||
private final ThreadStatusCallback callbackToController;
|
||||
private final BeaconStatus beaconStatus;
|
||||
|
||||
/**
|
||||
* Creates one execution of the shared beacon timer.
|
||||
@@ -37,30 +35,34 @@ public class BeaconTask extends TimerTask {
|
||||
) {
|
||||
this.chatController = chatController;
|
||||
this.callbackToController = callbackToController;
|
||||
this.beaconStatus = chatController.beaconStatusState();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
Thread.currentThread().setName("BeaconTask");
|
||||
reportStatus(THREAD_NICKNAME, true, "initialized", false);
|
||||
|
||||
sendMainCategoryBeacon();
|
||||
sendSecondCategoryBeacon();
|
||||
Attempt main = sendMainCategoryBeacon();
|
||||
Attempt second = sendSecondCategoryBeacon();
|
||||
String activity = "Main category: " + main.description
|
||||
+ "; second category: " + second.description;
|
||||
beaconStatus.recordRun(
|
||||
main == Attempt.INVALID,
|
||||
second == Attempt.INVALID,
|
||||
activity
|
||||
);
|
||||
callbackToController.onThreadStatus(
|
||||
BeaconStatus.SOURCE_NAME,
|
||||
beaconStatus.message(chatController.getChatPreferences())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the main-category beacon if it is currently enabled.
|
||||
*/
|
||||
private void sendMainCategoryBeacon() {
|
||||
private Attempt sendMainCategoryBeacon() {
|
||||
if (!chatController.getChatPreferences()
|
||||
.isBcn_beaconsEnabledMainCat()) {
|
||||
reportStatus(
|
||||
THREAD_NICKNAME + " 1",
|
||||
false,
|
||||
"off",
|
||||
false
|
||||
);
|
||||
return;
|
||||
return Attempt.OFF;
|
||||
}
|
||||
|
||||
ChatMessage beaconMessage = buildBeaconMessage(
|
||||
@@ -72,13 +74,7 @@ public class BeaconTask extends TimerTask {
|
||||
);
|
||||
|
||||
if (beaconMessage == null) {
|
||||
reportStatus(
|
||||
THREAD_NICKNAME + " 1",
|
||||
false,
|
||||
"invalid text",
|
||||
true
|
||||
);
|
||||
return;
|
||||
return Attempt.INVALID;
|
||||
}
|
||||
|
||||
System.out.println(
|
||||
@@ -88,31 +84,19 @@ public class BeaconTask extends TimerTask {
|
||||
);
|
||||
|
||||
chatController.getMessageTXBus().add(beaconMessage);
|
||||
|
||||
reportStatus(
|
||||
THREAD_NICKNAME + " 1",
|
||||
true,
|
||||
"on",
|
||||
false
|
||||
);
|
||||
return Attempt.SENT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the second-category beacon if the second login and its beacon are
|
||||
* currently enabled.
|
||||
*/
|
||||
private void sendSecondCategoryBeacon() {
|
||||
if (!chatController.getChatPreferences()
|
||||
.isLoginToSecondChatEnabled()
|
||||
|| !chatController.getChatPreferences()
|
||||
.isBcn_beaconsEnabledSecondCat()) {
|
||||
reportStatus(
|
||||
THREAD_NICKNAME + " 2",
|
||||
false,
|
||||
"off",
|
||||
false
|
||||
);
|
||||
return;
|
||||
private Attempt sendSecondCategoryBeacon() {
|
||||
if (!chatController.getChatPreferences().isLoginToSecondChatEnabled()) {
|
||||
return Attempt.UNAVAILABLE;
|
||||
}
|
||||
if (!chatController.getChatPreferences().isBcn_beaconsEnabledSecondCat()) {
|
||||
return Attempt.OFF;
|
||||
}
|
||||
|
||||
ChatMessage beaconMessage = buildBeaconMessage(
|
||||
@@ -124,13 +108,7 @@ public class BeaconTask extends TimerTask {
|
||||
);
|
||||
|
||||
if (beaconMessage == null) {
|
||||
reportStatus(
|
||||
THREAD_NICKNAME + " 2",
|
||||
false,
|
||||
"invalid text",
|
||||
true
|
||||
);
|
||||
return;
|
||||
return Attempt.INVALID;
|
||||
}
|
||||
|
||||
System.out.println(
|
||||
@@ -140,13 +118,7 @@ public class BeaconTask extends TimerTask {
|
||||
);
|
||||
|
||||
chatController.getMessageTXBus().add(beaconMessage);
|
||||
|
||||
reportStatus(
|
||||
THREAD_NICKNAME + " 2",
|
||||
true,
|
||||
"on",
|
||||
false
|
||||
);
|
||||
return Attempt.SENT;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,24 +171,16 @@ public class BeaconTask extends TimerTask {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forwards one state update to the existing thread-status display.
|
||||
*/
|
||||
private void reportStatus(
|
||||
String threadName,
|
||||
boolean running,
|
||||
String information,
|
||||
boolean criticalState
|
||||
) {
|
||||
ThreadStateMessage stateMessage = new ThreadStateMessage(
|
||||
threadName,
|
||||
running,
|
||||
information,
|
||||
criticalState
|
||||
);
|
||||
callbackToController.onThreadStatus(
|
||||
THREAD_NICKNAME,
|
||||
stateMessage
|
||||
);
|
||||
private enum Attempt {
|
||||
SENT("sent"),
|
||||
OFF("off"),
|
||||
UNAVAILABLE("unavailable"),
|
||||
INVALID("invalid text");
|
||||
|
||||
private final String description;
|
||||
|
||||
Attempt(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,9 +64,11 @@ public class ChatController implements ThreadStatusCallback {
|
||||
private static final boolean DEBUG_BAND_UPGRADE_HINT = true; //for new band hint
|
||||
|
||||
|
||||
public static final int MIN_BEACON_INTERVAL_MINUTES = 1;
|
||||
public static final String BEACON_STATUS_SOURCE = "MyBeacon";
|
||||
public static final int MIN_BEACON_INTERVAL_MINUTES = 1;
|
||||
public static final int MAX_BEACON_TEXT_LENGTH = 120;
|
||||
private static final long INITIAL_BEACON_DELAY_MILLIS = 10_000L;
|
||||
private final BeaconStatus beaconStatus = new BeaconStatus();
|
||||
|
||||
private Consumer<Double> viewRotorCallback;
|
||||
|
||||
@@ -3381,6 +3383,64 @@ private SimpleRoster<String>
|
||||
this.dbHandler = dbHandler;
|
||||
}
|
||||
|
||||
/** Applies the main-category beacon switch to the running session immediately. */
|
||||
public void setMainBeaconEnabled(boolean enabled) {
|
||||
getChatPreferences().setBcn_beaconsEnabledMainCat(enabled);
|
||||
publishBeaconStatus("Beacon configuration changed.");
|
||||
}
|
||||
|
||||
/** Applies the second-category beacon switch to the running session immediately. */
|
||||
public void setSecondBeaconEnabled(boolean enabled) {
|
||||
getChatPreferences().setBcn_beaconsEnabledSecondCat(enabled);
|
||||
publishBeaconStatus("Beacon configuration changed.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles every beacon usable in the current chat configuration.
|
||||
*
|
||||
* <p>The dormant second-category preference is deliberately left unchanged when
|
||||
* the second chat is disabled. Enabling that chat later therefore restores the
|
||||
* operator's previous category-specific choice.</p>
|
||||
*/
|
||||
public void toggleUsableBeacons() {
|
||||
ChatPreferences preferences = getChatPreferences();
|
||||
boolean secondAvailable = preferences.isLoginToSecondChatEnabled();
|
||||
boolean anyEnabled = preferences.isBcn_beaconsEnabledMainCat()
|
||||
|| (secondAvailable
|
||||
&& preferences.isBcn_beaconsEnabledSecondCat());
|
||||
boolean enable = !anyEnabled;
|
||||
|
||||
preferences.setBcn_beaconsEnabledMainCat(enable);
|
||||
if (secondAvailable) {
|
||||
preferences.setBcn_beaconsEnabledSecondCat(enable);
|
||||
}
|
||||
publishBeaconStatus("Beacon status toggled from the main window.");
|
||||
}
|
||||
|
||||
/** Publishes the loaded state without changing either category switch. */
|
||||
public void refreshBeaconStatus() {
|
||||
publishBeaconStatus("Beacon configuration loaded.");
|
||||
}
|
||||
|
||||
/** Returns the shared state used by the timer and manual controls. */
|
||||
BeaconStatus beaconStatusState() {
|
||||
return beaconStatus;
|
||||
}
|
||||
|
||||
/** Builds the current aggregate when the UI is ready to display it. */
|
||||
public ThreadStateMessage currentBeaconStatus() {
|
||||
return beaconStatus.message(getChatPreferences());
|
||||
}
|
||||
|
||||
/** Publishes the aggregate state used by the single beacon status chip. */
|
||||
private void publishBeaconStatus(String activity) {
|
||||
beaconStatus.recordActivity(activity);
|
||||
onThreadStatus(
|
||||
BeaconStatus.SOURCE_NAME,
|
||||
currentBeaconStatus()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validates a configured beacon template before it is stored.
|
||||
|
||||
@@ -366,8 +366,8 @@ public class ChatPreferences {
|
||||
* Beacon prefs
|
||||
*/
|
||||
|
||||
boolean bcn_beaconsEnabledMainCat = true;
|
||||
boolean bcn_beaconsEnabledSecondCat = false;
|
||||
MutableValue<Boolean> bcn_beaconsEnabledMainCat = new SimpleValue<>(true);
|
||||
MutableValue<Boolean> bcn_beaconsEnabledSecondCat = new SimpleValue<>(false);
|
||||
int bcn_beaconIntervalInMinutesMainCat = 20;
|
||||
int bcn_beaconIntervalInMinutesSecondCat = 20;
|
||||
String bcn_beaconTextMainCat = "Hi, pse call us";
|
||||
@@ -737,11 +737,15 @@ public class ChatPreferences {
|
||||
}
|
||||
|
||||
public boolean isBcn_beaconsEnabledSecondCat() {
|
||||
return bcn_beaconsEnabledSecondCat;
|
||||
return Boolean.TRUE.equals(bcn_beaconsEnabledSecondCat.get());
|
||||
}
|
||||
|
||||
public void setBcn_beaconsEnabledSecondCat(boolean bcn_beaconsEnabledSecondCat) {
|
||||
this.bcn_beaconsEnabledSecondCat = bcn_beaconsEnabledSecondCat;
|
||||
this.bcn_beaconsEnabledSecondCat.set(bcn_beaconsEnabledSecondCat);
|
||||
}
|
||||
|
||||
public MutableValue<Boolean> bcnBeaconsEnabledSecondCatProperty() {
|
||||
return bcn_beaconsEnabledSecondCat;
|
||||
}
|
||||
|
||||
public int getBcn_beaconIntervalInMinutesSecondCat() {
|
||||
@@ -1579,6 +1583,10 @@ public class ChatPreferences {
|
||||
// }
|
||||
|
||||
public boolean isBcn_beaconsEnabledMainCat() {
|
||||
return Boolean.TRUE.equals(bcn_beaconsEnabledMainCat.get());
|
||||
}
|
||||
|
||||
public MutableValue<Boolean> bcnBeaconsEnabledMainCatProperty() {
|
||||
return bcn_beaconsEnabledMainCat;
|
||||
}
|
||||
|
||||
@@ -1601,7 +1609,7 @@ public class ChatPreferences {
|
||||
}
|
||||
|
||||
public void setBcn_beaconsEnabledMainCat(boolean bcn_beaconsEnabledMainCat) {
|
||||
this.bcn_beaconsEnabledMainCat = bcn_beaconsEnabledMainCat;
|
||||
this.bcn_beaconsEnabledMainCat.set(bcn_beaconsEnabledMainCat);
|
||||
}
|
||||
|
||||
public int getBcn_beaconIntervalInMinutesMainCat() {
|
||||
@@ -3170,11 +3178,11 @@ public class ChatPreferences {
|
||||
|
||||
Element beaconCQEl = getFirstElement(doc, "beaconCQ");
|
||||
if (beaconCQEl != null) {
|
||||
bcn_beaconsEnabledMainCat = getBoolean(beaconCQEl, bcn_beaconsEnabledMainCat, "bcn_beaconsEnabledMainCat", "beaconCQEnabled");
|
||||
setBcn_beaconsEnabledMainCat(getBoolean(beaconCQEl, isBcn_beaconsEnabledMainCat(), "bcn_beaconsEnabledMainCat", "beaconCQEnabled"));
|
||||
bcn_beaconIntervalInMinutesMainCat = getInt(beaconCQEl, bcn_beaconIntervalInMinutesMainCat, "bcn_beaconIntervalInMinutesMainCat", "beaconCQIntervalMinutes");
|
||||
bcn_beaconTextMainCat = getText(beaconCQEl, bcn_beaconTextMainCat, "bcn_beaconTextMainCat", "beaconCQText");
|
||||
|
||||
bcn_beaconsEnabledSecondCat = getBoolean(beaconCQEl, bcn_beaconsEnabledSecondCat, "bcn_beaconsEnabledSecondCat", "beaconCQEnabledSecondCat");
|
||||
setBcn_beaconsEnabledSecondCat(getBoolean(beaconCQEl, isBcn_beaconsEnabledSecondCat(), "bcn_beaconsEnabledSecondCat", "beaconCQEnabledSecondCat"));
|
||||
bcn_beaconIntervalInMinutesSecondCat = getInt(beaconCQEl, bcn_beaconIntervalInMinutesSecondCat, "bcn_beaconIntervalInMinutesSecondCat", "beaconCQIntervalMinutesSecondCat");
|
||||
bcn_beaconTextSecondCat = getText(beaconCQEl, bcn_beaconTextSecondCat, "bcn_beaconTextSecondCat", "beaconCQTextSecondText");
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
@@ -81,12 +83,39 @@ public final class MaidenheadGridUtils {
|
||||
double northLat,
|
||||
double eastLon,
|
||||
GridPrecision precision) {
|
||||
|
||||
if (westLon > eastLon) {
|
||||
// Anti-meridian handling is not needed for Europe in this project stage.
|
||||
if (!Double.isFinite(westLon) || !Double.isFinite(eastLon)) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
double unwrappedEast = eastLon;
|
||||
if (unwrappedEast < westLon) {
|
||||
unwrappedEast += 360.0;
|
||||
}
|
||||
|
||||
if (unwrappedEast - westLon >= 360.0) {
|
||||
return buildVisibleCellsWithinWorld(southLat, -180.0, northLat, 180.0, precision);
|
||||
}
|
||||
|
||||
int firstWorld = (int) Math.floor((westLon + 180.0) / 360.0);
|
||||
int lastWorld = (int) Math.floor((unwrappedEast + 180.0 - EPSILON) / 360.0);
|
||||
Map<String, GridCell> cells = new LinkedHashMap<>();
|
||||
for (int world = firstWorld; world <= lastWorld; world++) {
|
||||
double worldWest = -180.0 + world * 360.0;
|
||||
double worldEast = 180.0 + world * 360.0;
|
||||
double localWest = Math.max(westLon, worldWest) - world * 360.0;
|
||||
double localEast = Math.min(unwrappedEast, worldEast) - world * 360.0;
|
||||
buildVisibleCellsWithinWorld(southLat, localWest, northLat, localEast, precision)
|
||||
.forEach(cell -> cells.putIfAbsent(cell.locatorLabel(), cell));
|
||||
}
|
||||
return List.copyOf(cells.values());
|
||||
}
|
||||
|
||||
private static List<GridCell> buildVisibleCellsWithinWorld(double southLat,
|
||||
double westLon,
|
||||
double northLat,
|
||||
double eastLon,
|
||||
GridPrecision precision) {
|
||||
|
||||
double clampedSouth = clamp(southLat, -90.0 + EPSILON, 90.0 - EPSILON);
|
||||
double clampedNorth = clamp(northLat, -90.0 + EPSILON, 90.0 - EPSILON);
|
||||
double clampedWest = clamp(westLon, -180.0 + EPSILON, 180.0 - EPSILON);
|
||||
@@ -217,4 +246,4 @@ public final class MaidenheadGridUtils {
|
||||
private static double clamp(double value, double min, double max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ public final class MapMarkerCluster {
|
||||
private final List<MapCallsignRawSnapshot> markers = new ArrayList<>();
|
||||
private double sumLon = 0.0;
|
||||
private double sumLat = 0.0;
|
||||
private double sumLonSin = 0.0;
|
||||
private double sumLonCos = 0.0;
|
||||
|
||||
public MapMarkerCluster(MapCallsignRawSnapshot first) {
|
||||
add(first);
|
||||
@@ -17,10 +19,16 @@ public final class MapMarkerCluster {
|
||||
markers.add(marker);
|
||||
sumLon += marker.longitudeDeg();
|
||||
sumLat += marker.latitudeDeg();
|
||||
double longitudeRad = Math.toRadians(marker.longitudeDeg());
|
||||
sumLonSin += Math.sin(longitudeRad);
|
||||
sumLonCos += Math.cos(longitudeRad);
|
||||
}
|
||||
|
||||
public double getCenterLon() {
|
||||
return sumLon / markers.size();
|
||||
if (Math.abs(sumLonSin) < 1e-12 && Math.abs(sumLonCos) < 1e-12) {
|
||||
return sumLon / markers.size();
|
||||
}
|
||||
return Math.toDegrees(Math.atan2(sumLonSin, sumLonCos));
|
||||
}
|
||||
|
||||
public double getCenterLat() {
|
||||
|
||||
@@ -99,6 +99,21 @@ public final class MapViewport {
|
||||
return widthPx / 2.0 + (WebMercatorProjection.lonToX(lon, tileZoom) - centerWorldX) * tileScale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Places a longitude in the closest wrapped copy of the world.
|
||||
*
|
||||
* <p>A target at 179 degrees east and a centre at 179 degrees west are two degrees
|
||||
* apart, not 358. Tiles already wrap at the anti-meridian; overlays and hit testing
|
||||
* need the same world copy to stay aligned with them.</p>
|
||||
*/
|
||||
public double screenXWrapped(double lon) {
|
||||
double worldSize = TILE_SIZE_PX * Math.pow(2.0, tileZoom);
|
||||
double worldX = WebMercatorProjection.lonToX(lon, tileZoom);
|
||||
double delta = worldX - centerWorldX;
|
||||
delta -= Math.rint(delta / worldSize) * worldSize;
|
||||
return widthPx / 2.0 + delta * tileScale;
|
||||
}
|
||||
|
||||
public double screenY(double lat) {
|
||||
return heightPx / 2.0 + (WebMercatorProjection.latToY(lat, tileZoom) - centerWorldY) * tileScale;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
/** Pure viewport calculations for deliberate station-map navigation. */
|
||||
public final class MapViewportNavigation {
|
||||
|
||||
private static final double WEB_MERCATOR_MAX_LATITUDE = 85.05112878;
|
||||
|
||||
private MapViewportNavigation() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Centres the path and zooms out only when both endpoints do not fit with padding.
|
||||
*
|
||||
* @return the fitted viewport, or the original viewport for invalid positions or size
|
||||
*/
|
||||
public static MapViewport fitPath(MapViewport current,
|
||||
double homeLatitudeDeg,
|
||||
double homeLongitudeDeg,
|
||||
double targetLatitudeDeg,
|
||||
double targetLongitudeDeg,
|
||||
double paddingPx) {
|
||||
if (current == null
|
||||
|| !isPosition(homeLatitudeDeg, homeLongitudeDeg)
|
||||
|| !isPosition(targetLatitudeDeg, targetLongitudeDeg)
|
||||
|| current.widthPx() <= 0.0
|
||||
|| current.heightPx() <= 0.0) {
|
||||
return current;
|
||||
}
|
||||
|
||||
double padding = Math.max(0.0, paddingPx);
|
||||
double availableWidth = current.widthPx() - 2.0 * padding;
|
||||
double availableHeight = current.heightPx() - 2.0 * padding;
|
||||
if (availableWidth <= 0.0 || availableHeight <= 0.0) {
|
||||
return current;
|
||||
}
|
||||
|
||||
double homeWorldX = WebMercatorProjection.lonToX(homeLongitudeDeg, 0);
|
||||
double targetWorldX = WebMercatorProjection.lonToX(targetLongitudeDeg, 0);
|
||||
double worldSize = 256.0;
|
||||
double deltaX = targetWorldX - homeWorldX;
|
||||
deltaX -= Math.rint(deltaX / worldSize) * worldSize;
|
||||
|
||||
double centerWorldX = homeWorldX + deltaX / 2.0;
|
||||
double homeWorldY = WebMercatorProjection.latToY(homeLatitudeDeg, 0);
|
||||
double targetWorldY = WebMercatorProjection.latToY(targetLatitudeDeg, 0);
|
||||
double centerWorldY = (homeWorldY + targetWorldY) / 2.0;
|
||||
|
||||
double centerLongitude = WebMercatorProjection.xToLon(centerWorldX, 0);
|
||||
double centerLatitude = WebMercatorProjection.yToLat(centerWorldY, 0);
|
||||
MapViewport centred = new MapViewport(
|
||||
centerLongitude,
|
||||
centerLatitude,
|
||||
current.zoom(),
|
||||
current.widthPx(),
|
||||
current.heightPx()
|
||||
);
|
||||
|
||||
double widthAtCurrentZoom = Math.abs(
|
||||
centred.screenXWrapped(targetLongitudeDeg)
|
||||
- centred.screenXWrapped(homeLongitudeDeg)
|
||||
);
|
||||
double heightAtCurrentZoom = Math.abs(
|
||||
centred.screenY(targetLatitudeDeg) - centred.screenY(homeLatitudeDeg)
|
||||
);
|
||||
|
||||
double widthRatio = widthAtCurrentZoom == 0.0
|
||||
? Double.POSITIVE_INFINITY
|
||||
: availableWidth / widthAtCurrentZoom;
|
||||
double heightRatio = heightAtCurrentZoom == 0.0
|
||||
? Double.POSITIVE_INFINITY
|
||||
: availableHeight / heightAtCurrentZoom;
|
||||
double fitRatio = Math.min(widthRatio, heightRatio);
|
||||
|
||||
if (!Double.isFinite(fitRatio) || fitRatio >= 1.0) {
|
||||
return centred;
|
||||
}
|
||||
|
||||
double fittedZoom = current.zoom() + Math.log(fitRatio) / Math.log(2.0);
|
||||
return new MapViewport(
|
||||
centerLongitude,
|
||||
centerLatitude,
|
||||
fittedZoom,
|
||||
current.widthPx(),
|
||||
current.heightPx()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves the zoom unchanged and places most of the viewport in the chosen direction.
|
||||
*/
|
||||
public static MapViewport centreOnBearing(MapViewport current,
|
||||
double homeLatitudeDeg,
|
||||
double homeLongitudeDeg,
|
||||
double bearingDeg,
|
||||
double forwardCentreFraction) {
|
||||
if (current == null
|
||||
|| !isPosition(homeLatitudeDeg, homeLongitudeDeg)
|
||||
|| !Double.isFinite(bearingDeg)
|
||||
|| current.widthPx() <= 0.0
|
||||
|| current.heightPx() <= 0.0) {
|
||||
return current;
|
||||
}
|
||||
|
||||
double fraction = Math.max(0.0, Math.min(0.45, forwardCentreFraction));
|
||||
double shiftPx = Math.min(current.widthPx(), current.heightPx()) * fraction;
|
||||
double bearingRad = Math.toRadians(normaliseBearing(bearingDeg));
|
||||
|
||||
MapViewport homeCentred = new MapViewport(
|
||||
homeLongitudeDeg,
|
||||
homeLatitudeDeg,
|
||||
current.zoom(),
|
||||
current.widthPx(),
|
||||
current.heightPx()
|
||||
);
|
||||
double centreX = current.widthPx() / 2.0 + Math.sin(bearingRad) * shiftPx;
|
||||
double centreY = current.heightPx() / 2.0 - Math.cos(bearingRad) * shiftPx;
|
||||
|
||||
return new MapViewport(
|
||||
homeCentred.lonAt(centreX),
|
||||
homeCentred.latAt(centreY),
|
||||
current.zoom(),
|
||||
current.widthPx(),
|
||||
current.heightPx()
|
||||
);
|
||||
}
|
||||
|
||||
public static MapViewport centreOnHome(MapViewport current,
|
||||
double homeLatitudeDeg,
|
||||
double homeLongitudeDeg) {
|
||||
if (current == null || !isPosition(homeLatitudeDeg, homeLongitudeDeg)) {
|
||||
return current;
|
||||
}
|
||||
return new MapViewport(
|
||||
homeLongitudeDeg,
|
||||
homeLatitudeDeg,
|
||||
current.zoom(),
|
||||
current.widthPx(),
|
||||
current.heightPx()
|
||||
);
|
||||
}
|
||||
|
||||
private static boolean isPosition(double latitudeDeg, double longitudeDeg) {
|
||||
return Double.isFinite(latitudeDeg)
|
||||
&& Double.isFinite(longitudeDeg)
|
||||
&& latitudeDeg >= -WEB_MERCATOR_MAX_LATITUDE
|
||||
&& latitudeDeg <= WEB_MERCATOR_MAX_LATITUDE;
|
||||
}
|
||||
|
||||
private static double normaliseBearing(double bearingDeg) {
|
||||
double normalised = bearingDeg % 360.0;
|
||||
return normalised < 0.0 ? normalised + 360.0 : normalised;
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ public final class StationMapClusterer {
|
||||
continue;
|
||||
}
|
||||
|
||||
long cellX = (long) Math.floor(viewport.screenX(marker.longitudeDeg()) / cellSizePx);
|
||||
long cellX = (long) Math.floor(viewport.screenXWrapped(marker.longitudeDeg()) / cellSizePx);
|
||||
long cellY = (long) Math.floor(viewport.screenY(marker.latitudeDeg()) / cellSizePx);
|
||||
|
||||
buckets.computeIfAbsent(cellKey(cellX, cellY), key -> new ArrayList<>()).add(marker);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import kst4contest.model.ChatPreferences;
|
||||
import kst4contest.model.ThreadStateMessage;
|
||||
|
||||
class BeaconControlTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private ChatPreferences preferences;
|
||||
private ChatController controller;
|
||||
private StatusUpdateListener listener;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
preferences = new ChatPreferences(tempDir.resolve("preferences.xml"));
|
||||
controller = new ChatController();
|
||||
controller.setChatPreferences(preferences);
|
||||
listener = mock(StatusUpdateListener.class);
|
||||
controller.setStatusListener(listener);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toggleWithSecondChatDisabledChangesOnlyTheMainBeacon() {
|
||||
preferences.setLoginToSecondChatEnabled(false);
|
||||
preferences.setBcn_beaconsEnabledMainCat(false);
|
||||
preferences.setBcn_beaconsEnabledSecondCat(true);
|
||||
|
||||
controller.toggleUsableBeacons();
|
||||
|
||||
assertTrue(preferences.isBcn_beaconsEnabledMainCat());
|
||||
assertTrue(preferences.isBcn_beaconsEnabledSecondCat(),
|
||||
"the dormant second-category choice must not be overwritten");
|
||||
assertLastDescription("on");
|
||||
|
||||
controller.toggleUsableBeacons();
|
||||
|
||||
assertFalse(preferences.isBcn_beaconsEnabledMainCat());
|
||||
assertTrue(preferences.isBcn_beaconsEnabledSecondCat());
|
||||
assertLastDescription("off");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toggleWithSecondChatEnabledSwitchesBothUsableBeacons() {
|
||||
preferences.setLoginToSecondChatEnabled(true);
|
||||
preferences.setBcn_beaconsEnabledMainCat(false);
|
||||
preferences.setBcn_beaconsEnabledSecondCat(false);
|
||||
|
||||
controller.toggleUsableBeacons();
|
||||
|
||||
assertTrue(preferences.isBcn_beaconsEnabledMainCat());
|
||||
assertTrue(preferences.isBcn_beaconsEnabledSecondCat());
|
||||
assertLastDescription("on");
|
||||
|
||||
preferences.setBcn_beaconsEnabledSecondCat(false);
|
||||
controller.toggleUsableBeacons();
|
||||
|
||||
assertFalse(preferences.isBcn_beaconsEnabledMainCat());
|
||||
assertFalse(preferences.isBcn_beaconsEnabledSecondCat());
|
||||
assertLastDescription("off");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anIndividualCheckboxPublishesThePartialStateImmediately() {
|
||||
preferences.setLoginToSecondChatEnabled(true);
|
||||
preferences.setBcn_beaconsEnabledMainCat(false);
|
||||
preferences.setBcn_beaconsEnabledSecondCat(false);
|
||||
|
||||
controller.setMainBeaconEnabled(true);
|
||||
|
||||
assertTrue(preferences.isBcn_beaconsEnabledMainCat());
|
||||
assertFalse(preferences.isBcn_beaconsEnabledSecondCat());
|
||||
assertLastDescription("1/2");
|
||||
}
|
||||
|
||||
private void assertLastDescription(String expected) {
|
||||
ArgumentCaptor<String> key = ArgumentCaptor.forClass(String.class);
|
||||
ArgumentCaptor<ThreadStateMessage> message = ArgumentCaptor.forClass(ThreadStateMessage.class);
|
||||
org.mockito.Mockito.verify(listener, org.mockito.Mockito.atLeastOnce())
|
||||
.onThreadStatusChanged(key.capture(), message.capture());
|
||||
assertEquals("MyBeacon", key.getValue());
|
||||
assertEquals(expected, message.getValue().getRunningInformationTextDescription());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import kst4contest.model.ChatCategory;
|
||||
import kst4contest.model.ChatMessage;
|
||||
import kst4contest.model.ChatPreferences;
|
||||
import kst4contest.model.ThreadStateMessage;
|
||||
|
||||
class BeaconTaskTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private ChatPreferences preferences;
|
||||
private LinkedBlockingQueue<ChatMessage> txBus;
|
||||
private List<ThreadStateMessage> statuses;
|
||||
private ChatController controller;
|
||||
private BeaconTask task;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
preferences = new ChatPreferences(tempDir.resolve("preferences.xml"));
|
||||
preferences.setLoginChatCategoryMain(new ChatCategory(ChatCategory.VUHF));
|
||||
preferences.setLoginChatCategorySecond(new ChatCategory(ChatCategory.MICROWAVE));
|
||||
preferences.setLoginToSecondChatEnabled(true);
|
||||
preferences.setBcn_beaconTextMainCat("CQ MAIN");
|
||||
preferences.setBcn_beaconTextSecondCat("CQ SECOND");
|
||||
preferences.setBcn_beaconsEnabledMainCat(false);
|
||||
preferences.setBcn_beaconsEnabledSecondCat(false);
|
||||
|
||||
statuses = new ArrayList<>();
|
||||
controller = new ChatController();
|
||||
controller.setChatPreferences(preferences);
|
||||
txBus = new LinkedBlockingQueue<>();
|
||||
controller.setMessageTXBus(txBus);
|
||||
controller.setStatusListener(new StatusUpdateListener() {
|
||||
@Override
|
||||
public void onThreadStatusChanged(String key, ThreadStateMessage message) {
|
||||
statuses.add(message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUserListUpdated() {
|
||||
// Not used by beacon tests.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUserListUpdated(String reason) {
|
||||
// Not used by beacon tests.
|
||||
}
|
||||
});
|
||||
task = new BeaconTask(controller, (key, message) -> statuses.add(message));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidEnabledBeaconIsNotQueuedAndReportsOneCriticalAggregateState() {
|
||||
preferences.setBcn_beaconsEnabledMainCat(true);
|
||||
preferences.setBcn_beaconTextMainCat("invalid|template");
|
||||
|
||||
task.run();
|
||||
|
||||
assertTrue(txBus.isEmpty());
|
||||
assertEquals(1, statuses.size());
|
||||
ThreadStateMessage status = statuses.get(0);
|
||||
assertEquals("FAILED", status.getRunningInformationTextDescription());
|
||||
assertTrue(status.isRunning());
|
||||
assertTrue(status.isCriticalState());
|
||||
}
|
||||
|
||||
@Test
|
||||
void changingTheOtherCheckboxDoesNotHideAnActiveCategoryFailure() {
|
||||
preferences.setBcn_beaconsEnabledMainCat(true);
|
||||
preferences.setBcn_beaconTextMainCat("invalid|template");
|
||||
task.run();
|
||||
statuses.clear();
|
||||
|
||||
controller.setSecondBeaconEnabled(true);
|
||||
|
||||
assertEquals(1, statuses.size());
|
||||
ThreadStateMessage status = statuses.get(0);
|
||||
assertEquals("FAILED", status.getRunningInformationTextDescription());
|
||||
assertTrue(status.isCriticalState());
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyRunUsesTheCurrentEnableFlagsAndReportsOneAggregateState() {
|
||||
task.run();
|
||||
assertTrue(txBus.isEmpty());
|
||||
assertAggregateStatus("off", false);
|
||||
|
||||
statuses.clear();
|
||||
preferences.setBcn_beaconsEnabledMainCat(true);
|
||||
task.run();
|
||||
assertEquals(1, txBus.size());
|
||||
assertEquals(ChatCategory.VUHF, txBus.peek().getChatCategory().getCategoryNumber());
|
||||
assertAggregateStatus("1/2", true);
|
||||
|
||||
statuses.clear();
|
||||
preferences.setBcn_beaconsEnabledSecondCat(true);
|
||||
task.run();
|
||||
assertEquals(3, txBus.size());
|
||||
assertAggregateStatus("on", true);
|
||||
}
|
||||
|
||||
private void assertAggregateStatus(String description, boolean running) {
|
||||
assertEquals(1, statuses.size(), "one timer run must leave one authoritative chip state");
|
||||
ThreadStateMessage status = statuses.get(0);
|
||||
assertEquals(description, status.getRunningInformationTextDescription());
|
||||
assertEquals(running, status.isRunning());
|
||||
assertFalse(status.isCriticalState());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package kst4contest.model;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ChatPreferencesBeaconStateTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void beaconActivationChangesAreObservableAndIndependent() {
|
||||
ChatPreferences preferences = preferences("observable.xml");
|
||||
List<Boolean> mainChanges = new ArrayList<>();
|
||||
List<Boolean> secondChanges = new ArrayList<>();
|
||||
preferences.bcnBeaconsEnabledMainCatProperty().addListener(mainChanges::add);
|
||||
preferences.bcnBeaconsEnabledSecondCatProperty().addListener(secondChanges::add);
|
||||
|
||||
preferences.setBcn_beaconsEnabledMainCat(false);
|
||||
preferences.setBcn_beaconsEnabledSecondCat(true);
|
||||
|
||||
assertFalse(preferences.isBcn_beaconsEnabledMainCat());
|
||||
assertTrue(preferences.isBcn_beaconsEnabledSecondCat());
|
||||
assertEquals(List.of(false), mainChanges);
|
||||
assertEquals(List.of(true), secondChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
void explicitWritePersistsBeaconActivationButAnUnsavedChangeDoesNot() {
|
||||
ChatPreferences preferences = preferences("persistence.xml");
|
||||
preferences.setBcn_beaconsEnabledMainCat(false);
|
||||
preferences.setBcn_beaconsEnabledSecondCat(true);
|
||||
assertTrue(preferences.writePreferencesToXmlFile());
|
||||
|
||||
preferences.setBcn_beaconsEnabledMainCat(true);
|
||||
preferences.setBcn_beaconsEnabledSecondCat(false);
|
||||
assertTrue(preferences.writeLayoutPreferencesToXmlFile());
|
||||
|
||||
ChatPreferences restored = preferences("persistence.xml");
|
||||
restored.readPreferencesFromXmlFile();
|
||||
|
||||
assertFalse(restored.isBcn_beaconsEnabledMainCat());
|
||||
assertTrue(restored.isBcn_beaconsEnabledSecondCat());
|
||||
}
|
||||
|
||||
private ChatPreferences preferences(String fileName) {
|
||||
return new ChatPreferences(tempDir.resolve(fileName));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class MaidenheadGridUtilsTest {
|
||||
|
||||
@Test
|
||||
void a_view_crossing_the_anti_meridian_contains_cells_from_both_world_edges() {
|
||||
var cells = MaidenheadGridUtils.buildVisibleCells(
|
||||
-2.0,
|
||||
175.0,
|
||||
2.0,
|
||||
185.0,
|
||||
MaidenheadGridUtils.GridPrecision.FIELD_2
|
||||
);
|
||||
Set<String> labels = cells.stream()
|
||||
.map(MaidenheadGridUtils.GridCell::locatorLabel)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
assertTrue(labels.stream().anyMatch(label -> label.startsWith("R")), labels.toString());
|
||||
assertTrue(labels.stream().anyMatch(label -> label.startsWith("A")), labels.toString());
|
||||
assertEquals(labels.size(), cells.size(), "a wrapped cell was returned twice");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class MapViewportNavigationTest {
|
||||
|
||||
private static final double PADDING_PX = 48.0;
|
||||
|
||||
@Test
|
||||
void a_path_that_fits_keeps_the_existing_zoom() {
|
||||
MapViewport before = new MapViewport(10.0, 51.0, 8.5, 1000.0, 700.0);
|
||||
|
||||
MapViewport fitted = MapViewportNavigation.fitPath(
|
||||
before, 51.0, 10.0, 51.2, 10.4, PADDING_PX);
|
||||
|
||||
assertEquals(8.5, fitted.zoom(), 1e-9);
|
||||
assertInsidePadding(fitted, 51.0, 10.0);
|
||||
assertInsidePadding(fitted, 51.2, 10.4);
|
||||
}
|
||||
|
||||
@Test
|
||||
void a_long_path_zooms_out_until_both_endpoints_fit() {
|
||||
MapViewport before = new MapViewport(10.0, 51.0, 12.0, 900.0, 600.0);
|
||||
|
||||
MapViewport fitted = MapViewportNavigation.fitPath(
|
||||
before, 51.0, 10.0, 44.0, -3.0, PADDING_PX);
|
||||
|
||||
assertTrue(fitted.zoom() < before.zoom());
|
||||
assertInsidePadding(fitted, 51.0, 10.0);
|
||||
assertInsidePadding(fitted, 44.0, -3.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fitting_across_the_anti_meridian_uses_the_short_path() {
|
||||
MapViewport before = new MapViewport(180.0, 0.0, 7.0, 900.0, 600.0);
|
||||
|
||||
MapViewport fitted = MapViewportNavigation.fitPath(
|
||||
before, 0.0, 179.0, 0.0, -179.0, PADDING_PX);
|
||||
|
||||
assertEquals(7.0, fitted.zoom(), 1e-9, "two degrees should not look like a world-spanning path");
|
||||
assertInsidePadding(fitted, 0.0, 179.0);
|
||||
assertInsidePadding(fitted, 0.0, -179.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bearing_navigation_preserves_zoom_and_moves_towards_the_sector() {
|
||||
MapViewport before = new MapViewport(10.0, 51.0, 8.25, 1000.0, 600.0);
|
||||
|
||||
MapViewport northEast = MapViewportNavigation.centreOnBearing(
|
||||
before, 51.0, 10.0, 45.0, 0.25);
|
||||
|
||||
assertEquals(8.25, northEast.zoom(), 1e-9);
|
||||
assertTrue(northEast.centerLon() > 10.0);
|
||||
assertTrue(northEast.centerLat() > 51.0);
|
||||
assertTrue(northEast.screenXWrapped(10.0) < northEast.widthPx() / 2.0);
|
||||
assertTrue(northEast.screenY(51.0) > northEast.heightPx() / 2.0);
|
||||
}
|
||||
|
||||
private static void assertInsidePadding(MapViewport viewport, double latitude, double longitude) {
|
||||
double x = viewport.screenXWrapped(longitude);
|
||||
double y = viewport.screenY(latitude);
|
||||
assertTrue(x >= PADDING_PX - 1e-6 && x <= viewport.widthPx() - PADDING_PX + 1e-6,
|
||||
"longitude outside padded viewport at x=" + x);
|
||||
assertTrue(y >= PADDING_PX - 1e-6 && y <= viewport.heightPx() - PADDING_PX + 1e-6,
|
||||
"latitude outside padded viewport at y=" + y);
|
||||
}
|
||||
}
|
||||
@@ -203,4 +203,12 @@ class MapViewportTest {
|
||||
assertEquals(179.0, new MapViewport(-181.0, 0.0, 6.0, 800.0, 600.0).centerLon(), TOLERANCE_DEG);
|
||||
assertEquals(0.0, new MapViewport(360.0, 0.0, 6.0, 800.0, 600.0).centerLon(), TOLERANCE_DEG);
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrapped_screen_coordinates_use_the_nearest_world_copy() {
|
||||
MapViewport viewport = new MapViewport(179.0, 0.0, 6.0, 800.0, 600.0);
|
||||
|
||||
assertTrue(Math.abs(viewport.screenXWrapped(-179.0) - viewport.screenXWrapped(179.0)) < 100.0);
|
||||
assertTrue(Math.abs(viewport.screenX(-179.0) - viewport.screenX(179.0)) > 10_000.0);
|
||||
}
|
||||
}
|
||||
@@ -174,6 +174,21 @@ class StationMapClustererTest {
|
||||
assertEquals(stations.size(), clustered + result.individual().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nearby_stations_across_the_anti_meridian_share_a_cluster() {
|
||||
MapViewport viewport = new MapViewport(180.0, 0.0, 5.0, 1000.0, 800.0);
|
||||
List<MapCallsignRawSnapshot> stations = List.of(
|
||||
station("ZL1AAA", 0.0, 179.9),
|
||||
station("ZL1BBB", 0.0, -179.9),
|
||||
station("ZL1CCC", 0.01, 179.8)
|
||||
);
|
||||
|
||||
StationMapClusterer.Result result = StationMapClusterer.cluster(stations, viewport, true);
|
||||
|
||||
assertEquals(1, result.clusters().size());
|
||||
assertTrue(Math.abs(Math.abs(result.clusters().get(0).getCenterLon()) - 180.0) < 0.2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selecting a station moves the map to it. It must not throw the operator's zoom
|
||||
* away doing so.
|
||||
|
||||
Reference in new issue
Block a user