mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-19 13:53:01 +02:00
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:
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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user