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
@@ -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) {