diff --git a/SPECS.md b/SPECS.md index f19f3a7..270b6e6 100644 --- a/SPECS.md +++ b/SPECS.md @@ -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. diff --git a/THEMES.md b/THEMES.md index 087fcc1..43e3bff 100644 --- a/THEMES.md +++ b/THEMES.md @@ -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 diff --git a/app/build.gradle b/app/build.gradle index e114d24..3d29ebd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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. diff --git a/app/src/main/java/com/evsuite/abrp/GridView.java b/app/src/main/java/com/evsuite/abrp/GridView.java index 74d4305..997bf5c 100644 --- a/app/src/main/java/com/evsuite/abrp/GridView.java +++ b/app/src/main/java/com/evsuite/abrp/GridView.java @@ -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 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); diff --git a/screenshots/themes/grid-map-panel.png b/screenshots/themes/grid-map-panel.png index cafab90..7975cbd 100644 Binary files a/screenshots/themes/grid-map-panel.png and b/screenshots/themes/grid-map-panel.png differ diff --git a/screenshots/themes/grid-map.png b/screenshots/themes/grid-map.png index 5084a46..f1f6faa 100644 Binary files a/screenshots/themes/grid-map.png and b/screenshots/themes/grid-map.png differ