feat: improve station map navigation and overlays

This commit is contained in:
Marc Froehlich committed 2026-11-05 00:38:48 +01:00
1 parent b8bb86977f
commit 3d2fb70f1a
38 files changed
+1783 -166

No files matched your search

@@ -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,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.