package com.evsuite.abrp; import android.content.Context; import android.graphics.Canvas; import android.graphics.LinearGradient; import android.graphics.Paint; import android.graphics.Path; import android.graphics.RectF; import android.graphics.Shader; import android.graphics.Typeface; import android.os.SystemClock; /** * The Grid Overview style, after the neon world of the Tron films: glowing lines on black, a * perspective grid floor that runs towards you with the speed, and shapes with cut corners. * * Centre: the speed, large and thin. Left: the battery as a segmented light wall standing on * the floor, with the charge and range beside it. Right: power on a disc — the ring lights from * the top, clockwise while drawing and anticlockwise while regenerating, and its inner ring * turns with the power. On the floor, the last two minutes of power as a light-cycle trail, * which only ever turns at right angles. P R N D as tiles on top, the clock top right, and a * row of read-outs along the bottom. Unread values show "—", light nothing and leave gaps in * the trail; the floor stands still when the speed is unread. * * With a map ("Grid with map", {@link MapRoads}): the roads round the car from OpenStreetMap lie * on the floor in its perspective, heading up, the bigger ones glowing brighter, with the car * as a light marker a little way up the floor; between GPS fixes the car's position runs on * with the speed. The floor's tiles are then 40 m, so they move with the roads, and the trail * makes way for the map. Without a fix, a server or the roads, the plain floor with a line * saying why. * * Two palettes: the original cyan, or the chosen accent colour ({@link #setAccentPalette}). A * low battery is orange in the original (the films' other side) and white in the accent one, * so it never looks like the accent itself. The floor and the disc animate (about 30 frames a * second) only while the car moves and the view is on screen. */ 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: metres per depth step of the floor, the car's depth (just above the read-outs), * the nearest depth drawn, how far ahead, and the depth from which roads are drawn fainter. */ private static final float MAP_UNIT_M = 40f, CAR_DEPTH = 1.6f, NEAR = 0.6f, FAR_M = 900f, FADE_DEPTH = 5f; private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint sky = new Paint(); private final Path path = new Path(); private android.graphics.DashPathEffect dash; private float dashFor; private final RectF r = new RectF(); private final Typeface thin = Typeface.create("sans-serif-thin", Typeface.NORMAL); private final Typeface label = Typeface.create("sans-serif-condensed", Typeface.BOLD); private final int accent, accentLight; /** Null for the plain Grid. */ private final MapRoads map; /** Per road class, near and far (from {@link #FADE_DEPTH}). */ private final Path[] roads = new Path[RoadTile.CLASSES * 2]; private float heading = Float.NaN; /** The pen while a road is added: the last point, and whether it continues from there. */ private float penX, penY; private boolean penDown; private Path penPath; private int main, light, dim, alert; /** The floor's position within one tile (0..1) and the disc's inner ring angle. */ private float floorPhase, discTurn; private long lastFrame; private int skyFor = -1; GridView(Context context) { this(context, null); } GridView(Context context, MapRoads map) { super(context); this.map = map; for (int i = 0; i < roads.length; i++) roads[i] = new Path(); accent = AccentColor.resolve(context, R.attr.evAccent); accentLight = AccentColor.resolve(context, R.attr.evAccentLight); line.setStyle(Paint.Style.STROKE); line.setStrokeCap(Paint.Cap.SQUARE); line.setStrokeJoin(Paint.Join.MITER); setAccentPalette(false); } /** The chosen accent colour instead of the original cyan. */ void setAccentPalette(boolean useAccent) { main = useAccent ? accent : 0xFF18E6FF; light = useAccent ? accentLight : 0xFFC8FCFF; dim = AccentColor.withAlpha(main, 0x40); alert = useAccent ? 0xFFFFFFFF : 0xFFFF8A1F; skyFor = -1; invalidate(); } @Override protected void onDraw(Canvas c) { super.onDraw(c); float w = getWidth(), h = getHeight(); if (w <= 0 || h <= 0) return; float u = Math.min(h / 640f, w / 1100f); advance(); c.drawColor(0xFF000000); float horizon = h * 0.55f; drawSky(c, w, horizon); drawFloor(c, w, h, horizon, u); boolean mapShown = map != null && drawMap(c, w, h, horizon, u); if (map != null && !mapShown) drawMapState(c, w, h, horizon, u); drawGears(c, w, u); drawClock(c, w, u); drawSpeed(c, w, horizon, u); drawWall(c, horizon, u); drawDisc(c, w, u); if (!mapShown) drawTrail(c, w, h, u); drawReadouts(c, w, h, u); 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); } /** Moves the floor by the distance driven since the last frame, and turns the disc. */ private void advance() { long now = SystemClock.uptimeMillis(); float dt = lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f); lastFrame = now; if (v.speedKmh != null && v.speedKmh > 0.5f) { float tile = map != null ? MAP_UNIT_M : TILE_M; floorPhase = (floorPhase + v.speedKmh / 3.6f * dt / tile) % 1f; } if (v.powerKw != null) discTurn = (discTurn + v.powerKw * 3f * dt) % 360f; } // ---------- Floor ---------- private void drawSky(Canvas c, float w, float horizon) { if (skyFor != (int) horizon) { sky.setShader(new LinearGradient(0, horizon - horizon * 0.45f, 0, horizon, 0x00000000, AccentColor.withAlpha(main, 0x30), Shader.TileMode.CLAMP)); skyFor = (int) horizon; } c.drawRect(0, horizon - horizon * 0.45f, w, horizon, sky); } /** Lines converging on the horizon, and cross lines that run towards the viewer. */ private void drawFloor(Canvas c, float w, float h, float horizon, float u) { float vx = w / 2f, depth = h - horizon; line.setStrokeWidth(1.2f * u); for (int i = -14; i <= 14; i++) { float bottomX = vx + i * w * 0.09f; line.setColor(AccentColor.withAlpha(main, 0x38)); c.drawLine(vx + (bottomX - vx) * 0.02f, horizon, bottomX, h, line); } for (int k = 0; k < 24; k++) { float d = k + 1f - floorPhase; if (d <= 0.05f) continue; float y = horizon + depth / d; if (y > h || y < horizon + 1f) continue; float near = (y - horizon) / depth; line.setColor(AccentColor.withAlpha(main, (int) (0x10 + 0x50 * near))); c.drawLine(0, y, w, y, line); } glowLine(c, 0, horizon, w, horizon, main, 1.5f * u); } // ---------- Map ---------- /** The roads round the car on the floor; false when there is nothing to draw. */ private boolean drawMap(Canvas c, float w, float h, float horizon, float u) { MapRoads.Fix fix = map.fix(); java.util.Collection tiles = map.tiles(); if (fix == null || tiles.isEmpty()) return false; // 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); } 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; float sin = (float) Math.sin(Math.toRadians(heading)), cos = (float) Math.cos(Math.toRadians(heading)); float lane = w * 0.09f, depth = h - horizon, vx = w / 2f, m = (float) mpu; double range = FAR_M / mpu; 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 + 2 * MapGeometry.EXTENT < -range || oy + 2 * 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]; penDown = false; float pa = 0f, pf = 0f; for (int k = 0; k < pts.length; k += 2) { float east = (fx + pts[k]) * m, north = -(fy + pts[k + 1]) * m; float fwd = east * sin + north * cos, side = east * cos - north * sin; if (k > 0) segment(cls, pa, pf, side, fwd, vx, horizon, lane, depth); pa = side; pf = fwd; } } } c.save(); c.clipRect(0, horizon, w, h); line.setStrokeJoin(Paint.Join.ROUND); line.setStrokeCap(Paint.Cap.ROUND); // Far roads thin and faint, so the horizon stays a line rather than a tangle. for (int cls = RoadTile.CLASSES - 1; cls >= 0; cls--) { line.setColor(AccentColor.withAlpha(cls == 0 ? light : main, cls >= 3 ? 0x30 : 0x50)); line.setStrokeWidth((cls == 0 ? 1.6f : 1f) * u); c.drawPath(roads[cls * 2 + 1], line); } line.setColor(AccentColor.withAlpha(main, 0x80)); line.setStrokeWidth(1.4f * u); c.drawPath(roads[3 * 2], line); line.setColor(AccentColor.withAlpha(main, 0xC0)); line.setStrokeWidth(2f * u); c.drawPath(roads[2 * 2], line); glow(c, roads[2], main, 2.4f * u); glow(c, roads[0], light, 3.2f * u); line.setStrokeJoin(Paint.Join.MITER); line.setStrokeCap(Paint.Cap.SQUARE); c.restore(); // The car: a light-cycle chevron pointing ahead. float cy = horizon + depth / CAR_DEPTH; path.reset(); path.moveTo(vx, cy - 16f * u); path.lineTo(vx + 11f * u, cy + 10f * u); path.lineTo(vx, cy + 4f * u); path.lineTo(vx - 11f * u, cy + 10f * u); path.close(); fill.setColor(light); c.drawPath(path, fill); glow(c, path, light, 1.5f * u); return true; } /** One stretch of road from (a0, f0) to (a1, f1) in metres (side, ahead), cut at the near edge. */ private void segment(int cls, float a0, float f0, float a1, float f1, float vx, float horizon, float lane, float depth) { float d0 = CAR_DEPTH + f0 / MAP_UNIT_M, d1 = CAR_DEPTH + f1 / MAP_UNIT_M; Path p = roads[cls * 2 + (Math.min(d0, d1) > FADE_DEPTH ? 1 : 0)]; if ((d0 < NEAR && d1 < NEAR) || (f0 > FAR_M && f1 > FAR_M)) { penDown = false; return; } boolean cut = false; if (d0 < NEAR || d1 < NEAR) { float t = (NEAR - d0) / (d1 - d0); float a = a0 + (a1 - a0) * t, d = NEAR; if (d0 < NEAR) { a0 = a; d0 = d; cut = true; } else { a1 = a; d1 = d; } } float x0 = vx + a0 / MAP_UNIT_M * lane / d0, y0 = horizon + depth / d0; float x1 = vx + a1 / MAP_UNIT_M * lane / d1, y1 = horizon + depth / d1; if (!penDown || cut || p != penPath || x0 != penX || y0 != penY) p.moveTo(x0, y0); p.lineTo(x1, y1); penPath = p; penX = x1; penY = y1; penDown = true; } /** Why the map is not there, on the floor. */ private void drawMapState(Canvas c, float w, float h, float horizon, float u) { MapRoads.State state = map.state(); String why = state == MapRoads.State.NO_SERVER ? "MAP · SET UP THE SERVER FIRST" : state == MapRoads.State.WAITING_FOR_GPS ? "MAP · WAITING FOR GPS" : state == MapRoads.State.UNAVAILABLE ? "MAP · NOT AVAILABLE, TRYING AGAIN" : "MAP · LOADING"; caption(c, why, w / 2f, horizon + (h - horizon) * 0.2f, u, Paint.Align.CENTER); } /** OpenStreetMap's licence asks for its name wherever its data is shown. */ private void drawAttribution(Canvas c, float w, float h, float u) { text.setTypeface(label); text.setTextSize(12f * u); text.setTextAlign(Paint.Align.RIGHT); text.setStyle(Paint.Style.FILL); text.setColor(dim); c.drawText("© OpenStreetMap contributors", w - 26f * u, h - 80f * u, text); } // ---------- Top ---------- private void drawGears(Canvas c, float w, float u) { String[] gears = { "P", "R", "N", "D" }; String now = Gear.label(v.gear); float tw = 58f * u, th = 40f * u, gap = 12f * u; float x = w / 2f - (gears.length * tw + (gears.length - 1) * gap) / 2f, top = 22f * u; text.setTypeface(label); text.setTextSize(24f * u); text.setTextAlign(Paint.Align.CENTER); for (String g : gears) { boolean on = g.equals(now); chamfer(x, top, x + tw, top + th, 9f * u); if (on) { fill.setColor(main); c.drawPath(path, fill); glow(c, path, main, 1.5f * u); } else { line.setColor(dim); line.setStrokeWidth(1.5f * u); c.drawPath(path, line); } text.setColor(on ? 0xFF000000 : dim); c.drawText(g, x + tw / 2f, top + th * 0.72f, text); x += tw + gap; } } private void drawClock(Canvas c, float w, float u) { text.setTypeface(thin); text.setTextSize(44f * u); text.setTextAlign(Paint.Align.RIGHT); glowText(c, v.clock.isEmpty() ? "—" : v.clock, w - 28f * u, 62f * u, light, u); } // ---------- Speed ---------- private void drawSpeed(Canvas c, float w, float horizon, float u) { text.setTypeface(thin); text.setTextSize(210f * u); text.setTextAlign(Paint.Align.CENTER); glowText(c, fmt(v.speedKmh, "%.0f", "—"), w / 2f, horizon - 62f * u, light, u); caption(c, "KM/H", w / 2f, horizon - 22f * u, u, Paint.Align.CENTER); } // ---------- Battery wall ---------- private void drawWall(Canvas c, float horizon, float u) { boolean low = v.socPercent != null && v.socPercent < 20f; int colour = low ? alert : main; float left = 34f * u, right = left + 52f * u, top = 96f * u, bottom = horizon + 70f * u; float seg = (bottom - top) / WALL; int lit = v.socPercent == null ? 0 : Math.round(v.socPercent / 100f * WALL); for (int i = 0; i < WALL; i++) { float b = bottom - i * seg, t = b - seg + 3f * u; r.set(left, t, right, b); if (i < lit) { fill.setColor(i == lit - 1 ? light : colour); c.drawRect(r, fill); } else { line.setColor(dim); line.setStrokeWidth(1f * u); c.drawRect(r, line); } } chamfer(left - 8f * u, top - 8f * u, right + 8f * u, bottom + 8f * u, 10f * u); glow(c, path, colour, 1.5f * u); float x = right + 30f * u; caption(c, low ? "LOW ENERGY" : "ENERGY", x, top + 18f * u, u, Paint.Align.LEFT); text.setTypeface(thin); text.setTextSize(96f * u); text.setTextAlign(Paint.Align.LEFT); String soc = fmt(v.socPercent, "%.0f", "—"); glowText(c, soc, x, top + 116f * u, low ? alert : light, u); float socWidth = text.measureText(soc); text.setTextSize(36f * u); glowText(c, "%", x + socWidth + 8f * u, top + 116f * u, colour, u); caption(c, "RANGE", x, top + 168f * u, u, Paint.Align.LEFT); text.setTypeface(thin); text.setTextSize(52f * u); glowText(c, fmt(v.rangeKm, "%.0f km", "—"), x, top + 222f * u, light, u); } // ---------- Power disc ---------- /** Zero at the top, −50 kW a quarter turn anticlockwise, 150 kW three quarters clockwise. */ private void drawDisc(Canvas c, float w, float u) { float cx = w - 170f * u, cy = 238f * u, outer = 132f * u, ring = 22f * u; line.setColor(dim); line.setStrokeWidth(1.5f * u); c.drawCircle(cx, cy, outer + 10f * u, line); float sweepPerKw = 270f / 200f; float power = v.powerKw == null ? 0f : Math.max(-50f, Math.min(150f, v.powerKw)); float segDeg = 270f / DISC; for (int i = 0; i < DISC; i++) { // Segment i covers −50 + i·(200/DISC) … one step more, drawn from −135° (top-left). float kwFrom = -50f + i * 200f / DISC, kwTo = kwFrom + 200f / DISC; boolean on = v.powerKw != null && (power >= 0 ? kwFrom >= 0f && kwFrom < power : kwTo <= 0f && kwTo > power); float start = -90f + kwFrom * sweepPerKw; r.set(cx - outer + ring / 2f, cy - outer + ring / 2f, cx + outer - ring / 2f, cy + outer - ring / 2f); line.setStrokeWidth(ring); line.setStrokeCap(Paint.Cap.BUTT); line.setColor(on ? (power < 0 ? light : main) : AccentColor.withAlpha(main, 0x22)); c.drawArc(r, start + 0.6f, segDeg - 1.2f, false, line); } line.setStrokeCap(Paint.Cap.SQUARE); // The zero mark, and an inner ring of dashes that turns with the power. glowLine(c, cx, cy - outer - 16f * u, cx, cy - outer + ring + 6f * u, light, 2f * u); float inner = outer - ring - 22f * u; line.setStrokeWidth(3f * u); line.setColor(main); r.set(cx - inner, cy - inner, cx + inner, cy + inner); for (int k = 0; k < 8; k++) c.drawArc(r, discTurn + k * 45f, 26f, false, line); line.setColor(dim); line.setStrokeWidth(1f * u); c.drawCircle(cx, cy, inner - 12f * u, line); text.setTypeface(thin); text.setTextSize(64f * u); text.setTextAlign(Paint.Align.CENTER); glowText(c, fmt(v.powerKw, "%.1f", "—"), cx, cy + 18f * u, light, u); caption(c, v.powerKw != null && v.powerKw < 0f ? "KW · REGEN" : "KW", cx, cy + 50f * u, u, Paint.Align.CENTER); caption(c, "POWER", cx, cy - 46f * u, u, Paint.Align.CENTER); } // ---------- Light trail ---------- /** Power over the last two minutes as a trail of right angles; NaN breaks it. */ private void drawTrail(Canvas c, float w, float h, float u) { float[] trail = v.powerTrail; float left = 300f * u, right = w - 360f * u, top = h * 0.66f, bottom = h * 0.84f; if (right - left < 200f * u) return; float max = 10f, min = -10f; if (trail != null) { for (float p : trail) { if (Float.isNaN(p)) continue; max = Math.max(max, (float) Math.ceil(p / 10f) * 10f); min = Math.min(min, (float) Math.floor(p / 10f) * 10f); } } float zero = bottom - (0f - min) / (max - min) * (bottom - top); line.setColor(dim); line.setStrokeWidth(1f * u); if (dash == null || dashFor != u) { dash = new android.graphics.DashPathEffect(new float[] { 6f * u, 8f * u }, 0); dashFor = u; } line.setPathEffect(dash); c.drawLine(left, zero, right, zero, line); line.setPathEffect(null); caption(c, "POWER · LAST 2 MIN", left, top - 10f * u, u, Paint.Align.LEFT); if (trail == null || trail.length < 2) return; float step = (right - left) / (trail.length - 1); path.reset(); boolean open = false; float lastX = 0, lastY = 0; for (int i = 0; i < trail.length; i++) { float p = trail[i]; if (Float.isNaN(p)) { open = false; continue; } float x = left + i * step, y = bottom - (p - min) / (max - min) * (bottom - top); if (!open) { path.moveTo(x, y); open = true; } else { path.lineTo(x, lastY); // along, then a right angle path.lineTo(x, y); } lastX = x; lastY = y; } glow(c, path, main, 2.5f * u); if (open) { fill.setColor(light); c.drawCircle(lastX, lastY, 5f * u, fill); fill.setColor(AccentColor.withAlpha(main, 0x50)); c.drawCircle(lastX, lastY, 12f * u, fill); } } // ---------- Read-outs ---------- private void drawReadouts(Canvas c, float w, float h, float u) { String[][] cells = { { "USAGE · 5 MIN", fmt(v.usage5Min, "%.1f", "—") }, { "SINCE START", fmt(v.usageTrip, "%.1f", "—") }, { "TRIP", fmt(v.tripKm, "%.1f km", "—") }, { "CLIMATE", fmt(v.climateCelsius, "%.0f °C", "—") }, { "ODOMETER", fmt(v.odometerKm, "%,.0f km", "—") }, }; float margin = 24f * u, gap = 14f * u, top = h - 70f * u, bottom = h - 14f * u; float cw = (w - 2 * margin - (cells.length - 1) * gap) / cells.length; for (int i = 0; i < cells.length; i++) { float l = margin + i * (cw + gap); chamfer(l, top, l + cw, bottom, 10f * u); fill.setColor(0xE6000000); c.drawPath(path, fill); line.setColor(dim); line.setStrokeWidth(1.5f * u); c.drawPath(path, line); caption(c, cells[i][0], l + 16f * u, top + 20f * u, u, Paint.Align.LEFT); text.setTypeface(thin); text.setTextSize(30f * u); text.setTextAlign(Paint.Align.RIGHT); glowText(c, cells[i][1], l + cw - 16f * u, bottom - 10f * u, light, u); } } // ---------- Light ---------- /** A rectangle with its top-left and bottom-right corners cut, into [path]. */ private void chamfer(float l, float t, float rr, float b, float cut) { path.reset(); path.moveTo(l + cut, t); path.lineTo(rr, t); path.lineTo(rr, b - cut); path.lineTo(rr - cut, b); path.lineTo(l, b); path.lineTo(l, t + cut); path.close(); } /** A line that glows: two wide faint strokes under a sharp one. */ private void glow(Canvas c, Path p, int colour, float width) { line.setColor(AccentColor.withAlpha(colour, 0x18)); line.setStrokeWidth(width * 6f); c.drawPath(p, line); line.setColor(AccentColor.withAlpha(colour, 0x40)); line.setStrokeWidth(width * 3f); c.drawPath(p, line); line.setColor(colour); line.setStrokeWidth(width); c.drawPath(p, line); } private void glowLine(Canvas c, float x1, float y1, float x2, float y2, int colour, float width) { path.reset(); path.moveTo(x1, y1); path.lineTo(x2, y2); glow(c, path, colour, width); } /** Text with a halo: a wide faint outline under the letters. */ private void glowText(Canvas c, String s, float x, float y, int colour, float u) { text.setStyle(Paint.Style.STROKE); text.setStrokeWidth(7f * u); text.setColor(AccentColor.withAlpha(colour, 0x30)); c.drawText(s, x, y, text); text.setStyle(Paint.Style.FILL); text.setColor(colour); c.drawText(s, x, y, text); } /** A small label in spaced capitals. */ private void caption(Canvas c, String s, float x, float y, float u, Paint.Align align) { text.setTypeface(label); text.setTextSize(15f * u); text.setLetterSpacing(0.2f); text.setTextAlign(align); text.setStyle(Paint.Style.FILL); text.setColor(main); c.drawText(s, x, y, text); text.setLetterSpacing(0f); } }