feat(map): packet flow - what the radio hears, as lines between nodes

Every packet heard runs from its origin through the repeaters with a
position to this device; a packet sent is a ring. ui-core/PacketFlow.h
resolves them (UiCore::flow, MyMesh logRx/logTx -> Listener). ui-lvgl
draws them on the map's marker layer and repaints only their area while
they move; ui-new draws the last minute on the Nodes map ("Flow" in its
options). Setting: Map options > On the map > Packet flow, CLI map.flow.
Off by default. Sim-checked only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-14 12:50:43 +02:00
co-authored by Claude Opus 5.5
parent 120ddfc830
commit a8440c890e
15 changed files with 478 additions and 7 deletions
@@ -258,6 +258,141 @@ static bool prefetchOne(double left, double top, int w, int h, int z) {
return true;
}
// ── Packet flow (ui-core/PacketFlow.h) ──
// Each packet heard runs along its line, sender -> repeaters -> here, as a
// dot leaving a trail that fades; a packet sent (and one heard on its way
// elsewhere) is a ring spreading from where it is. Drawn on the marker
// layer, under the names and dots. Only the area the flows cover is
// repainted while they move.
static PacketFlow* s_flow = nullptr;
static double s_view_left = 0, s_view_top = 0; // the view, from the last layout
static int s_view_z = 0;
static bool s_view_ok = false;
static const uint32_t FLOW_LIFE = 7000; // ms a flow stays on the map
static const uint32_t FLOW_TRAVEL = 1500; // ms the dot takes from end to end
static const uint32_t PULSE_MS = 1400;
static lv_area_t s_flow_area; // what the flows covered at the last tick
static bool s_flow_area_on = false;
static uint32_t flowColour(uint8_t kind) {
switch (kind) {
case PacketFlow::K_ADVERT: return 0x4FC3F7; // blue
case PacketFlow::K_MSG: return theme::OK; // green
case PacketFlow::K_GROUP: return theme::ACCENT;
case PacketFlow::K_SENT: return theme::TEXT;
case PacketFlow::K_AHEAD: return theme::TEXT_MUTED;
default: return 0xB39DDB; // violet
}
}
static void flowPx(int32_t lat, int32_t lon, int32_t& x, int32_t& y) {
double fx = lonToTileX(lon / 1e6, s_view_z) * TILE_PX - s_view_left;
double fy = latToTileY(lat / 1e6, s_view_z) * TILE_PX - s_view_top;
x = (int32_t)lround(fx < -30000 ? -30000 : fx > 30000 ? 30000 : fx);
y = (int32_t)lround(fy < -30000 ? -30000 : fy > 30000 ? 30000 : fy);
}
// The area (layer px) the live flows cover, with room for a ring; false: none.
static bool flowArea(lv_area_t& r) {
if (!s_flow || !s_view_ok || !s_flow->enabled()) return false;
const uint32_t now = millis();
bool any = false;
for (int i = 0; i < s_flow->count(); i++) {
const PacketFlow::Flow& f = s_flow->at(i);
if (now - f.t_ms >= FLOW_LIFE) break; // newest first: the rest are older
for (int k = 0; k < f.n; k++) {
int32_t x, y;
flowPx(f.lat[k], f.lon[k], x, y);
if (!any) { r = { x, y, x, y }; any = true; }
r.x1 = min(r.x1, x); r.y1 = min(r.y1, y); r.x2 = max(r.x2, x); r.y2 = max(r.y2, y);
}
}
if (any) { r.x1 -= 28; r.y1 -= 28; r.x2 += 28; r.y2 += 28; }
return any;
}
static void drawFlows(lv_layer_t* layer, const lv_area_t& a) {
if (!s_flow || !s_view_ok || !s_flow->enabled()) return;
const uint32_t now = millis();
lv_draw_line_dsc_t ln, halo; // the line, over a dark halo that lifts it off the map
lv_draw_line_dsc_init(&ln);
ln.width = 3;
ln.round_start = ln.round_end = 1;
halo = ln;
halo.width = 6;
halo.color = lv_color_hex(theme::BG);
lv_draw_arc_dsc_t ring;
lv_draw_arc_dsc_init(&ring);
ring.start_angle = 0; ring.end_angle = 360;
ring.width = 3;
lv_draw_rect_dsc_t dot;
lv_draw_rect_dsc_init(&dot);
dot.radius = LV_RADIUS_CIRCLE;
dot.bg_opa = LV_OPA_COVER;
dot.border_color = lv_color_hex(theme::BG);
dot.border_width = 2;
dot.border_opa = LV_OPA_COVER;
for (int i = s_flow->count() - 1; i >= 0; i--) { // oldest first: the newest on top
const PacketFlow::Flow& f = s_flow->at(i);
const uint32_t age = now - f.t_ms;
if (age >= FLOW_LIFE) continue;
const lv_color_t col = lv_color_hex(flowColour(f.kind));
int32_t px[PacketFlow::MAX_PTS], py[PacketFlow::MAX_PTS];
for (int k = 0; k < f.n; k++) { flowPx(f.lat[k], f.lon[k], px[k], py[k]); px[k] += a.x1; py[k] += a.y1; }
if (f.n == 1) { // a pulse
if (age >= PULSE_MS) continue;
ring.color = col;
ring.center.x = px[0]; ring.center.y = py[0];
ring.radius = (uint16_t)(5 + 22 * age / PULSE_MS);
ring.opa = (lv_opa_t)(255 - 255 * age / PULSE_MS);
lv_draw_arc(layer, &ring);
continue;
}
// The trail behind the dot, fading over the flow's life.
float len[PacketFlow::MAX_PTS];
float total = 0;
for (int k = 1; k < f.n; k++) {
float dx = px[k] - px[k - 1], dy = py[k] - py[k - 1];
len[k] = sqrtf(dx * dx + dy * dy);
total += len[k];
}
const float t = age >= FLOW_TRAVEL ? 1.0f : (float)age / FLOW_TRAVEL;
float left = total * t;
ln.color = col;
ln.opa = (lv_opa_t)(age < FLOW_TRAVEL ? 255 : 255 - 215 * (age - FLOW_TRAVEL) / (FLOW_LIFE - FLOW_TRAVEL));
halo.opa = (lv_opa_t)(ln.opa * 2 / 3);
int32_t hx = px[0], hy = py[0]; // the dot
int32_t ex_[PacketFlow::MAX_PTS], ey_[PacketFlow::MAX_PTS];
int segs = 0;
for (int k = 1; k < f.n && left > 0; k++, segs++) {
float part = left >= len[k] ? 1.0f : left / len[k];
ex_[k] = px[k - 1] + (int32_t)lroundf((px[k] - px[k - 1]) * part);
ey_[k] = py[k - 1] + (int32_t)lroundf((py[k] - py[k - 1]) * part);
hx = ex_[k]; hy = ey_[k];
left -= len[k];
}
for (int pass = 0; pass < 2; pass++) // all the halo, then all the line: joints stay clean
for (int k = 1; k <= segs; k++) {
lv_draw_line_dsc_t& d = pass ? ln : halo;
d.p1 = { px[k - 1], py[k - 1] };
d.p2 = { ex_[k], ey_[k] };
lv_draw_line(layer, &d);
}
if (age < FLOW_TRAVEL) {
dot.bg_color = col;
lv_area_t d = { hx - 5, hy - 5, hx + 5, hy + 5 };
lv_draw_rect(layer, &dot, &d);
} else if (age < FLOW_TRAVEL + PULSE_MS) { // arrived: a ring here
uint32_t p = age - FLOW_TRAVEL;
ring.color = col;
ring.center.x = px[f.n - 1]; ring.center.y = py[f.n - 1];
ring.radius = (uint16_t)(5 + 16 * p / PULSE_MS);
ring.opa = (lv_opa_t)(220 - 220 * p / PULSE_MS);
lv_draw_arc(layer, &ring);
}
}
}
static const int MARK_D = 12; // marker dot diameter
static const int MARK_LABELS_MAX = 20; // names shown at once: the rest stay dots
@@ -279,6 +414,7 @@ static void drawMarks(lv_event_t* e) {
lv_draw_line(layer, &ln);
}
}
drawFlows(layer, a);
lv_draw_rect_dsc_t pill;
lv_draw_rect_dsc_init(&pill);
pill.bg_color = lv_color_hex(theme::BG);
@@ -561,6 +697,9 @@ void UITask::buildMap() {
lv_obj_remove_flag(mapview::s_mark_layer, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_flag(mapview::s_mark_layer, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_event_cb(mapview::s_mark_layer, mapview::drawMarks, LV_EVENT_DRAW_MAIN, NULL);
mapview::s_flow = &_core->flow;
mapview::s_view_ok = false;
mapview::s_flow_area_on = false;
mapview::s_sig_n = 0;
areaBuildLayer(body); // download frame / area outlines, over the tiles (MapAreas.h)
@@ -744,6 +883,7 @@ void UITask::layoutMap() {
// One tile decode per call, nearest to the centre first.
void UITask::mapLoop() {
mapFlowTick();
if (_map_follow && (int32_t)(millis() - _next_follow_ms) >= 0) { // following you: every fix
_next_follow_ms = millis() + 1000;
int32_t lat, lon;
@@ -871,6 +1011,9 @@ void UITask::addMapMark(uint8_t kind, int idx, int32_t lat_e6, int32_t lon_e6, u
void UITask::layoutMapMarks(double left, double top, int w, int h) {
using namespace mapview;
if (!s_mark_layer) return;
if (s_view_ok && (s_view_left != left || s_view_top != top || s_view_z != _map_z) && flowArea(s_flow_area))
lv_obj_invalidate(s_mark_layer); // the view moved under live flows
s_view_left = left; s_view_top = top; s_view_z = _map_z; s_view_ok = true;
const int half = MARK_D / 2;
const int lh = lv_font_get_line_height(THEME_FONT_SMALL) + 2;
struct Box { int16_t x1, y1, x2, y2; };
@@ -1034,6 +1177,27 @@ void UITask::mapLongPress(int x, int y) {
}
// Packet flow: while flows move, the area they cover (and covered) is
// repainted ~20 times a second; nothing at all when none is live.
void UITask::mapFlowTick() {
using namespace mapview;
if (!s_mark_layer || (int32_t)(millis() - _next_flow_ms) < 0) return;
_next_flow_ms = millis() + 50;
lv_area_t r;
bool now_on = flowArea(r);
if (!now_on && !s_flow_area_on) return;
lv_area_t la;
lv_obj_get_coords(s_mark_layer, &la);
auto inval = [&](const lv_area_t& b) {
lv_area_t x = { la.x1 + b.x1, la.y1 + b.y1, la.x1 + b.x2, la.y1 + b.y2 };
lv_obj_invalidate_area(s_mark_layer, &x);
};
if (s_flow_area_on) inval(s_flow_area);
if (now_on) inval(r);
s_flow_area = r;
s_flow_area_on = now_on;
}
// ── Picking a route (Route > Map) ─────────────────────────────────────────────
static void onRouteUndo(lv_event_t* e) { (void)e; s_ui->routeMapUndo(); }
@@ -131,6 +131,7 @@ public:
void routeMapUndo();
void routeMapFrame();
void buildRouteBar(lv_obj_t* body);
void mapFlowTick();
void mapDownloadPopup();
// Map areas (MapAreas.h)
void areaBuildLayer(lv_obj_t* body);
@@ -366,6 +367,7 @@ public:
void openAdminFor(const ContactInfo& ci, bool from_picker) { (void)from_picker; openAdmin(ci.id.pub_key); }
void openRouteFor(const uint8_t* pub_key) { showRoute(pub_key); }
void openMessagesList() { showChats(); } // sim harness
bool openNodesMap() { openMap(false); return true; } // sim harness
private:
enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT, SCR_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER, SCR_MELODY, SCR_QUICK, SCR_ADMIN_PICK, SCR_OTA, SCR_STORAGE, SCR_GPS, SCR_BATT, SCR_ROUTE, SCR_ROUTE_PICK };
@@ -641,6 +643,7 @@ private:
lv_obj_t* _route_keep_btn = nullptr; // header: Keep (accent while kept)
uint32_t _route_shown = 0xFFFFFFFF; // what routeRebuild() drew (routeSig())
uint32_t _next_route_ms = 0;
uint32_t _next_flow_ms = 0; // the map's packet flow repaint
bool _route_pick = false; // the nodes map taps repeaters into the route
lv_obj_t* _route_bar = nullptr;
lv_obj_t* _route_lbl = nullptr;