feat(eink): use the room of tall screens, add e-ink graphics

Tall screens stack the Sky view and the map over their rows, show more
recent chats, give favourite cards the heard age and distance, and put
the trail map under the trail summary. Wide screens put the page icons
on the header line.

E-ink only (Features::IS_EINK):
- a month calendar under the clock and on the portrait lock screen, with
  the alarm's days marked; the lock screen also lists unread messages,
  the alarm and the clock fields
- 8-hour charts in Status: battery, noise and SNR, mesh traffic
- an elevation profile under the trail map, with the climb and the top
- a compass rose and the path's hops on a contact's detail, arrows by
  the distance in Nodes rows and on favourite cards
- an ordered-dither fade under chart lines

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-02 19:24:42 +02:00
co-authored by Claude Opus 5.5
parent 5c0b79b107
commit 6e6a0fb3ff
8 changed files with 591 additions and 58 deletions
@@ -0,0 +1,60 @@
#pragma once
// The current month as a grid of days, weeks from Monday, today in a filled
// soft box, marked days (the alarm's) underlined. For the room a tall e-ink
// screen has under its clock (the Clock page, the lock screen).
#include <time.h>
#include <helpers/ui/DisplayDriver.h>
#include "InfoKit.h"
namespace calendar {
inline int daysIn(int year, int mon) { // mon 0..11, year as in tm (since 1900)
static const uint8_t D[] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
const int y = year + 1900;
const bool leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
return D[mon] + (mon == 1 && leap ? 1 : 0);
}
// The column (0 = Monday) the 1st falls in.
inline int firstCol(const struct tm& t) {
const int wday1 = ((t.tm_wday - (t.tm_mday - 1)) % 7 + 7) % 7; // 0 = Sunday
return (wday1 + 6) % 7;
}
inline int weeks(const struct tm& t) { return (firstCol(t) + daysIn(t.tm_year, t.tm_mon) + 6) / 7; }
// Its height: the day names, a rule, then a row a week.
inline int height(DisplayDriver& d, const struct tm& t) {
return (1 + weeks(t)) * (d.getLineHeight() + 2) + 3;
}
// `marked`: bit (day - 1) set for each day to underline.
inline void draw(DisplayDriver& d, int x, int y, int w, const struct tm& t, uint32_t marked = 0) {
static const char* const NAMES[] = { "Mo", "Tu", "We", "Th", "Fr", "Sa", "Su" };
const int lh = d.getLineHeight(), step = lh + 2, cw = w / 7;
const int x0 = x + (w - 7 * cw) / 2;
d.setColor(DisplayDriver::LIGHT);
for (int c = 0; c < 7; c++) d.drawTextCentered(x0 + c * cw + cw / 2, y, NAMES[c]);
info::rule(d, x0, y + lh + 1, 7 * cw);
const int top = y + step + 3, col0 = firstCol(t), n = daysIn(t.tm_year, t.tm_mon);
char buf[3];
for (int day = 1; day <= n; day++) {
const int k = col0 + day - 1, cx = x0 + (k % 7) * cw, cy = top + (k / 7) * step;
snprintf(buf, sizeof(buf), "%d", day);
if (day == t.tm_mday) {
d.fillSoftRect(cx + 1, cy - 1, cw - 2, lh + 2);
d.setColor(DisplayDriver::DARK);
d.drawTextCentered(cx + cw / 2, cy, buf);
d.setColor(DisplayDriver::LIGHT);
} else {
d.drawTextCentered(cx + cw / 2, cy, buf);
}
if (marked & (1UL << (day - 1))) { // under the number, in the gap below it
const int uw = d.getTextWidth(buf);
d.setColor(day == t.tm_mday ? DisplayDriver::DARK : DisplayDriver::LIGHT);
d.fillRect(cx + (cw - uw) / 2, cy + lh, uw, 1);
d.setColor(DisplayDriver::LIGHT);
}
}
}
} // namespace calendar
+136
View File
@@ -7,6 +7,7 @@
#include <helpers/ui/DisplayDriver.h>
#include <string.h>
#include <math.h>
#include "icons.h"
namespace info {
@@ -80,6 +81,77 @@ inline void meter(DisplayDriver& d, int x, int y, int w, int h, float frac) {
if (fw > 0) d.fillRect(x + in, y + in, fw, h - 2 * in);
}
// Midpoint circle; `step` > 1 dots it (every step-th point of each octant).
inline void circle(DisplayDriver& d, int cx, int cy, int r, int step = 1) {
int x = r, y = 0, err = 1 - r, i = 0;
while (x >= y) {
if (i++ % step == 0) {
d.fillRect(cx + x, cy + y, 1, 1); d.fillRect(cx - x, cy + y, 1, 1);
d.fillRect(cx + x, cy - y, 1, 1); d.fillRect(cx - x, cy - y, 1, 1);
d.fillRect(cx + y, cy + x, 1, 1); d.fillRect(cx - y, cy + x, 1, 1);
d.fillRect(cx + y, cy - x, 1, 1); d.fillRect(cx - y, cy - x, 1, 1);
}
y++;
if (err < 0) err += 2 * y + 1;
else { x--; err += 2 * (y - x) + 1; }
}
}
// A 1 px line (Bresenham).
inline void line(DisplayDriver& d, int x0, int y0, int x1, int y1) {
const int dx = x1 > x0 ? x1 - x0 : x0 - x1, dy = y1 > y0 ? y0 - y1 : y1 - y0;
const int sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;
int err = dx + dy;
for (;;) {
d.fillRect(x0, y0, 1, 1);
if (x0 == x1 && y0 == y1) break;
const int e2 = 2 * err;
if (e2 >= dy) { err += dy; x0 += sx; }
if (e2 <= dx) { err += dx; y0 += sy; }
}
}
// An arrow of length 2r centred on (cx, cy), pointing to `deg` (0 = up /
// north, clockwise): a shaft and a two-stroke head at the tip.
inline void arrow(DisplayDriver& d, int cx, int cy, int r, int deg) {
const float a = deg * (float)M_PI / 180.0f, sx = sinf(a), sy = -cosf(a);
const int tx = cx + (int)lroundf(r * sx), ty = cy + (int)lroundf(r * sy);
line(d, cx - (int)lroundf(r * sx), cy - (int)lroundf(r * sy), tx, ty);
const float h = r * 0.6f;
for (int k = -1; k <= 1; k += 2) { // the head's strokes, 30 degrees off the shaft
const float b = a + (float)M_PI + k * 0.52f;
line(d, tx, ty, tx + (int)lroundf(h * sinf(b)), ty - (int)lroundf(h * cosf(b)));
}
}
// A compass rose of radius r round (cx, cy), north up: the ring, a tick in at
// each cardinal point, N in a gap at the top, and -- for deg >= 0 -- a needle
// from the centre to the ring, towards deg.
inline void rose(DisplayDriver& d, int cx, int cy, int r, int deg) {
const int s = miniIconScale(d), t = 3 * s, lh = d.getLineHeight(), cw = d.getCharWidth();
circle(d, cx, cy, r);
circle(d, cx, cy, r / 2, 3);
d.fillRect(cx - s / 2, cy + r - t, s, t); // S
d.fillRect(cx + r - t, cy - s / 2, t, s); // E
d.fillRect(cx - r, cy - s / 2, t, s); // W
d.setColor(DisplayDriver::DARK); // N, on a gap in the ring
d.fillRect(cx - cw / 2 - 1, cy - r - lh / 2, cw + 2, lh);
d.setColor(DisplayDriver::LIGHT);
d.setCursor(cx - cw / 2, cy - r - lh / 2);
d.print("N");
if (deg >= 0) {
const float a = deg * (float)M_PI / 180.0f;
const int tx = cx + (int)lroundf((r - t - s) * sinf(a)), ty = cy - (int)lroundf((r - t - s) * cosf(a));
for (int o = -(s / 2); o <= s / 2; o++) line(d, cx + o, cy, tx + o, ty);
const float h = r * 0.3f;
for (int k = -1; k <= 1; k += 2) {
const float b = a + (float)M_PI + k * 0.45f;
line(d, tx, ty, tx + (int)lroundf(h * sinf(b)), ty - (int)lroundf(h * cosf(b)));
}
}
d.fillRect(cx - s, cy - s, 2 * s + 1, 2 * s + 1); // the centre: you
}
// A chip: a state as a filled pill (FIX, REC), a parameter as an outline
// (SF8). One line plus 2 px tall; returns the x after it, gap included.
inline int chipW(DisplayDriver& d, const char* t) { return d.getTextWidth(t) + 5; }
@@ -200,6 +272,70 @@ inline void spark(DisplayDriver& d, int x, int y, int w, int h, const History<N>
}
}
// Ordered (Bayer 4x4) dithering: whether pixel (x, y) is ink at a density of
// level/16. For graded fills on large elements only -- it breaks up small
// ones.
inline bool bayer(int x, int y, int level) {
static const uint8_t M[4][4] = { { 0, 8, 2, 10 }, { 12, 4, 14, 6 }, { 3, 11, 1, 9 }, { 15, 7, 13, 5 } };
return M[y & 3][x & 3] < level;
}
// A column from y0 down to (not including) y1, dithered from dense at the top
// to sparse at the bottom: the glow under a chart's line.
inline void fadeColumn(DisplayDriver& d, int x, int y0, int y1) {
const int h = y1 - y0;
for (int y = y0; y < y1; y++) {
const int level = 9 - 8 * (y - y0) / (h > 1 ? h : 1); // 9/16 under the line -> 1/16 at the floor
if (bayer(x, y, level)) d.fillRect(x, y, 1, 1);
}
}
// A history as a chart, for a screen with the room (e-ink): a soft frame with
// a dotted middle line, the samples as a step line over a dithered glow --
// or, with `bars`, one bar each from the bottom, for counts -- the newest at
// the right edge, then a caption line under the frame: `left` at its left
// (how far back it goes), `right` at its right. chartH() is the whole
// block's height.
inline int chartH(DisplayDriver& d, int frame_h) { return frame_h + d.getLineHeight() + 3; }
template <int N>
inline void chart(DisplayDriver& d, int x, int y, int w, int h, const History<N>& hs, int min_span,
bool bars, const char* left, const char* right) {
d.drawSoftRect(x, y, w, h);
const int s = miniIconScale(d);
for (int i = x + 3; i < x + w - 3; i += 3 * s) d.fillRect(i, y + h / 2, s, s);
const int ix = x + 2, iy = y + 2, iw = w - 4, ih = h - 4;
if (hs.n < (bars ? 1 : 2)) { // nothing to draw yet: say so over the line
const int tw = d.getTextWidth("no data"), tx = x + (w - tw) / 2, ty = y + (h - d.getLineHeight()) / 2;
d.setColor(DisplayDriver::DARK);
d.fillRect(tx - 2, ty, tw + 4, d.getLineHeight());
d.setColor(DisplayDriver::LIGHT);
d.setCursor(tx, ty);
d.print("no data");
} else {
int lo = bars ? 0 : hs.at(0), hi = lo;
for (int i = 0; i < hs.n; i++) { int v = hs.at(i); if (v < lo) lo = v; if (v > hi) hi = v; }
if (hi - lo < min_span) { if (bars) hi = lo + min_span; else { int mid = (hi + lo) / 2; lo = mid - min_span / 2; hi = lo + min_span; } }
const int bw = iw / N > 1 ? iw / N : 1;
int px = -1, py = 0;
for (int i = 0; i < hs.n; i++) {
const int cx = ix + iw - 1 - (hs.n - 1 - i) * (iw - 1) / (N - 1);
const int cy = iy + ih - 1 - (hs.at(i) - lo) * (ih - 1) / (hi - lo);
if (bars) {
if (hs.at(i) > lo) d.fillRect(cx - bw + 1, cy, bw, iy + ih - cy);
} else {
if (px >= 0) {
d.fillRect(px, py, cx - px + 1, 1);
d.fillRect(cx, py < cy ? py : cy, 1, (py < cy ? cy - py : py - cy) + 1);
for (int c = px + 1; c <= cx; c++) fadeColumn(d, c, (c < cx ? py : cy) + 2, iy + ih);
}
px = cx; py = cy;
}
}
}
const int ty = y + h + 2;
if (left && *left) { d.setCursor(x, ty); d.print(left); }
if (right && *right) d.drawTextRightAlign(x + w, ty, right);
}
// Blocks stacked down from `top`, with the first `skip` left out: the way a
// tab scrolls by whole blocks. Call place(h) before drawing each block; it
// says whether the block is on screen and where (at). The totals then give
+51 -1
View File
@@ -1,4 +1,6 @@
#pragma once
#include "InfoKit.h"
#include "../Features.h"
#include "../GeoUtils.h"
#include "NavView.h"
#include "TabBar.h"
@@ -461,6 +463,46 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
if (e.is_live) snprintf(buf, sizeof(buf), "Sharing pos: %s %s", age, e.live_verified ? "(DM)" : "(chan)");
else snprintf(buf, sizeof(buf), "Seen: %s", age);
display.drawTextEllipsized(2, hdr + step * 4, display.width() - 4, buf);
const int W = display.width(), H = display.height(), lh = display.getLineHeight();
int rows = 5;
// How a message gets there, where there's a line for it (e-ink): a chain
// from you to it, a ring per repeater on the way.
ContactInfo ci;
if (hdr + step * 5 + lh <= H && e.contact_idx >= 0 && the_mesh.getContactByIdx(e.contact_idx, ci)) {
const int y = hdr + step * 5;
display.setCursor(2, y);
display.print("Path:");
int x = 2 + display.getTextWidth("Path: ");
if (ci.out_path_len == 0xFF) {
display.print(" flood");
} else {
const int hops = ci.out_path_len & 63, shown = hops > 5 ? 5 : hops;
const int r = lh / 4 > 2 ? lh / 4 : 2, cy = y + lh / 2 - 1, gap = r + 3;
for (int k = 0; k <= shown + 1; k++) { // you and it filled, the hops as rings
if (k) for (int dx = x - gap + 1; dx < x; dx += 2) display.fillRect(dx, cy, 1, 1);
info::circle(display, x + r, cy, r);
if (k == 0 || k == shown + 1) display.fillRect(x + 1, cy - r + 1, 2 * r - 1, 2 * r - 1);
x += 2 * r + 1 + gap;
}
if (hops == 0) snprintf(buf, sizeof(buf), "direct");
else snprintf(buf, sizeof(buf), "%d hop%s", hops, hops == 1 ? "" : "s");
display.setCursor(x, y);
display.print(buf);
}
rows = 6;
}
// Where it is, on a compass rose (north up), where the screen has the room
// (e-ink): beside the rows on a wide one, under them on a tall one.
if (e.dist_km < 0.0f) return;
const int below = hdr + step * rows + lh;
int r, cx, cy;
if (H - below > W / 2) { r = (W < H - below ? W : H - below) / 2 - lh; cx = W / 2; cy = below + lh / 2 + r; }
else { r = (H - hdr) / 2 - lh / 2 - 1; cx = W - r - 3; cy = hdr + lh / 2 + r; }
const int text_r = 2 + display.getTextWidth("Lon: -000.00000");
if (r < 2 * lh || (cy - r < below - lh && cx - r < text_r)) return;
info::rose(display, cx, cy, r, geo::bearingDeg(_own_lat, _own_lon, e.lat_e6, e.lon_e6));
}
void renderScanDetail(DisplayDriver& display) {
@@ -608,7 +650,9 @@ public:
}
int item_h = display.lineStep();
int dist_col = display.width() - display.getCharWidth() * 7;
// E-ink sets an arrow towards the node before its distance: one more cell.
const bool arrows = Features::IS_EINK && _source == SRC_STORED && _sort != SORT_TIME;
int dist_col = display.width() - display.getCharWidth() * (arrows ? 9 : 7);
display.setColor(DisplayDriver::LIGHT);
const char* flt = (_filter != F_ALL) ? filterLabel(_filter) : nullptr;
@@ -691,6 +735,12 @@ public:
else strcpy(right, "-"); // no fix of ours or theirs
}
if (right[0]) display.drawTextRightAlign(display.width() - reserve - 2, y, right);
if (arrows && e.dist_km >= 0.0f) {
const int deg = geo::bearingDeg(_own_lat, _own_lon, e.lat_e6, e.lon_e6);
const MiniIcon& ic = *ICON_ARROWS[((deg + 22) % 360) / 45];
miniIconDraw(display, display.width() - reserve - 2 - display.getTextWidth(right) - 3
- ic.w * miniIconScale(display), y, ic);
}
});
}
+12 -21
View File
@@ -22,6 +22,7 @@
#include <helpers/sensors/MicroNMEALocationProvider.h>
#endif
#include "icons.h" // miniIconScale()
#include "InfoKit.h" // info::circle()
namespace skyview {
@@ -38,20 +39,6 @@ namespace skyview {
inline void dot(DisplayDriver& d, int x, int y) { d.fillRect(x, y, 1, 1); }
// Midpoint circle; `step` > 1 dots it (every step-th point of each octant).
inline void circle(DisplayDriver& d, int cx, int cy, int r, int step = 1) {
int x = r, y = 0, err = 1 - r, i = 0;
while (x >= y) {
if (i++ % step == 0) {
dot(d, cx + x, cy + y); dot(d, cx - x, cy + y); dot(d, cx + x, cy - y); dot(d, cx - x, cy - y);
dot(d, cx + y, cy + x); dot(d, cx - y, cy + x); dot(d, cx + y, cy - x); dot(d, cx - y, cy - x);
}
y++;
if (err < 0) err += 2 * y + 1;
else { x--; err += 2 * (y - x) + 1; }
}
}
inline int usedCount(const GpsSky& g) {
int n = 0;
for (int i = 0; i < g.count; i++) if (g.used(g.sats[i])) n++;
@@ -62,11 +49,15 @@ namespace skyview {
const int lh = d.getLineHeight();
const int cw = d.getCharWidth();
const int s = miniIconScale(d);
const int r = (d.height() - 1 - top) / 2 - 1;
const int cx = r + 1, cy = top + r + 1;
// A wide screen sets the numbers beside the plot, a tall one (portrait
// e-ink) under it, the plot then as wide as the screen.
const bool stacked = d.height() - top > d.width();
const int r = stacked ? (d.width() - 1) / 2 - 1 : (d.height() - 1 - top) / 2 - 1;
const int cx = stacked ? d.width() / 2 : r + 1;
const int cy = top + (stacked ? lh / 2 : 0) + r + 1;
circle(d, cx, cy, r); // the horizon
circle(d, cx, cy, r / 2, 2); // 45 degrees up, dotted
info::circle(d, cx, cy, r); // the horizon
info::circle(d, cx, cy, r / 2, 2); // 45 degrees up, dotted
dot(d, cx, cy); // straight up
d.setColor(DisplayDriver::DARK); // north, on a gap in the rim
d.fillRect(cx - cw / 2 - 1, cy - r - lh / 2, cw + 2, lh);
@@ -95,9 +86,9 @@ namespace skyview {
}
}
// The numbers, right of the plot.
const int x0 = 2 * r + 6, step = lh + 1;
int y = top;
// The numbers, right of the plot (or under it).
const int x0 = stacked ? 1 : 2 * r + 6, step = lh + 1;
int y = stacked ? cy + r + lh / 2 + 2 : top;
char buf[24];
d.setCursor(x0, y);
d.print(g.fix_mode == 3 ? "3D fix" : g.hasFix() ? "2D fix" : "No fix");
+44 -10
View File
@@ -48,11 +48,28 @@ private:
CayenneLPP _lpp;
unsigned long _lpp_at = 0;
// History lines, one sample a minute (32 min).
static const unsigned long SAMPLE_MS = 60000;
// History lines, one sample a minute (32 min). E-ink draws them as charts
// (chartBlock()) over a longer span: a sample every 5 minutes, 8 hours.
static const int HIST_N = Features::IS_EINK ? 96 : 32;
static const unsigned long SAMPLE_MS = Features::IS_EINK ? 300000 : 60000;
static constexpr const char* HIST_SPAN = Features::IS_EINK ? "8 h ago" : "32 min";
unsigned long _next_sample = 0;
uint32_t _last_rx = 0;
info::History<32> _noise, _batt, _traffic;
info::History<HIST_N> _noise, _batt, _traffic;
// The SNR of each packet heard, the last 32 (e-ink's Radio chart), x4 dB.
info::History<32> _snr;
uint32_t _snr_rx = 0;
// A chart as a block (e-ink): `title` with the value now on its line, the
// chart under it.
template <int N>
void chartBlock(DisplayDriver& d, info::Flow& f, int rsv, const char* title, const char* now,
const info::History<N>& hs, int min_span, bool bars, const char* span) {
const int lh = d.getLineHeight(), ch = 3 * lh;
if (!f.place(d.lineStep() + info::chartH(d, ch) + 2)) return;
info::valueRow(d, f.at, title, now, false, rsv, 1);
info::chart(d, 1, f.at + d.lineStep(), d.width() - rsv - 3, ch, hs, min_span, bars, span, "now");
}
void addTab(uint8_t t, const char* label) { _tabs[_count] = t; _labels[_count] = label; _count++; }
uint8_t tab() const { return _tabs[_cur]; }
@@ -104,9 +121,11 @@ private:
}
snprintf(a, sizeof(a), "%d dBm", (int)radio_driver.getTxPower());
row(d, f, rsv, "TX power", a);
if (f.place(d.lineStep())) { // label, the last half hour, the value now
const int nf = (int)radio_driver.getNoiseFloor();
if (nf) snprintf(a, sizeof(a), "%d dBm", nf); else strcpy(a, "-");
const int nf = (int)radio_driver.getNoiseFloor();
if (nf) snprintf(a, sizeof(a), "%d dBm", nf); else strcpy(a, "-");
if (Features::IS_EINK) {
chartBlock(d, f, rsv, "Noise", a, _noise, 6, false, HIST_SPAN);
} else if (f.place(d.lineStep())) { // label, the last half hour, the value now
info::valueRow(d, f.at, "Noise", a, false, rsv, 1);
const int x0 = 1 + d.getTextWidth("Noise") + 6;
const int x1 = d.width() - rsv - 2 - d.getTextWidth(a) - 6;
@@ -119,7 +138,8 @@ private:
drawSignalBars(d, d.width() - rsv - 2 - d.getTextWidth(b) - 5, f.at, (int)(snr * 4));
}
snprintf(b, sizeof(b), "%.1f dB", radio_driver.getLastSNR());
row(d, f, rsv, "Last SNR", b);
if (Features::IS_EINK) chartBlock(d, f, rsv, "SNR", b, _snr, 20, false, "32 pkts");
else row(d, f, rsv, "Last SNR", b);
}
#if ENV_INCLUDE_GPS == 1
@@ -188,7 +208,9 @@ private:
snprintf(a, sizeof(a), "%d.%02d V", mv / 1000, (mv % 1000) / 10);
row(d, f, rsv, "Battery", a);
row(d, f, rsv, "Source", board.isExternalPowered() ? "USB" : "Battery");
if (f.place(d.lineStep())) {
if (Features::IS_EINK) {
chartBlock(d, f, rsv, "Trend", nullptr, _batt, 20, false, HIST_SPAN);
} else if (f.place(d.lineStep())) {
d.setCursor(1, f.at);
d.print("Trend");
const int x0 = 1 + d.getTextWidth("Trend") + 6;
@@ -229,7 +251,13 @@ private:
snprintf(a, sizeof(a), "TX %lu", (unsigned long)totalTx());
d.drawTextRightAlign(d.width() - rsv - 2, f.at, a);
}
if (f.place(lh + 6)) info::spark(d, 1, f.at, d.width() - rsv - 3, lh + 3, _traffic, 4);
if (Features::IS_EINK) { // packets per 5 minutes, as bars
const int ch = 3 * lh;
if (f.place(info::chartH(d, ch) + 2))
info::chart(d, 1, f.at, d.width() - rsv - 3, ch, _traffic, 4, true, HIST_SPAN, "now");
} else if (f.place(lh + 6)) {
info::spark(d, 1, f.at, d.width() - rsv - 3, lh + 3, _traffic, 4);
}
static const uint8_t MSG[] = { PAYLOAD_TYPE_TXT_MSG, PAYLOAD_TYPE_GRP_TXT };
static const uint8_t ADVERT[] = { PAYLOAD_TYPE_ADVERT };
@@ -319,6 +347,7 @@ public:
// Called from the UI loop on every pass: one sample a minute for the lines.
void sample() {
if (Features::IS_EINK) samplePacket();
const unsigned long now = millis();
if (_next_sample && (long)(now - _next_sample) < 0) return;
_next_sample = now + SAMPLE_MS;
@@ -329,8 +358,13 @@ public:
if (_last_rx || rx) _traffic.push((int16_t)(rx - _last_rx > 30000 ? 30000 : rx - _last_rx));
_last_rx = rx;
}
// On every pass: the SNR of a packet just heard (e-ink's Radio chart).
void samplePacket() {
const uint32_t rx = totalRx();
if (rx != _snr_rx) { _snr.push((int16_t)(radio_driver.getLastSNR() * 4)); _snr_rx = rx; }
}
const info::History<32>& battHistory() const { return _batt; }
const info::History<HIST_N>& battHistory() const { return _batt; }
// Contacts heard from in the last hour (cached for 10 s: it walks them all).
static int heardLastHour() {
+66 -3
View File
@@ -547,6 +547,68 @@ private:
summaryBlocks(display, f, rsv);
_summary_max_scroll = f.more ? _summary_scroll + 1 : _summary_scroll;
if (rsv) drawScrollIndicatorPx(display, top, view, f.total_px, view, f.skipped_px);
// A tall screen (portrait e-ink) has room left under the numbers: the map
// goes there, below a dotted rule, and the height profile under it when
// the points carry altitude.
else if (bottom - f.y >= 6 * display.lineStep()) {
info::rule(display, 1, f.y + 2, display.width() - 2);
const int ph = hasAltitude() ? 4 * display.getLineHeight() : 0;
renderMap(display, f.y + 4, bottom - 2 - ph);
if (ph) renderProfile(display, 1, bottom - ph, display.width() - 2, ph);
}
}
bool hasAltitude() const {
for (int i = 0; i < _store->count(); i++) if (_store->at(i).alt_m != TRAIL_ALT_NONE) return true;
return false;
}
// The height over the distance walked, in x, y, w, h: a framed area, a
// solid line over a dithered glow, then the climb so far and the highest
// point under it.
void renderProfile(DisplayDriver& d, int x, int y, int w, int h) {
const int lh = d.getLineHeight(), fh = h - lh - 2; // the frame; the caption under it
const int n = _store->count();
int lo = 32767, hi = -32768, gain = 0, last = TRAIL_ALT_NONE;
for (int i = 0; i < n; i++) {
const int a = _store->at(i).alt_m;
if (a == TRAIL_ALT_NONE) continue;
if (a < lo) lo = a;
if (a > hi) hi = a;
if (last == TRAIL_ALT_NONE || a < last) last = a; // the climb, past 3 m of jitter
else if (a - last >= 3) { gain += a - last; last = a; }
}
if (hi - lo < 20) { const int mid = (hi + lo) / 2; lo = mid - 10; hi = mid + 10; }
const float total = _store->totalDistanceMeters() > 0 ? (float)_store->totalDistanceMeters() : 1.0f;
d.drawSoftRect(x, y, w, fh);
const int ix = x + 2, iy = y + 2, iw = w - 4, ih = fh - 4;
float cum = 0;
int px = -1, py = 0;
for (int i = 0; i < n; i++) {
const TrailPoint& p = _store->at(i);
if (i > 0 && !(p.flags & TRAIL_FLAG_SEG_START))
cum += TrailStore::haversineMeters(_store->at(i - 1).lat_1e6, _store->at(i - 1).lon_1e6, p.lat_1e6, p.lon_1e6);
if (p.alt_m == TRAIL_ALT_NONE) continue;
int cx = ix + (int)(cum / total * (iw - 1));
if (cx > ix + iw - 1) cx = ix + iw - 1;
const int cy = iy + ih - 1 - (p.alt_m - lo) * (ih - 1) / (hi - lo);
if (px >= 0) {
for (int c = px + 1; c <= cx; c++) { // each column: the line, the fill under it
const int ly = py + (cy - py) * (c - px) / (cx - px);
d.fillRect(c, ly, 1, 1);
info::fadeColumn(d, c, ly + 2, iy + ih);
}
}
px = cx; py = cy;
}
char a[16], b[16];
snprintf(a, sizeof(a), "+%d m", gain);
snprintf(b, sizeof(b), "max %d m", hi);
const int cy = y + fh + 2;
miniIconDraw(d, x, cy, ICON_ARROW_NE);
d.setCursor(x + ICON_ARROW_NE.w * miniIconScale(d) + 2, cy);
d.print(a);
d.drawTextRightAlign(x + w, cy, b);
}
void renderList(DisplayDriver& display) {
@@ -707,9 +769,10 @@ private:
}
}
void renderMap(DisplayDriver& display) {
const int top = display.listStart();
const int bottom = display.height() - 2;
// The map between top and bottom (default: the whole view under the header).
void renderMap(DisplayDriver& display, int top = -1, int bottom = -1) {
if (top < 0) top = display.listStart();
if (bottom < 0) bottom = display.height() - 2;
WaypointStore& wp = _task->waypoints();
const int nwp = wp.count();
+153 -23
View File
@@ -182,6 +182,7 @@ static const telemetry::Style L1_INFO = { "\xc2\xb0", true, false, 5 };
#endif
#include "ToolsScreen.h"
#include "ClockToolsScreen.h" // Alarm / Timer / Stopwatch (Clock page › Enter)
#include "CalendarView.h" // the month under a tall clock
#include "../ui-core/Battery.h"
@@ -334,10 +335,25 @@ class HomeScreen : public UIScreen {
uint8_t _slide_from = 0;
unsigned long _slide_t0 = 0;
// A wide screen (landscape e-ink) sets the page icons on the header line,
// between the time and the status icons, instead of on a row of their own.
// Measured for every page at full spacing, so it doesn't flip as pages are
// shown or hidden.
static int stripHalfW(DisplayDriver& d) {
const int s = miniIconScale(d);
return (10 * s * ((int)Count - 1) + 5 * s + 8) / 2;
}
static bool stripOnHeader(DisplayDriver& d) {
const int mid = d.width() / 2, half = stripHalfW(d);
// Left: the time and a gap. Right: the battery icon and two status icons.
const int right_need = 2 * d.getLineHeight() + 6 + 2 * (d.getCharWidth() + 3);
return mid - half >= 6 * d.getCharWidth() && mid + half <= d.width() - right_need;
}
// First row under the header and the page-icon row (see render()).
static int contentTop(DisplayDriver& d) {
const int pg_half = (5 * miniIconScale(d) + 1) / 2;
return d.getLineHeight() + 2 * pg_half + 4;
const int pg_half = (5 * miniIconScale(d) + 1) / 2, lh = d.getLineHeight();
if (stripOnHeader(d)) return (lh > 2 * pg_half + 2 ? lh : 2 * pg_half + 2) + 3;
return lh + 2 * pg_half + 4;
}
// 0..1 eased progress of the running slide, or -1 when none.
float slideProgress() {
@@ -362,7 +378,7 @@ class HomeScreen : public UIScreen {
unsigned long _advert_sent_ms = 0; // last advert sent from this page (0: none yet)
// ── Quick panels: an icon page as two or three rows with margins round
// ── Quick panels: an icon page as a few rows with margins round
// them, the last one opening the full screen. Up/Down picks a row, Enter
// acts on it. The rows are the Settings list's own (value, switch, bar).
enum QKind : uint8_t { Q_VALUE, Q_SWITCH, Q_BAR, Q_BADGE, Q_MORE };
@@ -380,6 +396,15 @@ class HomeScreen : public UIScreen {
uint8_t prefix[4];
char name[24]; // QA_CHAT: the label's storage
};
static const int QUICK_MAX = 8;
// How many rows a quick panel has room for (at least the 3 a 128x64 OLED
// fits): a taller screen lists more recent chats.
int quickFit() const {
DisplayDriver* d = _task->getDisplay();
if (!d) return 3;
const int n = (d->height() - contentTop(*d) - 5) / (d->lineStep() + 1);
return n < 3 ? 3 : n > QUICK_MAX ? QUICK_MAX : n;
}
uint8_t _quick_sel = 0;
bool _quick_edit = false; // the selected row is being changed with Left/Right
bool _quick_dirty = false; // ...and has moved: saved once when it ends
@@ -419,12 +444,13 @@ class HomeScreen : public UIScreen {
buf[n - 1] = 0;
}
// The two newest conversations, DM / room or channel, newest first.
// The `max` newest conversations, DM / room or channel, newest first.
int recentChats(QRow* out, int max) {
MessageHistory& h = _task->core().history;
struct Cand { bool is_ch; uint8_t ch; uint8_t prefix[4]; uint32_t ts; } c[4];
struct Cand { bool is_ch; uint8_t ch; uint8_t prefix[4]; uint32_t ts; } c[2 * QUICK_MAX];
if (max > QUICK_MAX) max = QUICK_MAX;
int nc = 0;
for (int j = 0; j < h.dmHistCount() && nc < 2; j++) {
for (int j = 0; j < h.dmHistCount() && nc < max; j++) {
const DmHistEntry& e = h.dmAtPos(h.dmHistPosNewest(j));
bool seen = false;
for (int k = 0; k < nc; k++) if (memcmp(c[k].prefix, e.prefix, 4) == 0) seen = true;
@@ -432,7 +458,7 @@ class HomeScreen : public UIScreen {
c[nc].is_ch = false; c[nc].ch = 0; memcpy(c[nc].prefix, e.prefix, 4); c[nc].ts = e.timestamp; nc++;
}
const int nd = nc;
for (int j = 0; j < h.chHistCount() && nc < nd + 2; j++) {
for (int j = 0; j < h.chHistCount() && nc < nd + max; j++) {
const ChHistEntry& e = h.chAtPos(h.chHistPosNewest(j));
bool seen = false;
for (int k = nd; k < nc; k++) if (c[k].ch == e.ch_idx) seen = true;
@@ -487,7 +513,7 @@ class HomeScreen : public UIScreen {
#endif
add("All settings", Q_MORE, QA_MORE, 0);
} else if (page == QUICK_MSG) {
n = recentChats(rows, 2);
n = recentChats(rows, quickFit() - 1);
add("All messages", Q_MORE, QA_MORE, 0);
} else if (page == BLUETOOTH) {
const bool on = _task->isSerialEnabled();
@@ -713,7 +739,7 @@ class HomeScreen : public UIScreen {
const int cw = display.getCharWidth();
const int ind = cw + 2; // single-char indicator width
const int ind_h = display.isSingleFont() ? lh - 2 : lh;
const int ind_gap = display.isLandscape() ? 3 : 1; // gap between indicator boxes
const int ind_gap = display.pixelScale() > 1 ? 3 : 1; // gap between indicator boxes
int battLeftX;
if (mode == battery::PERCENT) {
@@ -731,7 +757,7 @@ class HomeScreen : public UIScreen {
} else { // icon — scales with lh, same box height as the status icons beside it (ind_h)
const int iconH = ind_h;
const int iconW = lh * 2;
const int bm = display.isLandscape() ? 3 : 2; // inner margin: 3px on landscape e-ink, 2px on OLED/portrait
const int bm = display.pixelScale() > 1 ? 3 : 2; // inner margin: 3px on a doubled panel, else 2px
battLeftX = display.width() - iconW - 3;
display.drawRect(battLeftX, 0, iconW, iconH);
// Nub height/2, vertically centred by remaining-space/2 rather than a flat
@@ -969,10 +995,13 @@ public:
// The Map page: the mini-map in a soft frame on the left, what it shows
// spelled out in a column on the right (GPS, the trail, live contacts or
// the target). Without a position the column still says what's missing.
// A tall screen (portrait e-ink) stacks them: the map, the four rows under it.
void drawMapPage(DisplayDriver& d, int top) {
const int W = d.width(), H = d.height(), lh = d.getLineHeight();
const int s = miniIconScale(d);
const int pw = W / 2, ph = H - top; // map panel
const bool tall = H - top > W;
const int rows_h = tall ? 4 * (lh + lh / 2) : 0;
const int pw = tall ? W : W / 2, ph = H - top - rows_h; // map panel
d.setColor(DisplayDriver::LIGHT);
d.drawSoftRect(0, top, pw, ph);
if (!drawMapPreview(d, 2, top + 2, pw - 4, ph - 4)) {
@@ -989,9 +1018,9 @@ public:
}
// The column: four rows, always, like the Status tiles.
const int cx = pw + 3, cw = W - cx;
const int row_h = ph / 4;
int y = top + (row_h - lh) / 2 + 1;
const int cx = tall ? 2 : pw + 3, cw = W - cx;
const int row_h = tall ? rows_h / 4 : ph / 4;
int y = (tall ? top + ph : top) + (row_h - lh) / 2 + 1;
auto line = [&](const MiniIcon& ic, const char* text) {
miniIconDraw(d, cx, y, ic);
const int tx = cx + ic.w * s + 2 * s;
@@ -1051,6 +1080,25 @@ public:
// ── Clock page: the big clock, a seconds bar under it, the date, and the
// clock fields (Settings) as columns along the bottom, name over value.
// Without fields the clock and date sit in the middle.
// The days left this month the alarm goes off on, as calendar::draw()'s
// marks: the repeat days, or a one-shot's next (today or tomorrow).
uint32_t alarmDays(const struct tm& t) const {
if (!_node_prefs || !_node_prefs->alarm_on) return 0;
const int n = calendar::daysIn(t.tm_year, t.tm_mon);
const uint8_t mask = _node_prefs->alarm_repeat_mask;
const bool passed = t.tm_hour * 60 + t.tm_min >= _node_prefs->alarm_hour * 60 + _node_prefs->alarm_min;
uint32_t days = 0;
for (int day = t.tm_mday; day <= n; day++) {
if (day == t.tm_mday && passed) continue;
const int wday = (t.tm_wday + day - t.tm_mday) % 7;
if (mask ? (mask & (1 << wday)) : true) {
days |= 1UL << (day - 1);
if (!mask) break; // one-shot: only the next
}
}
return days;
}
void drawClockPage(DisplayDriver& d, uint32_t unix_ts) {
struct tm ti;
localTm(unix_ts, _node_prefs ? _node_prefs->tz_offset_hours : 0, ti);
@@ -1066,7 +1114,7 @@ public:
int date_y;
if (d.height() > d.width()) { // portrait e-ink: stacked digits
date_y = drawClockTall(d, 0, &ti, h12);
date_y = drawClockTall(d, lh / 2, &ti, h12);
} else {
const BigClock bc = bigClockSize(d);
const int bar = 3 * bc.sc / 2; // the seconds bar and its gaps
@@ -1088,6 +1136,12 @@ public:
snprintf(buf, sizeof(buf), "%s %d %s %d", WDAY[ti.tm_wday], ti.tm_mday, MON[ti.tm_mon], 1900 + ti.tm_year);
d.drawTextCentered(W / 2, date_y, buf);
// A tall screen (portrait e-ink): the month under the date, in the room
// above the clock fields.
const int cal_top = date_y + lh + lh / 2 + 2;
const int cal_bottom = nf ? H - 2 * lh - 1 - lh / 2 : H;
if (H > W && calendar::height(d, ti) <= cal_bottom - cal_top) calendar::draw(d, 0, cal_top, W, ti, alarmDays(ti));
if (nf == 0) return;
refresh_sensors();
const int val_y = H - lh, lab_y = val_y - lh - 1, cw = W / nf;
@@ -1173,8 +1227,9 @@ public:
char buf[16];
snprintf(buf, sizeof(buf), "%s %d %s", WDAY[ti.tm_wday], ti.tm_mday, MON[ti.tm_mon]);
d.drawTextCentered(W / 2, date_y, buf);
if (H > W) drawLockTallExtras(d, date_y + lh + lh / 2 + 2, hint ? pill_y - 3 : H, ti, unread);
}
if (!hint && unread > 0) snprintf(pill, sizeof(pill), "%d%s new", unread, _task->getAnyUnreadOverflow() ? "+" : "");
if (!hint && unread > 0 && !(synced && H > W)) snprintf(pill, sizeof(pill), "%d%s new", unread, _task->getAnyUnreadOverflow() ? "+" : "");
}
if (hint) {
@@ -1194,6 +1249,40 @@ public:
d.setColor(DisplayDriver::LIGHT);
}
// Under a tall lock clock (portrait e-ink), between y and bottom: the month,
// then a row each for unread messages, the armed alarm and the clock fields
// -- the rows first, the calendar only if there's room for it as well.
void drawLockTallExtras(DisplayDriver& d, int y, int bottom, const struct tm& ti, int unread) {
const int W = d.width(), m = W / 16, step = d.lineStep(), s = miniIconScale(d);
struct Row { const MiniIcon* ic; const char* label; char value[20]; } rows[6];
int n = 0;
auto add = [&](const MiniIcon* ic, const char* label) -> char* {
rows[n].ic = ic; rows[n].label = label; rows[n].value[0] = 0;
return rows[n++].value;
};
if (unread > 0)
snprintf(add(&ICON_PG_MSG, "Messages"), sizeof(rows[0].value), "%d%s new", unread, _task->getAnyUnreadOverflow() ? "+" : "");
if (_node_prefs && _node_prefs->alarm_on)
snprintf(add(&ICON_ALARM, "Alarm"), sizeof(rows[0].value), "%02d:%02d", _node_prefs->alarm_hour, _node_prefs->alarm_min);
uint8_t fields[3];
const int nf = clockFields(fields);
if (nf) refresh_sensors();
for (int k = 0; k < nf && n < 6; k++) {
if (fields[k] == telemetry::MSGS && unread > 0) continue; // already a row
clockFieldValue(fields[k], add(nullptr, telemetry::LABEL[fields[k]]), sizeof(rows[0].value));
}
const int rows_h = n * step;
if (calendar::height(d, ti) + d.getLineHeight() / 2 + rows_h <= bottom - y) {
calendar::draw(d, 0, y, W, ti, alarmDays(ti));
y += calendar::height(d, ti) + d.getLineHeight() / 2;
}
for (int k = 0; k < n && y + d.getLineHeight() <= bottom; k++, y += step) {
int x = m;
if (rows[k].ic) { miniIconDraw(d, x, y, *rows[k].ic); x += rows[k].ic->w * s + 2 * s; }
info::valueRow(d, y, rows[k].label, rows[k].value, false, m - 2, x);
}
}
// The Status page: four tiles split by dotted rules, each one subject's
// headline (icon + main value) over a detail line. Enter opens the Status
// screen with all of it.
@@ -1286,8 +1375,11 @@ public:
// gap scale with the font so the band clears the header above and content
// below (identical to the old lh+4 / +6 dots layout at 1x).
const int pg_half = (5 * miniIconScale(display) + 1) / 2;
const int dots_y = lh + pg_half + 1; // icon-row centre, below the header
const int content_y = dots_y + pg_half + 3; // first content row, below the icons (= contentTop())
const bool one_row = stripOnHeader(display); // the icons on the header line
const int dots_y = one_row ? lh / 2 : lh + pg_half + 1; // icon-row centre
const int content_y = contentTop(display); // first content row, below the icons
const int strip_l = display.width() / 2 - stripHalfW(display);
const int strip_r = display.width() / 2 + stripHalfW(display);
const float slide = slideProgress(); // -1, or how far a page turn has got
// Title bar displaying node name (except on lock screen), status icons and battery.
@@ -1296,8 +1388,9 @@ public:
display.setColor(DisplayDriver::LIGHT);
// The lock page has nothing on the left of the bar (its clock sits
// below it), so the status icons may use the whole row.
const int lock_reserve = _page == LOCK ? 0 : -1;
const int lock_reserve = _page == LOCK ? 0 : one_row ? strip_r + 2 : -1;
int rightEdge = renderBatteryIndicator(display, _task->getBattMilliVolts(), lock_reserve);
if (one_row && _page != LOCK) rightEdge = strip_l; // the time stops at the icons
display.setColor(DisplayDriver::LIGHT);
if (_page != LOCK) {
@@ -1391,7 +1484,7 @@ public:
} else if (isQuickPage(_page) && !(_page == HomePage::SHUTDOWN && _shutdown_init)) {
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
QRow rows[3];
QRow rows[QUICK_MAX];
const int n = buildQuick(_page, rows);
int q = drawQuickPanel(display, content_y, rows, n);
if (_page == HomePage::TOOLS && _task->isTimerRunning()) q = 1000; // the timer's countdown
@@ -1439,6 +1532,8 @@ public:
uint8_t unread = 0;
bool overflow = false;
bool resolved = false;
uint32_t last_ts = 0; // heard from / last post, for a tall card
int32_t plat = 0, plon = 0; // a contact's advertised position
if (prefix && _task->favouriteSlotKind(i) == NodePrefs::FAV_KIND_CHANNEL) {
uint8_t ch_idx = prefix[0];
@@ -1451,6 +1546,11 @@ public:
unread = _task->getChannelUnread(ch_idx);
overflow = unread > 0 && _task->getChannelUnreadOverflow(ch_idx);
resolved = true;
MessageHistory& h = _task->core().history;
for (int j = 0; j < h.chHistCount(); j++) {
const ChHistEntry& e = h.chAtPos(h.chHistPosNewest(j));
if (e.ch_idx == ch_idx) { last_ts = e.timestamp; break; }
}
}
} else if (prefix) {
for (int idx = 0; ; idx++) {
@@ -1461,6 +1561,8 @@ public:
unread = _task->getDMUnread(c.id.pub_key);
overflow = unread > 0 && _task->getDMUnreadOverflow(c.id.pub_key);
resolved = true;
last_ts = c.lastmod;
plat = c.gps_lat; plon = c.gps_lon;
break;
}
}
@@ -1479,13 +1581,41 @@ public:
// Reserve space for the unread badge so the name's ellipsis lands
// before it instead of underneath. Badge and name share one baseline.
int bw = unread > 0 ? display.unreadBadgeWidth(unread, overflow) + 3 : 0; // badge + 3 px gap
int name_y = ty + (th - line_h) / 2;
// A tall card (e-ink) has the name at the top and lines of detail
// under it: when it was last heard (a channel: its last post) and,
// for a contact with a position, how far away it is.
const bool tall = th >= 3 * line_h + 6;
int name_y = tall ? ty + 3 : ty + (th - line_h) / 2;
int name_max_w = tw - 6 - bw;
if (name_max_w < 6) name_max_w = 6;
int r = display.drawTextEllipsized(tx + 3, name_y, name_max_w, name, sel);
if (sel && r > 0) mq_delay = r;
if (unread > 0)
display.drawUnreadBadge(tx + tw - 3, name_y, unread, sel, overflow);
if (tall) {
display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT); // the badge leaves its own
const int s = miniIconScale(display), gap = line_h / 3;
int y = name_y + line_h + gap;
auto detail = [&](const MiniIcon& ic, const char* text) {
if (y + line_h > ty + th - 2) return;
miniIconDraw(display, tx + 3, y, ic);
const int x = tx + 3 + ic.w * s + 2 * s;
display.drawTextEllipsized(x, y, tx + tw - 3 - x, text);
y += line_h + gap;
};
char buf[16];
const uint32_t now = rtc_clock.getCurrentTime();
if (last_ts) {
geo::fmtAgeShort(buf, sizeof(buf), now, last_ts);
detail(prefix && _task->favouriteSlotKind(i) == NodePrefs::FAV_KIND_CHANNEL ? ICON_PG_MSG : ICON_PG_CLOCK, buf);
}
int32_t mla, mlo;
if ((plat || plon) && _task->currentLocation(mla, mlo)) {
// The distance after an arrow towards it (north up).
geo::fmtDist(buf, sizeof(buf), geo::haversineKm(mla, mlo, plat, plon), _task->useImperial(), true);
detail(*ICON_ARROWS[((geo::bearingDeg(mla, mlo, plat, plon) + 22) % 360) / 45], buf);
}
}
} else {
display.drawTextCentered(tx + tw / 2, ty + (th - line_h) / 2, "+");
}
@@ -1588,7 +1718,7 @@ public:
if (isQuickPage(_page) && _quick_edit) {
// Changing a bar: shown live, saved once on the way out.
if ((c == KEY_LEFT || c == KEY_RIGHT) && _node_prefs) {
QRow rows[3];
QRow rows[QUICK_MAX];
const int n = buildQuick(_page, rows);
const QAct act = n > 0 ? rows[_quick_sel < n ? _quick_sel : n - 1].act : QA_NONE;
const int d = c == KEY_RIGHT ? 1 : -1;
@@ -1615,7 +1745,7 @@ public:
return true; // nothing else while editing
}
if (isQuickPage(_page)) {
QRow rows[3];
QRow rows[QUICK_MAX];
const int n = buildQuick(_page, rows);
if (c == KEY_UP) { if (_quick_sel > 0) _quick_sel--; return true; }
if (c == KEY_DOWN) { if (_quick_sel + 1 < n) _quick_sel++; return true; }
+69
View File
@@ -386,6 +386,75 @@ MINI_ICON(ICON_MAP_TARGET, 5, // ⚑ flag on a pole — the active Locator/N
packRow("#...."),
packRow("#...."));
// Arrows towards the eight compass points (north up), for a bearing beside
// a distance: ICON_ARROWS[((deg + 22) % 360) / 45].
MINI_ICON(ICON_ARROW_N, 7,
packRow("...#..."),
packRow("..###.."),
packRow(".#.#.#."),
packRow("...#..."),
packRow("...#..."),
packRow("...#..."),
packRow("...#..."));
MINI_ICON(ICON_ARROW_NE, 7,
packRow("...####"),
packRow(".....##"),
packRow("....#.#"),
packRow("...#..#"),
packRow("..#...."),
packRow(".#....."),
packRow("#......"));
MINI_ICON(ICON_ARROW_E, 7,
packRow("......."),
packRow("....#.."),
packRow(".....#."),
packRow("#######"),
packRow(".....#."),
packRow("....#.."),
packRow("......."));
MINI_ICON(ICON_ARROW_SE, 7,
packRow("#......"),
packRow(".#....."),
packRow("..#...."),
packRow("...#..#"),
packRow("....#.#"),
packRow(".....##"),
packRow("...####"));
MINI_ICON(ICON_ARROW_S, 7,
packRow("...#..."),
packRow("...#..."),
packRow("...#..."),
packRow("...#..."),
packRow(".#.#.#."),
packRow("..###.."),
packRow("...#..."));
MINI_ICON(ICON_ARROW_SW, 7,
packRow("......#"),
packRow(".....#."),
packRow("....#.."),
packRow("#..#..."),
packRow("#.#...."),
packRow("##....."),
packRow("####..."));
MINI_ICON(ICON_ARROW_W, 7,
packRow("......."),
packRow("..#...."),
packRow(".#....."),
packRow("#######"),
packRow(".#....."),
packRow("..#...."),
packRow("......."));
MINI_ICON(ICON_ARROW_NW, 7,
packRow("####..."),
packRow("##....."),
packRow("#.#...."),
packRow("#..#..."),
packRow("....#.."),
packRow(".....#."),
packRow("......#"));
static constexpr const MiniIcon* ICON_ARROWS[8] = { &ICON_ARROW_N, &ICON_ARROW_NE, &ICON_ARROW_E, &ICON_ARROW_SE,
&ICON_ARROW_S, &ICON_ARROW_SW, &ICON_ARROW_W, &ICON_ARROW_NW };
// Keyboard special-key glyphs.
MINI_ICON(ICON_KEYBOARD, 7, // PIN keyboard to ABC keyboard switch icon
packRow("#.#.#.#"),