From ab767e7ae19c7b3b91d7883bedda146e912b2779 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:22:44 +0200 Subject: [PATCH] feat(ui-lvgl): charts with a value axis, grid and a finger's readout Chart.h draws Diagnostics > History, the noise sweep and the trail's height profile: round values up the left, labels along the bottom, a solid fill (no transparency) and, under a finger, the sample's value and when / where. Sim: sim_open("histdemo") / ("profdemo") fill them with made-up data. Co-Authored-By: Claude Opus 5.5 --- examples/companion_radio/ui-lvgl/Chart.h | 280 ++++++++++++++++++ examples/companion_radio/ui-lvgl/DiagScreen.h | 185 +++++------- examples/companion_radio/ui-lvgl/NavMap.h | 4 +- .../companion_radio/ui-lvgl/ProfileView.h | 86 +++--- examples/companion_radio/ui-lvgl/UITask.cpp | 32 ++ 5 files changed, 429 insertions(+), 158 deletions(-) create mode 100644 examples/companion_radio/ui-lvgl/Chart.h diff --git a/examples/companion_radio/ui-lvgl/Chart.h b/examples/companion_radio/ui-lvgl/Chart.h new file mode 100644 index 00000000..b6fb8d4e --- /dev/null +++ b/examples/companion_radio/ui-lvgl/Chart.h @@ -0,0 +1,280 @@ +#pragma once +// A chart drawn by its object (LV_EVENT_DRAW_MAIN_END): round values up the +// left with a grid line each, labels along the bottom, the samples as a line +// over a fill or as bars. A finger on it shows the sample under it -- a line +// down the plot, the value and where / when on a pill. Diagnostics > History +// and the trail's height profile (ProfileView.h) draw with it; the GPS bars +// take the axis alone. +// +// No transparency: the fill is the accent mixed into the card's colour up +// front, so the panel's renderer only ever copies solid colour. +// +// Single-TU fragment: included by ui-lvgl/UITask.cpp before ProfileView.h. + +namespace chart { + +static const int MAX = 128; + +struct Chart { + int16_t v[MAX]; + uint16_t x[MAX]; // with `spaced`: each sample's place, 0..10000 of the width + int n = 0, cap = MAX; // without: newest at the right, `cap` samples wide + bool spaced = false; + bool bars = false; + bool from_zero = false; // the scale starts at 0 (counts) + int min_span = 1; // the scale spans at least this (a steady reading stays flat) + int unit = 1; // the scale's steps are 1, 2, 5 x 10^k of this many units + int top = 0; // room over the plot (a caption) + uint32_t bg = theme::SURFACE; // what it's drawn on + void (*axis)(int v, int step, char* out, size_t n) = nullptr; // a value up the left + void (*value)(const Chart& c, int i, char* out, size_t n) = nullptr; // the pill: the sample... + void (*where)(const Chart& c, int i, char* out, size_t n) = nullptr; // ...and where / when + const char* under[5] = {}; // along the bottom, first at the left, last at the right + int16_t mark = -1; // bars: one drawn in the accent, the rest in `bar` + uint32_t bar = theme::ACCENT; + int16_t touch = -1; // the sample under the finger + mutable int16_t axis_w = 0; // the plot's left edge from the object's, as last drawn +}; + +// The step, 1 / 2 / 5 x 10^k units, that cuts `span` into at most `ticks` parts. +static int niceStep(int span, int unit, int ticks) { + for (int s = unit; ; s *= 10) + for (int m : { 1, 2, 5 }) if (s * m * ticks >= span) return s * m; +} + +struct Scale { int lo, hi, step; }; + +// lo..hi widened to whole steps. +static Scale scaleFor(int lo, int hi, int min_span, int unit, bool from_zero, int ticks) { + if (from_zero && lo > 0) lo = 0; + if (hi - lo < min_span) { + if (from_zero) hi = lo + min_span; + else { const int mid = (hi + lo) / 2; lo = mid - min_span / 2; hi = lo + min_span; } + } + Scale s; + s.step = niceStep(hi - lo, unit, ticks); + s.lo = (lo >= 0 ? lo / s.step : -((-lo + s.step - 1) / s.step)) * s.step; + s.hi = (hi >= 0 ? (hi + s.step - 1) / s.step : -(-hi / s.step)) * s.step; + if (s.hi == s.lo) s.hi = s.lo + s.step; + return s; +} + +static void text(lv_layer_t* layer, const char* t, int32_t x1, int32_t y1, int32_t x2, lv_text_align_t al, uint32_t col) { + lv_draw_label_dsc_t d; + lv_draw_label_dsc_init(&d); + d.font = THEME_FONT_SMALL; + d.color = lv_color_hex(col); + d.align = al; + d.text = t; + d.text_local = 1; + lv_area_t r = { x1, y1, x2, y1 + lv_font_get_line_height(d.font) }; + lv_draw_label(layer, &d, &r); +} + +static void hline(lv_layer_t* layer, int32_t x1, int32_t x2, int32_t y, uint32_t col, int dash) { + lv_draw_line_dsc_t d; + lv_draw_line_dsc_init(&d); + d.color = lv_color_hex(col); + d.width = 1; + d.dash_width = d.dash_gap = dash; + d.p1.x = x1; d.p2.x = x2; d.p1.y = d.p2.y = y; + lv_draw_line(layer, &d); +} + +static int textW(const char* t) { + lv_point_t sz; + lv_text_get_size(&sz, t, THEME_FONT_SMALL, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); + return sz.x; +} + +// The values up the left of y_top..y_bot and a grid line across to x2 at +// each; returns where the plot starts. `fmt` writes a value. +static int32_t axis(lv_layer_t* layer, int32_t x1, int32_t x2, int32_t y_top, int32_t y_bot, const Scale& s, + void (*fmt)(int v, int step, char* out, size_t n), uint32_t grid) { + char t[16]; + int w = 0; + for (int v = s.lo; v <= s.hi; v += s.step) { fmt(v, s.step, t, sizeof(t)); const int tw = textW(t); if (tw > w) w = tw; } + const int32_t px = x1 + w + 6; + const int lh = lv_font_get_line_height(THEME_FONT_SMALL); + for (int v = s.lo; v <= s.hi; v += s.step) { + const int32_t y = y_bot - (int32_t)(v - s.lo) * (y_bot - y_top) / (s.hi - s.lo); + hline(layer, px, x2, y, grid, 0); + fmt(v, s.step, t, sizeof(t)); + text(layer, t, x1, y - lh / 2, px - 6, LV_TEXT_ALIGN_RIGHT, theme::TEXT_MUTED); + } + return px; +} + +// Where the plot is inside the object's area. +struct Plot { int32_t x1, x2, y1, y2; }; + +static Plot plotOf(const Chart& c, const lv_area_t& a, int32_t px) { + const int lh = lv_font_get_line_height(THEME_FONT_SMALL); + return { px, a.x2 - 2, a.y1 + c.top + lh / 2, a.y2 - lh - 2 }; +} + +static int32_t xOf(const Chart& c, const Plot& p, int i) { + const int32_t w = p.x2 - p.x1; + if (c.spaced) return p.x1 + (int32_t)c.x[i] * w / 10000; + return p.x2 - (int32_t)(c.n - 1 - i) * w / (c.cap > 1 ? c.cap - 1 : 1); +} + +static void onDraw(lv_event_t* e) { + lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e); + const Chart& c = *(const Chart*)lv_event_get_user_data(e); + lv_layer_t* layer = lv_event_get_layer(e); + lv_area_t a; + lv_obj_get_coords(o, &a); + const int lh = lv_font_get_line_height(THEME_FONT_SMALL); + const uint32_t grid = theme::mix(theme::TEXT_MUTED, c.bg, 22); + + int lo = c.n ? c.v[0] : 0, hi = lo; + for (int i = 0; i < c.n; i++) { if (c.v[i] < lo) lo = c.v[i]; if (c.v[i] > hi) hi = c.v[i]; } + const Scale s = scaleFor(lo, hi, c.min_span, c.unit, c.from_zero || c.bars, 3); + const int32_t y_top = a.y1 + c.top + lh / 2, y_bot = a.y2 - lh - 2; + const int32_t px = c.n ? axis(layer, a.x1, a.x2 - 2, y_top, y_bot, s, c.axis, grid) : a.x1 + 2; + const Plot p = plotOf(c, a, px); + c.axis_w = (int16_t)(px - a.x1); + + // Along the bottom, spread evenly. + int nu = 0; + while (nu < 5 && c.under[nu]) nu++; + for (int k = 0; k < nu; k++) { + const int32_t x = nu > 1 ? p.x1 + (p.x2 - p.x1) * k / (nu - 1) : p.x1; + const lv_text_align_t al = k == 0 ? LV_TEXT_ALIGN_LEFT : k == nu - 1 ? LV_TEXT_ALIGN_RIGHT : LV_TEXT_ALIGN_CENTER; + const int32_t x1 = k == 0 ? x : k == nu - 1 ? x - 60 : x - 30; + text(layer, c.under[k], x1, p.y2 + 2, x1 + 59, al, theme::TEXT_MUTED); + } + + if (c.n < (c.bars ? 1 : 2)) { // nothing yet: a dotted baseline + hline(layer, p.x1, p.x2, p.y2, theme::TEXT_MUTED, 2); + return; + } + auto Y = [&](int v) { return (int32_t)(p.y2 - (int32_t)(v - s.lo) * (p.y2 - p.y1) / (s.hi - s.lo)); }; + + if (c.bars) { // a bar a sample, a pixel apart + const int pitch = ((p.x2 - p.x1) + c.cap / 2) / c.cap, bw = pitch > 1 ? pitch - 1 : 1; + lv_draw_rect_dsc_t rd; + lv_draw_rect_dsc_init(&rd); + for (int i = 0; i < c.n; i++) { + const int32_t y = Y(c.v[i]); + if (y >= p.y2) continue; // nothing (none heard, or the bottom of the scale) + rd.bg_color = lv_color_hex(i == c.touch ? theme::TEXT : i == c.mark ? theme::ACCENT : c.bar); + const int32_t x = xOf(c, p, i); + lv_area_t r = { x - bw + 1, y, x, p.y2 }; + lv_draw_rect(layer, &rd, &r); + } + } else { + lv_draw_line_dsc_t fill; // a column at a time under the line (triangles would leave seams) + lv_draw_line_dsc_init(&fill); + fill.color = lv_color_hex(theme::mix(theme::ACCENT, c.bg, 14)); + fill.width = 1; + for (int i = 1; i < c.n; i++) { + const int32_t xa = xOf(c, p, i - 1), xb = xOf(c, p, i), ya = Y(c.v[i - 1]), yb = Y(c.v[i]); + for (int32_t x = (i == 1 ? xa : xa + 1); x <= xb; x++) { + fill.p1.x = fill.p2.x = x; + fill.p1.y = xb > xa ? ya + (yb - ya) * (x - xa) / (xb - xa) : yb; + fill.p2.y = p.y2; + if (fill.p1.y < fill.p2.y) lv_draw_line(layer, &fill); + } + } + lv_draw_line_dsc_t ld; + lv_draw_line_dsc_init(&ld); + ld.color = lv_color_hex(theme::ACCENT); + ld.width = 2; + ld.round_start = ld.round_end = 1; + for (int i = 1; i < c.n; i++) { + ld.p1.x = xOf(c, p, i - 1); ld.p1.y = Y(c.v[i - 1]); + ld.p2.x = xOf(c, p, i); ld.p2.y = Y(c.v[i]); + lv_draw_line(layer, &ld); + } + } + + if (c.touch < 0 || c.touch >= c.n) return; + // The sample under the finger: a line down, a dot on the line, a pill over the plot. + const int i = c.touch; + const int32_t x = xOf(c, p, i), y = Y(c.v[i]); + lv_draw_line_dsc_t vl; + lv_draw_line_dsc_init(&vl); + vl.color = lv_color_hex(theme::TEXT_MUTED); + vl.width = 1; + vl.p1.x = vl.p2.x = x; vl.p1.y = a.y1 + c.top; vl.p2.y = p.y2; + lv_draw_line(layer, &vl); + if (!c.bars) { + lv_draw_rect_dsc_t dd; + lv_draw_rect_dsc_init(&dd); + dd.radius = LV_RADIUS_CIRCLE; + dd.bg_color = lv_color_hex(theme::TEXT); + dd.border_color = lv_color_hex(c.bg); + dd.border_width = 2; + lv_area_t da = { x - 5, y - 5, x + 5, y + 5 }; + lv_draw_rect(layer, &dd, &da); + } + char val[24] = "", at[24] = "", t[52]; + if (c.value) c.value(c, i, val, sizeof(val)); + if (c.where) c.where(c, i, at, sizeof(at)); + snprintf(t, sizeof(t), at[0] ? "%s %s" : "%s", val, at); + const int32_t w = textW(t) + 12; + int32_t bx = x - w / 2; + if (bx < a.x1) bx = a.x1; + if (bx + w > a.x2) bx = a.x2 - w; + lv_draw_rect_dsc_t bg; + lv_draw_rect_dsc_init(&bg); + bg.radius = theme::RADIUS_SM; + bg.bg_color = lv_color_hex(theme::SURFACE_2); + lv_area_t ba = { bx, a.y1 + c.top, bx + w - 1, a.y1 + c.top + lh + 1 }; + lv_draw_rect(layer, &bg, &ba); + text(layer, t, bx, a.y1 + c.top + 1, bx + w - 1, LV_TEXT_ALIGN_CENTER, theme::TEXT); +} + +// The finger: the nearest sample along the width, while it's down. +static void onTouch(lv_event_t* e) { + const lv_event_code_t code = lv_event_get_code(e); + lv_obj_t* o = (lv_obj_t*)lv_event_get_current_target(e); + Chart& c = *(Chart*)lv_event_get_user_data(e); + int16_t t = c.touch; + if (code == LV_EVENT_PRESSED || code == LV_EVENT_PRESSING) { + lv_indev_t* in = lv_indev_active(); + if (!in || !c.n) return; + lv_point_t pt; + lv_indev_get_point(in, &pt); + lv_area_t a; + lv_obj_get_coords(o, &a); + const Plot p = plotOf(c, a, a.x1 + c.axis_w); + int best = -1; + int32_t bd = 0; + for (int i = 0; i < c.n; i++) { + const int32_t d = LV_ABS(xOf(c, p, i) - pt.x); + if (best < 0 || d < bd) { best = i; bd = d; } + } + t = (int16_t)best; + } else if (code == LV_EVENT_RELEASED || code == LV_EVENT_PRESS_LOST) { + t = -1; + } else { + return; + } + if (t != c.touch) { c.touch = t; lv_obj_invalidate(o); } +} + +// The chart's object, `h` tall, drawing `c`. +static lv_obj_t* create(lv_obj_t* parent, Chart& c, int h) { + lv_obj_t* o = lv_obj_create(parent); + lv_obj_remove_style_all(o); + lv_obj_remove_flag(o, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(o, LV_PCT(100)); + lv_obj_set_height(o, h); + c.touch = -1; + lv_obj_add_event_cb(o, onDraw, LV_EVENT_DRAW_MAIN_END, &c); + lv_obj_add_event_cb(o, onTouch, LV_EVENT_ALL, &c); + return o; +} + +// "35 min ago", "2 h 10 min ago", "now". +static void ago(uint32_t mins, char* out, size_t n) { + if (!mins) snprintf(out, n, "now"); + else if (mins < 60) snprintf(out, n, "%lu min ago", (unsigned long)mins); + else if (mins % 60) snprintf(out, n, "%lu h %lu min ago", (unsigned long)(mins / 60), (unsigned long)(mins % 60)); + else snprintf(out, n, "%lu h ago", (unsigned long)(mins / 60)); +} + +} // namespace chart diff --git a/examples/companion_radio/ui-lvgl/DiagScreen.h b/examples/companion_radio/ui-lvgl/DiagScreen.h index 459fe083..3022a8fd 100644 --- a/examples/companion_radio/ui-lvgl/DiagScreen.h +++ b/examples/companion_radio/ui-lvgl/DiagScreen.h @@ -36,83 +36,60 @@ static uint32_t totalRecv() { } // One chart's samples as drawn: a copy, so the draw callback needn't know -// which ring (of which size) they came from. -struct Chart { int16_t v[96]; int n, cap, min_span; bool bars; }; -static Chart s_charts[4]; +// which ring (of which size) they came from. What the axis and the pill +// say is set once (setupCharts()). +static chart::Chart s_charts[4]; template -static Chart* chartOf(int k, const uicore::History& h, int min_span, bool bars) { - Chart& c = s_charts[k]; - c.n = h.n; c.cap = N; c.min_span = min_span; c.bars = bars; +static void chartOf(int k, const uicore::History& h) { + chart::Chart& c = s_charts[k]; + c.n = h.n; c.cap = N; for (int i = 0; i < h.n; i++) c.v[i] = h.at(i); - return &c; } -// A history in its box, newest at the right: a line over a faint fill, or -// bars from the bottom, scaled between the samples' own min and max (at -// least min_span apart, so a steady reading stays flat). -static void onChartDraw(lv_event_t* e) { - lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e); - const Chart& c = *(const Chart*)lv_event_get_user_data(e); - lv_layer_t* layer = lv_event_get_layer(e); - lv_area_t a; - lv_obj_get_coords(o, &a); - const int x0 = a.x1 + 2, w = a.x2 - a.x1 - 4, y0 = a.y1 + 3, h = a.y2 - a.y1 - 5, base = a.y2 - 1; - lv_draw_line_dsc_t ld; - lv_draw_line_dsc_init(&ld); - if (c.n < (c.bars ? 1 : 2)) { // nothing yet: a dotted baseline - ld.color = lv_color_hex(theme::TEXT_MUTED); - ld.width = 1; ld.dash_width = 2; ld.dash_gap = 4; - ld.p1.x = x0; ld.p2.x = x0 + w; ld.p1.y = ld.p2.y = base - 1; - lv_draw_line(layer, &ld); - return; - } - int lo = c.bars ? 0 : c.v[0], hi = c.v[0]; - for (int i = 0; i < c.n; i++) { if (c.v[i] < lo) lo = c.v[i]; if (c.v[i] > hi) hi = c.v[i]; } - if (hi - lo < c.min_span) { - if (c.bars) hi = lo + c.min_span; - else { const int mid = (hi + lo) / 2; lo = mid - c.min_span / 2; hi = lo + c.min_span; } - } - auto X = [&](int i) { return (int32_t)(x0 + w - (c.n - 1 - i) * w / (c.cap - 1)); }; - auto Y = [&](int v) { return (int32_t)(y0 + h - (v - lo) * h / (hi - lo)); }; - ld.color = lv_color_hex(theme::ACCENT); - if (c.bars) { // a bar a sample, a pixel apart - const int pitch = (w + c.cap / 2) / c.cap, bw = pitch > 2 ? pitch - 1 : 1; - lv_draw_rect_dsc_t rd; - lv_draw_rect_dsc_init(&rd); - rd.bg_color = lv_color_hex(theme::ACCENT); - rd.bg_opa = LV_OPA_70; - for (int i = 0; i < c.n; i++) { - if (c.v[i] <= 0) continue; - lv_area_t r = { X(i) - bw + 1, Y(c.v[i]), X(i), base }; - lv_draw_rect(layer, &rd, &r); - } - return; - } - lv_draw_line_dsc_t fill; - lv_draw_line_dsc_init(&fill); - fill.color = lv_color_hex(theme::ACCENT); - fill.opa = LV_OPA_20; - fill.width = 1; - ld.width = 2; - ld.round_start = ld.round_end = 1; - for (int i = 1; i < c.n; i++) { - const int32_t xa = X(i - 1), xb = X(i), ya = Y(c.v[i - 1]), yb = Y(c.v[i]); - for (int32_t x = (i == 1 ? xa : xa + 1); x <= xb; x++) { - fill.p1.x = fill.p2.x = x; - fill.p1.y = xb > xa ? ya + (yb - ya) * (x - xa) / (xb - xa) : yb; - fill.p2.y = base; - lv_draw_line(layer, &fill); - } - ld.p1.x = xa; ld.p1.y = ya; ld.p2.x = xb; ld.p2.y = yb; - lv_draw_line(layer, &ld); +// A sample's age: the charts sample every 5 minutes, SNR every packet. +static void whenAgo(const chart::Chart& c, int i, char* out, size_t n) { + chart::ago((uint32_t)(c.n - 1 - i) * (s_hist.INTERVAL_MS / 60000UL), out, n); +} +static void setupCharts() { + using chart::Chart; + static const char* const HOURS[5] = { "8 h", "6 h", "4 h", "2 h", "now" }; + for (int k = 0; k < 4; k++) { + Chart& c = s_charts[k]; + c.where = whenAgo; + for (int j = 0; j < 5; j++) c.under[j] = HOURS[j]; } + Chart& b = s_charts[0]; // mV, in steps of 10 mV and up + b.min_span = 100; b.unit = 10; + b.axis = [](int v, int step, char* o, size_t n) { + if (step % 100 == 0) snprintf(o, n, "%d.%d V", v / 1000, v % 1000 / 100); + else snprintf(o, n, "%d.%02d", v / 1000, v % 1000 / 10); + }; + b.value = [](const Chart& c, int i, char* o, size_t n) { snprintf(o, n, "%.2f V", c.v[i] / 1000.0f); }; + Chart& f = s_charts[1]; // dBm + f.min_span = 6; + f.axis = [](int v, int, char* o, size_t n) { snprintf(o, n, "%d", v); }; + f.value = [](const Chart& c, int i, char* o, size_t n) { snprintf(o, n, "%d dBm", c.v[i]); }; + Chart& r = s_charts[2]; // dB x4, a sample a packet + r.min_span = 20; r.unit = 4; + r.axis = [](int v, int, char* o, size_t n) { snprintf(o, n, "%d", v / 4); }; + r.value = [](const Chart& c, int i, char* o, size_t n) { snprintf(o, n, "%.1f dB", c.v[i] / 4.0f); }; + r.where = [](const Chart& c, int i, char* o, size_t n) { + const int k = c.n - 1 - i; + if (!k) snprintf(o, n, "last packet"); else snprintf(o, n, "%d packet%s back", k, k == 1 ? "" : "s"); + }; + static const char* const PKTS[3] = { "32 back", "16", "last" }; + for (int j = 0; j < 5; j++) r.under[j] = j < 3 ? PKTS[j] : nullptr; + Chart& t = s_charts[3]; // packets per interval + t.bars = true; t.min_span = 4; + t.axis = [](int v, int, char* o, size_t n) { snprintf(o, n, "%d", v); }; + t.value = [](const Chart& c, int i, char* o, size_t n) { snprintf(o, n, "%d packet%s", c.v[i], c.v[i] == 1 ? "" : "s"); }; } -// A chart card: the title and the value now, the chart, the span under it. -static void chartCard(lv_obj_t* parent, int k, const char* title, const char* from) { +// A chart card: the title and the value now over the chart. +static void chartCard(lv_obj_t* parent, int k, const char* title) { lv_obj_t* card = infoCard(parent); lv_obj_set_style_pad_ver(card, 6, 0); - lv_obj_set_style_pad_row(card, 2, 0); + lv_obj_set_style_pad_row(card, 4, 0); lv_obj_t* r = lv_obj_create(card); lv_obj_remove_style_all(r); fillWidth(r); @@ -120,19 +97,7 @@ static void chartCard(lv_obj_t* parent, int k, const char* title, const char* fr lv_obj_align(label(r, title, THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_LEFT_MID, 0, 0); s_ch_now[k] = label(r, "", THEME_FONT_BODY, theme::TEXT); lv_obj_align(s_ch_now[k], LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_t* ch = lv_obj_create(card); - lv_obj_remove_style_all(ch); - lv_obj_remove_flag(ch, LV_OBJ_FLAG_CLICKABLE); - fillWidth(ch); - lv_obj_set_height(ch, 48); - lv_obj_add_event_cb(ch, onChartDraw, LV_EVENT_DRAW_MAIN_END, &s_charts[k]); - s_ch_obj[k] = ch; - lv_obj_t* ax = lv_obj_create(card); - lv_obj_remove_style_all(ax); - fillWidth(ax); - lv_obj_set_height(ax, LV_SIZE_CONTENT); - lv_obj_align(label(ax, from, THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_LEFT_MID, 0, 0); - lv_obj_align(label(ax, "now", THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_RIGHT_MID, 0, 0); + s_ch_obj[k] = chart::create(card, s_charts[k], 96); } static void extraRow(diag::Row* rows, int& n, const char* lbl, const char* fmt, ...) { @@ -251,8 +216,10 @@ static lv_obj_t* s_noise_status = nullptr; static void onNoiseRun(lv_event_t* e) { (void)e; s_ui->diagNoiseRun(); } -// A sweep as bars from -130 dBm up (1.5 px a dB), the step nearest the mesh's -// frequency in the accent colour, then its quietest / loudest / mesh values. +// A sweep as bars (Chart.h), the step nearest the mesh's frequency in the +// accent, then its quietest / loudest values. +static chart::Chart s_sweep_chart; +static char s_sweep_under[3][12]; static void plotSweep(lv_obj_t* list, const char* title, const Sweep& sw, float mesh_f) { sectionTitle(list, title); int lo = 0, hi = 0; @@ -260,23 +227,24 @@ static void plotSweep(lv_obj_t* list, const char* title, const Sweep& sw, float if (sw.v[i] < sw.v[lo]) lo = i; if (sw.v[i] > sw.v[hi]) hi = i; } - lv_obj_t* plot = lv_obj_create(list); - lv_obj_remove_style_all(plot); - lv_obj_set_size(plot, sw.n * 3, 100); - lv_obj_set_style_bg_color(plot, lv_color_hex(theme::SURFACE), 0); - lv_obj_set_style_bg_opa(plot, LV_OPA_COVER, 0); - lv_obj_remove_flag(plot, LV_OBJ_FLAG_SCROLLABLE); - int mesh_i = (int)lroundf((mesh_f - sw.f0) / sw.step); - for (int i = 0; i < sw.n; i++) { - int h = (sw.v[i] + 130) * 3 / 2; - h = h < 1 ? 1 : (h > 100 ? 100 : h); - lv_obj_t* bar = lv_obj_create(plot); - lv_obj_remove_style_all(bar); - lv_obj_set_size(bar, 2, h); - lv_obj_set_pos(bar, i * 3, 100 - h); - lv_obj_set_style_bg_color(bar, lv_color_hex(i == mesh_i ? theme::ACCENT : theme::TEXT_MUTED), 0); - lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0); - } + chart::Chart& c = s_sweep_chart; + c = chart::Chart(); + c.n = c.cap = sw.n; + for (int i = 0; i < sw.n; i++) c.v[i] = sw.v[i]; + c.bars = true; + c.bar = theme::TEXT_MUTED; + c.mark = (int16_t)lroundf((mesh_f - sw.f0) / sw.step); + c.min_span = 10; + c.axis = [](int v, int, char* o, size_t n) { snprintf(o, n, "%d", v); }; + c.value = [](const chart::Chart& c, int i, char* o, size_t n) { snprintf(o, n, "%d dBm", c.v[i]); }; + c.where = [](const chart::Chart&, int i, char* o, size_t n) { snprintf(o, n, "%.3f MHz", s_near.f0 + s_near.step * i); }; + snprintf(s_sweep_under[0], sizeof(s_sweep_under[0]), "%.2f", sw.f0); + snprintf(s_sweep_under[1], sizeof(s_sweep_under[1]), "%.3f", mesh_f); + snprintf(s_sweep_under[2], sizeof(s_sweep_under[2]), "%.2f MHz", sw.f0 + sw.step * (sw.n - 1)); + for (int j = 0; j < 3; j++) c.under[j] = s_sweep_under[j]; + lv_obj_t* box = infoCard(list); + lv_obj_set_style_pad_ver(box, 6, 0); + chart::create(box, c, 120); lv_obj_t* card = infoCard(list); char v[40]; snprintf(v, sizeof(v), "%.3f MHz: %d dBm", sw.f0 + sw.step * lo, sw.v[lo]); @@ -385,10 +353,11 @@ void UITask::buildDiag() { s_noise_status = nullptr; if (s_tab == TAB_HISTORY) { - chartCard(s_list, 0, "Battery", "8 h ago"); - chartCard(s_list, 1, "Noise floor", "8 h ago"); - chartCard(s_list, 2, "SNR, last packets", "32 packets ago"); - chartCard(s_list, 3, "Packets heard, per 5 min", "8 h ago"); + setupCharts(); + chartCard(s_list, 0, "Battery"); + chartCard(s_list, 1, "Noise floor (dBm)"); + chartCard(s_list, 2, "SNR of the last packets (dB)"); + chartCard(s_list, 3, "Packets heard, per 5 min"); fillHistory(); return; } @@ -491,10 +460,10 @@ void UITask::diagSection(int sec) { void UITask::fillHistory() { using namespace diagview; s_hist_shown = s_hist.version; - chartOf(0, s_hist.batt, 20, false); - chartOf(1, s_hist.noise, 6, false); - chartOf(2, s_hist.snr, 20, false); - chartOf(3, s_hist.traffic, 4, true); + chartOf(0, s_hist.batt); + chartOf(1, s_hist.noise); + chartOf(2, s_hist.snr); + chartOf(3, s_hist.traffic); for (int k = 0; k < 4; k++) lv_obj_invalidate(s_ch_obj[k]); setTextFmt(s_ch_now[0], "%.2f V", (_batt_mv ? _batt_mv : getBattMilliVolts()) / 1000.0f); const int nf = (int)radio_driver.getNoiseFloor(); diff --git a/examples/companion_radio/ui-lvgl/NavMap.h b/examples/companion_radio/ui-lvgl/NavMap.h index a1cb1348..133d0b3c 100644 --- a/examples/companion_radio/ui-lvgl/NavMap.h +++ b/examples/companion_radio/ui-lvgl/NavMap.h @@ -977,7 +977,7 @@ void UITask::navToolsPopup() { _nav_trail_lbl = label(list, "", THEME_FONT_SMALL, theme::TEXT); profileview::s_prof = trailprofile::Sampler(); // its height profile, as it is now profileview::s_prof.feedAll(_core->trail.store()); - profileChart(list, 72); + profileChart(list, 120); lv_obj_t* r = buttonBar(list); _nav_trail_btn = toolButton(r, "", navmap::TL_TRAIL_TOGGLE, true); toolButton(r, LV_SYMBOL_SAVE " Save", navmap::TL_TRAIL_SAVE, false); @@ -1251,7 +1251,7 @@ void UITask::savedTrailPopup(int idx) { if (ok) snprintf(info, sizeof(info), "%s - %s - %d points", dist, dur, pts); else snprintf(info, sizeof(info), "Can't read this file"); label(panel, info, THEME_FONT_BODY, theme::TEXT); - if (ok) profileChart(panel, 60); + if (ok) profileChart(panel, 104); if (!_core->trail.store().empty()) label(panel, "Loading replaces the trail on the map.", THEME_FONT_SMALL, theme::TEXT_MUTED); lv_obj_t* r = buttonBar(panel); diff --git a/examples/companion_radio/ui-lvgl/ProfileView.h b/examples/companion_radio/ui-lvgl/ProfileView.h index 50f3c310..9471c71f 100644 --- a/examples/companion_radio/ui-lvgl/ProfileView.h +++ b/examples/companion_radio/ui-lvgl/ProfileView.h @@ -1,7 +1,7 @@ #pragma once -// A trail's height profile (ui-core/TrailProfile.h) as a chart: the height -// over the distance, a line in the accent over a faint fill, the climb and -// the highest and lowest points over it, top left. In the live trail's Map tools section and +// A trail's height profile (ui-core/TrailProfile.h) as a chart (Chart.h): +// the height over the distance, the climb and the highest and lowest points +// over it. In the live trail's Map tools section and // a saved trail's popup. ui-new draws the same on e-ink and the OLED // (TrailScreen.h). // @@ -10,42 +10,12 @@ namespace profileview { static trailprofile::Sampler s_prof; // what the chart on screen shows +static chart::Chart s_chart; +static char s_under[3][12]; -static void onDraw(lv_event_t* e) { - const trailprofile::Sampler& p = s_prof; - if (p.n < 2) return; - lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e); - lv_layer_t* layer = lv_event_get_layer(e); - lv_area_t a; - lv_obj_get_coords(o, &a); - int lo = p.st.lo, hi = p.st.hi; - if (hi - lo < 20) { const int mid = (hi + lo) / 2; lo = mid - 10; hi = mid + 10; } - const float total = p.total > 0 ? p.total : 1.0f; - const int x0 = a.x1 + 2, w = a.x2 - a.x1 - 4, y0 = a.y1 + 20, h = a.y2 - y0 - 2, base = a.y2 - 1; // under the caption - auto X = [&](int i) { return (int32_t)(x0 + p.dist[i] / total * w); }; - auto Y = [&](int i) { return (int32_t)(y0 + h - (p.alt[i] - lo) * h / (hi - lo)); }; - - lv_draw_line_dsc_t fill; // under the line, a column at a time (triangles would leave seams) - lv_draw_line_dsc_init(&fill); - fill.color = lv_color_hex(theme::ACCENT); - fill.opa = LV_OPA_20; - fill.width = 1; - lv_draw_line_dsc_t ld; - lv_draw_line_dsc_init(&ld); - ld.color = lv_color_hex(theme::ACCENT); - ld.width = 2; - ld.round_start = ld.round_end = 1; - for (int i = 1; i < p.n; i++) { - const int32_t xa = X(i - 1), xb = X(i), ya = Y(i - 1), yb = Y(i); - for (int32_t x = (i == 1 ? xa : xa + 1); x <= xb; x++) { - fill.p1.x = fill.p2.x = x; - fill.p1.y = xb > xa ? ya + (yb - ya) * (x - xa) / (xb - xa) : yb; - fill.p2.y = base; - lv_draw_line(layer, &fill); - } - ld.p1.x = xa; ld.p1.y = ya; ld.p2.x = xb; ld.p2.y = yb; - lv_draw_line(layer, &ld); - } +static void distText(float m, char* out, size_t n) { + if (m < 1000) snprintf(out, n, "%d m", (int)lroundf(m)); + else snprintf(out, n, "%.1f km", m / 1000.0f); } } // namespace profileview @@ -55,16 +25,36 @@ static void onDraw(lv_event_t* e) { static lv_obj_t* profileChart(lv_obj_t* parent, int h) { using namespace profileview; if (!s_prof.st.any || s_prof.n < 2) return nullptr; - lv_obj_t* c = lv_obj_create(parent); - styleSurface(c, theme::SURFACE); - lv_obj_remove_flag(c, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_remove_flag(c, LV_OBJ_FLAG_CLICKABLE); - fillWidth(c); - lv_obj_set_height(c, h); - lv_obj_set_style_radius(c, theme::RADIUS_SM, 0); - lv_obj_add_event_cb(c, onDraw, LV_EVENT_DRAW_MAIN_END, NULL); + chart::Chart& c = s_chart; + c = chart::Chart(); + const float total = s_prof.total > 0 ? s_prof.total : 1.0f; + c.n = s_prof.n; + c.spaced = true; + for (int i = 0; i < c.n; i++) { + c.v[i] = s_prof.alt[i]; + c.x[i] = (uint16_t)lroundf(s_prof.dist[i] / total * 10000); + } + c.min_span = 20; + c.axis = [](int v, int, char* o, size_t n) { snprintf(o, n, "%d m", v); }; + c.value = [](const chart::Chart& c, int i, char* o, size_t n) { snprintf(o, n, "%d m", c.v[i]); }; + c.where = [](const chart::Chart& c, int i, char* o, size_t n) { (void)c; distText(s_prof.dist[i], o, n); }; + distText(0, s_under[0], sizeof(s_under[0])); + distText(total / 2, s_under[1], sizeof(s_under[1])); + distText(total, s_under[2], sizeof(s_under[2])); + for (int j = 0; j < 3; j++) c.under[j] = s_under[j]; + const int lh = lv_font_get_line_height(THEME_FONT_SMALL); + c.top = lh + 2; // the caption + lv_obj_t* box = lv_obj_create(parent); + styleSurface(box, theme::SURFACE); + lv_obj_remove_flag(box, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(box, LV_OBJ_FLAG_CLICKABLE); + fillWidth(box); + lv_obj_set_height(box, h); + lv_obj_set_style_radius(box, theme::RADIUS_SM, 0); + lv_obj_set_style_pad_all(box, 6, 0); + lv_obj_t* o = chart::create(box, c, h - 12); char t[64]; snprintf(t, sizeof(t), "Climb %d m - highest %d m - lowest %d m", s_prof.st.gain, s_prof.st.hi, s_prof.st.lo); - lv_obj_align(label(c, t, THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_TOP_LEFT, 6, 2); - return c; + lv_obj_align(label(o, t, THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_TOP_LEFT, 0, 0); + return box; } diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 1471c2db..ca3e0ac9 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -3530,6 +3530,7 @@ void UITask::nodeAction(uint8_t action) { static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub, uint8_t* pref); // below #include "MapScreen.h" +#include "Chart.h" #include "ProfileView.h" #include "NavMap.h" #include "MapAreas.h" @@ -4641,6 +4642,37 @@ extern "C" EMSCRIPTEN_KEEPALIVE void sim_open(const char* name) { if (!strcmp(name, "region0")) { s_ui->mapRegionPopup(0); return; } if (!strcmp(name, "advert")) { s_ui->advertPopup(); return; } if (!strcmp(name, "trailsave")) { s_ui->navToolAction(navmap::TL_TRAIL_SAVE); return; } // Map tools > Save + if (!strcmp(name, "histdemo")) { // 8 h of made-up history, for the charts + using namespace diagview; + for (int i = 0; i < 96; i++) { + s_hist.batt.push((int16_t)(4150 - i * 4 + (i % 7) * 3)); + s_hist.noise.push((int16_t)(-112 + (i % 11 == 0 ? 6 : 0) + (i / 24))); + s_hist.traffic.push((int16_t)((i * 37) % 13 + (i % 16 == 0 ? 20 : 0))); + } + for (int i = 0; i < 32; i++) s_hist.snr.push((int16_t)(4 * (8 - (i * 7) % 19))); + s_hist.version++; + s_near.f0 = 869.525f - s_near.step * (s_near.n / 2); // and a noise sweep + for (int i = 0; i < s_near.n; i++) s_near.v[i] = (int16_t)(-118 + (i % 9 == 0 ? 7 : (i * 5) % 4)); + s_floor_med = -116; s_floor_lo = -118; + s_noise_have = true; + return; + } + if (!strcmp(name, "profdemo")) { // a made-up height profile in a popup, for the chart + using namespace profileview; + s_prof = trailprofile::Sampler(); + for (int i = 0; i < 100; i++) { + s_prof.dist[i] = i * 62.0f; + s_prof.alt[i] = (int16_t)(420 + 80 * sinf(i / 14.0f) + i * 1.5f); + s_prof.st.add(s_prof.alt[i]); + } + s_prof.n = 100; s_prof.total = 99 * 62.0f; + lv_obj_t* box = lv_obj_create(lv_layer_top()); + styleSurface(box, theme::BG); + lv_obj_set_size(box, 304, 132); + lv_obj_center(box); + profileChart(box, 120); + return; + } if (!strcmp(name, "savedtrail0")) { navmap::scanTrails(); s_ui->savedTrailPopup(0); return; } if (!strncmp(name, "trail@", 6)) { // "trail@name.trl" from /sdcard/trails: Load, as the saved-trail popup does navmap::scanTrails();