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:
@@ -76,7 +76,7 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`),
|
|||||||
|
|
||||||
| Tab | Contents |
|
| 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 |
|
| Consumption / Charging | period totals, daily log, charging sessions and curve |
|
||||||
| Live data | on/off, ABRP API key and user token, upload frequency |
|
| 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 |
|
| Server | URL, pairing by QR, ingest token, installation ID (read-only), config import |
|
||||||
|
|||||||
@@ -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
|
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.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:
|
- 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
|
"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
|
installer session ("Not allowed to install non-system apps on internal storage"); only a
|
||||||
|
|||||||
+2
-2
@@ -34,8 +34,8 @@ android {
|
|||||||
applicationId "com.stinnesbeck.nilsmg4"
|
applicationId "com.stinnesbeck.nilsmg4"
|
||||||
minSdk 27
|
minSdk 27
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 146
|
versionCode 147
|
||||||
versionName "2.40.1"
|
versionName "2.40.2"
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ import android.os.SystemClock;
|
|||||||
* marker low on the screen; 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 is then the whole background, straight from above (about
|
* 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
|
* 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
|
* 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.
|
||||||
*
|
*
|
||||||
@@ -41,11 +43,6 @@ final class GridView extends ClusterCanvasView {
|
|||||||
/** Battery segments in the light wall, disc segments, metres per grid tile. */
|
/** Battery segments in the light wall, disc segments, metres per grid tile. */
|
||||||
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, 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 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);
|
||||||
@@ -60,12 +57,10 @@ final class GridView extends ClusterCanvasView {
|
|||||||
private final int accent, accentLight;
|
private final int accent, accentLight;
|
||||||
/** Null for the plain Grid. */
|
/** Null for the plain Grid. */
|
||||||
private final MapRoads map;
|
private final MapRoads map;
|
||||||
/** Per road class, near and far (from {@link #FADE_M} ahead). */
|
/** The map under this view (a hardware layer, moved by the GPU); null for the plain Grid. */
|
||||||
private final Path[] roads = new Path[RoadTile.CLASSES * 2];
|
private final MapLayerView mapLayer;
|
||||||
private float heading = Float.NaN;
|
private final Paint fade = new Paint();
|
||||||
private final Path gridPath = new Path();
|
private int fadeFor = -1;
|
||||||
/** 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;
|
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. */
|
||||||
@@ -80,7 +75,7 @@ final class GridView extends ClusterCanvasView {
|
|||||||
GridView(Context context, MapRoads map) {
|
GridView(Context context, MapRoads map) {
|
||||||
super(context);
|
super(context);
|
||||||
this.map = map;
|
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);
|
accent = AccentColor.resolve(context, R.attr.evAccent);
|
||||||
accentLight = AccentColor.resolve(context, R.attr.evAccentLight);
|
accentLight = AccentColor.resolve(context, R.attr.evAccentLight);
|
||||||
line.setStyle(Paint.Style.STROKE);
|
line.setStyle(Paint.Style.STROKE);
|
||||||
@@ -96,9 +91,24 @@ final class GridView extends ClusterCanvasView {
|
|||||||
dim = AccentColor.withAlpha(main, 0x40);
|
dim = AccentColor.withAlpha(main, 0x40);
|
||||||
alert = useAccent ? 0xFFFFFFFF : 0xFFFF8A1F;
|
alert = useAccent ? 0xFFFFFFFF : 0xFFFF8A1F;
|
||||||
skyFor = -1;
|
skyFor = -1;
|
||||||
|
if (mapLayer != null) mapLayer.setColours(main, light);
|
||||||
invalidate();
|
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
|
@Override
|
||||||
protected void onDraw(Canvas c) {
|
protected void onDraw(Canvas c) {
|
||||||
super.onDraw(c);
|
super.onDraw(c);
|
||||||
@@ -106,15 +116,14 @@ final class GridView extends ClusterCanvasView {
|
|||||||
if (w <= 0 || h <= 0) return;
|
if (w <= 0 || h <= 0) return;
|
||||||
float u = Math.min(h / 640f, w / 1100f);
|
float u = Math.min(h / 640f, w / 1100f);
|
||||||
advance();
|
advance();
|
||||||
c.drawColor(0xFF000000);
|
// 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.
|
||||||
// With a map, the map is the whole background, seen from straight above, and the rest
|
boolean mapShown = mapLayer != null && mapLayer.isShowing();
|
||||||
// stands on dark panels over it.
|
|
||||||
boolean mapShown = map != null && map.fix() != null && !map.tiles().isEmpty();
|
|
||||||
float horizon = h * 0.55f;
|
float horizon = h * 0.55f;
|
||||||
if (mapShown) {
|
if (mapShown) {
|
||||||
drawMap(c, w, h, u);
|
drawCar(c, w, h, u);
|
||||||
} else {
|
} else {
|
||||||
|
c.drawColor(0xFF000000);
|
||||||
drawSky(c, w, horizon);
|
drawSky(c, w, horizon);
|
||||||
drawFloor(c, w, h, horizon, u);
|
drawFloor(c, w, h, horizon, u);
|
||||||
}
|
}
|
||||||
@@ -131,8 +140,8 @@ final class GridView extends ClusterCanvasView {
|
|||||||
if (mapShown) drawAttribution(c, w, h, u);
|
if (mapShown) drawAttribution(c, w, h, u);
|
||||||
|
|
||||||
boolean moving = v.speedKmh != null && v.speedKmh > 0.5f;
|
boolean moving = v.speedKmh != null && v.speedKmh > 0.5f;
|
||||||
// The map costs more per frame: 20 a second is smooth enough for it.
|
// Over the map only the readings change; the map layer moves itself.
|
||||||
keepAnimating(moving, mapShown ? 50 : 33);
|
keepAnimating(moving && !mapShown, 33);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Moves the floor by the distance driven since the last frame, and turns the disc. */
|
/** 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 ----------
|
// ---------- Map ----------
|
||||||
|
|
||||||
/** The roads round the car from straight above, heading up, over a ground grid. */
|
/** Over the map: the far roads fading into the dark towards the top, and the car. */
|
||||||
private void drawMap(Canvas c, float w, float h, float u) {
|
private void drawCar(Canvas c, float w, float h, float u) {
|
||||||
MapRoads.Fix fix = map.fix();
|
float carSx = w / 2f, carSy = h - MapLayerView.CAR_FROM_BOTTOM * u;
|
||||||
java.util.Collection<RoadTile> tiles = map.tiles();
|
if (fadeFor != (int) (carSy + h)) {
|
||||||
if (fix == null) return;
|
fade.setShader(new LinearGradient(0, 0, 0, carSy, 0xB0000000, 0x00000000, Shader.TileMode.CLAMP));
|
||||||
// Heading up: ease round the shorter way; north up until the car has moved.
|
fadeFor = (int) (carSy + h);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
double run = Math.toRadians(Float.isNaN(fix.bearing) ? 0f : fix.bearing);
|
c.drawRect(0, 0, w, carSy, fade);
|
||||||
double carX = MapGeometry.worldX(fix.lon) + ran * Math.sin(run) / mpu;
|
// A light-cycle chevron pointing ahead.
|
||||||
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.
|
|
||||||
path.reset();
|
path.reset();
|
||||||
path.moveTo(carSx, carSy - 18f * u);
|
path.moveTo(carSx, carSy - 18f * u);
|
||||||
path.lineTo(carSx + 12f * u, carSy + 11f * 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);
|
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. */
|
/** 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) {
|
private void drawPanels(Canvas c, float w, float horizon, float u) {
|
||||||
chamfer(16f * u, 78f * u, 360f * u, horizon + 86f * u, 14f * 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));
|
new QuestView(this), visorView, new EmpireView(this));
|
||||||
View[] canvasPanes = new View[canvasStyles.size()];
|
View[] canvasPanes = new View[canvasStyles.size()];
|
||||||
for (int i = 0; i < canvasPanes.length; i++) canvasPanes[i] = drawnPane(canvasStyles.get(i));
|
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,
|
View[] fixedPanes = { vehiclePane, gaugesPane, cockpitPane, retroPane,
|
||||||
futuristicPane, sciFiPane, cartoonPane, mgPane };
|
futuristicPane, sciFiPane, cartoonPane, mgPane };
|
||||||
// By OverviewStyle ordinal: the fixed styles, then the canvas styles in their order.
|
// 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;
|
return t;
|
||||||
});
|
});
|
||||||
private volatile Fix fix;
|
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. */
|
/** The last bearing while moving; GPS has none at a standstill. */
|
||||||
private volatile float bearing = Float.NaN;
|
private volatile float bearing = Float.NaN;
|
||||||
private volatile boolean serverMissing;
|
private volatile boolean serverMissing;
|
||||||
@@ -100,6 +102,10 @@ final class MapRoads {
|
|||||||
return f == null || SystemClock.elapsedRealtime() - f.elapsedMs > FIX_FRESH_MS ? null : f;
|
return f == null || SystemClock.elapsedRealtime() - f.elapsedMs > FIX_FRESH_MS ? null : f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int version() {
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
Collection<RoadTile> tiles() {
|
Collection<RoadTile> tiles() {
|
||||||
return tiles.values();
|
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. */
|
/** Queues the 3×3 tiles round (cx, cy), the car's own first, and forgets far ones. */
|
||||||
private void want(int cx, int cy) {
|
private void want(int cx, int cy) {
|
||||||
for (Long key : tiles.keySet()) {
|
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} };
|
int[][] order = { {0, 0}, {0, -1}, {1, 0}, {0, 1}, {-1, 0}, {1, -1}, {1, 1}, {-1, 1}, {-1, -1} };
|
||||||
long now = SystemClock.elapsedRealtime();
|
long now = SystemClock.elapsedRealtime();
|
||||||
@@ -152,6 +161,7 @@ final class MapRoads {
|
|||||||
}
|
}
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
tiles.put(key, cached);
|
tiles.put(key, cached);
|
||||||
|
version++;
|
||||||
if (System.currentTimeMillis() - file.lastModified() < DISK_AGE_MS) return;
|
if (System.currentTimeMillis() - file.lastModified() < DISK_AGE_MS) return;
|
||||||
}
|
}
|
||||||
ServerConfig config = ServerConfig.get(context);
|
ServerConfig config = ServerConfig.get(context);
|
||||||
@@ -172,6 +182,7 @@ final class MapRoads {
|
|||||||
}
|
}
|
||||||
failedAt.remove(key);
|
failedAt.remove(key);
|
||||||
tiles.put(key, tile);
|
tiles.put(key, tile);
|
||||||
|
version++;
|
||||||
save(file, r.body);
|
save(file, r.body);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.w(TAG, "Map tile not loaded: " + e.getClass().getSimpleName());
|
Log.w(TAG, "Map tile not loaded: " + e.getClass().getSimpleName());
|
||||||
|
|||||||
Reference in New Issue
Block a user