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
284 lines
14 KiB
Java
284 lines
14 KiB
Java
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);
|
|
}
|
|
}
|