Draw the whole interface in Compose

Etappen 3 to 6a of the migration (129 commits). Every window the operator sees
is Compose; the JavaFX ones still exist beside them so the two could be
compared against a live session, which is the only way the differences came to
light.

The toolchain first, then the settings window with its eleven tabs and 72 real
preferences; then the multi-column table as a component, and with it the rules
it needs -- stable row identity, a selection that survives a refresh, column
order the operator can drag, and a repaint when the rows change rather than
when the list instance does. Then the update and monitor windows, and the main
window: 22 station columns, the filter bar, the selected-station panel, the
message tabs, the priority bar, the sked controls, the status indicators and
the timeline, assembled behind a splitter because Compose has none.

The map stops being a WebView. A Compose canvas projects WebMercator tiles
itself, with the clustering in core and an asynchronous fetcher in place of the
local tile proxy; derive() becomes a pure Kotlin reimplementation instead of a
reflection call into com.sun.javafx.

Most of the volume here is parity the operator would notice rather than new
behaviour: desktop density instead of Material's phone minimums, colours taken
from the JavaFX stylesheets instead of Material's palette, row dividers, band
columns, grid-square labels and their colours, the home marker and antenna
beam, pinch-to-zoom under Wayland, and layouts that survive a window narrower
than the chart they stack.

Two lessons are worth more than the diff. A control bound to a value Compose
cannot observe writes through and never redraws -- that one shipped repeatedly
with a green build, because no test drew anything. And a layout that clips
instead of wrapping is worse than one that is merely too narrow: half a word
is the worst available outcome.
This commit is contained in:
Rsclub2_2 committed 2026-10-05 22:33:28 +02:00
1 parent a6fba12f01
commit cc8c90204e
173 files changed
+26500 -4305

No files matched your search

@@ -92,8 +92,14 @@ public final class MapCallsignRawSnapshotBuilder {
boolean worked = variants.stream().anyMatch(this::isWorkedAtAnyBand);
boolean selected = callSignRaw.equals(selectedCallsignRaw);
double qrbKm = representative.getQrb() != null ? representative.getQrb() : 0.0;
double qtfDeg = representative.getQTFdirection() != null ? representative.getQTFdirection() : 0.0;
/*
* NaN and not 0.0 when the value is absent. Zero is a legitimate reading —
* a station at the operator's own locator is 0 km away — so collapsing null
* onto it destroys the difference between "right here" and "not known", and
* every consumer then shows a distance that was never measured.
*/
double qrbKm = representative.getQrb() != null ? representative.getQrb() : Double.NaN;
double qtfDeg = representative.getQTFdirection() != null ? representative.getQTFdirection() : Double.NaN;
int reachableAirplanes = variants.stream()
.map(ChatMember::getAirPlaneReflectInfo)
@@ -0,0 +1,53 @@
package kst4contest.view.map;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class MapMarkerCluster {
private final List<MapCallsignRawSnapshot> markers = new ArrayList<>();
private double sumLon = 0.0;
private double sumLat = 0.0;
public MapMarkerCluster(MapCallsignRawSnapshot first) {
add(first);
}
public void add(MapCallsignRawSnapshot marker) {
markers.add(marker);
sumLon += marker.longitudeDeg();
sumLat += marker.latitudeDeg();
}
public double getCenterLon() {
return sumLon / markers.size();
}
public double getCenterLat() {
return sumLat / markers.size();
}
public double getCenterX(int zoom) {
return WebMercatorProjection.lonToX(getCenterLon(), zoom);
}
public double getCenterY(int zoom) {
return WebMercatorProjection.latToY(getCenterLat(), zoom);
}
public List<MapCallsignRawSnapshot> getMarkers() {
return Collections.unmodifiableList(markers);
}
public int size() {
return markers.size();
}
public boolean isSingle() {
return markers.size() == 1;
}
public MapCallsignRawSnapshot getSingle() {
return markers.get(0);
}
}
@@ -0,0 +1,51 @@
package kst4contest.view.map;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* What hovering a cluster bubble says.
*
* The count first, because that is the question the bubble raises. Then the callsigns,
* alphabetically and capped, because a cell at low zoom can hold a hundred of them and
* a tooltip taller than the window hides the map it was meant to explain. Then the hint
* that clicking zooms in, without which a bubble looks like a dead end.
*
* Kept as the Leaflet tooltip had it (buildClusterTooltipHtml); only the line breaks
* differ, because this one is drawn on a canvas rather than written into HTML.
*/
public final class MapMarkerClusterTooltip {
/** Twenty, as the Leaflet tooltip did. Beyond that a list stops being readable. */
public static final int MAX_PREVIEWED_STATIONS = 20;
private MapMarkerClusterTooltip() {
}
public static String build(MapMarkerCluster cluster) {
List<String> callsigns = new ArrayList<>();
for (MapCallsignRawSnapshot marker : cluster.getMarkers()) {
String label = marker.markerLabel();
if (label != null && !label.isBlank()) {
callsigns.add(label.trim());
}
}
Collections.sort(callsigns);
StringBuilder text = new StringBuilder();
text.append(cluster.size()).append(" stations");
int previewed = Math.min(MAX_PREVIEWED_STATIONS, callsigns.size());
for (int i = 0; i < previewed; i++) {
text.append('\n').append(callsigns.get(i));
}
int remaining = callsigns.size() - previewed;
if (remaining > 0) {
text.append('\n').append('+').append(remaining).append(" more");
}
return text.append('\n').append("Click to zoom in").toString();
}
}
@@ -0,0 +1,230 @@
package kst4contest.view.map;
/**
* What the map currently shows, and where a position lands on the canvas.
*
* Everything the map draws except the tiles is drawn in screen pixels: a station dot is
* 12 px at every zoom, a label is 12 px at every zoom, a grid line is 1.4 px at every
* zoom. Leaflet behaved that way because its overlays live in the DOM above a scaled
* tile layer. The first Compose port instead computed positions at the integer zoom and
* then scaled the whole canvas by the fractional part, which made every drawn size a
* function of that fraction and forced the drawing code to divide each size back out
* again. Sizes were then right at one zoom and wrong at the next.
*
* This class folds the fraction into the projection instead. {@link #screenX(double)}
* and {@link #screenY(double)} answer in canvas pixels, so the drawing code needs no
* scaling and no compensation. Only the tiles are scaled, by {@link #tileScale()},
* because tiles exist at integer levels only.
*
* Immutable: a viewport describes one frame. Panning and zooming produce a new one.
*/
public final class MapViewport {
/**
* The range the tile source serves. Leaflet was configured for exactly this
* ({@code KST_MIN_ZOOM}/{@code KST_MAX_ZOOM}); outside it the requested tiles do
* not exist.
*/
public static final double MIN_ZOOM = 3.0;
public static final double MAX_ZOOM = 18.0;
private static final double TILE_SIZE_PX = 256.0;
private final double centerLon;
private final double centerLat;
private final double zoom;
private final double widthPx;
private final double heightPx;
private final int tileZoom;
private final double tileScale;
private final double centerWorldX;
private final double centerWorldY;
public MapViewport(double centerLon,
double centerLat,
double zoom,
double widthPx,
double heightPx) {
this.centerLon = normaliseLongitude(centerLon);
this.centerLat = centerLat;
this.zoom = clamp(zoom, MIN_ZOOM, MAX_ZOOM);
this.widthPx = Math.max(0.0, widthPx);
this.heightPx = Math.max(0.0, heightPx);
this.tileZoom = (int) Math.floor(this.zoom);
this.tileScale = Math.pow(2.0, this.zoom - this.tileZoom);
this.centerWorldX = WebMercatorProjection.lonToX(this.centerLon, this.tileZoom);
this.centerWorldY = WebMercatorProjection.latToY(centerLat, this.tileZoom);
}
public double centerLon() {
return centerLon;
}
public double centerLat() {
return centerLat;
}
/** The zoom actually in force, after clamping to the served range. */
public double zoom() {
return zoom;
}
public double widthPx() {
return widthPx;
}
public double heightPx() {
return heightPx;
}
/** The integer level the tiles come from. */
public int tileZoom() {
return tileZoom;
}
/** What a tile of that level must be scaled by to match the fractional zoom. */
public double tileScale() {
return tileScale;
}
/** The on-screen size of one tile, in canvas pixels. */
public double tileSizePx() {
return TILE_SIZE_PX * tileScale;
}
public double screenX(double lon) {
return widthPx / 2.0 + (WebMercatorProjection.lonToX(lon, tileZoom) - centerWorldX) * tileScale;
}
public double screenY(double lat) {
return heightPx / 2.0 + (WebMercatorProjection.latToY(lat, tileZoom) - centerWorldY) * tileScale;
}
/** Where a world pixel of {@link #tileZoom()} lands on the canvas. */
public double screenXForWorldX(double worldX) {
return widthPx / 2.0 + (worldX - centerWorldX) * tileScale;
}
public double screenYForWorldY(double worldY) {
return heightPx / 2.0 + (worldY - centerWorldY) * tileScale;
}
public double lonAt(double screenX) {
return WebMercatorProjection.xToLon(worldXAt(screenX), tileZoom);
}
public double latAt(double screenY) {
return WebMercatorProjection.yToLat(worldYAt(screenY), tileZoom);
}
public double worldXAt(double screenX) {
return centerWorldX + (screenX - widthPx / 2.0) / tileScale;
}
public double worldYAt(double screenY) {
return centerWorldY + (screenY - heightPx / 2.0) / tileScale;
}
public double westLon() {
return lonAt(0.0);
}
public double eastLon() {
return lonAt(widthPx);
}
/** North is the top of the canvas, which is y = 0. */
public double northLat() {
return latAt(0.0);
}
public double southLat() {
return latAt(heightPx);
}
/**
* The same view moved by a drag, in canvas pixels.
*
* A drag of n pixels moves what was under the pointer by exactly n pixels, which is
* why the delta is divided by the tile scale rather than applied to the world
* coordinates directly.
*/
public MapViewport pannedBy(double deltaX, double deltaY) {
return centredOnWorld(
centerWorldX - deltaX / tileScale,
centerWorldY - deltaY / tileScale,
zoom
);
}
/**
* The same view at a different zoom, with the position under
* {@code anchorX}/{@code anchorY} left where it is.
*
* Zooming about the pointer rather than the centre is what makes a wheel zoom feel
* like a map. The anchor's position is read before the zoom changes and the new
* centre is derived from it afterwards, because the world coordinates of a position
* change with the tile level.
*/
public MapViewport zoomedTo(double newZoom, double anchorX, double anchorY) {
double anchorLon = lonAt(anchorX);
double anchorLat = latAt(anchorY);
double clampedZoom = clamp(newZoom, MIN_ZOOM, MAX_ZOOM);
int newTileZoom = (int) Math.floor(clampedZoom);
double newTileScale = Math.pow(2.0, clampedZoom - newTileZoom);
double anchorWorldX = WebMercatorProjection.lonToX(anchorLon, newTileZoom);
double anchorWorldY = WebMercatorProjection.latToY(anchorLat, newTileZoom);
return new MapViewport(
WebMercatorProjection.xToLon(anchorWorldX - (anchorX - widthPx / 2.0) / newTileScale, newTileZoom),
WebMercatorProjection.yToLat(anchorWorldY - (anchorY - heightPx / 2.0) / newTileScale, newTileZoom),
clampedZoom,
widthPx,
heightPx
);
}
/** The same view on a canvas of a different size. */
public MapViewport resizedTo(double newWidthPx, double newHeightPx) {
return new MapViewport(centerLon, centerLat, zoom, newWidthPx, newHeightPx);
}
private MapViewport centredOnWorld(double worldX, double worldY, double atZoom) {
return new MapViewport(
WebMercatorProjection.xToLon(worldX, tileZoom),
WebMercatorProjection.yToLat(worldY, tileZoom),
atZoom,
widthPx,
heightPx
);
}
/**
* Brings a longitude back into [-180, 180).
*
* Panning east is unbounded arithmetic. The tile indices wrap, so the basemap keeps
* drawing, but nothing else does: a centre at longitude 262 puts a station at -170
* thousands of pixels off the canvas and hands the grid a span it clamps to
* nothing. The stations and the grid then disappear while the map underneath looks
* perfectly fine.
*/
private static double normaliseLongitude(double lon) {
if (!Double.isFinite(lon)) {
return 0.0;
}
double wrapped = (lon + 180.0) % 360.0;
if (wrapped < 0.0) {
wrapped += 360.0;
}
return wrapped - 180.0;
}
private static double clamp(double value, double min, double max) {
return Math.max(min, Math.min(max, value));
}
}
@@ -0,0 +1,160 @@
package kst4contest.view.map;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Groups stations that sit on top of each other on screen.
*
* The rules come from the Leaflet map and are kept as they were, because an operator
* has learned them: grouping is by screen cell rather than by distance, a cell holds a
* bubble only from three stations upwards, grouping stops once the map is zoomed in far
* enough to show everything, and two kinds of station are never hidden in a bubble.
*
* Screen based and not locator based on purpose. The problem being solved is too many
* labels in one part of the window, and that is a question about pixels.
*
* The first Compose port replaced this with nearest-neighbour grouping at a fixed 50 px
* from two stations upwards and never switched it off, which put a "2" bubble over
* most of the map.
*/
public final class StationMapClusterer {
/**
* Two nearby stations are still readable and should stay individually clickable, so
* a bubble starts at three.
*/
public static final int MIN_STATIONS_PER_CLUSTER = 3;
/** From this zoom upwards the map shows every station. */
public static final double DISABLE_AT_ZOOM = 8.0;
private static final double CELL_SIZE_HIGH_ZOOM_PX = 55.0;
private static final double CELL_SIZE_MEDIUM_ZOOM_PX = 70.0;
private static final double CELL_SIZE_LOW_ZOOM_PX = 95.0;
private static final double CELL_SIZE_VERY_LOW_ZOOM_PX = 125.0;
private StationMapClusterer() {
}
/**
* What the map should draw: bubbles for the crowded cells, single markers for
* everything else.
*
* @param clusters stations grouped into a bubble; never fewer than
* {@link #MIN_STATIONS_PER_CLUSTER} in one
* @param individual stations to draw as themselves, with their label
*/
public record Result(List<MapMarkerCluster> clusters, List<MapCallsignRawSnapshot> individual) {
public Result {
clusters = List.copyOf(clusters);
individual = List.copyOf(individual);
}
}
/**
* @param viewport decides both the cell size, through its zoom, and which screen
* cell a station falls into
* @param clusteringEnabled the operator's "Group nearby stations" setting
*/
public static Result cluster(List<MapCallsignRawSnapshot> markers,
MapViewport viewport,
boolean clusteringEnabled) {
if (markers == null || markers.isEmpty()) {
return new Result(List.of(), List.of());
}
List<MapCallsignRawSnapshot> positioned = new ArrayList<>();
for (MapCallsignRawSnapshot marker : markers) {
if (marker != null && marker.hasUsablePosition()) {
positioned.add(marker);
}
}
if (!clusteringEnabled || viewport.zoom() >= DISABLE_AT_ZOOM) {
return new Result(List.of(), positioned);
}
double cellSizePx = cellSizePxForZoom(viewport.zoom());
List<MapCallsignRawSnapshot> individual = new ArrayList<>();
Map<Long, List<MapCallsignRawSnapshot>> buckets = new LinkedHashMap<>();
for (MapCallsignRawSnapshot marker : positioned) {
if (mustStayVisible(marker)) {
individual.add(marker);
continue;
}
long cellX = (long) Math.floor(viewport.screenX(marker.longitudeDeg()) / cellSizePx);
long cellY = (long) Math.floor(viewport.screenY(marker.latitudeDeg()) / cellSizePx);
buckets.computeIfAbsent(cellKey(cellX, cellY), key -> new ArrayList<>()).add(marker);
}
List<MapMarkerCluster> clusters = new ArrayList<>();
for (List<MapCallsignRawSnapshot> bucket : buckets.values()) {
if (bucket.size() >= MIN_STATIONS_PER_CLUSTER) {
MapMarkerCluster cluster = new MapMarkerCluster(bucket.get(0));
for (int i = 1; i < bucket.size(); i++) {
cluster.add(bucket.get(i));
}
clusters.add(cluster);
} else {
individual.addAll(bucket);
}
}
return new Result(Collections.unmodifiableList(clusters), Collections.unmodifiableList(individual));
}
/**
* The zoom at which a station the operator just selected is drawn as itself.
*
* Their current zoom, unless grouping would hide the station inside a bubble — and
* then only far enough to break the bubble open. Selecting a station is not a
* reason to throw away the zoom they chose.
*/
public static double zoomToRevealStations(double currentZoom, boolean clusteringEnabled) {
if (!clusteringEnabled) {
return currentZoom;
}
return Math.max(currentZoom, DISABLE_AT_ZOOM);
}
/**
* The cluster grid size in screen pixels for a zoom level.
*
* Smaller cells make grouping less eager: stations have to be closer together on
* screen before they are grouped at all.
*/
public static double cellSizePxForZoom(double zoom) {
if (zoom >= 7.0) {
return CELL_SIZE_HIGH_ZOOM_PX;
}
if (zoom >= 6.0) {
return CELL_SIZE_MEDIUM_ZOOM_PX;
}
if (zoom >= 5.0) {
return CELL_SIZE_LOW_ZOOM_PX;
}
return CELL_SIZE_VERY_LOW_ZOOM_PX;
}
/**
* The station the operator is working, and the one pointing at their antenna. A
* bubble over either of them hides the thing it was opened for.
*/
private static boolean mustStayVisible(MapCallsignRawSnapshot marker) {
return marker.selected() || marker.warningToMyDirection();
}
private static long cellKey(long cellX, long cellY) {
return (cellX << 32) ^ (cellY & 0xFFFFFFFFL);
}
}
@@ -0,0 +1,99 @@
package kst4contest.view.map;
import java.util.Locale;
/**
* The one line of text above the station map.
*
* It is the only place the operator is told how many stations are on the map, whether a
* filter is hiding some of them, and what is known about the one they picked. It lives
* in core rather than beside the window because it is string building with rules, and
* because the Compose port lost all of it but the callsign — a loss no test could see
* while it was written inline in the window.
*
* The wording and the separators are the ones {@code StationMapView.updateStatusLabel()}
* has produced since release 1.4x and are not ours to change casually.
*/
public final class StationMapStatusText {
private static final String SEPARATOR = " | ";
private static final String UNAVAILABLE = "-";
private StationMapStatusText() {
}
/**
* @param visibleStationCount how many markers the map was handed
* @param filteredViewActive whether a filter is hiding stations, without which the
* count reads as "that is everyone on the band"
* @param selected the station the operator picked, or null
*/
public static String build(int visibleStationCount,
boolean filteredViewActive,
MapCallsignRawSnapshot selected) {
StringBuilder text = new StringBuilder();
text.append("Showing ").append(visibleStationCount).append(" visible stations");
if (filteredViewActive) {
text.append(SEPARATOR).append("filtered view active");
}
if (selected == null) {
return text.toString();
}
text.append(SEPARATOR).append("Selected: ").append(selected.displayCallSign());
if (!selected.locator6().isBlank()) {
text.append(SEPARATOR).append(selected.locator6());
}
text.append(SEPARATOR)
.append(distanceAndBearingText(selected.qrbKm(), selected.qtfDeg()));
text.append(SEPARATOR).append("Bands: ").append(bands(selected));
/*
* "-" is kept rather than dropped. A station whose frequency is not known is
* different from one whose frequency fell off the end of an ellipsis, and the
* operator can only tell the two apart if the field is there.
*/
String frequencies = singleLine(selected.detailFrequencyText());
if (!frequencies.isBlank()) {
text.append(SEPARATOR).append("QRG: ").append(frequencies);
}
return text.toString();
}
/**
* Distance and bearing can both be absent, and absent is not zero.
*
* Public because the JavaFX status label shows the same pair and must not disagree
* with this one while both windows exist.
*/
public static String distanceAndBearingText(double qrbKm, double qtfDeg) {
return formatRounded(qrbKm, " km") + " / " + formatRounded(qtfDeg, "°");
}
private static String formatRounded(double value, String unit) {
if (!Double.isFinite(value)) {
return UNAVAILABLE;
}
return String.format(Locale.US, "%.0f", value) + unit;
}
private static String bands(MapCallsignRawSnapshot selected) {
String bandText = selected.bandSummary().isBlank() ? UNAVAILABLE : selected.bandSummary();
return selected.offersSelectedBand() ? bandText + " B+" : bandText;
}
/** The detail text is written over several lines; the status bar has one. */
private static String singleLine(String raw) {
if (raw == null) {
return "";
}
return raw.replace('\n', ' ').replace('\r', ' ').replaceAll("\\s+", " ").trim();
}
}
@@ -0,0 +1,311 @@
package kst4contest.view.map;
import kst4contest.utils.ApplicationFileUtils;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Supplies map tiles, from memory, from disk, or from OpenStreetMap, in that order.
*
* Three rules it has to keep. The tile service asks callers to identify themselves and
* not to re-download what they already have, which is what the disk cache is for — a
* contest station restarts the client more than once an evening and the view it comes
* back to is the view it left. The retired {@code a}/{@code b}/{@code c} subdomains are
* not used. And a request that fails is counted and visible through
* {@link #hasReachedTileSource()}, because the alternative is what the first version
* did: return null quietly and leave the operator looking at an empty map with no idea
* whether the problem was the network or the band.
*/
public final class TileFetcher {
private static final int MEMORY_CACHE_MAX_TILES = 2048;
/**
* OpenStreetMap's tile usage policy allows a client at most two concurrent
* downloads. Going wider fills the map faster and gets the application blocked.
*/
public static final int MAX_CONCURRENT_REQUESTS = 2;
/**
* How long a tile that failed is left alone.
*
* The map re-requests every visible tile whenever the view changes, so without a
* pause a server answering 429 would be asked again on every drag — which the same
* usage policy calls abuse, and which is how a client gets blocked rather than
* throttled.
*/
private static final Duration DEFAULT_RETRY_PAUSE = Duration.ofSeconds(30);
/**
* OpenStreetMap's tile usage policy asks for an identifying agent. A generic one
* gets the whole application blocked rather than throttled.
*/
private static final String USER_AGENT = "kst4contest/1.0 amateur-radio-contest-tool";
private static final String TILE_HOST = "https://tile.openstreetmap.org";
/** Where a tile comes from when neither cache has it. Replaced in tests. */
public interface TileSource {
byte[] fetch(String url) throws IOException, InterruptedException;
}
private final TileSource source;
private final Path cacheDirectory;
private final ExecutorService executor;
private final Map<String, byte[]> memoryCache;
private final AtomicInteger failedRequests = new AtomicInteger();
/*
* Which happened more recently, an arrival or a failure. A single latched "we got a
* tile once" flag cannot answer the question the warning is for: after a restart
* without a network, the first tile out of the disk cache would set it and every
* failure after that would be silent. Counters rather than timestamps, so the
* comparison does not depend on clock resolution.
*/
private final Duration retryPause;
/** When each recently failed tile may be asked for again. */
private final Map<String, Long> retryNotBeforeNanos = new java.util.concurrent.ConcurrentHashMap<>();
private final AtomicInteger arrivals = new AtomicInteger();
private final AtomicInteger lastArrivalAtFailureCount = new AtomicInteger(-1);
private final AtomicBoolean closed = new AtomicBoolean(false);
public TileFetcher() {
this(defaultCacheDirectory(), new HttpTileSource());
}
public TileFetcher(Path cacheDirectory, TileSource source) {
this(cacheDirectory, source, DEFAULT_RETRY_PAUSE);
}
public TileFetcher(Path cacheDirectory, TileSource source, Duration retryPause) {
this.cacheDirectory = cacheDirectory;
this.source = source;
this.retryPause = retryPause;
this.executor = Executors.newFixedThreadPool(MAX_CONCURRENT_REQUESTS, runnable -> {
Thread thread = new Thread(runnable, "map-tile-fetch");
thread.setDaemon(true);
return thread;
});
this.memoryCache = Collections.synchronizedMap(
new LinkedHashMap<>(MEMORY_CACHE_MAX_TILES, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, byte[]> eldest) {
return size() > MEMORY_CACHE_MAX_TILES;
}
}
);
}
/** The tile directory beside the rest of the application's files. */
public static Path defaultCacheDirectory() {
return Path.of(ApplicationFileUtils.getFilePath("kst4contest", "tile-cache"));
}
public static String tileUrl(int zoom, int x, int y) {
return TILE_HOST + "/" + zoom + "/" + x + "/" + y + ".png";
}
/**
* @return the tile, or null when it could not be had. Null is a gap in the map, not
* an error to act on; {@link #hasReachedTileSource()} is what tells the map
* whether to say something about it.
*/
public CompletableFuture<byte[]> fetchTileAsync(int zoom, int x, int y) {
String key = zoom + "/" + x + "/" + y;
byte[] fromMemory = memoryCache.get(key);
if (fromMemory != null) {
return CompletableFuture.completedFuture(fromMemory);
}
return CompletableFuture.supplyAsync(() -> loadTile(zoom, x, y, key), executor);
}
private byte[] loadTile(int zoom, int x, int y, String key) {
byte[] fromDisk = readFromDisk(zoom, x, y);
if (fromDisk != null) {
memoryCache.put(key, fromDisk);
recordArrival();
return fromDisk;
}
Long notBefore = retryNotBeforeNanos.get(key);
if (notBefore != null && System.nanoTime() < notBefore) {
/* Still inside the pause after a failure; do not ask again yet. */
return null;
}
try {
byte[] tile = source.fetch(tileUrl(zoom, x, y));
if (tile == null || tile.length == 0) {
recordFailure(key);
return null;
}
memoryCache.put(key, tile);
writeToDisk(zoom, x, y, tile);
retryNotBeforeNanos.remove(key);
recordArrival();
return tile;
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
recordFailure(key);
return null;
} catch (Exception unreachable) {
/* Held back, not cached: after the pause the next pan tries again. */
recordFailure(key);
return null;
}
}
/**
* Whether the map should say that tiles are not arriving.
*
* True once a request has failed and nothing has arrived since — from the network
* or from the disk cache. It therefore covers both the restart without a network
* and the network lost halfway through an evening, and it clears itself as soon as
* a tile comes in again.
*/
public boolean isTileSourceUnavailable() {
int failures = failedRequests.get();
return failures > 0 && lastArrivalAtFailureCount.get() < failures;
}
/** Whether any tile has ever arrived, from either cache or the network. */
public boolean hasReachedTileSource() {
return arrivals.get() > 0;
}
public int failedRequestCount() {
return failedRequests.get();
}
/**
* Forgets a tile that turned out to be unusable, on disk as well as in memory.
*
* The caller is the only one who can tell: this class hands out bytes, and whether
* they decode into an image is discovered one layer further up. Without this a
* truncated file — the process died mid-write — is served again on every later run
* and that square of the map stays blank for good.
*/
public void discardTile(int zoom, int x, int y) {
memoryCache.remove(zoom + "/" + x + "/" + y);
try {
Files.deleteIfExists(tilePath(zoom, x, y));
} catch (Exception undeletable) {
/* Then it stays, and the map has a gap rather than a crash. */
}
}
/**
* Stops the fetch threads.
*
* Not optional housekeeping: a live pool thread is a GC root, so an unclosed
* fetcher holds its whole memory cache reachable. The map window builds one per
* open, and an evening of opening and closing it would retain every one of them.
*/
public void close() {
if (closed.compareAndSet(false, true)) {
executor.shutdownNow();
}
}
public boolean isClosed() {
return closed.get();
}
private void recordFailure(String key) {
failedRequests.incrementAndGet();
retryNotBeforeNanos.put(key, System.nanoTime() + retryPause.toNanos());
}
private void recordArrival() {
arrivals.incrementAndGet();
lastArrivalAtFailureCount.set(failedRequests.get());
}
private Path tilePath(int zoom, int x, int y) {
return cacheDirectory.resolve(String.valueOf(zoom)).resolve(String.valueOf(x)).resolve(y + ".png");
}
private byte[] readFromDisk(int zoom, int x, int y) {
try {
Path path = tilePath(zoom, x, y);
return Files.exists(path) ? Files.readAllBytes(path) : null;
} catch (Exception unreadable) {
/* A cache is an optimisation. An unreadable one is a cache miss. */
return null;
}
}
/**
* Written under a temporary name and then moved into place.
*
* A plain write is not atomic. If the process dies partway through — battery, a
* kill, an operating-system shutdown — a truncated file is left under the real name
* and is served as a tile on every later run.
*/
private void writeToDisk(int zoom, int x, int y, byte[] tile) {
Path path = tilePath(zoom, x, y);
Path partial = path.resolveSibling(path.getFileName() + ".tmp");
try {
Files.createDirectories(path.getParent());
Files.write(partial, tile);
Files.move(partial, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (Exception unwritable) {
/* A read-only or full disk costs the cache, not the map. */
try {
Files.deleteIfExists(partial);
} catch (Exception alsoUndeletable) {
/* Nothing further to try; a stray temporary file is harmless. */
}
}
}
/** The real source: one HTTPS request per tile. */
private static final class HttpTileSource implements TileSource {
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
@Override
public byte[] fetch(String url) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("User-Agent", USER_AGENT)
.timeout(Duration.ofSeconds(15))
.GET()
.build();
HttpResponse<byte[]> response =
httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200) {
throw new IOException("tile request answered " + response.statusCode());
}
return response.body();
}
}
}
@@ -0,0 +1,33 @@
package kst4contest.view.map;
/**
* Standard EPSG:3857 Web Mercator projection mathematics.
*/
public final class WebMercatorProjection {
private WebMercatorProjection() {
}
public static double lonToX(double lon, int zoom) {
double n = Math.pow(2.0, zoom);
return ((lon + 180.0) / 360.0) * 256.0 * n;
}
public static double latToY(double lat, int zoom) {
double latRad = Math.toRadians(lat);
double n = Math.pow(2.0, zoom);
double y = (1.0 - Math.log(Math.tan(latRad) + (1.0 / Math.cos(latRad))) / Math.PI) / 2.0;
return y * 256.0 * n;
}
public static double xToLon(double x, int zoom) {
double n = Math.pow(2.0, zoom);
return (x / (256.0 * n)) * 360.0 - 180.0;
}
public static double yToLat(double y, int zoom) {
double n = Math.pow(2.0, zoom);
double latRad = Math.atan(Math.sinh(Math.PI * (1.0 - 2.0 * y / (256.0 * n))));
return Math.toDegrees(latRad);
}
}
@@ -149,4 +149,43 @@ class MapCallsignRawSnapshotBuilderTest {
chatMember.setActivityTimeLastInEpoch(activityEpoch);
return chatMember;
}
/**
* An absent QRB is not a QRB of zero.
*
* ChatMember.getQrb() and getQTFdirection() are nullable Doubles, and the builder
* used to coerce a null to 0.0 before the snapshot existed. Zero is a legitimate
* reading — a station at the operator's own locator is 0 km away — so the coercion
* destroyed the difference between "right here" and "not known", and the map's
* status line read "0 km / 0°" for every station whenever the operator had no home
* locator configured. AGENTS.md: null means unavailable, not zero.
*/
@Test
void anAbsentDistanceOrBearingStaysAbsentRatherThanBecomingZero() {
ChatMember station = buildStation("DL1ABC", "Hans", "JO50JP", 1_000L);
MapCallsignRawSnapshot snapshot =
new MapCallsignRawSnapshotBuilder()
.buildSnapshots(List.of(station), null, EnumSet.noneOf(Band.class))
.get(0);
assertTrue(Double.isNaN(snapshot.qrbKm()), "an unknown QRB must not read as 0 km");
assertTrue(Double.isNaN(snapshot.qtfDeg()), "an unknown QTF must not read as 0 degrees");
}
/** A station at the operator's own locator really is zero away, and must say so. */
@Test
void aGenuineZeroDistanceIsKept() {
ChatMember station = buildStation("DL1ABC", "Hans", "JO50JP", 1_000L);
station.setQrb(0.0);
station.setQTFdirection(0.0);
MapCallsignRawSnapshot snapshot =
new MapCallsignRawSnapshotBuilder()
.buildSnapshots(List.of(station), null, EnumSet.noneOf(Band.class))
.get(0);
assertEquals(0.0, snapshot.qrbKm());
assertEquals(0.0, snapshot.qtfDeg());
}
}
@@ -0,0 +1,80 @@
package kst4contest.view.map;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* What hovering a cluster bubble says.
*
* The Leaflet tooltip (buildClusterTooltipHtml, MapHtmlResources.java) led with the
* count, listed at most twenty callsigns in alphabetical order, said how many more there
* were, and ended by telling the operator that clicking zooms in. The Compose port
* listed every member and nothing else: at zoom 3 a European roster puts a hundred
* stations into one cell, which is a white box taller than the window, and the
* click-to-zoom behaviour became undiscoverable.
*/
class MapMarkerClusterTooltipTest {
private MapMarkerCluster clusterOf(int count) {
List<MapCallsignRawSnapshot> members = new ArrayList<>();
for (int i = 0; i < count; i++) {
members.add(new MapCallsignRawSnapshot(
String.format("DL%03dAA", i), String.format("DL%03dAA", i), "JO50JP",
51.0, 10.0, "", Map.of(), false, false, false, false, 1.0, 1.0, 0, 0L
));
}
MapMarkerCluster cluster = new MapMarkerCluster(members.get(0));
for (int i = 1; i < members.size(); i++) {
cluster.add(members.get(i));
}
return cluster;
}
@Test
void it_leads_with_the_count() {
assertTrue(MapMarkerClusterTooltip.build(clusterOf(5)).startsWith("5 stations"));
}
@Test
void it_ends_by_saying_that_clicking_zooms_in() {
assertTrue(MapMarkerClusterTooltip.build(clusterOf(5)).endsWith("Click to zoom in"));
}
@Test
void it_lists_the_members_in_alphabetical_order() {
List<String> lines = List.of(MapMarkerClusterTooltip.build(clusterOf(3)).split("\\n"));
assertEquals("3 stations", lines.get(0));
assertEquals("DL000AA", lines.get(1));
assertEquals("DL001AA", lines.get(2));
assertEquals("DL002AA", lines.get(3));
}
/** A hundred callsigns is a box taller than the window. */
@Test
void a_crowded_cluster_is_summarised_rather_than_listed_in_full() {
String tooltip = MapMarkerClusterTooltip.build(clusterOf(100));
List<String> lines = List.of(tooltip.split("\\n"));
assertEquals(
MapMarkerClusterTooltip.MAX_PREVIEWED_STATIONS + 3,
lines.size(),
"count line, 20 callsigns, a remainder line and the hint"
);
assertTrue(tooltip.contains("+80 more"), tooltip);
}
@Test
void a_cluster_of_exactly_twenty_needs_no_remainder_line() {
String tooltip = MapMarkerClusterTooltip.build(clusterOf(MapMarkerClusterTooltip.MAX_PREVIEWED_STATIONS));
assertTrue(!tooltip.contains("more"), tooltip);
assertEquals(MapMarkerClusterTooltip.MAX_PREVIEWED_STATIONS + 2, List.of(tooltip.split("\\n")).size());
}
}
@@ -0,0 +1,206 @@
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;
/**
* The viewport that turns a position into a place on the canvas.
*
* The Compose map used to compute positions at the integer zoom level and then scale the
* whole canvas by the fraction, which made every drawn size — dot radii, stroke widths,
* font sizes — depend on the fraction. Leaflet drew overlays in screen pixels and kept
* them constant. This class is how that is recovered: the fraction is folded into the
* projection, so the drawing code works in screen pixels and needs no scaling at all.
*/
class MapViewportTest {
private static final double TOLERANCE_PX = 1e-6;
private static final double TOLERANCE_DEG = 1e-9;
private MapViewport viewport(double zoom) {
return new MapViewport(10.0, 51.0, zoom, 800.0, 600.0);
}
@Test
void the_centre_of_the_viewport_lands_in_the_middle_of_the_canvas() {
MapViewport viewport = viewport(6.0);
assertEquals(400.0, viewport.screenX(10.0), TOLERANCE_PX);
assertEquals(300.0, viewport.screenY(51.0), TOLERANCE_PX);
}
@Test
void the_fractional_part_of_the_zoom_is_folded_into_the_projection() {
MapViewport viewport = viewport(6.5);
assertEquals(400.0, viewport.screenX(10.0), TOLERANCE_PX);
assertEquals(300.0, viewport.screenY(51.0), TOLERANCE_PX);
}
/**
* Half a zoom level is a factor of sqrt(2) in scale. This is the property the old
* drawing code faked by scaling the canvas, and the reason sizes drifted with zoom.
*/
@Test
void half_a_zoom_level_doubles_the_distance_on_screen_over_a_whole_level() {
double atSix = viewport(6.0).screenX(15.0) - viewport(6.0).screenX(10.0);
double atSixAndAHalf = viewport(6.5).screenX(15.0) - viewport(6.5).screenX(10.0);
double atSeven = viewport(7.0).screenX(15.0) - viewport(7.0).screenX(10.0);
assertEquals(2.0, atSeven / atSix, 1e-9);
assertEquals(Math.sqrt(2.0), atSixAndAHalf / atSix, 1e-9);
}
@Test
void a_screen_position_maps_back_to_the_position_it_came_from() {
MapViewport viewport = viewport(6.5);
assertEquals(12.5, viewport.lonAt(viewport.screenX(12.5)), TOLERANCE_DEG);
assertEquals(48.25, viewport.latAt(viewport.screenY(48.25)), TOLERANCE_DEG);
}
@Test
void the_edges_of_the_canvas_are_the_edges_of_the_viewport() {
MapViewport viewport = viewport(6.5);
assertEquals(viewport.lonAt(0.0), viewport.westLon(), TOLERANCE_DEG);
assertEquals(viewport.lonAt(800.0), viewport.eastLon(), TOLERANCE_DEG);
assertEquals(viewport.latAt(0.0), viewport.northLat(), TOLERANCE_DEG);
assertEquals(viewport.latAt(600.0), viewport.southLat(), TOLERANCE_DEG);
assertTrue(viewport.northLat() > viewport.southLat());
assertTrue(viewport.eastLon() > viewport.westLon());
}
/** Tiles only exist at integer levels; the fraction becomes the scale they are drawn at. */
@Test
void tiles_come_from_the_level_below_the_fractional_zoom() {
assertEquals(6, viewport(6.0).tileZoom());
assertEquals(6, viewport(6.99).tileZoom());
assertEquals(7, viewport(7.0).tileZoom());
assertEquals(1.0, viewport(6.0).tileScale(), TOLERANCE_PX);
assertEquals(Math.sqrt(2.0), viewport(6.5).tileScale(), 1e-9);
}
/**
* Leaflet was configured for 3 to 18 and the tile source carries nothing outside
* that; a zoom beyond it would request tiles that do not exist.
*/
@Test
void the_zoom_stays_within_the_range_the_tile_source_serves() {
assertEquals(MapViewport.MIN_ZOOM, new MapViewport(10.0, 51.0, 1.0, 800.0, 600.0).zoom(), TOLERANCE_PX);
assertEquals(MapViewport.MAX_ZOOM, new MapViewport(10.0, 51.0, 25.0, 800.0, 600.0).zoom(), TOLERANCE_PX);
assertEquals(3.0, MapViewport.MIN_ZOOM, TOLERANCE_PX);
assertEquals(18.0, MapViewport.MAX_ZOOM, TOLERANCE_PX);
}
/**
* A canvas can be measured at zero before the window is laid out. The viewport must
* answer rather than divide by it.
*/
@Test
void a_canvas_without_a_size_still_answers() {
MapViewport viewport = new MapViewport(10.0, 51.0, 6.0, 0.0, 0.0);
assertEquals(0.0, viewport.screenX(10.0), TOLERANCE_PX);
assertTrue(Double.isFinite(viewport.westLon()));
assertTrue(Double.isFinite(viewport.northLat()));
}
@Test
void a_drag_moves_the_view_opposite_to_the_pointer() {
MapViewport dragged = viewport(6.5).pannedBy(100.0, 50.0);
/* Dragging the map right brings positions further west into view. */
assertTrue(dragged.centerLon() < 10.0);
assertTrue(dragged.centerLat() > 51.0);
}
/** A drag of n pixels moves what was under the pointer by exactly n pixels. */
@Test
void a_drag_moves_a_position_by_the_distance_dragged() {
MapViewport before = viewport(6.5);
double xBefore = before.screenX(12.0);
MapViewport after = before.pannedBy(100.0, -40.0);
assertEquals(xBefore + 100.0, after.screenX(12.0), 1e-6);
assertEquals(before.screenY(49.0) - 40.0, after.screenY(49.0), 1e-6);
}
@Test
void a_drag_and_its_reverse_return_the_view_it_started_from() {
MapViewport roundTrip = viewport(6.5).pannedBy(130.0, -70.0).pannedBy(-130.0, 70.0);
assertEquals(10.0, roundTrip.centerLon(), 1e-9);
assertEquals(51.0, roundTrip.centerLat(), 1e-9);
}
/**
* The point under the pointer stays under the pointer. This is what makes a wheel
* zoom feel like a map rather than a jump to the centre.
*/
@Test
void zooming_keeps_the_position_under_the_pointer_in_place() {
MapViewport before = viewport(6.0);
double anchorX = 650.0;
double anchorY = 120.0;
double anchorLon = before.lonAt(anchorX);
double anchorLat = before.latAt(anchorY);
MapViewport after = before.zoomedTo(8.25, anchorX, anchorY);
assertEquals(8.25, after.zoom(), 1e-9);
assertEquals(anchorX, after.screenX(anchorLon), 1e-6);
assertEquals(anchorY, after.screenY(anchorLat), 1e-6);
}
@Test
void zooming_about_the_centre_leaves_the_centre_alone() {
MapViewport after = viewport(6.0).zoomedTo(7.5, 400.0, 300.0);
assertEquals(10.0, after.centerLon(), 1e-9);
assertEquals(51.0, after.centerLat(), 1e-9);
}
@Test
void a_resize_keeps_the_centre_and_the_zoom() {
MapViewport resized = viewport(6.5).resizedTo(1200.0, 400.0);
assertEquals(10.0, resized.centerLon(), 1e-9);
assertEquals(51.0, resized.centerLat(), 1e-9);
assertEquals(6.5, resized.zoom(), 1e-9);
assertEquals(600.0, resized.screenX(10.0), 1e-6);
assertEquals(200.0, resized.screenY(51.0), 1e-6);
}
/**
* Panning east forever must not walk the centre off the world.
*
* The tile indices wrap, so the basemap keeps drawing; the overlays do not wrap, so
* a centre at longitude 262 puts a station at -170 nine thousand pixels off the
* canvas and leaves the grid with a degenerate column. Stations and grid then vanish
* while the map underneath looks fine.
*/
@Test
void panning_east_past_the_antimeridian_keeps_the_centre_on_the_world() {
MapViewport viewport = new MapViewport(175.0, 0.0, 5.0, 800.0, 600.0);
for (int drag = 0; drag < 10; drag++) {
viewport = viewport.pannedBy(-200.0, 0.0);
}
assertTrue(viewport.centerLon() >= -180.0 && viewport.centerLon() < 180.0,
"centre ran off the world at longitude " + viewport.centerLon());
}
@Test
void a_longitude_handed_in_past_the_antimeridian_is_brought_back_round() {
assertEquals(-179.0, new MapViewport(181.0, 0.0, 6.0, 800.0, 600.0).centerLon(), TOLERANCE_DEG);
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);
}
}
@@ -0,0 +1,206 @@
package kst4contest.view.map;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Grouping stations that sit on top of each other on screen.
*
* The rules are not ours to invent: they are the ones the Leaflet map ran for the whole
* of release 1.4x, and an operator who has learned them must not have to relearn them.
* They live here rather than in the drawing code because they are decisions about what
* the operator sees, and because the drawing code has no test harness.
*
* Every constant below is pinned against the JavaScript it came from —
* MapHtmlResources.java, KST_CLUSTER_* and renderClusteredStations().
*/
class StationMapClustererTest {
private MapViewport viewportAtZoom(double zoom) {
return new MapViewport(10.0, 51.0, zoom, 1000.0, 800.0);
}
private MapCallsignRawSnapshot station(String call, double lat, double lon) {
return station(call, lat, lon, false, false);
}
private MapCallsignRawSnapshot station(String call,
double lat,
double lon,
boolean selected,
boolean warning) {
return new MapCallsignRawSnapshot(
call, call, "JO50JP", lat, lon, "144", Map.of(),
false, warning, false, selected, 100.0, 90.0, 0, 0L
);
}
/** Stations close enough to share one cluster cell at the given zoom. */
private List<MapCallsignRawSnapshot> huddle(int count, double lat, double lon) {
List<MapCallsignRawSnapshot> stations = new ArrayList<>();
for (int i = 0; i < count; i++) {
stations.add(station("DL" + i + "AA", lat + i * 0.0001, lon + i * 0.0001));
}
return stations;
}
@Test
void two_stations_on_the_same_spot_stay_separate() {
StationMapClusterer.Result result = StationMapClusterer.cluster(
huddle(2, 51.0, 10.0), viewportAtZoom(6.0), true);
assertTrue(result.clusters().isEmpty(), "two nearby stations are still readable");
assertEquals(2, result.individual().size());
}
@Test
void three_stations_on_the_same_spot_become_one_cluster() {
StationMapClusterer.Result result = StationMapClusterer.cluster(
huddle(3, 51.0, 10.0), viewportAtZoom(6.0), true);
assertEquals(1, result.clusters().size());
assertEquals(3, result.clusters().get(0).size());
assertTrue(result.individual().isEmpty());
}
@Test
void clustering_stops_once_the_stations_are_far_enough_apart_on_screen() {
StationMapClusterer.Result result = StationMapClusterer.cluster(
huddle(5, 51.0, 10.0), viewportAtZoom(StationMapClusterer.DISABLE_AT_ZOOM), true);
assertTrue(result.clusters().isEmpty(), "at zoom 8 and above the map shows every station");
assertEquals(5, result.individual().size());
}
@Test
void clustering_can_be_switched_off_entirely() {
StationMapClusterer.Result result = StationMapClusterer.cluster(
huddle(5, 51.0, 10.0), viewportAtZoom(4.0), false);
assertTrue(result.clusters().isEmpty());
assertEquals(5, result.individual().size());
}
/**
* The one the operator is working and the one pointing at their antenna are the two
* they must not lose in a bubble.
*/
@Test
void the_selected_station_is_never_hidden_in_a_cluster() {
List<MapCallsignRawSnapshot> stations = new ArrayList<>(huddle(4, 51.0, 10.0));
stations.add(station("F5JMI", 51.0, 10.0, true, false));
StationMapClusterer.Result result = StationMapClusterer.cluster(
stations, viewportAtZoom(5.0), true);
assertTrue(result.individual().stream().anyMatch(s -> s.callSignRaw().equals("F5JMI")));
assertFalse(
result.clusters().stream().anyMatch(c -> c.getMarkers().stream()
.anyMatch(s -> s.callSignRaw().equals("F5JMI"))),
"a selected station inside a bubble cannot be seen"
);
}
@Test
void a_station_warning_towards_my_direction_is_never_hidden_in_a_cluster() {
List<MapCallsignRawSnapshot> stations = new ArrayList<>(huddle(4, 51.0, 10.0));
stations.add(station("OE5SOS", 51.0, 10.0, false, true));
StationMapClusterer.Result result = StationMapClusterer.cluster(
stations, viewportAtZoom(5.0), true);
assertTrue(result.individual().stream().anyMatch(s -> s.callSignRaw().equals("OE5SOS")));
}
@Test
void a_station_without_a_usable_position_is_dropped_rather_than_drawn_at_zero() {
List<MapCallsignRawSnapshot> stations = List.of(
new MapCallsignRawSnapshot("DL1AAA", "DL1AAA", "", 0.0, 0.0, "", Map.of(),
false, false, false, false, 0.0, 0.0, 0, 0L)
);
StationMapClusterer.Result result = StationMapClusterer.cluster(
stations, viewportAtZoom(6.0), true);
assertTrue(result.clusters().isEmpty());
assertTrue(result.individual().isEmpty());
}
/** The four sizes the Leaflet map used, by the zoom thresholds it used. */
@Test
void the_cell_grows_as_the_map_zooms_out() {
assertEquals(55.0, StationMapClusterer.cellSizePxForZoom(7.0));
assertEquals(55.0, StationMapClusterer.cellSizePxForZoom(7.9));
assertEquals(70.0, StationMapClusterer.cellSizePxForZoom(6.0));
assertEquals(95.0, StationMapClusterer.cellSizePxForZoom(5.0));
assertEquals(125.0, StationMapClusterer.cellSizePxForZoom(4.0));
assertEquals(125.0, StationMapClusterer.cellSizePxForZoom(3.0));
}
@Test
void stations_in_different_screen_cells_are_not_grouped_together() {
double cell = StationMapClusterer.cellSizePxForZoom(6.0);
MapViewport viewport = viewportAtZoom(6.0);
/* Two huddles, placed more than one cell apart on screen. */
List<MapCallsignRawSnapshot> stations = new ArrayList<>(huddle(3, 51.0, 10.0));
double farLon = viewport.lonAt(viewport.screenX(10.0) + cell * 3.0);
for (int i = 0; i < 3; i++) {
stations.add(station("PA" + i + "BB", 51.0 + i * 0.0001, farLon + i * 0.0001));
}
StationMapClusterer.Result result = StationMapClusterer.cluster(stations, viewport, true);
assertEquals(2, result.clusters().size());
assertTrue(result.clusters().stream().allMatch(c -> c.size() == 3));
}
@Test
void every_station_handed_in_comes_back_exactly_once() {
List<MapCallsignRawSnapshot> stations = new ArrayList<>(huddle(7, 51.0, 10.0));
stations.add(station("F5JMI", 51.0, 10.0, true, false));
StationMapClusterer.Result result = StationMapClusterer.cluster(
stations, viewportAtZoom(5.0), true);
int clustered = result.clusters().stream().mapToInt(MapMarkerCluster::size).sum();
assertEquals(stations.size(), clustered + result.individual().size());
}
/**
* Selecting a station moves the map to it. It must not throw the operator's zoom
* away doing so.
*
* The Leaflet map panned and kept the zoom, and touched the zoom only when the
* station was hidden inside a bubble — then just far enough to break the bubble
* open (focusCallsignRaw, MapHtmlResources.java). The first Compose port instead
* recomputed a zoom from a bounding box on every selection and clamped it into
* 4..12, so an operator working at zoom 15 was yanked out to about 7 by clicking
* the next station. AGENTS.md names zoom first among the things not to change as an
* incidental side effect.
*/
@Test
void selecting_a_station_that_is_already_visible_keeps_the_zoom() {
assertEquals(15.0, StationMapClusterer.zoomToRevealStations(15.0, true));
assertEquals(9.0, StationMapClusterer.zoomToRevealStations(9.0, true));
}
@Test
void selecting_a_station_hidden_in_a_bubble_zooms_just_far_enough_to_open_it() {
assertEquals(StationMapClusterer.DISABLE_AT_ZOOM, StationMapClusterer.zoomToRevealStations(5.0, true));
assertEquals(StationMapClusterer.DISABLE_AT_ZOOM, StationMapClusterer.zoomToRevealStations(7.9, true));
}
@Test
void with_grouping_switched_off_every_station_is_already_visible() {
assertEquals(4.0, StationMapClusterer.zoomToRevealStations(4.0, false));
assertEquals(3.0, StationMapClusterer.zoomToRevealStations(3.0, false));
}
}
@@ -0,0 +1,150 @@
package kst4contest.view.map;
import org.junit.jupiter.api.Test;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
/**
* The one line of text above the map.
*
* It is the only place the operator is told how many stations are on the map at all,
* whether a filter is hiding some of them, and what is known about the one they picked.
* The Compose port showed "Selected: F5JMI" and nothing else.
*
* Pinned against updateStatusLabel() in StationMapView.java, which is what an operator
* has been reading since release 1.4x.
*/
class StationMapStatusTextTest {
private MapCallsignRawSnapshot snapshot(String call,
String locator,
String bandSummary,
boolean offersSelectedBand,
double qrbKm,
double qtfDeg,
Map<String, String> frequencies) {
return new MapCallsignRawSnapshot(
call, call, locator, 51.0, 10.0, bandSummary, frequencies,
offersSelectedBand, false, false, true, qrbKm, qtfDeg, 0, 0L
);
}
@Test
void without_a_selection_it_reports_only_the_count() {
assertEquals(
"Showing 49 visible stations",
StationMapStatusText.build(49, false, null)
);
}
@Test
void a_filtered_view_says_so_because_the_count_alone_would_mislead() {
assertEquals(
"Showing 12 visible stations | filtered view active",
StationMapStatusText.build(12, true, null)
);
}
@Test
void a_selected_station_adds_its_callsign_locator_distance_and_bearing() {
MapCallsignRawSnapshot selected =
snapshot("PA3EKM", "JO33GE", "", false, 316.4, 251.0, Map.of());
assertEquals(
"Showing 50 visible stations | Selected: PA3EKM | JO33GE | 316 km / 251° | Bands: - | QRG: -",
StationMapStatusText.build(50, false, selected)
);
}
@Test
void the_band_summary_is_marked_when_the_station_offers_the_selected_band() {
MapCallsignRawSnapshot selected =
snapshot("DG1BHA", "JO43", "144, 432, 1296", true, 100.0, 90.0, Map.of());
assertEquals(
"Showing 1 visible stations | Selected: DG1BHA | JO43 | 100 km / 90° | Bands: 144, 432, 1296 B+ | QRG: -",
StationMapStatusText.build(1, false, selected)
);
}
/** The detail text is multi-line; the status bar is one line. */
@Test
void known_frequencies_are_folded_onto_one_line() {
Map<String, String> frequencies = new LinkedHashMap<>();
frequencies.put("144", "144.300");
frequencies.put("432", "432.200");
MapCallsignRawSnapshot selected =
snapshot("DK2EA", "JO50UF", "144", false, 79.0, 125.0, frequencies);
String status = StationMapStatusText.build(3, false, selected);
assertEquals(
"Showing 3 visible stations | Selected: DK2EA | JO50UF | 79 km / 125° | Bands: 144"
+ " | QRG: 144: 144.300 432: 432.200",
status
);
assertFalse(status.contains("\n"), "the status bar is a single line");
}
@Test
void a_station_without_a_locator_is_not_given_an_empty_field() {
MapCallsignRawSnapshot selected =
snapshot("DL1ABC", "", "144", false, 50.0, 10.0, Map.of());
assertEquals(
"Showing 2 visible stations | Selected: DL1ABC | 50 km / 10° | Bands: 144 | QRG: -",
StationMapStatusText.build(2, false, selected)
);
}
/**
* QRB and QTF can be absent. Absent is not zero, and "NaN km" is not a reading an
* operator should be shown.
*/
@Test
void an_unknown_distance_or_bearing_is_shown_as_unavailable() {
MapCallsignRawSnapshot selected =
snapshot("DL1ABC", "JO50", "144", false, Double.NaN, Double.NaN, Map.of());
assertEquals(
"Showing 2 visible stations | Selected: DL1ABC | JO50 | - / - | Bands: 144 | QRG: -",
StationMapStatusText.build(2, false, selected)
);
}
@Test
void the_count_is_the_number_of_markers_the_map_was_given() {
List<MapCallsignRawSnapshot> markers = List.of(
snapshot("A", "JO50", "", false, 1.0, 1.0, Map.of()),
snapshot("B", "JO50", "", false, 1.0, 1.0, Map.of())
);
assertEquals(
"Showing 2 visible stations",
StationMapStatusText.build(markers.size(), false, null)
);
}
/**
* "Known to be unknown" is information. The JavaFX line appended " | QRG: -"
* because "-" is not blank, and dropping the segment instead leaves the operator
* unable to tell a frequency that is not known from one that fell off the end of an
* ellipsis.
*/
@Test
void a_station_with_no_known_frequency_still_gets_a_qrg_field() {
MapCallsignRawSnapshot selected =
snapshot("DL1ABC", "JO50", "144", false, 50.0, 10.0, Map.of());
assertEquals(
"Showing 2 visible stations | Selected: DL1ABC | JO50 | 50 km / 10° | Bands: 144 | QRG: -",
StationMapStatusText.build(2, false, selected)
);
}
}
@@ -0,0 +1,279 @@
package kst4contest.view.map;
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 java.time.Duration;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
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;
/**
* Getting map tiles, and admitting when it cannot.
*
* Three things were wrong with the first version. It asked the {@code a}/{@code b}/{@code c}
* subdomains, which OpenStreetMap has retired. It kept tiles in memory only, so every
* restart re-downloaded the same view from a service that asks callers not to. And a
* failed request returned null and said nothing, which is why a map with no network
* looked like a map with no stations.
*/
class TileFetcherTest {
/** A source that records what it was asked for and answers from a script. */
private static final class RecordingSource implements TileFetcher.TileSource {
private final List<String> requestedUrls = new CopyOnWriteArrayList<>();
private final byte[] answer;
private final boolean fail;
RecordingSource(byte[] answer, boolean fail) {
this.answer = answer;
this.fail = fail;
}
@Override
public byte[] fetch(String url) throws IOException {
requestedUrls.add(url);
if (fail) {
throw new IOException("no route to host");
}
return answer;
}
}
private TileFetcher fetcherWith(Path cacheDirectory, RecordingSource source) {
return new TileFetcher(cacheDirectory, source);
}
@Test
void the_url_is_the_one_openstreetmap_still_serves() {
String url = TileFetcher.tileUrl(6, 33, 21);
assertEquals("https://tile.openstreetmap.org/6/33/21.png", url);
assertFalse(url.contains("://a.") || url.contains("://b.") || url.contains("://c."),
"the lettered subdomains are retired");
}
@Test
void a_tile_is_fetched_once_and_then_answered_from_memory(@TempDir Path cache) throws Exception {
RecordingSource source = new RecordingSource(new byte[]{1, 2, 3}, false);
TileFetcher fetcher = fetcherWith(cache, source);
assertArrayEquals(new byte[]{1, 2, 3}, fetcher.fetchTileAsync(6, 33, 21).get());
assertArrayEquals(new byte[]{1, 2, 3}, fetcher.fetchTileAsync(6, 33, 21).get());
assertEquals(1, source.requestedUrls.size());
}
@Test
void a_tile_survives_a_restart(@TempDir Path cache) throws Exception {
RecordingSource first = new RecordingSource(new byte[]{9, 9, 9}, false);
fetcherWith(cache, first).fetchTileAsync(7, 1, 2).get();
RecordingSource afterRestart = new RecordingSource(new byte[]{0}, false);
byte[] tile = fetcherWith(cache, afterRestart).fetchTileAsync(7, 1, 2).get();
assertArrayEquals(new byte[]{9, 9, 9}, tile);
assertTrue(afterRestart.requestedUrls.isEmpty(), "the disk cache should have answered");
}
@Test
void a_cached_tile_is_written_where_its_coordinates_say(@TempDir Path cache) throws Exception {
fetcherWith(cache, new RecordingSource(new byte[]{4}, false)).fetchTileAsync(8, 5, 6).get();
assertTrue(Files.exists(cache.resolve("8").resolve("5").resolve("6.png")));
}
@Test
void a_failed_request_is_reported_rather_than_swallowed(@TempDir Path cache) throws Exception {
TileFetcher fetcher = fetcherWith(cache, new RecordingSource(null, true));
assertNull(fetcher.fetchTileAsync(6, 33, 21).get());
assertFalse(fetcher.hasReachedTileSource(), "nothing has arrived, and the map should be able to say so");
assertTrue(fetcher.failedRequestCount() > 0);
}
@Test
void a_source_that_answers_is_reported_as_reachable(@TempDir Path cache) throws Exception {
TileFetcher fetcher = fetcherWith(cache, new RecordingSource(new byte[]{1}, false));
fetcher.fetchTileAsync(6, 33, 21).get();
assertTrue(fetcher.hasReachedTileSource());
assertEquals(0, fetcher.failedRequestCount());
}
/**
* A failure must not be cached as an answer, or the tile never arrives at all.
*
* Retried after the pause, not on the next request: an immediate retry is what the
* tile service's usage policy calls abuse, and the map re-requests every visible
* tile on every change of view.
*/
@Test
void a_failure_is_not_remembered_as_an_answer(@TempDir Path cache) throws Exception {
RecordingSource source = new RecordingSource(null, true);
TileFetcher fetcher = new TileFetcher(cache, source, Duration.ofMillis(1));
fetcher.fetchTileAsync(6, 33, 21).get();
Thread.sleep(20);
fetcher.fetchTileAsync(6, 33, 21).get();
assertEquals(2, source.requestedUrls.size());
}
@Test
void a_cache_directory_that_cannot_be_written_does_not_stop_the_map(@TempDir Path cache) throws Exception {
Path notADirectory = cache.resolve("blocked");
Files.writeString(notADirectory, "this is a file, not a directory");
TileFetcher fetcher = fetcherWith(notADirectory, new RecordingSource(new byte[]{7}, false));
assertArrayEquals(new byte[]{7}, fetcher.fetchTileAsync(6, 1, 1).get());
}
/**
* The banner has to reach the one case it was written for: a restart without a
* network and a cache that holds some of the view.
*
* A latched "we reached the source once" flag cannot do that. The first cached tile
* sets it, every uncached tile then fails quietly, and the operator is left looking
* at half a map with no explanation — which is the screenshot this whole step
* started from.
*/
@Test
void a_cache_hit_does_not_silence_the_failures_around_it(@TempDir Path cache) throws Exception {
fetcherWith(cache, new RecordingSource(new byte[]{1, 2, 3}, false)).fetchTileAsync(6, 1, 1).get();
TileFetcher offline = fetcherWith(cache, new RecordingSource(null, true));
offline.fetchTileAsync(6, 1, 1).get();
offline.fetchTileAsync(6, 2, 2).get();
assertTrue(offline.isTileSourceUnavailable(),
"one cached tile must not hide that the rest cannot be had");
}
@Test
void a_tile_arriving_again_clears_the_warning(@TempDir Path cache) throws Exception {
TileFetcher fetcher = fetcherWith(cache, new RecordingSource(null, true));
fetcher.fetchTileAsync(6, 1, 1).get();
assertTrue(fetcher.isTileSourceUnavailable());
TileFetcher recovered = fetcherWith(cache, new RecordingSource(new byte[]{5}, false));
recovered.fetchTileAsync(6, 1, 1).get();
assertFalse(recovered.isTileSourceUnavailable());
}
@Test
void nothing_requested_yet_is_not_a_warning(@TempDir Path cache) {
assertFalse(fetcherWith(cache, new RecordingSource(new byte[]{1}, false)).isTileSourceUnavailable());
}
/**
* A tile that cannot be decoded has to be removable, or it is cached forever.
*
* The process can die mid-write; a truncated PNG then sits in the cache and is
* served on every later run, so that square of the map is blank for good and there
* is no way back except deleting the directory by hand.
*/
@Test
void a_tile_that_turned_out_to_be_unusable_can_be_thrown_away(@TempDir Path cache) throws Exception {
TileFetcher fetcher = fetcherWith(cache, new RecordingSource(new byte[]{1, 2, 3}, false));
fetcher.fetchTileAsync(9, 4, 5).get();
assertTrue(Files.exists(cache.resolve("9").resolve("4").resolve("5.png")));
fetcher.discardTile(9, 4, 5);
assertFalse(Files.exists(cache.resolve("9").resolve("4").resolve("5.png")));
RecordingSource again = new RecordingSource(new byte[]{7}, false);
assertArrayEquals(new byte[]{7}, fetcherWith(cache, again).fetchTileAsync(9, 4, 5).get());
}
/** A half-written tile must never be visible under its real name. */
@Test
void a_tile_is_written_whole_or_not_at_all(@TempDir Path cache) throws Exception {
fetcherWith(cache, new RecordingSource(new byte[]{1, 2, 3, 4}, false)).fetchTileAsync(9, 4, 5).get();
try (var paths = Files.walk(cache)) {
assertTrue(paths.noneMatch(path -> path.getFileName().toString().endsWith(".tmp")),
"a temporary file was left behind");
}
assertArrayEquals(new byte[]{1, 2, 3, 4}, Files.readAllBytes(cache.resolve("9").resolve("4").resolve("5.png")));
}
/** Closing has to stop the threads, or every window open leaks a pool. */
@Test
void closing_the_fetcher_stops_its_threads(@TempDir Path cache) {
TileFetcher fetcher = fetcherWith(cache, new RecordingSource(new byte[]{1}, false));
fetcher.fetchTileAsync(6, 1, 1).join();
fetcher.close();
assertTrue(fetcher.isClosed());
}
/**
* The tile service caps a client at two concurrent downloads and treats sustained
* retrying as abuse. This class quotes that policy in its own javadoc, so it had
* better keep it: eight connections plus an immediate retry of every failure is how
* an application gets blocked rather than throttled.
*/
@Test
void it_does_not_open_more_connections_than_the_tile_service_allows() {
assertTrue(TileFetcher.MAX_CONCURRENT_REQUESTS <= 2,
"OpenStreetMap's usage policy allows at most two concurrent downloads");
}
/**
* A tile that just failed is not asked for again on the very next pan.
*
* The map re-requests every visible tile whenever the view changes, so without a
* pause a server answering 429 would be hammered once per drag.
*/
@Test
void a_tile_that_just_failed_is_not_asked_for_again_immediately(@TempDir Path cache) throws Exception {
RecordingSource source = new RecordingSource(null, true);
TileFetcher fetcher = fetcherWith(cache, source);
fetcher.fetchTileAsync(6, 33, 21).get();
fetcher.fetchTileAsync(6, 33, 21).get();
fetcher.fetchTileAsync(6, 33, 21).get();
assertEquals(1, source.requestedUrls.size(),
"the retries should have been held back rather than sent");
}
@Test
void a_tile_is_asked_for_again_once_the_pause_is_over(@TempDir Path cache) throws Exception {
RecordingSource source = new RecordingSource(null, true);
TileFetcher fetcher = new TileFetcher(cache, source, Duration.ofMillis(1));
fetcher.fetchTileAsync(6, 33, 21).get();
Thread.sleep(20);
fetcher.fetchTileAsync(6, 33, 21).get();
assertEquals(2, source.requestedUrls.size());
}
/** A tile that arrives after a failure is not held back by the old failure. */
@Test
void a_pause_does_not_outlive_a_successful_fetch(@TempDir Path cache) throws Exception {
TileFetcher failing = new TileFetcher(cache, new RecordingSource(null, true), Duration.ofMillis(1));
failing.fetchTileAsync(6, 33, 21).get();
Thread.sleep(20);
RecordingSource working = new RecordingSource(new byte[]{3}, false);
TileFetcher recovered = new TileFetcher(cache, working, Duration.ofMillis(1));
assertArrayEquals(new byte[]{3}, recovered.fetchTileAsync(6, 33, 21).get());
}
}