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); } }