Author SHA1 Message Date
nilsandClaude Opus 5.5 69b1adee2b App 2.40.0: Grid with map straight from above, full height
The map fills the whole screen, top-down and heading up, about 400 m
ahead and 450 m to each side, over a ground grid that turns with it.
The speed moves to a panel under the gears; the battery wall, the
clock, the gears and the disc stand on dark panels over the map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-27 00:12:06 +01:00
nilsandClaude Opus 5.5 90c2c75fc6 SPECS: app 2.39.2 published
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-27 00:06:51 +01:00
6 changed files with 105 additions and 107 deletions
+9
View File
@@ -138,6 +138,15 @@ 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 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` 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). (Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
- 2026-10-27 — App 2.40.0: **Grid with map straight from above, full height** (owner: 2.39.2
"does not look right"; wanted the map over the whole height and fully top-down). The map is
the whole background in map mode: orthographic, heading up, 1.2 layout units per metre (about
400 m ahead, 125 m behind, 450 m to each side on the car's screen), the car 150 units above
the bottom edge; ground grid every 50 m fixed to the ground; roads from 250 m ahead fainter;
the camera, the sky and the floor are gone there. The speed moves to a panel under the gears
(120 units), and the battery wall, the clock, the gears and the disc get dark panels. The
plain Grid is unchanged. Emulator with the Berlin tiles, both layouts. Not tried on the car.
- 2026-10-27 — App 2.39.2 published (`car-2.39.2`).
- 2026-10-27 — App 2.39.2: **Grid with map seen from higher up** (owner: the map looked like a - 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 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 up, the car on its axis just above the read-outs, the top of the floor about 420 m ahead
+5 -5
View File
@@ -165,11 +165,11 @@ films' other side.
## Grid with map ## Grid with map
The Grid, with the roads round the car from OpenStreetMap (through OpenFreeMap) laid on its floor as light lanes, seen The Grid over a map: the roads round the car from OpenStreetMap (through OpenFreeMap) fill the
from a camera high above and behind the car, tilted down, so the streets keep their shape some whole screen as light lanes, straight from above and turned with the direction you drive, about
400 m ahead, and turned with the direction you drive: motorways glow brightest, then main 400 m ahead and 450 m to each side: motorways glow brightest, then main roads, then the smaller
roads, then the smaller streets, the far ones fainter. A ground grid every 50 m turns and slides streets, the far ones fainter, over a ground grid every 50 m that turns and slides with them.
with them. The car is the chevron on the The speed, the battery wall, the power disc and the clock stand on dark panels over the map. 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 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 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 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" applicationId "com.stinnesbeck.nilsmg4"
minSdk 27 minSdk 27
targetSdk 36 targetSdk 36
versionCode 144 versionCode 145
versionName "2.39.2" versionName "2.40.0"
// Nothing about the server ships in the APK: its URL and ingest token are entered in // 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. // the app's Server tab (ServerConfig), and the database is only reachable by the server.
@@ -23,12 +23,12 @@ import android.os.SystemClock;
* the trail; the floor stands still when the speed is unread. * 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 * With a map ("Grid with map", {@link MapRoads}): the roads round the car from OpenStreetMap lie
* on the floor in perspective, heading up, the bigger ones glowing brighter, with the car * under everything, heading up, the bigger ones glowing brighter, with the car as a light
* as a light marker a little way up the floor; between GPS fixes the car's position runs on * marker low on the screen; between GPS fixes the car's position runs on
* with the speed. The map has its own camera, higher and steeper than the plain * with the speed. The map is then the whole background, straight from above (about
* floor's, so far roads shrink less; the floor starts higher, the speed is a little smaller, its * 400 m ahead, 450 m to each side), over a ground grid (a line every 50 m) that turns and slides
* grid is fixed to the ground (a line every 50 m) and turns with the roads, and the trail makes * with the roads; the speed, the battery wall, the disc and the clock stand on dark panels over
* way for the map. Without a fix, a server or the roads, the plain floor with a line * it, and the trail makes way for the map. Without a fix, a server or the roads, the plain floor with a line
* saying why. * saying why.
* *
* Two palettes: the original cyan, or the chosen accent colour ({@link #setAccentPalette}). A * Two palettes: the original cyan, or the chosen accent colour ({@link #setAccentPalette}). A
@@ -42,13 +42,10 @@ final class GridView extends ClusterCanvasView {
private static final int WALL = 20, DISC = 60; private static final int WALL = 20, DISC = 60;
private static final float TILE_M = 4f; private static final float TILE_M = 4f;
/** /**
* The map's camera: tilted 45° down from about 300 m up, the car on its axis, the top of the * The map, straight from above: pixels (in layout units) per metre, the car's height above
* floor some 420 m ahead; nothing nearer to the lens than NEAR_M is drawn, nothing beyond * the bottom edge, roads from FADE_M ahead fainter, a ground-grid line every GRID_M.
* 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 PITCH = (float) Math.toRadians(45), CAM_H = 300f, AHEAD_M = 420f, private static final float MAP_SCALE = 1.2f, CAR_FROM_BOTTOM = 150f, FADE_M = 250f, GRID_M = 50f;
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 line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
@@ -66,13 +63,9 @@ final class GridView extends ClusterCanvasView {
/** Per road class, near and far (from {@link #FADE_M} ahead). */ /** Per road class, near and far (from {@link #FADE_M} ahead). */
private final Path[] roads = new Path[RoadTile.CLASSES * 2]; private final Path[] roads = new Path[RoadTile.CLASSES * 2];
private float heading = Float.NaN; 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(); private final Path gridPath = new Path();
/** The camera of this frame: focal length and centre in pixels. */ /** This frame's map: pixels per metre and where the car is on screen. */
private float camF, camCx, camCy; private float mapPx, carSx, carSy;
private int main, light, dim, alert; private int main, light, dim, alert;
/** The floor's position within one tile (0..1) and the disc's inner ring angle. */ /** The floor's position within one tile (0..1) and the disc's inner ring angle. */
@@ -115,16 +108,22 @@ final class GridView extends ClusterCanvasView {
advance(); advance();
c.drawColor(0xFF000000); c.drawColor(0xFF000000);
// With a map the floor starts higher, to give the roads room. // With a map, the map is the whole background, seen from straight above, and the rest
// stands on dark panels over it.
boolean mapShown = map != null && map.fix() != null && !map.tiles().isEmpty(); boolean mapShown = map != null && map.fix() != null && !map.tiles().isEmpty();
float horizon = h * (mapShown ? 0.5f : 0.55f); float horizon = h * 0.55f;
if (mapShown) {
drawMap(c, w, h, u);
} else {
drawSky(c, w, horizon); drawSky(c, w, horizon);
if (mapShown) drawMap(c, w, h, horizon, u); drawFloor(c, w, h, horizon, u);
else drawFloor(c, w, h, horizon, u); }
if (map != null && !mapShown) drawMapState(c, w, h, horizon, u); if (map != null && !mapShown) drawMapState(c, w, h, horizon, u);
if (mapShown) drawPanels(c, w, horizon, u);
drawGears(c, w, u); drawGears(c, w, u);
drawClock(c, w, u); drawClock(c, w, u);
drawSpeed(c, w, horizon, mapShown ? 170f : 210f, u); if (mapShown) drawMapSpeed(c, w, u);
else drawSpeed(c, w, horizon, u);
drawWall(c, horizon, u); drawWall(c, horizon, u);
drawDisc(c, w, mapShown, u); drawDisc(c, w, mapShown, u);
if (!mapShown) drawTrail(c, w, h, u); if (!mapShown) drawTrail(c, w, h, u);
@@ -181,8 +180,8 @@ final class GridView extends ClusterCanvasView {
// ---------- Map ---------- // ---------- Map ----------
/** The roads round the car on the floor, seen by the map's camera, with a ground grid. */ /** The roads round the car from straight above, heading up, over a ground grid. */
private void drawMap(Canvas c, float w, float h, float horizon, float u) { private void drawMap(Canvas c, float w, float h, float u) {
MapRoads.Fix fix = map.fix(); MapRoads.Fix fix = map.fix();
java.util.Collection<RoadTile> tiles = map.tiles(); java.util.Collection<RoadTile> tiles = map.tiles();
if (fix == null) return; if (fix == null) return;
@@ -201,27 +200,24 @@ final class GridView extends ClusterCanvasView {
double carX = MapGeometry.worldX(fix.lon) + ran * Math.sin(run) / mpu; double carX = MapGeometry.worldX(fix.lon) + ran * Math.sin(run) / mpu;
double carY = MapGeometry.worldY(fix.lat) - ran * Math.cos(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. mapPx = MAP_SCALE * u;
float carScreenY = h - 96f * u; carSx = w / 2f;
float z0 = CAM_H / (float) Math.tan(PITCH); carSy = h - CAR_FROM_BOTTOM * u;
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 sin = (float) Math.sin(Math.toRadians(heading)), cos = (float) Math.cos(Math.toRadians(heading));
float m = (float) mpu; float m = (float) mpu;
double range = MAP_RANGE_M / mpu; // Everything on screen is within this many metres of the car.
float reach = (float) Math.hypot(w / 2f, carSy) / mapPx + GRID_M;
double range = reach / mpu;
// The ground grid, fixed to the ground: it turns and slides with the roads. // The ground grid, fixed to the ground: it turns and slides with the roads.
gridPath.reset(); gridPath.reset();
penPath = null;
double carEast = carX * mpu, carNorth = -carY * mpu; double carEast = carX * mpu, carNorth = -carY * mpu;
float offE = (float) (carEast % GRID_M), offN = (float) (carNorth % GRID_M); float offE = (float) (carEast % GRID_M), offN = (float) (carNorth % GRID_M);
int lines = (int) (MAP_RANGE_M / GRID_M); int lines = (int) (reach / GRID_M) + 1;
for (int k = -lines; k <= lines; k++) { for (int k = -lines; k <= lines; k++) {
float e = k * GRID_M - offE, n = k * GRID_M - offN; float e = k * GRID_M - offE, n = k * GRID_M - offN;
gridLine(e, -MAP_RANGE_M, e, MAP_RANGE_M, sin, cos); gridLine(e, -reach, e, reach, sin, cos);
gridLine(-MAP_RANGE_M, n, MAP_RANGE_M, n, sin, cos); gridLine(-reach, n, reach, n, sin, cos);
} }
for (Path p : roads) p.reset(); for (Path p : roads) p.reset();
@@ -236,102 +232,95 @@ final class GridView extends ClusterCanvasView {
if (fx + b[2] < -range || fx + b[0] > range || fy + b[3] < -range || fy + b[1] > range) continue; if (fx + b[2] < -range || fx + b[0] > range || fy + b[3] < -range || fy + b[1] > range) continue;
int cls = tile.classes[i]; int cls = tile.classes[i];
int[] pts = tile.points[i]; int[] pts = tile.points[i];
penDown = false;
float pa = 0f, pf = 0f; float pa = 0f, pf = 0f;
Path prev = null;
for (int k = 0; k < pts.length; k += 2) { for (int k = 0; k < pts.length; k += 2) {
float east = (fx + pts[k]) * m, north = -(fy + pts[k + 1]) * m; float east = (fx + pts[k]) * m, north = -(fy + pts[k + 1]) * m;
float fwd = east * sin + north * cos, side = east * cos - north * sin; float fwd = east * sin + north * cos, side = east * cos - north * sin;
if (k > 0) segment(roads[cls * 2 + (Math.min(pf, fwd) > FADE_M ? 1 : 0)], pa, pf, side, fwd); if (k > 0) {
Path p = roads[cls * 2 + (Math.min(pf, fwd) > FADE_M ? 1 : 0)];
if (p != prev) p.moveTo(screenX(pa), screenY(pf));
p.lineTo(screenX(side), screenY(fwd));
prev = p;
}
pa = side; pa = side;
pf = fwd; pf = fwd;
} }
} }
} }
c.save();
c.clipRect(0, horizon, w, h);
line.setStrokeJoin(Paint.Join.ROUND); line.setStrokeJoin(Paint.Join.ROUND);
line.setStrokeCap(Paint.Cap.ROUND); line.setStrokeCap(Paint.Cap.ROUND);
line.setColor(AccentColor.withAlpha(main, 0x2A)); line.setColor(AccentColor.withAlpha(main, 0x26));
line.setStrokeWidth(1f * u); line.setStrokeWidth(1f * u);
c.drawPath(gridPath, line); c.drawPath(gridPath, line);
// Far roads thinner and fainter. // Far roads thinner and fainter.
for (int cls = RoadTile.CLASSES - 1; cls >= 0; cls--) { for (int cls = RoadTile.CLASSES - 1; cls >= 0; cls--) {
line.setColor(AccentColor.withAlpha(cls == 0 ? light : main, cls >= 3 ? 0x50 : 0x80)); line.setColor(AccentColor.withAlpha(cls == 0 ? light : main, cls >= 3 ? 0x60 : 0x90));
line.setStrokeWidth((cls == 0 ? 2f : cls == 3 ? 1f : 1.4f) * u); line.setStrokeWidth((cls == 0 ? 2.4f : cls == 3 ? 1.2f : 1.8f) * u);
c.drawPath(roads[cls * 2 + 1], line); c.drawPath(roads[cls * 2 + 1], line);
} }
line.setColor(AccentColor.withAlpha(main, 0x90)); line.setColor(AccentColor.withAlpha(main, 0xA0));
line.setStrokeWidth(1.5f * u); line.setStrokeWidth(1.8f * u);
c.drawPath(roads[3 * 2], line); c.drawPath(roads[3 * 2], line);
line.setColor(AccentColor.withAlpha(main, 0xD0)); line.setColor(AccentColor.withAlpha(main, 0xD8));
line.setStrokeWidth(2.2f * u); line.setStrokeWidth(2.4f * u);
c.drawPath(roads[2 * 2], line); c.drawPath(roads[2 * 2], line);
glow(c, roads[2], main, 2.6f * u); glow(c, roads[2], main, 2.8f * u);
glow(c, roads[0], light, 3.4f * u); glow(c, roads[0], light, 3.6f * u);
line.setStrokeJoin(Paint.Join.MITER); line.setStrokeJoin(Paint.Join.MITER);
line.setStrokeCap(Paint.Cap.SQUARE); 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. // The car: a light-cycle chevron pointing ahead.
path.reset(); path.reset();
path.moveTo(camCx, carScreenY - 16f * u); path.moveTo(carSx, carSy - 18f * u);
path.lineTo(camCx + 11f * u, carScreenY + 10f * u); path.lineTo(carSx + 12f * u, carSy + 11f * u);
path.lineTo(camCx, carScreenY + 4f * u); path.lineTo(carSx, carSy + 5f * u);
path.lineTo(camCx - 11f * u, carScreenY + 10f * u); path.lineTo(carSx - 12f * u, carSy + 11f * u);
path.close(); path.close();
fill.setColor(light); fill.setColor(light);
c.drawPath(path, fill); c.drawPath(path, fill);
glow(c, path, light, 1.5f * u); glow(c, path, light, 1.5f * u);
} }
/** The camera's height of a ground point z metres ahead of the spot below it, over its depth. */ private float screenX(float side) {
private static float up(float z) { return carSx + side * mapPx;
float cos = (float) Math.cos(PITCH), sin = (float) Math.sin(PITCH); }
return (z * sin - CAM_H * cos) / (z * cos + CAM_H * sin);
private float screenY(float ahead) {
return carSy - ahead * mapPx;
} }
/** A grid line between two ground points given east and north of the car, in metres. */ /** 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) { private void gridLine(float e0, float n0, float e1, float n1, float sin, float cos) {
penDown = false; gridPath.moveTo(screenX(e0 * cos - n0 * sin), screenY(e0 * sin + n0 * cos));
segment(gridPath, e0 * cos - n0 * sin, e0 * sin + n0 * cos, e1 * cos - n1 * sin, e1 * sin + n1 * cos); gridPath.lineTo(screenX(e1 * cos - n1 * sin), screenY(e1 * sin + n1 * cos));
} }
/** /** Dark panels under the battery wall and the clock, so the map does not run through them. */
* One stretch of ground from (side, ahead) a0, f0 to a1, f1 in metres from the car, through private void drawPanels(Canvas c, float w, float horizon, float u) {
* the camera, cut where it comes nearer to the lens than NEAR_M. chamfer(16f * u, 78f * u, 360f * u, horizon + 86f * u, 14f * u);
*/ fill.setColor(0xC8000000);
private void segment(Path p, float a0, float f0, float a1, float f1) { c.drawPath(path, fill);
float cos = (float) Math.cos(PITCH), sin = (float) Math.sin(PITCH), z0 = CAM_H / (float) Math.tan(PITCH); chamfer(w - 210f * u, 12f * u, w - 12f * u, 82f * u, 10f * u);
float za = z0 + f0, zb = z0 + f1; c.drawPath(path, fill);
float d0 = za * cos + CAM_H * sin, d1 = zb * cos + CAM_H * sin; // Behind the gears as well.
if (d0 < NEAR_M && d1 < NEAR_M) { chamfer(w / 2f - 150f * u, 12f * u, w / 2f + 150f * u, 72f * u, 10f * u);
penDown = false; c.drawPath(path, fill);
return;
} }
boolean cut = false;
if (d0 < NEAR_M || d1 < NEAR_M) { /** The speed over the map: smaller, on a panel under the gears. */
float t = (NEAR_M - d0) / (d1 - d0); private void drawMapSpeed(Canvas c, float w, float u) {
float a = a0 + (a1 - a0) * t, z = za + (zb - za) * t; chamfer(w / 2f - 130f * u, 80f * u, w / 2f + 130f * u, 232f * u, 14f * u);
if (d0 < NEAR_M) { fill.setColor(0xC8000000);
a0 = a; c.drawPath(path, fill);
za = z; line.setColor(dim);
d0 = NEAR_M; line.setStrokeWidth(1.5f * u);
cut = true; c.drawPath(path, line);
} else { text.setTypeface(thin);
a1 = a; text.setTextSize(120f * u);
zb = z; text.setTextAlign(Paint.Align.CENTER);
d1 = NEAR_M; glowText(c, fmt(v.speedKmh, "%.0f", "—"), w / 2f, 194f * u, light, u);
} caption(c, "KM/H", w / 2f, 220f * u, u, Paint.Align.CENTER);
}
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);
penX = x1;
penY = y1;
penPath = p;
penDown = true;
} }
/** Why the map is not there, on the floor. */ /** Why the map is not there, on the floor. */
@@ -391,9 +380,9 @@ final class GridView extends ClusterCanvasView {
// ---------- Speed ---------- // ---------- Speed ----------
private void drawSpeed(Canvas c, float w, float horizon, float size, float u) { private void drawSpeed(Canvas c, float w, float horizon, float u) {
text.setTypeface(thin); text.setTypeface(thin);
text.setTextSize(size * u); text.setTextSize(210f * u);
text.setTextAlign(Paint.Align.CENTER); text.setTextAlign(Paint.Align.CENTER);
glowText(c, fmt(v.speedKmh, "%.0f", "—"), w / 2f, horizon - 62f * u, light, u); 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); caption(c, "KM/H", w / 2f, horizon - 22f * u, u, Paint.Align.CENTER);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 105 KiB