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
This commit is contained in:
2026-10-27 00:12:06 +01:00
co-authored by Claude Opus 5.5
parent 90c2c75fc6
commit 69b1adee2b
6 changed files with 104 additions and 107 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4"
minSdk 27
targetSdk 36
versionCode 144
versionName "2.39.2"
versionCode 145
versionName "2.40.0"
// 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.
+89 -100
View File
@@ -23,12 +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 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 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
* under everything, heading up, the bigger ones glowing brighter, with the car as a light
* marker low on the screen; between GPS fixes the car's position runs on
* with the speed. The map is then the whole background, straight from above (about
* 400 m ahead, 450 m to each side), over a ground grid (a line every 50 m) that turns and slides
* with the roads; the speed, the battery wall, the disc and the clock stand on dark panels over
* 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.
*
* 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 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
* 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.
* The map, straight from above: pixels (in layout units) per metre, the car's height above
* the bottom edge, roads from FADE_M ahead fainter, a ground-grid line every GRID_M.
*/
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 static final float MAP_SCALE = 1.2f, CAR_FROM_BOTTOM = 150f, 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);
@@ -66,13 +63,9 @@ final class GridView extends ClusterCanvasView {
/** 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;
/** This frame's map: pixels per metre and where the car is on screen. */
private float mapPx, carSx, carSy;
private int main, light, dim, alert;
/** 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();
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();
float horizon = h * (mapShown ? 0.5f : 0.55f);
drawSky(c, w, horizon);
if (mapShown) drawMap(c, w, h, horizon, u);
else drawFloor(c, w, h, horizon, u);
float horizon = h * 0.55f;
if (mapShown) {
drawMap(c, w, h, u);
} else {
drawSky(c, w, horizon);
drawFloor(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);
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);
drawDisc(c, w, mapShown, u);
if (!mapShown) drawTrail(c, w, h, u);
@@ -181,8 +180,8 @@ final class GridView extends ClusterCanvasView {
// ---------- Map ----------
/** 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) {
/** The roads round the car from straight above, heading up, over a ground grid. */
private void drawMap(Canvas c, float w, float h, float u) {
MapRoads.Fix fix = map.fix();
java.util.Collection<RoadTile> tiles = map.tiles();
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 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;
mapPx = MAP_SCALE * u;
carSx = w / 2f;
carSy = h - CAR_FROM_BOTTOM * u;
float sin = (float) Math.sin(Math.toRadians(heading)), cos = (float) Math.cos(Math.toRadians(heading));
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.
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);
int lines = (int) (reach / GRID_M) + 1;
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);
gridLine(e, -reach, e, reach, sin, cos);
gridLine(-reach, n, reach, n, sin, cos);
}
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;
int cls = tile.classes[i];
int[] pts = tile.points[i];
penDown = false;
float pa = 0f, pf = 0f;
Path prev = null;
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(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;
pf = fwd;
}
}
}
c.save();
c.clipRect(0, horizon, w, h);
line.setStrokeJoin(Paint.Join.ROUND);
line.setStrokeCap(Paint.Cap.ROUND);
line.setColor(AccentColor.withAlpha(main, 0x2A));
line.setColor(AccentColor.withAlpha(main, 0x26));
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 ? 0x50 : 0x80));
line.setStrokeWidth((cls == 0 ? 2f : cls == 3 ? 1f : 1.4f) * u);
line.setColor(AccentColor.withAlpha(cls == 0 ? light : main, cls >= 3 ? 0x60 : 0x90));
line.setStrokeWidth((cls == 0 ? 2.4f : cls == 3 ? 1.2f : 1.8f) * u);
c.drawPath(roads[cls * 2 + 1], line);
}
line.setColor(AccentColor.withAlpha(main, 0x90));
line.setStrokeWidth(1.5f * u);
line.setColor(AccentColor.withAlpha(main, 0xA0));
line.setStrokeWidth(1.8f * u);
c.drawPath(roads[3 * 2], line);
line.setColor(AccentColor.withAlpha(main, 0xD0));
line.setStrokeWidth(2.2f * u);
line.setColor(AccentColor.withAlpha(main, 0xD8));
line.setStrokeWidth(2.4f * u);
c.drawPath(roads[2 * 2], line);
glow(c, roads[2], main, 2.6f * u);
glow(c, roads[0], light, 3.4f * u);
glow(c, roads[2], main, 2.8f * u);
glow(c, roads[0], light, 3.6f * 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.
path.reset();
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.moveTo(carSx, carSy - 18f * u);
path.lineTo(carSx + 12f * u, carSy + 11f * u);
path.lineTo(carSx, carSy + 5f * u);
path.lineTo(carSx - 12f * u, carSy + 11f * u);
path.close();
fill.setColor(light);
c.drawPath(path, fill);
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 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);
private float screenX(float side) {
return carSx + side * mapPx;
}
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. */
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);
gridPath.moveTo(screenX(e0 * cos - n0 * sin), screenY(e0 * sin + n0 * cos));
gridPath.lineTo(screenX(e1 * cos - n1 * sin), screenY(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_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;
za = z;
d0 = NEAR_M;
cut = true;
} else {
a1 = a;
zb = z;
d1 = NEAR_M;
}
}
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;
/** Dark panels under the battery wall and the clock, so the map does not run through them. */
private void drawPanels(Canvas c, float w, float horizon, float u) {
chamfer(16f * u, 78f * u, 360f * u, horizon + 86f * u, 14f * u);
fill.setColor(0xC8000000);
c.drawPath(path, fill);
chamfer(w - 210f * u, 12f * u, w - 12f * u, 82f * u, 10f * u);
c.drawPath(path, fill);
// Behind the gears as well.
chamfer(w / 2f - 150f * u, 12f * u, w / 2f + 150f * u, 72f * u, 10f * u);
c.drawPath(path, fill);
}
/** The speed over the map: smaller, on a panel under the gears. */
private void drawMapSpeed(Canvas c, float w, float u) {
chamfer(w / 2f - 130f * u, 80f * u, w / 2f + 130f * u, 232f * u, 14f * u);
fill.setColor(0xC8000000);
c.drawPath(path, fill);
line.setColor(dim);
line.setStrokeWidth(1.5f * u);
c.drawPath(path, line);
text.setTypeface(thin);
text.setTextSize(120f * u);
text.setTextAlign(Paint.Align.CENTER);
glowText(c, fmt(v.speedKmh, "%.0f", "—"), w / 2f, 194f * u, light, u);
caption(c, "KM/H", w / 2f, 220f * u, u, Paint.Align.CENTER);
}
/** Why the map is not there, on the floor. */
@@ -391,9 +380,9 @@ final class GridView extends ClusterCanvasView {
// ---------- 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.setTextSize(size * u);
text.setTextSize(210f * 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);