App 2.39.2: Grid with map seen from higher up

The map gets its own camera, tilted 45 degrees down from about 300 m up,
so roads keep their shape some 400 m ahead instead of collapsing into
the horizon. In map mode the floor starts at half height, the speed is a
little smaller, the floor's grid is fixed to the ground and turns with
the roads, and the power disc has a dark backing.

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-27 00:06:07 +01:00
co-authored by Claude Opus 5.5
parent 09cbd21634
commit 2d7b324899
6 changed files with 122 additions and 59 deletions
+8
View File
@@ -138,6 +138,14 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces
upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator`
(Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
- 2026-10-27 — App 2.39.2: **Grid with map seen from higher up** (owner: the map looked like a
fish-eye; wanted it more top-down). The map has its own pinhole camera now: 45° down, 300 m
up, the car on its axis just above the read-outs, the top of the floor about 420 m ahead
(scale there ~60 % of the car's, before ~1/20 at 900 m); the floor starts at half height in
map mode and the speed is 170 instead of 210 units; the floor's grid becomes a ground grid
(a line every 50 m, fixed to the ground, turning with the roads); roads from 250 m ahead
fainter; the power disc gets a dark backing over the map. Emulator with the Berlin tiles,
both layouts. Not tried on the car.
- 2026-10-26 — Server 2.8.0 image pushed and pinned in `k8s/server.yaml` by digest
`sha256:8b8d94e0562833d196162866b7fdc6910d5dd219f6eb84572d4d5c91b74f7ff1` (checked with skopeo);
app 2.39.1 published (`car-2.39.1`). Not applied yet: the owner applies it.
+5 -3
View File
@@ -165,9 +165,11 @@ films' other side.
## Grid with map
The Grid, with the roads round the car from OpenStreetMap (through OpenFreeMap) laid on its floor as light lanes, in
the floor's perspective and turned with the direction you drive: motorways glow brightest, then
main roads, then the smaller streets, fading towards the horizon. The car is the chevron on the
The Grid, with the roads round the car from OpenStreetMap (through OpenFreeMap) laid on its floor as light lanes, seen
from a camera high above and behind the car, tilted down, so the streets keep their shape some
400 m ahead, and turned with the direction you drive: motorways glow brightest, then main
roads, then the smaller streets, the far ones fainter. A ground grid every 50 m turns and slides
with them. The car is the chevron on the
floor, and between GPS fixes it runs on with the speed. The roads come through the car's own
server, which fetches only the map tiles you drive through and keeps them (see the README); the car also keeps
the last few hundred tiles itself. Without GPS, the server or the roads, the floor says so and
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4"
minSdk 27
targetSdk 36
versionCode 143
versionName "2.39.1"
versionCode 144
versionName "2.39.2"
// Nothing about the server ships in the APK: its URL and ingest token are entered in
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
+107 -54
View File
@@ -23,10 +23,12 @@ import android.os.SystemClock;
* 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
* on the floor in 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
* with the speed. The map has its own camera, higher and steeper than the plain
* floor's, so far roads shrink less; the floor starts higher, the speed is a little smaller, its
* grid is fixed to the ground (a line every 50 m) and turns 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
@@ -40,10 +42,13 @@ final class GridView extends ClusterCanvasView {
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.
* The map's camera: tilted 45° down from about 300 m up, the car on its axis, the top of the
* floor some 420 m ahead; nothing nearer to the lens than NEAR_M is drawn, nothing beyond
* MAP_RANGE_M looked at, and roads from FADE_M ahead are fainter. The ground grid under the
* roads has a line every GRID_M.
*/
private static final float MAP_UNIT_M = 40f, CAR_DEPTH = 1.6f, NEAR = 0.6f, FAR_M = 900f, FADE_DEPTH = 5f;
private static final float PITCH = (float) Math.toRadians(45), CAM_H = 300f, AHEAD_M = 420f,
NEAR_M = 40f, MAP_RANGE_M = 1300f, FADE_M = 250f, GRID_M = 50f;
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
@@ -58,13 +63,16 @@ final class GridView extends ClusterCanvasView {
private final int accent, accentLight;
/** Null for the plain Grid. */
private final MapRoads map;
/** Per road class, near and far (from {@link #FADE_DEPTH}). */
/** Per road class, near and far (from {@link #FADE_M} ahead). */
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 final Path gridPath = new Path();
/** The camera of this frame: focal length and centre in pixels. */
private float camF, camCx, camCy;
private int main, light, dim, alert;
/** The floor's position within one tile (0..1) and the disc's inner ring angle. */
@@ -107,16 +115,18 @@ final class GridView extends ClusterCanvasView {
advance();
c.drawColor(0xFF000000);
float horizon = h * 0.55f;
// With a map the floor starts higher, to give the roads room.
boolean mapShown = map != null && map.fix() != null && !map.tiles().isEmpty();
float horizon = h * (mapShown ? 0.5f : 0.55f);
drawSky(c, w, horizon);
drawFloor(c, w, h, horizon, u);
boolean mapShown = map != null && drawMap(c, w, h, horizon, u);
if (mapShown) drawMap(c, w, h, horizon, u);
else drawFloor(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);
drawSpeed(c, w, horizon, mapShown ? 170f : 210f, u);
drawWall(c, horizon, u);
drawDisc(c, w, u);
drawDisc(c, w, mapShown, u);
if (!mapShown) drawTrail(c, w, h, u);
drawReadouts(c, w, h, u);
if (mapShown) drawAttribution(c, w, h, u);
@@ -132,8 +142,7 @@ 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) {
float tile = map != null ? MAP_UNIT_M : TILE_M;
floorPhase = (floorPhase + v.speedKmh / 3.6f * dt / tile) % 1f;
floorPhase = (floorPhase + v.speedKmh / 3.6f * dt / TILE_M) % 1f;
}
if (v.powerKw != null) discTurn = (discTurn + v.powerKw * 3f * dt) % 360f;
}
@@ -172,11 +181,11 @@ final class GridView extends ClusterCanvasView {
// ---------- 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) {
/** The roads round the car on the floor, seen by the map's camera, with a ground grid. */
private void 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;
if (fix == null) return;
// 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;
@@ -192,13 +201,33 @@ final class GridView extends ClusterCanvasView {
double carX = MapGeometry.worldX(fix.lon) + ran * Math.sin(run) / mpu;
double carY = MapGeometry.worldY(fix.lat) - ran * Math.cos(run) / mpu;
// The camera: the car where the chevron stands, the top of the floor AHEAD_M beyond it.
float carScreenY = h - 96f * u;
float z0 = CAM_H / (float) Math.tan(PITCH);
camF = (carScreenY - horizon) / (up(z0 + AHEAD_M) - up(z0));
camCy = carScreenY + camF * up(z0);
camCx = w / 2f;
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;
float m = (float) mpu;
double range = MAP_RANGE_M / mpu;
// The ground grid, fixed to the ground: it turns and slides with the roads.
gridPath.reset();
penPath = null;
double carEast = carX * mpu, carNorth = -carY * mpu;
float offE = (float) (carEast % GRID_M), offN = (float) (carNorth % GRID_M);
int lines = (int) (MAP_RANGE_M / GRID_M);
for (int k = -lines; k <= lines; k++) {
float e = k * GRID_M - offE, n = k * GRID_M - offN;
gridLine(e, -MAP_RANGE_M, e, MAP_RANGE_M, sin, cos);
gridLine(-MAP_RANGE_M, n, MAP_RANGE_M, n, sin, cos);
}
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) {
if (ox > range || oy > range || ox + MapGeometry.EXTENT < -range || oy + MapGeometry.EXTENT < -range) {
continue;
}
float fx = (float) ox, fy = (float) oy;
@@ -212,7 +241,7 @@ final class GridView extends ClusterCanvasView {
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);
if (k > 0) segment(roads[cls * 2 + (Math.min(pf, fwd) > FADE_M ? 1 : 0)], pa, pf, side, fwd);
pa = side;
pf = fwd;
}
@@ -222,67 +251,86 @@ final class GridView extends ClusterCanvasView {
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.
line.setColor(AccentColor.withAlpha(main, 0x2A));
line.setStrokeWidth(1f * u);
c.drawPath(gridPath, line);
// Far roads thinner and fainter.
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);
line.setColor(AccentColor.withAlpha(cls == 0 ? light : main, cls >= 3 ? 0x50 : 0x80));
line.setStrokeWidth((cls == 0 ? 2f : cls == 3 ? 1f : 1.4f) * u);
c.drawPath(roads[cls * 2 + 1], line);
}
line.setColor(AccentColor.withAlpha(main, 0x80));
line.setStrokeWidth(1.4f * u);
line.setColor(AccentColor.withAlpha(main, 0x90));
line.setStrokeWidth(1.5f * u);
c.drawPath(roads[3 * 2], line);
line.setColor(AccentColor.withAlpha(main, 0xC0));
line.setStrokeWidth(2f * u);
line.setColor(AccentColor.withAlpha(main, 0xD0));
line.setStrokeWidth(2.2f * u);
c.drawPath(roads[2 * 2], line);
glow(c, roads[2], main, 2.4f * u);
glow(c, roads[0], light, 3.2f * u);
glow(c, roads[2], main, 2.6f * u);
glow(c, roads[0], light, 3.4f * u);
line.setStrokeJoin(Paint.Join.MITER);
line.setStrokeCap(Paint.Cap.SQUARE);
c.restore();
glowLine(c, 0, horizon, w, horizon, main, 1.5f * u);
// 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.moveTo(camCx, carScreenY - 16f * u);
path.lineTo(camCx + 11f * u, carScreenY + 10f * u);
path.lineTo(camCx, carScreenY + 4f * u);
path.lineTo(camCx - 11f * u, carScreenY + 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)) {
/** The camera's height of a ground point z metres ahead of the spot below it, over its depth. */
private static float up(float z) {
float cos = (float) Math.cos(PITCH), sin = (float) Math.sin(PITCH);
return (z * sin - CAM_H * cos) / (z * cos + CAM_H * sin);
}
/** A grid line between two ground points given east and north of the car, in metres. */
private void gridLine(float e0, float n0, float e1, float n1, float sin, float cos) {
penDown = false;
segment(gridPath, e0 * cos - n0 * sin, e0 * sin + n0 * cos, e1 * cos - n1 * sin, e1 * sin + n1 * cos);
}
/**
* One stretch of ground from (side, ahead) a0, f0 to a1, f1 in metres from the car, through
* the camera, cut where it comes nearer to the lens than NEAR_M.
*/
private void segment(Path p, float a0, float f0, float a1, float f1) {
float cos = (float) Math.cos(PITCH), sin = (float) Math.sin(PITCH), z0 = CAM_H / (float) Math.tan(PITCH);
float za = z0 + f0, zb = z0 + f1;
float d0 = za * cos + CAM_H * sin, d1 = zb * cos + CAM_H * sin;
if (d0 < NEAR_M && d1 < NEAR_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) {
if (d0 < NEAR_M || d1 < NEAR_M) {
float t = (NEAR_M - d0) / (d1 - d0);
float a = a0 + (a1 - a0) * t, z = za + (zb - za) * t;
if (d0 < NEAR_M) {
a0 = a;
d0 = d;
za = z;
d0 = NEAR_M;
cut = true;
} else {
a1 = a;
d1 = d;
zb = z;
d1 = NEAR_M;
}
}
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;
float x0 = camCx + camF * a0 / d0, y0 = camCy - camF * (za * sin - CAM_H * cos) / d0;
float x1 = camCx + camF * a1 / d1, y1 = camCy - camF * (zb * sin - CAM_H * cos) / d1;
if (!penDown || cut || p != penPath || x0 != penX || y0 != penY) p.moveTo(x0, y0);
p.lineTo(x1, y1);
penPath = p;
penX = x1;
penY = y1;
penPath = p;
penDown = true;
}
@@ -343,9 +391,9 @@ final class GridView extends ClusterCanvasView {
// ---------- Speed ----------
private void drawSpeed(Canvas c, float w, float horizon, float u) {
private void drawSpeed(Canvas c, float w, float horizon, float size, float u) {
text.setTypeface(thin);
text.setTextSize(210f * u);
text.setTextSize(size * u);
text.setTextAlign(Paint.Align.CENTER);
glowText(c, fmt(v.speedKmh, "%.0f", "—"), w / 2f, horizon - 62f * u, light, u);
caption(c, "KM/H", w / 2f, horizon - 22f * u, u, Paint.Align.CENTER);
@@ -393,8 +441,13 @@ final class GridView extends ClusterCanvasView {
// ---------- Power disc ----------
/** Zero at the top, −50 kW a quarter turn anticlockwise, 150 kW three quarters clockwise. */
private void drawDisc(Canvas c, float w, float u) {
private void drawDisc(Canvas c, float w, boolean overMap, float u) {
float cx = w - 170f * u, cy = 238f * u, outer = 132f * u, ring = 22f * u;
if (overMap) {
// The map runs under the disc: a dark backing keeps it readable.
fill.setColor(0xD0000000);
c.drawCircle(cx, cy, outer + 16f * u, fill);
}
line.setColor(dim);
line.setStrokeWidth(1.5f * u);
c.drawCircle(cx, cy, outer + 10f * u, line);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 81 KiB