From f7c489858e4131f9419de6ec026efafd47d5d923 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Tue, 27 Oct 2026 00:32:50 +0100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6 --- AGENTS.md | 2 +- SPECS.md | 15 ++ app/build.gradle | 4 +- .../main/java/com/evsuite/abrp/GridView.java | 163 +++---------- .../java/com/evsuite/abrp/MainActivity.java | 2 + .../java/com/evsuite/abrp/MapLayerView.java | 223 ++++++++++++++++++ .../main/java/com/evsuite/abrp/MapRoads.java | 13 +- 7 files changed, 293 insertions(+), 129 deletions(-) create mode 100644 app/src/main/java/com/evsuite/abrp/MapLayerView.java diff --git a/AGENTS.md b/AGENTS.md index e78aa04..84295cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | diff --git a/SPECS.md b/SPECS.md index 56aaf39..f8207af 100644 --- a/SPECS.md +++ b/SPECS.md @@ -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 diff --git a/app/build.gradle b/app/build.gradle index 501827a..d6282ff 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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. diff --git a/app/src/main/java/com/evsuite/abrp/GridView.java b/app/src/main/java/com/evsuite/abrp/GridView.java index ba6f8e8..95e2816 100644 --- a/app/src/main/java/com/evsuite/abrp/GridView.java +++ b/app/src/main/java/com/evsuite/abrp/GridView.java @@ -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 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); diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 4274600..f9b6598 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -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. diff --git a/app/src/main/java/com/evsuite/abrp/MapLayerView.java b/app/src/main/java/com/evsuite/abrp/MapLayerView.java new file mode 100644 index 0000000..968924a --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/MapLayerView.java @@ -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); + } +} diff --git a/app/src/main/java/com/evsuite/abrp/MapRoads.java b/app/src/main/java/com/evsuite/abrp/MapRoads.java index 00f3977..f7da82d 100644 --- a/app/src/main/java/com/evsuite/abrp/MapRoads.java +++ b/app/src/main/java/com/evsuite/abrp/MapRoads.java @@ -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 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());