Files
mg4/app/src/main/java/com/evsuite/abrp/GridView.java
T
nilsandClaude Opus 5.5 65dc1ba847 App 2.39.0, server 2.7.0: Grid with map
A new Overview style: the Grid over the roads round the car from
OpenStreetMap, drawn in the floor's perspective, heading up, the bigger
roads glowing brighter, the car as a chevron.

Server: POST /v1/map/roads {z:14,x,y} fetches a tile's drivable roads
from Overpass (OVERPASS_URL; one request at a time, at least a second
apart), simplifies them to tile-local points in four classes, caches
them for 30 days and never logs the tile.

App: MapRoads keeps the 3x3 tiles round the car from its own cache or
the server; GPS only while the style is shown; bearing from the GPS or
from successive positions. Without GPS, server or roads the plain floor
says why. OSM attribution on screen and in LICENSE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-26 23:34:21 +01:00

580 lines
25 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<RoadTile> 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);
}
}