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
224 lines
9.5 KiB
Java
224 lines
9.5 KiB
Java
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);
|
|
}
|
|
}
|