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:
@@ -22,6 +22,13 @@ import android.os.SystemClock;
|
||||
* row of read-outs along the bottom. Unread values show "—", light nothing and leave gaps in
|
||||
* the trail; the floor stands still when the speed is unread.
|
||||
*
|
||||
* With a map ("Grid with map", {@link MapRoads}): the roads round the car from OpenStreetMap lie
|
||||
* on the floor in its perspective, heading up, the bigger ones glowing brighter, with the car
|
||||
* as a light marker a little way up the floor; between GPS fixes the car's position runs on
|
||||
* with the speed. The floor's tiles are then 40 m, so they move with the roads, and the trail
|
||||
* makes way for the map. Without a fix, a server or the roads, the plain floor with a line
|
||||
* saying why.
|
||||
*
|
||||
* Two palettes: the original cyan, or the chosen accent colour ({@link #setAccentPalette}). A
|
||||
* low battery is orange in the original (the films' other side) and white in the accent one,
|
||||
* so it never looks like the accent itself. The floor and the disc animate (about 30 frames a
|
||||
@@ -32,6 +39,11 @@ final class GridView extends ClusterCanvasView {
|
||||
/** Battery segments in the light wall, disc segments, metres per grid tile. */
|
||||
private static final int WALL = 20, DISC = 60;
|
||||
private static final float TILE_M = 4f;
|
||||
/**
|
||||
* The map: metres per depth step of the floor, the car's depth (just above the read-outs),
|
||||
* the nearest depth drawn, how far ahead, and the depth from which roads are drawn fainter.
|
||||
*/
|
||||
private static final float MAP_UNIT_M = 40f, CAR_DEPTH = 1.6f, NEAR = 0.6f, FAR_M = 900f, FADE_DEPTH = 5f;
|
||||
|
||||
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
@@ -44,6 +56,15 @@ final class GridView extends ClusterCanvasView {
|
||||
private final Typeface thin = Typeface.create("sans-serif-thin", Typeface.NORMAL);
|
||||
private final Typeface label = Typeface.create("sans-serif-condensed", Typeface.BOLD);
|
||||
private final int accent, accentLight;
|
||||
/** Null for the plain Grid. */
|
||||
private final MapRoads map;
|
||||
/** Per road class, near and far (from {@link #FADE_DEPTH}). */
|
||||
private final Path[] roads = new Path[RoadTile.CLASSES * 2];
|
||||
private float heading = Float.NaN;
|
||||
/** The pen while a road is added: the last point, and whether it continues from there. */
|
||||
private float penX, penY;
|
||||
private boolean penDown;
|
||||
private Path penPath;
|
||||
|
||||
private int main, light, dim, alert;
|
||||
/** The floor's position within one tile (0..1) and the disc's inner ring angle. */
|
||||
@@ -52,7 +73,13 @@ final class GridView extends ClusterCanvasView {
|
||||
private int skyFor = -1;
|
||||
|
||||
GridView(Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
GridView(Context context, MapRoads map) {
|
||||
super(context);
|
||||
this.map = map;
|
||||
for (int i = 0; i < roads.length; i++) roads[i] = new Path();
|
||||
accent = AccentColor.resolve(context, R.attr.evAccent);
|
||||
accentLight = AccentColor.resolve(context, R.attr.evAccentLight);
|
||||
line.setStyle(Paint.Style.STROKE);
|
||||
@@ -83,16 +110,20 @@ final class GridView extends ClusterCanvasView {
|
||||
float horizon = h * 0.55f;
|
||||
drawSky(c, w, horizon);
|
||||
drawFloor(c, w, h, horizon, u);
|
||||
boolean mapShown = map != null && drawMap(c, w, h, horizon, u);
|
||||
if (map != null && !mapShown) drawMapState(c, w, h, horizon, u);
|
||||
drawGears(c, w, u);
|
||||
drawClock(c, w, u);
|
||||
drawSpeed(c, w, horizon, u);
|
||||
drawWall(c, horizon, u);
|
||||
drawDisc(c, w, u);
|
||||
drawTrail(c, w, h, u);
|
||||
if (!mapShown) drawTrail(c, w, h, u);
|
||||
drawReadouts(c, w, h, u);
|
||||
if (mapShown) drawAttribution(c, w, h, u);
|
||||
|
||||
boolean moving = v.speedKmh != null && v.speedKmh > 0.5f;
|
||||
keepAnimating(moving, 33);
|
||||
// The map costs more per frame: 20 a second is smooth enough for it.
|
||||
keepAnimating(moving, mapShown ? 50 : 33);
|
||||
}
|
||||
|
||||
/** Moves the floor by the distance driven since the last frame, and turns the disc. */
|
||||
@@ -101,7 +132,8 @@ final class GridView extends ClusterCanvasView {
|
||||
float dt = lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f);
|
||||
lastFrame = now;
|
||||
if (v.speedKmh != null && v.speedKmh > 0.5f) {
|
||||
floorPhase = (floorPhase + v.speedKmh / 3.6f * dt / TILE_M) % 1f;
|
||||
float tile = map != null ? MAP_UNIT_M : TILE_M;
|
||||
floorPhase = (floorPhase + v.speedKmh / 3.6f * dt / tile) % 1f;
|
||||
}
|
||||
if (v.powerKw != null) discTurn = (discTurn + v.powerKw * 3f * dt) % 360f;
|
||||
}
|
||||
@@ -138,6 +170,142 @@ final class GridView extends ClusterCanvasView {
|
||||
glowLine(c, 0, horizon, w, horizon, main, 1.5f * u);
|
||||
}
|
||||
|
||||
// ---------- Map ----------
|
||||
|
||||
/** The roads round the car on the floor; false when there is nothing to draw. */
|
||||
private boolean drawMap(Canvas c, float w, float h, float horizon, float u) {
|
||||
MapRoads.Fix fix = map.fix();
|
||||
java.util.Collection<RoadTile> tiles = map.tiles();
|
||||
if (fix == null || tiles.isEmpty()) return false;
|
||||
// Heading up: ease round the shorter way; north up until the car has moved.
|
||||
float target = Float.isNaN(fix.bearing) ? (Float.isNaN(heading) ? 0f : heading) : fix.bearing;
|
||||
if (Float.isNaN(heading)) heading = target;
|
||||
else heading = (heading + (((target - heading) % 360f + 540f) % 360f - 180f) * 0.2f + 360f) % 360f;
|
||||
|
||||
// Between fixes the car runs on with the speed, for at most 2.5 s.
|
||||
double mpu = MapGeometry.metresPerUnit(fix.lat);
|
||||
float ran = 0f;
|
||||
if (v.speedKmh != null && !Float.isNaN(fix.bearing)) {
|
||||
ran = v.speedKmh / 3.6f * Math.min(2.5f, (SystemClock.elapsedRealtime() - fix.elapsedMs) / 1000f);
|
||||
}
|
||||
double run = Math.toRadians(Float.isNaN(fix.bearing) ? 0f : fix.bearing);
|
||||
double carX = MapGeometry.worldX(fix.lon) + ran * Math.sin(run) / mpu;
|
||||
double carY = MapGeometry.worldY(fix.lat) - ran * Math.cos(run) / mpu;
|
||||
|
||||
float sin = (float) Math.sin(Math.toRadians(heading)), cos = (float) Math.cos(Math.toRadians(heading));
|
||||
float lane = w * 0.09f, depth = h - horizon, vx = w / 2f, m = (float) mpu;
|
||||
double range = FAR_M / mpu;
|
||||
for (Path p : roads) p.reset();
|
||||
for (RoadTile tile : tiles) {
|
||||
double ox = (double) tile.x * MapGeometry.EXTENT - carX, oy = (double) tile.y * MapGeometry.EXTENT - carY;
|
||||
if (ox > range || oy > range || ox + 2 * MapGeometry.EXTENT < -range || oy + 2 * MapGeometry.EXTENT < -range) {
|
||||
continue;
|
||||
}
|
||||
float fx = (float) ox, fy = (float) oy;
|
||||
for (int i = 0; i < tile.points.length; i++) {
|
||||
int[] b = tile.bounds[i];
|
||||
if (fx + b[2] < -range || fx + b[0] > range || fy + b[3] < -range || fy + b[1] > range) continue;
|
||||
int cls = tile.classes[i];
|
||||
int[] pts = tile.points[i];
|
||||
penDown = false;
|
||||
float pa = 0f, pf = 0f;
|
||||
for (int k = 0; k < pts.length; k += 2) {
|
||||
float east = (fx + pts[k]) * m, north = -(fy + pts[k + 1]) * m;
|
||||
float fwd = east * sin + north * cos, side = east * cos - north * sin;
|
||||
if (k > 0) segment(cls, pa, pf, side, fwd, vx, horizon, lane, depth);
|
||||
pa = side;
|
||||
pf = fwd;
|
||||
}
|
||||
}
|
||||
}
|
||||
c.save();
|
||||
c.clipRect(0, horizon, w, h);
|
||||
line.setStrokeJoin(Paint.Join.ROUND);
|
||||
line.setStrokeCap(Paint.Cap.ROUND);
|
||||
// Far roads thin and faint, so the horizon stays a line rather than a tangle.
|
||||
for (int cls = RoadTile.CLASSES - 1; cls >= 0; cls--) {
|
||||
line.setColor(AccentColor.withAlpha(cls == 0 ? light : main, cls >= 3 ? 0x30 : 0x50));
|
||||
line.setStrokeWidth((cls == 0 ? 1.6f : 1f) * u);
|
||||
c.drawPath(roads[cls * 2 + 1], line);
|
||||
}
|
||||
line.setColor(AccentColor.withAlpha(main, 0x80));
|
||||
line.setStrokeWidth(1.4f * u);
|
||||
c.drawPath(roads[3 * 2], line);
|
||||
line.setColor(AccentColor.withAlpha(main, 0xC0));
|
||||
line.setStrokeWidth(2f * u);
|
||||
c.drawPath(roads[2 * 2], line);
|
||||
glow(c, roads[2], main, 2.4f * u);
|
||||
glow(c, roads[0], light, 3.2f * u);
|
||||
line.setStrokeJoin(Paint.Join.MITER);
|
||||
line.setStrokeCap(Paint.Cap.SQUARE);
|
||||
c.restore();
|
||||
|
||||
// The car: a light-cycle chevron pointing ahead.
|
||||
float cy = horizon + depth / CAR_DEPTH;
|
||||
path.reset();
|
||||
path.moveTo(vx, cy - 16f * u);
|
||||
path.lineTo(vx + 11f * u, cy + 10f * u);
|
||||
path.lineTo(vx, cy + 4f * u);
|
||||
path.lineTo(vx - 11f * u, cy + 10f * u);
|
||||
path.close();
|
||||
fill.setColor(light);
|
||||
c.drawPath(path, fill);
|
||||
glow(c, path, light, 1.5f * u);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** One stretch of road from (a0, f0) to (a1, f1) in metres (side, ahead), cut at the near edge. */
|
||||
private void segment(int cls, float a0, float f0, float a1, float f1, float vx, float horizon, float lane,
|
||||
float depth) {
|
||||
float d0 = CAR_DEPTH + f0 / MAP_UNIT_M, d1 = CAR_DEPTH + f1 / MAP_UNIT_M;
|
||||
Path p = roads[cls * 2 + (Math.min(d0, d1) > FADE_DEPTH ? 1 : 0)];
|
||||
if ((d0 < NEAR && d1 < NEAR) || (f0 > FAR_M && f1 > FAR_M)) {
|
||||
penDown = false;
|
||||
return;
|
||||
}
|
||||
boolean cut = false;
|
||||
if (d0 < NEAR || d1 < NEAR) {
|
||||
float t = (NEAR - d0) / (d1 - d0);
|
||||
float a = a0 + (a1 - a0) * t, d = NEAR;
|
||||
if (d0 < NEAR) {
|
||||
a0 = a;
|
||||
d0 = d;
|
||||
cut = true;
|
||||
} else {
|
||||
a1 = a;
|
||||
d1 = d;
|
||||
}
|
||||
}
|
||||
float x0 = vx + a0 / MAP_UNIT_M * lane / d0, y0 = horizon + depth / d0;
|
||||
float x1 = vx + a1 / MAP_UNIT_M * lane / d1, y1 = horizon + depth / d1;
|
||||
if (!penDown || cut || p != penPath || x0 != penX || y0 != penY) p.moveTo(x0, y0);
|
||||
p.lineTo(x1, y1);
|
||||
penPath = p;
|
||||
penX = x1;
|
||||
penY = y1;
|
||||
penDown = true;
|
||||
}
|
||||
|
||||
/** Why the map is not there, on the floor. */
|
||||
private void drawMapState(Canvas c, float w, float h, float horizon, float u) {
|
||||
MapRoads.State state = map.state();
|
||||
String why = state == MapRoads.State.NO_SERVER ? "MAP · SET UP THE SERVER FIRST"
|
||||
: state == MapRoads.State.WAITING_FOR_GPS ? "MAP · WAITING FOR GPS"
|
||||
: state == MapRoads.State.UNAVAILABLE ? "MAP · NOT AVAILABLE, TRYING AGAIN"
|
||||
: "MAP · LOADING";
|
||||
caption(c, why, w / 2f, horizon + (h - horizon) * 0.2f, u, Paint.Align.CENTER);
|
||||
}
|
||||
|
||||
/** OpenStreetMap's licence asks for its name wherever its data is shown. */
|
||||
private void drawAttribution(Canvas c, float w, float h, float u) {
|
||||
text.setTypeface(label);
|
||||
text.setTextSize(12f * u);
|
||||
text.setTextAlign(Paint.Align.RIGHT);
|
||||
text.setStyle(Paint.Style.FILL);
|
||||
text.setColor(dim);
|
||||
c.drawText("© OpenStreetMap contributors", w - 26f * u, h - 80f * u, text);
|
||||
}
|
||||
|
||||
// ---------- Top ----------
|
||||
|
||||
private void drawGears(Canvas c, float w, float u) {
|
||||
|
||||
@@ -70,6 +70,11 @@ public class MainActivity extends AppCompatActivity {
|
||||
private TerminalView terminalView;
|
||||
private GridView gridView;
|
||||
private VisorView visorView;
|
||||
/** "Grid with map": the same style over the roads round the car (MapRoads, fed by GPS here). */
|
||||
private GridView gridMapView;
|
||||
private MapRoads mapRoads;
|
||||
private android.location.LocationListener mapListener;
|
||||
private boolean inFront;
|
||||
/** The pane of each Overview style, by its {@link OverviewStyle} ordinal. */
|
||||
private View[] stylePanes;
|
||||
private View consumptionPane;
|
||||
@@ -345,9 +350,11 @@ public class MainActivity extends AppCompatActivity {
|
||||
mgPane = drawnPane(octagonView);
|
||||
terminalView = new TerminalView(this);
|
||||
gridView = new GridView(this);
|
||||
mapRoads = new MapRoads(this);
|
||||
gridMapView = new GridView(this, mapRoads);
|
||||
visorView = new VisorView(this);
|
||||
java.util.Collections.addAll(canvasStyles, new MinimalView(this), terminalView,
|
||||
new NixieView(this), new ChronographView(this), gridView, new TimeCircuitsView(this),
|
||||
new NixieView(this), new ChronographView(this), gridView, gridMapView, new TimeCircuitsView(this),
|
||||
new QuestView(this), visorView, new EmpireView(this));
|
||||
View[] canvasPanes = new View[canvasStyles.size()];
|
||||
for (int i = 0; i < canvasPanes.length; i++) canvasPanes[i] = drawnPane(canvasStyles.get(i));
|
||||
@@ -487,6 +494,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
// Poll state and log only while the screen is up; onPause cancels it.
|
||||
uiHandler.post(uiRefresh);
|
||||
uiHandler.post(overviewRefresh);
|
||||
inFront = true;
|
||||
updateMapLocation();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -534,6 +543,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
// Stop polling off-screen: this activity has no reason to spend cycles then.
|
||||
uiHandler.removeCallbacks(uiRefresh);
|
||||
uiHandler.removeCallbacks(overviewRefresh);
|
||||
inFront = false;
|
||||
updateMapLocation();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1725,9 +1736,56 @@ public class MainActivity extends AppCompatActivity {
|
||||
retroView.setAccentPalette(!originalColours(OverviewStyle.RETRO));
|
||||
terminalView.setAccentPalette(!originalColours(OverviewStyle.TERMINAL));
|
||||
gridView.setAccentPalette(!originalColours(OverviewStyle.GRID));
|
||||
gridMapView.setAccentPalette(!originalColours(OverviewStyle.GRID_MAP));
|
||||
visorView.setAccentPalette(!originalColours(OverviewStyle.VISOR));
|
||||
// The accent choices depend on the style ("Original colours" for Retro and Terminal).
|
||||
if (settingsPane != null) bindAccentColor();
|
||||
updateMapLocation();
|
||||
}
|
||||
|
||||
/**
|
||||
* GPS for the Grid's map, only while "Grid with map" is the style and the app is in front;
|
||||
* every fix goes to {@link MapRoads}. Without the location permission there is no map (the
|
||||
* floor says it waits for GPS), never a crash.
|
||||
*/
|
||||
private void updateMapLocation() {
|
||||
android.location.LocationManager lm =
|
||||
(android.location.LocationManager) getSystemService(LOCATION_SERVICE);
|
||||
if (lm == null || mapRoads == null) return;
|
||||
boolean wanted = inFront && overviewStyle() == OverviewStyle.GRID_MAP;
|
||||
if (!wanted) {
|
||||
if (mapListener != null) {
|
||||
try {
|
||||
lm.removeUpdates(mapListener);
|
||||
} catch (RuntimeException ignored) {
|
||||
// already gone
|
||||
}
|
||||
mapListener = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (mapListener != null) return;
|
||||
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||
return;
|
||||
}
|
||||
android.location.LocationListener listener = new android.location.LocationListener() {
|
||||
@Override public void onLocationChanged(android.location.Location location) { mapRoads.onFix(location); }
|
||||
@Override public void onStatusChanged(String provider, int status, android.os.Bundle extras) { }
|
||||
@Override public void onProviderEnabled(String provider) { }
|
||||
@Override public void onProviderDisabled(String provider) { }
|
||||
};
|
||||
try {
|
||||
lm.requestLocationUpdates(android.location.LocationManager.GPS_PROVIDER, 1000L, 0f, listener);
|
||||
mapListener = listener;
|
||||
android.location.Location last = lm.getLastKnownLocation(android.location.LocationManager.GPS_PROVIDER);
|
||||
if (last != null && android.os.SystemClock.elapsedRealtimeNanos() - last.getElapsedRealtimeNanos()
|
||||
< MapRoads.FIX_FRESH_MS * 1_000_000L) {
|
||||
mapRoads.onFix(last);
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
android.util.Log.w("MainActivity", "No GPS for the map: " + e.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1757,6 +1815,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
getString(R.string.overview_style_minimal), getString(R.string.overview_style_terminal),
|
||||
getString(R.string.overview_style_nixie),
|
||||
getString(R.string.overview_style_chronograph), getString(R.string.overview_style_grid),
|
||||
getString(R.string.overview_style_grid_map),
|
||||
getString(R.string.overview_style_time_circuits), getString(R.string.overview_style_quest),
|
||||
getString(R.string.overview_style_visor), getString(R.string.overview_style_empire) };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
/**
|
||||
* Web-mercator arithmetic for the Grid's map: which tile the car is in, positions in the
|
||||
* server's tile units (4096 across a zoom-14 tile) and metres per unit. Pure Java, unit-tested.
|
||||
*/
|
||||
final class MapGeometry {
|
||||
|
||||
static final int ZOOM = 14;
|
||||
static final int EXTENT = 4096;
|
||||
/** Tile units across the world at {@link #ZOOM}. */
|
||||
static final double WORLD = (double) (1 << ZOOM) * EXTENT;
|
||||
private static final double EQUATOR_M = 40_075_016.686;
|
||||
|
||||
private MapGeometry() {}
|
||||
|
||||
/** The position east, in tile units from the date line. */
|
||||
static double worldX(double lon) {
|
||||
return (lon + 180d) / 360d * WORLD;
|
||||
}
|
||||
|
||||
/** The position south, in tile units from the top of the map. */
|
||||
static double worldY(double lat) {
|
||||
double r = Math.toRadians(lat);
|
||||
return (1d - Math.log(Math.tan(r) + 1d / Math.cos(r)) / Math.PI) / 2d * WORLD;
|
||||
}
|
||||
|
||||
static int tileX(double lon) {
|
||||
return clampTile((int) Math.floor(worldX(lon) / EXTENT));
|
||||
}
|
||||
|
||||
static int tileY(double lat) {
|
||||
return clampTile((int) Math.floor(worldY(lat) / EXTENT));
|
||||
}
|
||||
|
||||
private static int clampTile(int t) {
|
||||
return Math.max(0, Math.min((1 << ZOOM) - 1, t));
|
||||
}
|
||||
|
||||
/** Metres per tile unit on the ground at this latitude. */
|
||||
static double metresPerUnit(double lat) {
|
||||
return EQUATOR_M * Math.cos(Math.toRadians(lat)) / WORLD;
|
||||
}
|
||||
|
||||
/** One number for a tile, for maps and file names. */
|
||||
static long key(int x, int y) {
|
||||
return ((long) x << 20) | y;
|
||||
}
|
||||
|
||||
static int keyX(long key) {
|
||||
return (int) (key >> 20);
|
||||
}
|
||||
|
||||
static int keyY(long key) {
|
||||
return (int) (key & 0xFFFFF);
|
||||
}
|
||||
|
||||
/** Whether the position is one a map can show (web mercator stops short of the poles). */
|
||||
static boolean usable(double lat, double lon) {
|
||||
return Double.isFinite(lat) && Double.isFinite(lon) && Math.abs(lat) < 85d && Math.abs(lon) <= 180d
|
||||
&& !(lat == 0d && lon == 0d);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ enum OverviewStyle {
|
||||
NIXIE(false, false),
|
||||
CHRONOGRAPH(true, false),
|
||||
GRID(true, true),
|
||||
GRID_MAP(true, true),
|
||||
TIME_CIRCUITS(false, false),
|
||||
QUEST(false, false),
|
||||
VISOR(true, true),
|
||||
@@ -53,7 +54,8 @@ enum OverviewStyle {
|
||||
|
||||
/** Where the choice between the original palette and the accent colour is kept. */
|
||||
String originalKey() {
|
||||
return "original_colours_" + name();
|
||||
// Grid with map is the Grid over a map: one choice for both.
|
||||
return "original_colours_" + (this == GRID_MAP ? GRID : this).name();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/**
|
||||
* The roads of one map tile as the server sends them (server/maps.go): per road its class
|
||||
* (0 motorway and trunk, 1 primary and secondary, 2 tertiary and unclassified, 3 residential)
|
||||
* and its points in tile units, plus each road's bounding box for skipping far ones quickly.
|
||||
* {@link #parse} fails closed: anything unexpected is no tile at all. Pure Java, unit-tested.
|
||||
*/
|
||||
final class RoadTile {
|
||||
|
||||
static final int CLASSES = 4;
|
||||
/** Points may lie a little beyond the tile (a road is kept to its first point outside). */
|
||||
private static final int SLACK = MapGeometry.EXTENT;
|
||||
private static final int MAX_ROADS = 20_000;
|
||||
|
||||
final int x, y;
|
||||
final int[] classes;
|
||||
/** Per road: x0, y0, x1, y1, … in tile units. */
|
||||
final int[][] points;
|
||||
/** Per road: minX, minY, maxX, maxY. */
|
||||
final int[][] bounds;
|
||||
|
||||
private RoadTile(int x, int y, int[] classes, int[][] points, int[][] bounds) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.classes = classes;
|
||||
this.points = points;
|
||||
this.bounds = bounds;
|
||||
}
|
||||
|
||||
/** The tile in {@code json}, which must be the one asked for; null when it is not usable. */
|
||||
static RoadTile parse(String json, int expectX, int expectY) {
|
||||
try {
|
||||
JSONObject o = new JSONObject(json);
|
||||
if (o.getInt("z") != MapGeometry.ZOOM || o.getInt("x") != expectX || o.getInt("y") != expectY
|
||||
|| o.getInt("extent") != MapGeometry.EXTENT) {
|
||||
return null;
|
||||
}
|
||||
JSONArray ways = o.getJSONArray("ways");
|
||||
if (ways.length() > MAX_ROADS) return null;
|
||||
int n = ways.length();
|
||||
int[] classes = new int[n];
|
||||
int[][] points = new int[n][];
|
||||
int[][] bounds = new int[n][];
|
||||
for (int i = 0; i < n; i++) {
|
||||
JSONObject way = ways.getJSONObject(i);
|
||||
int c = way.getInt("c");
|
||||
JSONArray p = way.getJSONArray("p");
|
||||
if (c < 0 || c >= CLASSES || p.length() < 4 || p.length() % 2 != 0) return null;
|
||||
int[] pts = new int[p.length()];
|
||||
int minX = Integer.MAX_VALUE, minY = Integer.MAX_VALUE, maxX = Integer.MIN_VALUE, maxY = Integer.MIN_VALUE;
|
||||
for (int k = 0; k < pts.length; k++) {
|
||||
int value = p.getInt(k);
|
||||
if (value < -SLACK || value > MapGeometry.EXTENT + SLACK) return null;
|
||||
pts[k] = value;
|
||||
if (k % 2 == 0) {
|
||||
minX = Math.min(minX, value);
|
||||
maxX = Math.max(maxX, value);
|
||||
} else {
|
||||
minY = Math.min(minY, value);
|
||||
maxY = Math.max(maxY, value);
|
||||
}
|
||||
}
|
||||
classes[i] = c;
|
||||
points[i] = pts;
|
||||
bounds[i] = new int[] { minX, minY, maxX, maxY };
|
||||
}
|
||||
return new RoadTile(expectX, expectY, classes, points, bounds);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,8 @@ final class StylePalette {
|
||||
case SCIFI: return R.style.ThemeOverlay_EV_Style_SciFi;
|
||||
case CARTOON: return R.style.ThemeOverlay_EV_Style_Cartoon;
|
||||
case NIXIE: return R.style.ThemeOverlay_EV_Style_Nixie;
|
||||
case GRID: return R.style.ThemeOverlay_EV_Style_Grid;
|
||||
case GRID:
|
||||
case GRID_MAP: return R.style.ThemeOverlay_EV_Style_Grid;
|
||||
case TIME_CIRCUITS: return R.style.ThemeOverlay_EV_Style_TimeCircuits;
|
||||
case QUEST: return R.style.ThemeOverlay_EV_Style_Quest;
|
||||
case VISOR: return R.style.ThemeOverlay_EV_Style_Visor;
|
||||
|
||||
Reference in New Issue
Block a user