App 2.39.0, server 2.7.0: Grid with map
A new Overview style: the Grid over the roads round the car from
OpenStreetMap, drawn in the floor's perspective, heading up, the bigger
roads glowing brighter, the car as a chevron.
Server: POST /v1/map/roads {z:14,x,y} fetches a tile's drivable roads
from Overpass (OVERPASS_URL; one request at a time, at least a second
apart), simplifies them to tile-local points in four classes, caches
them for 30 days and never logs the tile.
App: MapRoads keeps the 3x3 tiles round the car from its own cache or
the server; GPS only while the style is shown; bearing from the GPS or
from successive positions. Without GPS, server or roads the plain floor
says why. OSM attribution on screen and in LICENSE.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.location.Location;
|
||||
import android.os.SystemClock;
|
||||
import android.util.Log;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* The roads around the car for the Grid's map, and where the car is: the activity hands in
|
||||
* GPS fixes ({@link #onFix}) while "Grid with map" is shown; this keeps the 3×3 tiles round
|
||||
* the car loaded from the app's own cache (files/map, 30 days, at most {@value #DISK_TILES}
|
||||
* tiles) or from the car's server ({@code POST /v1/map/roads}, server/maps.go), one at a time
|
||||
* on a background thread, and drops tiles from memory once the car is two tiles away. The car
|
||||
* asks only its own server, naming a tile rather than its position. Without a configured
|
||||
* server, a fix or the roads, the Grid shows its plain floor ({@link #state}).
|
||||
*/
|
||||
final class MapRoads {
|
||||
|
||||
/** Why the map shows nothing (or {@link #SHOWING}). */
|
||||
enum State { NO_SERVER, WAITING_FOR_GPS, LOADING, UNAVAILABLE, SHOWING }
|
||||
|
||||
/** One GPS fix: where, which way (degrees from north, NaN before the car has moved), when. */
|
||||
static final class Fix {
|
||||
final double lat, lon;
|
||||
final float bearing;
|
||||
final long elapsedMs;
|
||||
|
||||
Fix(double lat, double lon, float bearing, long elapsedMs) {
|
||||
this.lat = lat;
|
||||
this.lon = lon;
|
||||
this.bearing = bearing;
|
||||
this.elapsedMs = elapsedMs;
|
||||
}
|
||||
}
|
||||
|
||||
private static final String TAG = "MapRoads";
|
||||
private static final int DISK_TILES = 400;
|
||||
private static final long DISK_AGE_MS = 30L * 24 * 60 * 60 * 1000;
|
||||
private static final long RETRY_MS = 60_000L;
|
||||
/** A fix older than this is not drawn from. */
|
||||
static final long FIX_FRESH_MS = 15_000L;
|
||||
|
||||
private final Context context;
|
||||
private final File dir;
|
||||
private final Map<Long, RoadTile> tiles = new ConcurrentHashMap<>();
|
||||
private final Map<Long, Long> failedAt = new ConcurrentHashMap<>();
|
||||
private final Map<Long, Boolean> queued = new ConcurrentHashMap<>();
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "map-roads");
|
||||
t.setPriority(Thread.MIN_PRIORITY);
|
||||
return t;
|
||||
});
|
||||
private volatile Fix fix;
|
||||
/** The last bearing while moving; GPS has none at a standstill. */
|
||||
private volatile float bearing = Float.NaN;
|
||||
private volatile boolean serverMissing;
|
||||
/** Where the bearing was last worked out from, for a GPS that gives none. */
|
||||
private Location anchor;
|
||||
|
||||
MapRoads(Context context) {
|
||||
this.context = context.getApplicationContext();
|
||||
dir = new File(this.context.getFilesDir(), "map");
|
||||
}
|
||||
|
||||
/** A GPS fix. Main thread; the loading happens on the worker. */
|
||||
void onFix(Location location) {
|
||||
if (location == null || !MapGeometry.usable(location.getLatitude(), location.getLongitude())) return;
|
||||
if (location.hasBearing() && location.hasSpeed() && location.getSpeed() > 1.5f) {
|
||||
bearing = location.getBearing();
|
||||
anchor = location;
|
||||
} else if (anchor == null || location.getElapsedRealtimeNanos() - anchor.getElapsedRealtimeNanos() > 10_000_000_000L) {
|
||||
anchor = location;
|
||||
} else if (anchor.distanceTo(location) >= 8f) {
|
||||
// No bearing from the GPS: the way from the last place 8 m or more behind.
|
||||
bearing = (anchor.bearingTo(location) + 360f) % 360f;
|
||||
anchor = location;
|
||||
}
|
||||
Fix f = new Fix(location.getLatitude(), location.getLongitude(), bearing, SystemClock.elapsedRealtime());
|
||||
fix = f;
|
||||
want(MapGeometry.tileX(f.lon), MapGeometry.tileY(f.lat));
|
||||
}
|
||||
|
||||
/** The latest fix, or null when there is none or it is stale. */
|
||||
Fix fix() {
|
||||
Fix f = fix;
|
||||
return f == null || SystemClock.elapsedRealtime() - f.elapsedMs > FIX_FRESH_MS ? null : f;
|
||||
}
|
||||
|
||||
Collection<RoadTile> tiles() {
|
||||
return tiles.values();
|
||||
}
|
||||
|
||||
State state() {
|
||||
if (serverMissing) return State.NO_SERVER;
|
||||
Fix f = fix();
|
||||
if (f == null) return State.WAITING_FOR_GPS;
|
||||
long here = MapGeometry.key(MapGeometry.tileX(f.lon), MapGeometry.tileY(f.lat));
|
||||
if (tiles.containsKey(here)) return State.SHOWING;
|
||||
return failedAt.containsKey(here) ? State.UNAVAILABLE : State.LOADING;
|
||||
}
|
||||
|
||||
/** Queues the 3×3 tiles round (cx, cy), the car's own first, and forgets far ones. */
|
||||
private void want(int cx, int cy) {
|
||||
for (Long key : tiles.keySet()) {
|
||||
if (Math.abs(MapGeometry.keyX(key) - cx) > 2 || Math.abs(MapGeometry.keyY(key) - cy) > 2) tiles.remove(key);
|
||||
}
|
||||
int[][] order = { {0, 0}, {0, -1}, {1, 0}, {0, 1}, {-1, 0}, {1, -1}, {1, 1}, {-1, 1}, {-1, -1} };
|
||||
long now = SystemClock.elapsedRealtime();
|
||||
for (int[] d : order) {
|
||||
int x = cx + d[0], y = cy + d[1];
|
||||
if (x < 0 || y < 0 || x >= (1 << MapGeometry.ZOOM) || y >= (1 << MapGeometry.ZOOM)) continue;
|
||||
long key = MapGeometry.key(x, y);
|
||||
if (tiles.containsKey(key) || queued.containsKey(key)) continue;
|
||||
Long failed = failedAt.get(key);
|
||||
if (failed != null && now - failed < RETRY_MS) continue;
|
||||
queued.put(key, Boolean.TRUE);
|
||||
worker.execute(() -> {
|
||||
try {
|
||||
load(key, x, y);
|
||||
} finally {
|
||||
queued.remove(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** From the cache when fresh enough, else from the server; a stale copy beats nothing. */
|
||||
private void load(long key, int x, int y) {
|
||||
File file = new File(dir, MapGeometry.ZOOM + "_" + x + "_" + y + ".json");
|
||||
RoadTile cached = null;
|
||||
try {
|
||||
if (file.isFile()) {
|
||||
cached = RoadTile.parse(new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8), x, y);
|
||||
if (cached == null) file.delete();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Cached map tile unreadable: " + e.getClass().getSimpleName());
|
||||
}
|
||||
if (cached != null) {
|
||||
tiles.put(key, cached);
|
||||
if (System.currentTimeMillis() - file.lastModified() < DISK_AGE_MS) return;
|
||||
}
|
||||
ServerConfig config = ServerConfig.get(context);
|
||||
if (!config.isConfigured()) {
|
||||
serverMissing = true;
|
||||
return;
|
||||
}
|
||||
serverMissing = false;
|
||||
try {
|
||||
JSONObject body = new JSONObject().put("z", MapGeometry.ZOOM).put("x", x).put("y", y);
|
||||
ServerHttp.Response r = ServerHttp.request("POST", config.url + "/v1/map/roads", config.token, body);
|
||||
RoadTile tile = r.status == 200 ? RoadTile.parse(r.body, x, y) : null;
|
||||
if (tile == null) {
|
||||
// The tile stays out of the log: it says where the car is.
|
||||
Log.w(TAG, "Map tile not loaded: HTTP " + r.status);
|
||||
if (cached == null) failedAt.put(key, SystemClock.elapsedRealtime());
|
||||
return;
|
||||
}
|
||||
failedAt.remove(key);
|
||||
tiles.put(key, tile);
|
||||
save(file, r.body);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Map tile not loaded: " + e.getClass().getSimpleName());
|
||||
if (cached == null) failedAt.put(key, SystemClock.elapsedRealtime());
|
||||
}
|
||||
}
|
||||
|
||||
private void save(File file, String body) {
|
||||
try {
|
||||
if (!dir.isDirectory() && !dir.mkdirs()) return;
|
||||
File tmp = new File(dir, file.getName() + ".tmp");
|
||||
try (FileOutputStream out = new FileOutputStream(tmp)) {
|
||||
out.write(body.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
if (!tmp.renameTo(file)) tmp.delete();
|
||||
File[] all = dir.listFiles((d, name) -> name.endsWith(".json"));
|
||||
if (all != null && all.length > DISK_TILES) {
|
||||
Arrays.sort(all, (a, b) -> Long.compare(a.lastModified(), b.lastModified()));
|
||||
for (int i = 0; i < all.length - DISK_TILES; i++) all[i].delete();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Map tile not cached: " + e.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user