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:
2026-10-26 23:34:21 +01:00
co-authored by Claude Opus 5.5
parent 776a11b182
commit 65dc1ba847
24 changed files with 1312 additions and 18 deletions
@@ -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) };
}