App 2.40.2: the Grid map moves on the GPU

The map was hardware-rendered, but every frame recomputed every road
point, rebuilt every path and drew the big roads three times, at 20
frames a second, and the car jumped with each GPS fix. Now it is its own
view under the Grid (MapLayerView, a hardware layer): drawn once into a
GPU texture and only redrawn when tiles change or after about 230 m;
each display frame only sets its translation and rotation. The car
glides between fixes, and the Grid over the map redraws only with the
readings.

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:32:50 +01:00
co-authored by Claude Opus 5.5
parent c3c47f5159
commit f7c489858e
7 changed files with 293 additions and 129 deletions
+1 -1
View File
@@ -76,7 +76,7 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`),
| Tab | Contents |
|---|---|
| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info (odometer, lease allowance and the odometer against it), tyre pressures; read every 0.2 s on a background thread. Styles (Settings → Overview style): Tiles, Gauges (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace), Retro (`RetroView`, seven-segment VFD), Futuristic (`FlowView`, animated energy flow), Sci-fi (`SciFiView`, starship console), Cartoon (`CartoonView`), MG (`OctagonView`, octagonal dials after the classic MG dashboards), and on `ClusterCanvasView`: Minimal, Terminal, Nixie tubes, Chronograph, Grid (`GridView`, neon lines over a moving grid floor, power as a light-cycle trail from `ClusterValues.powerTrail`), Grid with map (the same `GridView` over the roads round the car: GPS while it is shown, tiles from the server's `/v1/map/roads` through `MapRoads`, cached in `files/map`; `RoadTile`, `MapGeometry`), Time circuits (`TimeCircuitsView`, the time machine's LED rows with `Segments14` letters and a flux capacitor), Quest (`QuestView`, a fantasy game interface), Visor (`VisorView`, a helmet display with a motion tracker of the power trail), Empire (`EmpireView`, a strategy game with an isometric world and ages by odometer, `EmpireAge`); the drawn styles share `ClusterValues`. A style shown in its own palette (Sci-fi, Cartoon, Nixie, Time circuits, Quest, Empire; Retro, Terminal, Grid and Visor with "Original") gives the whole app its colours (`StylePalette`, `res/values/style_palettes.xml`) instead of the accent colour. A button in the navigation's header hides the navigation for a full-width Overview (`PREF_OVERVIEW_FULLSCREEN`); a swipe in from the left edge (`EdgeSwipe`) brings it back; without the app's own full screen the car's sidebar takes that edge, so a tab at the left edge (`edge_tab`) is tapped or swiped instead. Pages change only by the navigation (the pager's own swiping is off) |
| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info (odometer, lease allowance and the odometer against it), tyre pressures; read every 0.2 s on a background thread. Styles (Settings → Overview style): Tiles, Gauges (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace), Retro (`RetroView`, seven-segment VFD), Futuristic (`FlowView`, animated energy flow), Sci-fi (`SciFiView`, starship console), Cartoon (`CartoonView`), MG (`OctagonView`, octagonal dials after the classic MG dashboards), and on `ClusterCanvasView`: Minimal, Terminal, Nixie tubes, Chronograph, Grid (`GridView`, neon lines over a moving grid floor, power as a light-cycle trail from `ClusterValues.powerTrail`), Grid with map (the same `GridView` over the roads round the car, top-down: GPS while it is shown, tiles from the server's `/v1/map/roads` through `MapRoads`, cached in `files/map`; `RoadTile`, `MapGeometry`; the map is `MapLayerView`, a hardware layer drawn once and moved and turned by the GPU every display frame), Time circuits (`TimeCircuitsView`, the time machine's LED rows with `Segments14` letters and a flux capacitor), Quest (`QuestView`, a fantasy game interface), Visor (`VisorView`, a helmet display with a motion tracker of the power trail), Empire (`EmpireView`, a strategy game with an isometric world and ages by odometer, `EmpireAge`); the drawn styles share `ClusterValues`. A style shown in its own palette (Sci-fi, Cartoon, Nixie, Time circuits, Quest, Empire; Retro, Terminal, Grid and Visor with "Original") gives the whole app its colours (`StylePalette`, `res/values/style_palettes.xml`) instead of the accent colour. A button in the navigation's header hides the navigation for a full-width Overview (`PREF_OVERVIEW_FULLSCREEN`); a swipe in from the left edge (`EdgeSwipe`) brings it back; without the app's own full screen the car's sidebar takes that edge, so a tab at the left edge (`edge_tab`) is tapped or swiped instead. Pages change only by the navigation (the pager's own swiping is off) |
| Consumption / Charging | period totals, daily log, charging sessions and curve |
| Live data | on/off, ABRP API key and user token, upload frequency |
| Server | URL, pairing by QR, ingest token, installation ID (read-only), config import |
+15
View File
@@ -138,6 +138,21 @@ 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.40.2: **the map moves on the GPU** (owner: "is the map screen gpu
accelerated? it looks a bit slow on update"). It was hardware-rendered, but every frame (20 a
second) recomputed every road point on the main thread, rebuilt all paths, and drew the big
roads three times for the glow; and the car jumped with each GPS fix (1 Hz). Now the map is
its own view under the Grid (`MapLayerView`, `LAYER_TYPE_HARDWARE`): drawn once into a GPU
texture round an anchor and only redrawn when tiles come or go or the car is ~280 layout units
(≈230 m) from the anchor; each display frame (`postOnAnimation`, only while that style is on
screen and the map shows) sets its translation, pivot and rotation, which the GPU composites.
The car glides towards each fix (eased, snaps beyond 200 m), the heading eases; GridView over
the map no longer animates itself (only the readings redraw it), the "far roads fainter" sort
is a fixed dark gradient towards the top. Layer size on the car about 2500 px square (~25 MB).
Measured on the emulator: with SwiftShader (software GPU) old and new both ~57 ms a frame, and
with the host's Intel GPU (`-gpu host`) both run at the display rate (~18 ms) — the emulator
is too fast to show the car's slowness, so the gain on the head unit is reasoned, not
measured. Not tried on the car.
- 2026-10-27 — App 2.40.1: **an update that downloaded but never reached the installer** (owner:
"sometimes … downloads the update but then doesn't start the installer"). The car refuses the
installer session ("Not allowed to install non-system apps on internal storage"); only a
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4"
minSdk 27
targetSdk 36
versionCode 146
versionName "2.40.1"
versionCode 147
versionName "2.40.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.
+38 -125
View File
@@ -27,7 +27,9 @@ import android.os.SystemClock;
* 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
* with the roads, fading into the dark towards the top. It is its own view under this one
* ({@link MapLayerView}): drawn once into a hardware layer and moved and turned by the GPU each
* display frame, the car gliding between fixes; 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.
*
@@ -41,11 +43,6 @@ 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, 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 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);
@@ -60,12 +57,10 @@ 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_M} ahead). */
private final Path[] roads = new Path[RoadTile.CLASSES * 2];
private float heading = Float.NaN;
private final Path gridPath = new Path();
/** This frame's map: pixels per metre and where the car is on screen. */
private float mapPx, carSx, carSy;
/** The map under this view (a hardware layer, moved by the GPU); null for the plain Grid. */
private final MapLayerView mapLayer;
private final Paint fade = new Paint();
private int fadeFor = -1;
private int main, light, dim, alert;
/** The floor's position within one tile (0..1) and the disc's inner ring angle. */
@@ -80,7 +75,7 @@ final class GridView extends ClusterCanvasView {
GridView(Context context, MapRoads map) {
super(context);
this.map = map;
for (int i = 0; i < roads.length; i++) roads[i] = new Path();
mapLayer = map == null ? null : new MapLayerView(context, map);
accent = AccentColor.resolve(context, R.attr.evAccent);
accentLight = AccentColor.resolve(context, R.attr.evAccentLight);
line.setStyle(Paint.Style.STROKE);
@@ -96,9 +91,24 @@ final class GridView extends ClusterCanvasView {
dim = AccentColor.withAlpha(main, 0x40);
alert = useAccent ? 0xFFFFFFFF : 0xFFFF8A1F;
skyFor = -1;
if (mapLayer != null) mapLayer.setColours(main, light);
invalidate();
}
/** The map layer, for the pane to put under this view; null for the plain Grid. */
MapLayerView mapLayer() {
return mapLayer;
}
@Override
void setValues(ClusterValues values) {
super.setValues(values);
if (mapLayer != null) {
mapLayer.setSpeed(v.speedKmh);
mapLayer.kick();
}
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
@@ -106,15 +116,14 @@ final class GridView extends ClusterCanvasView {
if (w <= 0 || h <= 0) return;
float u = Math.min(h / 640f, w / 1100f);
advance();
c.drawColor(0xFF000000);
// 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();
// With a map, the map layer underneath is the whole background (this view stays clear
// there), and the rest stands on dark panels over it.
boolean mapShown = mapLayer != null && mapLayer.isShowing();
float horizon = h * 0.55f;
if (mapShown) {
drawMap(c, w, h, u);
drawCar(c, w, h, u);
} else {
c.drawColor(0xFF000000);
drawSky(c, w, horizon);
drawFloor(c, w, h, horizon, u);
}
@@ -131,8 +140,8 @@ final class GridView extends ClusterCanvasView {
if (mapShown) drawAttribution(c, w, h, u);
boolean moving = v.speedKmh != null && v.speedKmh > 0.5f;
// The map costs more per frame: 20 a second is smooth enough for it.
keepAnimating(moving, mapShown ? 50 : 33);
// Over the map only the readings change; the map layer moves itself.
keepAnimating(moving && !mapShown, 33);
}
/** Moves the floor by the distance driven since the last frame, and turns the disc. */
@@ -180,97 +189,15 @@ final class GridView extends ClusterCanvasView {
// ---------- Map ----------
/** 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;
// 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);
/** Over the map: the far roads fading into the dark towards the top, and the car. */
private void drawCar(Canvas c, float w, float h, float u) {
float carSx = w / 2f, carSy = h - MapLayerView.CAR_FROM_BOTTOM * u;
if (fadeFor != (int) (carSy + h)) {
fade.setShader(new LinearGradient(0, 0, 0, carSy, 0xB0000000, 0x00000000, Shader.TileMode.CLAMP));
fadeFor = (int) (carSy + h);
}
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;
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;
// 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();
double carEast = carX * mpu, carNorth = -carY * mpu;
float offE = (float) (carEast % GRID_M), offN = (float) (carNorth % 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, -reach, e, reach, sin, cos);
gridLine(-reach, n, reach, 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 + MapGeometry.EXTENT < -range || oy + 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];
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) {
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;
}
}
}
line.setStrokeJoin(Paint.Join.ROUND);
line.setStrokeCap(Paint.Cap.ROUND);
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 ? 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, 0xA0));
line.setStrokeWidth(1.8f * u);
c.drawPath(roads[3 * 2], line);
line.setColor(AccentColor.withAlpha(main, 0xD8));
line.setStrokeWidth(2.4f * u);
c.drawPath(roads[2 * 2], line);
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);
// The car: a light-cycle chevron pointing ahead.
c.drawRect(0, 0, w, carSy, fade);
// A light-cycle chevron pointing ahead.
path.reset();
path.moveTo(carSx, carSy - 18f * u);
path.lineTo(carSx + 12f * u, carSy + 11f * u);
@@ -282,20 +209,6 @@ final class GridView extends ClusterCanvasView {
glow(c, path, light, 1.5f * u);
}
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) {
gridPath.moveTo(screenX(e0 * cos - n0 * sin), screenY(e0 * sin + n0 * 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. */
private void drawPanels(Canvas c, float w, float horizon, float u) {
chamfer(16f * u, 78f * u, 360f * u, horizon + 86f * u, 14f * u);
@@ -358,6 +358,8 @@ public class MainActivity extends AppCompatActivity {
new QuestView(this), visorView, new EmpireView(this));
View[] canvasPanes = new View[canvasStyles.size()];
for (int i = 0; i < canvasPanes.length; i++) canvasPanes[i] = drawnPane(canvasStyles.get(i));
// Grid with map: its map layer goes under it, in the same pane.
((android.widget.FrameLayout) canvasPanes[canvasStyles.indexOf(gridMapView)]).addView(gridMapView.mapLayer(), 0);
View[] fixedPanes = { vehiclePane, gaugesPane, cockpitPane, retroPane,
futuristicPane, sciFiPane, cartoonPane, mgPane };
// By OverviewStyle ordinal: the fixed styles, then the canvas styles in their order.
@@ -0,0 +1,223 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.os.SystemClock;
import android.view.View;
/**
* The map under "Grid with map": the roads round the car and a ground grid, straight from
* above, heading up — drawn once into a hardware layer (a GPU texture) and then only moved and
* turned, by the view's own translation and rotation, once per display frame. Nothing is
* redrawn while driving until new tiles arrive or the car nears the layer's edge; then it is
* drawn again round the car. {@link GridView} draws everything else on top.
*
* The layer is a square about twice the distance from the car to the farthest screen corner,
* plus a margin, so it covers the screen at any angle; the car sits {@link #CAR_FROM_BOTTOM}
* layout units above the bottom edge.
*/
final class MapLayerView extends View {
/** Pixels (layout units) per metre, the car's height above the bottom edge, grid spacing. */
static final float MAP_SCALE = 1.2f, CAR_FROM_BOTTOM = 150f;
private static final float GRID_M = 50f;
/** How far the car may move from the layer's centre before it is drawn again, in layout units. */
private static final float MARGIN = 280f;
private final MapRoads map;
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path[] roads = new Path[RoadTile.CLASSES];
private final Path grid = new Path();
private int main = 0xFF18E6FF, light = 0xFFC8FCFF;
/** The layer's centre (world tile units) and what it was drawn from. */
private double anchorX = Double.NaN, anchorY;
private int drawnVersion = -1;
private float drawnScale;
/** The car as shown, eased towards where it is (world tile units), and the heading. */
private double shownX = Double.NaN, shownY;
private float heading = Float.NaN;
private boolean showing, ticking;
/** The car's speed, from the readings; NaN when unread. */
private float speedMps = Float.NaN;
MapLayerView(Context context, MapRoads map) {
super(context);
this.map = map;
for (int i = 0; i < roads.length; i++) roads[i] = new Path();
line.setStyle(Paint.Style.STROKE);
line.setStrokeJoin(Paint.Join.ROUND);
line.setStrokeCap(Paint.Cap.ROUND);
setLayerType(LAYER_TYPE_HARDWARE, null);
setVisibility(INVISIBLE);
setWillNotDraw(false);
}
void setColours(int main, int light) {
this.main = main;
this.light = light;
invalidate();
}
/** Whether the map is there to be seen (a fix and roads). */
boolean isShowing() {
return showing;
}
/** Layout units, as GridView uses them, for this size. */
private float unit() {
View parent = (View) getParent();
int w = parent == null ? getWidth() : parent.getWidth() - parent.getPaddingLeft() - parent.getPaddingRight();
int h = parent == null ? getHeight() : parent.getHeight() - parent.getPaddingTop() - parent.getPaddingBottom();
return Math.min(h / 640f, w / 1100f);
}
@Override
protected void onMeasure(int widthSpec, int heightSpec) {
int w = MeasureSpec.getSize(widthSpec), h = MeasureSpec.getSize(heightSpec);
float u = Math.min(h / 640f, w / 1100f);
int side = (int) (2 * (Math.hypot(w / 2f, h - CAR_FROM_BOTTOM * u) + MARGIN * u));
setMeasuredDimension(Math.max(side, 1), Math.max(side, 1));
}
/** The car's speed in km/h, or null when unread; GridView passes it with every reading. */
void setSpeed(Float kmh) {
speedMps = kmh == null ? Float.NaN : kmh / 3.6f;
}
/** Starts the frame loop if it is not running; GridView calls it with every reading. */
void kick() {
if (!ticking && isAttachedToWindow()) {
ticking = true;
postOnAnimation(this::tick);
}
}
/** One display frame: where the car is, which way, and the layer moved there. */
private void tick() {
View pane = (View) getParent();
if (pane == null || !pane.isShown()) {
ticking = false;
return;
}
MapRoads.Fix fix = map.fix();
boolean ready = fix != null && !map.tiles().isEmpty() && getWidth() > 0;
if (ready != showing) {
showing = ready;
setVisibility(ready ? VISIBLE : INVISIBLE);
}
if (!ready) {
ticking = false;
return;
}
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.12f + 360f) % 360f;
// Between fixes the car runs on with the speed (at most 2.5 s), and what is shown glides
// there rather than jumping with each fix.
double mpu = MapGeometry.metresPerUnit(fix.lat);
float ran = Float.isNaN(fix.bearing) || Float.isNaN(speedMps) ? 0f
: speedMps * 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;
if (Double.isNaN(shownX) || Math.hypot(carX - shownX, carY - shownY) * mpu > 200) {
shownX = carX;
shownY = carY;
} else {
shownX += (carX - shownX) * 0.15;
shownY += (carY - shownY) * 0.15;
}
float u = unit(), px = MAP_SCALE * u;
if (drawnVersion != map.version() || Double.isNaN(anchorX) || drawnScale != px
|| Math.hypot(shownX - anchorX, shownY - anchorY) * mpu * px > MARGIN * u) {
build(shownX, shownY, mpu);
drawnScale = px;
invalidate(); // the only redraw of the layer
}
// The car's spot in the layer goes to its spot on screen, turned round it.
float half = getWidth() / 2f;
float inX = half + (float) ((shownX - anchorX) * mpu) * px;
float inY = half + (float) ((shownY - anchorY) * mpu) * px;
float carSx = pane.getPaddingLeft() + (pane.getWidth() - pane.getPaddingLeft() - pane.getPaddingRight()) / 2f;
float carSy = pane.getHeight() - pane.getPaddingBottom() - CAR_FROM_BOTTOM * u;
setPivotX(inX);
setPivotY(inY);
setRotation(-heading);
setTranslationX(carSx - getLeft() - inX);
setTranslationY(carSy - getTop() - inY);
postOnAnimation(this::tick);
}
/** The roads and the grid in metres east and south of an anchor at the car. */
private void build(double atX, double atY, double mpu) {
anchorX = atX;
anchorY = atY;
drawnVersion = map.version();
float m = (float) mpu;
for (Path p : roads) p.reset();
float u = unit();
double reach = getWidth() / 2f / (MAP_SCALE * u);
double range = reach / mpu;
for (RoadTile tile : map.tiles()) {
double ox = (double) tile.x * MapGeometry.EXTENT - atX, oy = (double) tile.y * MapGeometry.EXTENT - atY;
if (ox > range || oy > range || ox + MapGeometry.EXTENT < -range || oy + 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;
Path p = roads[tile.classes[i]];
int[] pts = tile.points[i];
p.moveTo((fx + pts[0]) * m, (fy + pts[1]) * m);
for (int k = 2; k < pts.length; k += 2) p.lineTo((fx + pts[k]) * m, (fy + pts[k + 1]) * m);
}
}
// Grid lines on whole multiples of GRID_M east and north, so they stay put on the ground.
grid.reset();
float r = (float) reach;
float offE = (float) ((atX * mpu) % GRID_M), offS = (float) ((atY * mpu) % GRID_M);
int lines = (int) (r / GRID_M) + 1;
for (int k = -lines; k <= lines; k++) {
float x = k * GRID_M - offE, y = k * GRID_M - offS;
grid.moveTo(x, -r);
grid.lineTo(x, r);
grid.moveTo(-r, y);
grid.lineTo(r, y);
}
}
@Override
protected void onDraw(Canvas c) {
if (Double.isNaN(anchorX) || drawnScale <= 0f) return;
float u = unit(), px = drawnScale;
c.save();
c.translate(getWidth() / 2f, getHeight() / 2f);
c.scale(px, px);
stroke(c, grid, AccentColor.withAlpha(main, 0x26), 1f * u / px);
stroke(c, roads[3], AccentColor.withAlpha(main, 0xA0), 1.6f * u / px);
stroke(c, roads[2], AccentColor.withAlpha(main, 0xD8), 2.2f * u / px);
glow(c, roads[1], main, 2.6f * u / px);
glow(c, roads[0], light, 3.4f * u / px);
c.restore();
}
private void stroke(Canvas c, Path p, int colour, float width) {
line.setColor(colour);
line.setStrokeWidth(width);
c.drawPath(p, line);
}
/** As GridView's glow: two wide faint strokes under a sharp one. */
private void glow(Canvas c, Path p, int colour, float width) {
stroke(c, p, AccentColor.withAlpha(colour, 0x18), width * 6f);
stroke(c, p, AccentColor.withAlpha(colour, 0x40), width * 3f);
stroke(c, p, colour, width);
}
}
@@ -65,6 +65,8 @@ final class MapRoads {
return t;
});
private volatile Fix fix;
/** Goes up whenever a tile arrives or is dropped, so a view knows to rebuild its shapes. */
private volatile int version;
/** The last bearing while moving; GPS has none at a standstill. */
private volatile float bearing = Float.NaN;
private volatile boolean serverMissing;
@@ -100,6 +102,10 @@ final class MapRoads {
return f == null || SystemClock.elapsedRealtime() - f.elapsedMs > FIX_FRESH_MS ? null : f;
}
int version() {
return version;
}
Collection<RoadTile> tiles() {
return tiles.values();
}
@@ -116,7 +122,10 @@ final class MapRoads {
/** Queues the 3×3 tiles round (cx, cy), the car's own first, and forgets far ones. */
private void want(int cx, int cy) {
for (Long key : tiles.keySet()) {
if (Math.abs(MapGeometry.keyX(key) - cx) > 2 || Math.abs(MapGeometry.keyY(key) - cy) > 2) tiles.remove(key);
if (Math.abs(MapGeometry.keyX(key) - cx) > 2 || Math.abs(MapGeometry.keyY(key) - cy) > 2) {
tiles.remove(key);
version++;
}
}
int[][] order = { {0, 0}, {0, -1}, {1, 0}, {0, 1}, {-1, 0}, {1, -1}, {1, 1}, {-1, 1}, {-1, -1} };
long now = SystemClock.elapsedRealtime();
@@ -152,6 +161,7 @@ final class MapRoads {
}
if (cached != null) {
tiles.put(key, cached);
version++;
if (System.currentTimeMillis() - file.lastModified() < DISK_AGE_MS) return;
}
ServerConfig config = ServerConfig.get(context);
@@ -172,6 +182,7 @@ final class MapRoads {
}
failedAt.remove(key);
tiles.put(key, tile);
version++;
save(file, r.body);
} catch (Exception e) {
Log.w(TAG, "Map tile not loaded: " + e.getClass().getSimpleName());