App 2.24.0: Overview styles Sci-fi, Cartoon and Blueprint

Sci-fi: a starship console with elbow frames, pill labels and block
graphs, red alert below 20 %. Cartoon: comic panels, a battery with a
face, a springy speedometer and sound words for power. Blueprint: a
top-view drawing with the battery hatched to the charge, tyre pressures
on leader lines, power as a force arrow and a title block. Styles are
now mapped to panes in a table. Emulator only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
This commit is contained in:
2026-10-24 20:08:24 +02:00
co-authored by Claude Opus 5.5
parent 2879c43e26
commit 5fb367d7cd
14 changed files with 926 additions and 20 deletions
@@ -0,0 +1,283 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.DashPathEffect;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RectF;
import android.graphics.Typeface;
import android.util.AttributeSet;
import android.view.View;
import java.util.Locale;
/**
* The Blueprint Overview style: a technical drawing of the car from above, white lines on a navy
* sheet with a grid.
*
* The battery pack between the axles is hatched as far as it is charged. Each tyre has a leader
* line to its pressure. Power is a force arrow at the front — out of the car while drawing,
* into it (dashed) while regenerating — its length scaled to the power. Dimension lines give the
* range and the trip, the cabin is annotated with the climate setpoint, and a title block in the
* corner holds speed, power, gear, usage, odometer and time. Unread values show "—", no hatch and
* no arrow. Redrawn only when a reading arrives.
*/
public final class BlueprintView extends View {
/** The MG4's length over its width, 4.287 m by 1.836 m. */
private static final float ASPECT = 4.287f / 1.836f;
private final Paint sheet = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint grid = new Paint();
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint faint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint dashed = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path path = new Path();
private final RectF rect = new RectF();
private final int ink;
private ClusterValues v = new ClusterValues();
public BlueprintView(Context context, AttributeSet attrs) {
super(context, attrs);
float d = context.getResources().getDisplayMetrics().density;
ink = context.getColor(R.color.blueprint_line);
sheet.setColor(context.getColor(R.color.blueprint_sheet));
grid.setStyle(Paint.Style.STROKE);
line.setStyle(Paint.Style.STROKE);
line.setColor(ink);
line.setStrokeJoin(Paint.Join.ROUND);
line.setStrokeCap(Paint.Cap.ROUND);
faint.setStyle(Paint.Style.STROKE);
faint.setColor(AccentColor.withAlpha(ink, 0x99));
dashed.setStyle(Paint.Style.STROKE);
dashed.setColor(AccentColor.withAlpha(ink, 0xB0));
dashed.setPathEffect(new DashPathEffect(new float[] { 8f * d, 6f * d }, 0f));
text.setColor(ink);
text.setTypeface(Typeface.create(Typeface.MONOSPACE, Typeface.NORMAL));
text.setFontFeatureSettings("tnum");
}
void setValues(ClusterValues values) {
v = values == null ? new ClusterValues() : values;
invalidate();
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
float w = getWidth(), h = getHeight();
if (w <= 0 || h <= 0) return;
float d = getResources().getDisplayMetrics().density;
// The sheet and its grid: fine every 20 dp, bolder every 100 dp; a border inset.
rect.set(0, 0, w, h);
c.drawRect(rect, sheet);
for (float step : new float[] { 20f * d, 100f * d }) {
grid.setColor(AccentColor.withAlpha(ink, step > 50f * d ? 0x22 : 0x10));
grid.setStrokeWidth(Math.max(1f, d * 0.75f));
for (float x = 0; x <= w; x += step) c.drawLine(x, 0, x, h, grid);
for (float y = 0; y <= h; y += step) c.drawLine(0, y, w, y, grid);
}
line.setStrokeWidth(2f * d);
c.drawRect(8f * d, 8f * d, w - 8f * d, h - 8f * d, line);
// The car, front to the right, its size from the space left of the title block.
// Centred, with its power arrow, in the space left of the title block.
float carH = Math.min(h * 0.40f, w * 0.44f / ASPECT), carW = carH * ASPECT;
float l = w * 0.04f + (w * 0.60f - carW - w * 0.12f) / 2f, t = (h - carH) / 2f, r = l + carW, b = t + carH;
drawCar(c, l, t, r, b, d);
drawTyres(c, l, t, r, b, d);
drawPower(c, r, (t + b) / 2f, w * 0.10f, d);
drawDimensions(c, l, t, r, b, d);
drawTitleBlock(c, w * 0.66f, h * 0.10f, w - 22f * d, h - 22f * d, d);
}
private void drawCar(Canvas c, float l, float t, float r, float b, float d) {
float len = r - l, wid = b - t;
// Body: rounded, fuller at the front.
path.reset();
path.moveTo(l + len * 0.10f, t);
path.lineTo(r - len * 0.14f, t);
path.cubicTo(r - len * 0.02f, t, r, t + wid * 0.12f, r, t + wid * 0.30f);
path.lineTo(r, b - wid * 0.30f);
path.cubicTo(r, b - wid * 0.12f, r - len * 0.02f, b, r - len * 0.14f, b);
path.lineTo(l + len * 0.10f, b);
path.cubicTo(l + len * 0.02f, b, l, b - wid * 0.14f, l, b - wid * 0.32f);
path.lineTo(l, t + wid * 0.32f);
path.cubicTo(l, t + wid * 0.14f, l + len * 0.02f, t, l + len * 0.10f, t);
line.setStrokeWidth(2.5f * d);
c.drawPath(path, line);
// Glass: windscreen, roof and rear window, dashed.
rect.set(l + len * 0.24f, t + wid * 0.12f, l + len * 0.74f, b - wid * 0.12f);
c.drawRoundRect(rect, wid * 0.12f, wid * 0.12f, dashed);
c.drawLine(l + len * 0.62f, t + wid * 0.13f, l + len * 0.62f, b - wid * 0.13f, dashed);
c.drawLine(l + len * 0.33f, t + wid * 0.13f, l + len * 0.33f, b - wid * 0.13f, dashed);
// Mirrors.
float mx = l + len * 0.66f;
line.setStrokeWidth(2f * d);
c.drawLine(mx, t, mx - len * 0.03f, t - wid * 0.09f, line);
c.drawLine(mx - len * 0.03f, t - wid * 0.09f, mx - len * 0.07f, t - wid * 0.02f, line);
c.drawLine(mx, b, mx - len * 0.03f, b + wid * 0.09f, line);
c.drawLine(mx - len * 0.03f, b + wid * 0.09f, mx - len * 0.07f, b + wid * 0.02f, line);
// The battery between the axles, hatched as far as it is charged (from the rear).
float pl = l + len * 0.25f, pr = l + len * 0.73f, pt = t + wid * 0.24f, pb = b - wid * 0.24f;
faint.setStrokeWidth(1.5f * d);
c.drawRect(pl, pt, pr, pb, faint);
if (v.socPercent != null) {
float fx = pl + (pr - pl) * Math.max(0f, Math.min(100f, v.socPercent)) / 100f;
c.save();
c.clipRect(pl, pt, fx, pb);
float step = 9f * d;
for (float x = pl - (pb - pt); x < fx; x += step) c.drawLine(x, pb, x + (pb - pt), pt, faint);
c.restore();
line.setStrokeWidth(2f * d);
c.drawLine(fx, pt - 6f * d, fx, pb + 6f * d, line);
}
text.setTextAlign(Paint.Align.CENTER);
text.setTextSize(14f * d);
String soc = v.socPercent == null ? "—" : String.format(Locale.US, "%.0f %%", v.socPercent);
float labelY = (pt + pb) / 2f + 5f * d;
rect.set((pl + pr) / 2f - 70f * d, labelY - 18f * d, (pl + pr) / 2f + 70f * d, labelY + 8f * d);
c.drawRect(rect, sheet);
c.drawText("BATTERY " + soc, (pl + pr) / 2f, labelY, text);
// The cabin's climate setpoint, as a note.
String climate = v.climateCelsius == null ? "—" : String.format(Locale.US, "%.0f °C", v.climateCelsius);
text.setTextSize(12f * d);
c.drawText("CABIN SET " + climate, l + len * 0.49f, t + wid * 0.19f, text);
}
private void drawTyres(Canvas c, float l, float t, float r, float b, float d) {
float len = r - l, wid = b - t, tw = len * 0.13f, th = wid * 0.13f;
float rear = l + len * 0.20f, front = l + len * 0.80f;
// Driving to the right, the car's left side is at the top.
Object[][] tyres = {
{ front, t, v.tyreFl, true, "FL" }, { front, b, v.tyreFr, false, "FR" },
{ rear, t, v.tyreRl, true, "RL" }, { rear, b, v.tyreRr, false, "RR" },
};
line.setStrokeWidth(2f * d);
text.setTextSize(15f * d);
for (Object[] tyre : tyres) {
float x = (Float) tyre[0], y = (Float) tyre[1];
boolean top = (Boolean) tyre[3];
rect.set(x - tw / 2f, y - th / 2f, x + tw / 2f, y + th / 2f);
c.drawRect(rect, sheet);
c.drawRoundRect(rect, 4f * d, 4f * d, line);
// A leader line out to the pressure.
float ly = top ? y - th / 2f - wid * 0.24f : y + th / 2f + wid * 0.24f;
float lx = x + len * 0.06f;
faint.setStrokeWidth(1.5f * d);
c.drawLine(x, top ? y - th / 2f : y + th / 2f, lx, ly, faint);
c.drawLine(lx, ly, lx + 110f * d, ly, faint);
c.drawCircle(x, top ? y - th / 2f : y + th / 2f, 3f * d, line);
text.setTextAlign(Paint.Align.LEFT);
c.drawText(tyre[4] + " " + tyre[2], lx + 4f * d, ly - 6f * d, text);
}
}
/** Power as a force arrow at the front; length scaled to 150 kW; regeneration points back. */
private void drawPower(Canvas c, float x, float y, float maxLen, float d) {
text.setTextAlign(Paint.Align.LEFT);
text.setTextSize(15f * d);
if (v.powerKw == null || Math.abs(v.powerKw) < 0.1f) {
c.drawText(v.powerKw == null ? "P = —" : "P = 0 kW", x + 16f * d, y + 5f * d, text);
return;
}
float kw = v.powerKw;
float len = Math.max(24f * d, maxLen * Math.min(150f, Math.abs(kw)) / 150f);
float x0 = x + 10f * d, x1 = x0 + len;
Paint p = kw >= 0f ? line : dashed;
p.setStrokeWidth(2.5f * d);
c.drawLine(x0, y, x1, y, p);
// The head: at the far end drawing, at the car regenerating.
float hx = kw >= 0f ? x1 : x0, dir = kw >= 0f ? 1f : -1f, hs = 10f * d;
line.setStrokeWidth(2.5f * d);
c.drawLine(hx, y, hx - dir * hs, y - hs * 0.6f, line);
c.drawLine(hx, y, hx - dir * hs, y + hs * 0.6f, line);
c.drawText(String.format(Locale.US, "P = %.1f kW", kw), x0, y - 10f * d, text);
if (kw < 0f) c.drawText("(REGEN)", x0, y + 22f * d, text);
}
/** Dimension lines: the range under the car, the trip above it. */
private void drawDimensions(Canvas c, float l, float t, float r, float b, float d) {
float wid = b - t;
String range = v.rangeKm == null ? "RANGE —" : String.format(Locale.US, "RANGE %.0f km", v.rangeKm);
dimension(c, l, r, b + wid * 0.50f, range, d);
String trip = v.tripKm == null ? "TRIP —" : String.format(Locale.US, "TRIP %.1f km", v.tripKm);
if (v.usageTrip != null) trip += String.format(Locale.US, " @ %.1f kWh/100 km", v.usageTrip);
dimension(c, l, r, t - wid * 0.50f, trip, d);
}
private void dimension(Canvas c, float x0, float x1, float y, String label, float d) {
faint.setStrokeWidth(1.5f * d);
c.drawLine(x0, y - 10f * d, x0, y + 10f * d, faint);
c.drawLine(x1, y - 10f * d, x1, y + 10f * d, faint);
c.drawLine(x0, y, x1, y, faint);
float hs = 9f * d;
c.drawLine(x0, y, x0 + hs, y - hs * 0.45f, faint);
c.drawLine(x0, y, x0 + hs, y + hs * 0.45f, faint);
c.drawLine(x1, y, x1 - hs, y - hs * 0.45f, faint);
c.drawLine(x1, y, x1 - hs, y + hs * 0.45f, faint);
text.setTextAlign(Paint.Align.CENTER);
text.setTextSize(15f * d);
float tw = text.measureText(label);
float mid = (x0 + x1) / 2f;
rect.set(mid - tw / 2f - 8f * d, y - 12f * d, mid + tw / 2f + 8f * d, y + 12f * d);
c.drawRect(rect, sheet);
c.drawText(label, mid, y + 5f * d, text);
}
/** The drawing's title block: rows of fields, the speed large. */
private void drawTitleBlock(Canvas c, float l, float t, float r, float b, float d) {
line.setStrokeWidth(2f * d);
rect.set(l, t, r, b);
c.drawRect(rect, sheet);
c.drawRect(rect, line);
// Eight rows: the speed takes two, the last holds scale and time.
float rowH = (b - t) / 8f;
String speed = v.speedKmh == null ? "—" : String.format(Locale.US, "%.0f", v.speedKmh);
String[][] rows = {
{ "DRAWING", "NILS' MG4 · TOP VIEW" },
{ "SPEED", null },
{ "POWER", v.powerKw == null ? "—" : String.format(Locale.US, "%.1f kW", v.powerKw) },
{ "GEAR", Gear.label(v.gear).isEmpty() ? "—" : Gear.label(v.gear) },
{ "USAGE 5 MIN", v.usage5Min == null ? "—" : String.format(Locale.US, "%.1f kWh/100 km", v.usage5Min) },
{ "ODOMETER", v.odometerKm == null ? "—" : String.format(Locale.US, "%,.0f km", v.odometerKm) },
};
float split = l + (r - l) * 0.34f;
float y = t;
for (int i = 0; i < rows.length; i++) {
float rh = i == 1 ? rowH * 2f : rowH;
if (i > 0) c.drawLine(l, y, r, y, faint);
text.setTextAlign(Paint.Align.LEFT);
text.setTextSize(11f * d);
c.drawText(rows[i][0], l + 10f * d, y + 18f * d, text);
if (i == 1) {
text.setTextSize(rh * 0.62f);
text.setTextAlign(Paint.Align.RIGHT);
c.drawText(speed, r - (r - l) * 0.22f, y + rh * 0.80f, text);
text.setTextSize(14f * d);
text.setTextAlign(Paint.Align.LEFT);
c.drawText("km/h", r - (r - l) * 0.20f, y + rh * 0.80f, text);
} else {
text.setTextSize(Math.min(rh * 0.42f, 18f * d));
c.drawText(rows[i][1], i == 0 ? l + 10f * d : split + 12f * d, y + rh * 0.72f, text);
}
y += rh;
}
// The last row: scale and time, split in two.
c.drawLine(l, y, r, y, faint);
c.drawLine(split, y, split, b, faint);
text.setTextSize(11f * d);
text.setTextAlign(Paint.Align.LEFT);
c.drawText("SCALE 1:20", l + 10f * d, y + rowH * 0.62f, text);
c.drawText("TIME", split + 10f * d, y + 18f * d, text);
text.setTextSize(Math.min(rowH * 0.5f, 22f * d));
text.setTextAlign(Paint.Align.RIGHT);
c.drawText(v.clock.isEmpty() ? "—" : v.clock, r - 12f * d, y + rowH * 0.72f, text);
c.drawLine(split, t + rowH * 3f, split, t + rowH * 7f, faint);
}
}