feat(map): packet flow shows all traffic - kinds in colours, a legend, its own nodes

- Every packet heard is drawn, by kind in fixed colours: advert, DM,
  channel, request, ack, other, sent. One that can't be placed on the map
  is a ring at your own position.
- A legend pill at the top counts each kind over the last minute.
- On a map without node markers, a flow brings its own nodes (a dot and
  the name), fading with it.
- Copies of one packet (the same packet hash) share one fade, so a path
  goes out as a whole, not segment by segment.
- 32 flows kept on ESP32 (12 on nRF).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-14 17:24:24 +02:00
co-authored by Claude Opus 5.5
parent 2f2db6045c
commit 85ef06b6be
5 changed files with 237 additions and 44 deletions
+6 -2
View File
@@ -43,8 +43,12 @@ back out, **Enter** opens its details and **Hold Enter** its options.
> [!NOTE]
> **Wio Tracker L2:** **Packet flow** is in the map's options (On the map).
> Each packet runs along its line as a dot and fades, coloured by kind:
> adverts blue, direct messages green, channels amber, the rest violet.
> Every packet heard shows. One that can be placed runs along its line as a
> dot and fades. One that can't (its sender or repeaters have no position)
> is a ring around you. Colours by kind: adverts blue, direct messages
> green, channels amber, requests violet, acks and paths teal, the rest
> pink, your own white. The pill at the top counts each kind over the last
> minute.
> [!NOTE]
> **Wio Tracker L2:** the **Nodes** app. Filter chips at the top, the sort in
+122 -31
View File
@@ -14,9 +14,14 @@
// sender's one-byte hash (matched like a hop, to any contact);
// - its end: this device (the GPS, else the position set in Privacy).
// Points without a position are skipped, so a flow may be shorter than its
// path or none at all (a group message from a node in direct range).
// A direct packet heard on its way elsewhere carries the hops still ahead:
// a pulse at the next one.
// path. A packet nothing of which can be placed (a group message from a node
// in direct range, a sender with no position) is still a pulse here: every
// packet heard shows. A direct packet heard on its way elsewhere carries the
// hops still ahead: a pulse at the next one (here when it isn't known).
// Each kind is counted over the last minute (count()), for a legend.
// One packet is often heard several times (direct, then via each repeater):
// its flows share one fading clock, restarted by every copy (t_fade), so the
// whole picture of it goes at once.
//
// Off (NodePrefs::flow_show 0) nothing is resolved: a contact scan per hop is
// cheap, but not free on every packet.
@@ -25,19 +30,53 @@
class PacketFlow {
public:
static const int MAX_FLOWS = 12;
#if defined(ESP32)
static const int MAX_FLOWS = 32; // a busy mesh: a few seconds of it
#else
static const int MAX_FLOWS = 12; // the nRF52's heap is short
#endif
static const int MAX_PTS = 10; // origin, up to 8 repeaters, this device
enum Kind : uint8_t { K_ADVERT, K_MSG, K_GROUP, K_OTHER, K_SENT, K_AHEAD };
// What a packet is (its payload type), and one of our own.
enum Kind : uint8_t { K_ADVERT, K_MSG, K_GROUP, K_REQ, K_ACK, K_OTHER, K_SENT, K_COUNT };
struct Flow {
uint32_t t_ms; // when it was heard
uint32_t t_fade; // when its fading clock starts: the last copy of the same packet heard
uint32_t id; // the packet (its hash): copies heard via other repeaters go together
uint8_t kind;
uint8_t n; // points (1 for a pulse)
bool origin; // point 0 is the sender (else the first repeater placed)
bool here; // a pulse here: nothing of it could be placed
int32_t lat[MAX_PTS], lon[MAX_PTS];
int16_t node[MAX_PTS]; // the contact at each point (getContactByIdx), -1 this device / unknown
};
static uint8_t kindOf(uint8_t type) {
switch (type) {
case PAYLOAD_TYPE_ADVERT: return K_ADVERT;
case PAYLOAD_TYPE_TXT_MSG: return K_MSG;
case PAYLOAD_TYPE_GRP_TXT:
case PAYLOAD_TYPE_GRP_DATA: return K_GROUP;
case PAYLOAD_TYPE_REQ:
case PAYLOAD_TYPE_RESPONSE:
case PAYLOAD_TYPE_ANON_REQ: return K_REQ;
case PAYLOAD_TYPE_ACK:
case PAYLOAD_TYPE_PATH: return K_ACK;
default: return K_OTHER; // trace, control, multipart, raw
}
}
// Packets of `kind` heard (K_SENT: sent) in the last minute.
int count(uint8_t kind) const {
if (kind >= K_COUNT) return 0;
const uint32_t now = millis() / BUCKET_MS;
int n = 0;
for (int b = 0; b < BUCKETS; b++)
if (now - _bucket_at[b] < BUCKETS) n += _cnt[b][kind];
return n;
}
void begin(NodePrefs* prefs, CourseEngine* course, SensorManager* sensors) {
_prefs = prefs; _course = course; _sensors = sensors;
}
@@ -58,17 +97,23 @@ public:
f.t_ms = millis();
f.n = 0;
f.origin = false;
f.here = false;
f.id = idOf(pkt);
const uint8_t type = pkt->getPayloadType();
f.kind = type == PAYLOAD_TYPE_ADVERT ? K_ADVERT
: type == PAYLOAD_TYPE_TXT_MSG ? K_MSG
: type == PAYLOAD_TYPE_GRP_TXT || type == PAYLOAD_TYPE_GRP_DATA ? K_GROUP : K_OTHER;
f.kind = kindOf(type);
tally(f.kind);
const uint8_t hs = pkt->getPathHashSize(), hc = pkt->getPathHashCount();
int32_t ola, olo;
const bool own = ownPosition(ola, olo);
if (pkt->isRouteDirect()) { // on its way elsewhere: a pulse at the next hop
// On its way elsewhere: a pulse at the next hop. (A trace's path is its
// SNRs, not hashes.)
if (pkt->isRouteDirect() || type == PAYLOAD_TYPE_TRACE) {
int32_t lat, lon;
if (hc == 0 || !placeHash(pkt->path, hs, false, 0, 0, false, lat, lon)) return;
f.kind = K_AHEAD;
add(f, lat, lon);
int16_t idx;
if (type != PAYLOAD_TYPE_TRACE && hc > 0 && placeHash(pkt->path, hs, false, 0, 0, false, lat, lon, idx)) add(f, lat, lon, idx);
else if (own) { add(f, ola, olo); f.here = true; }
else return;
push(f);
return;
}
@@ -76,28 +121,36 @@ public:
// Back from this device: each hash nearest the point after it, which
// it was heard by -- the last repeater is the one heard here.
int32_t lat[MAX_PTS], lon[MAX_PTS];
int16_t node[MAX_PTS];
int k = MAX_PTS;
int32_t ola, olo;
const bool own = ownPosition(ola, olo);
if (own) { k--; lat[k] = ola; lon[k] = olo; }
if (own) { k--; lat[k] = ola; lon[k] = olo; node[k] = -1; }
for (int i = hc - 1; i >= 0 && k > 1; i--) {
int32_t a, b;
int16_t idx;
const bool near = k < MAX_PTS;
if (placeHash(&pkt->path[i * hs], hs, true, near ? lat[k] : 0, near ? lon[k] : 0, near, a, b)) {
k--; lat[k] = a; lon[k] = b;
if (placeHash(&pkt->path[i * hs], hs, true, near ? lat[k] : 0, near ? lon[k] : 0, near, a, b, idx)) {
k--; lat[k] = a; lon[k] = b; node[k] = idx;
}
}
int32_t a, b;
if (k > 0 && origin(pkt, k < MAX_PTS, k < MAX_PTS ? lat[k] : 0, k < MAX_PTS ? lon[k] : 0, a, b)) {
k--; lat[k] = a; lon[k] = b;
int16_t idx;
if (k > 0 && origin(pkt, k < MAX_PTS, k < MAX_PTS ? lat[k] : 0, k < MAX_PTS ? lon[k] : 0, a, b, idx)) {
k--; lat[k] = a; lon[k] = b; node[k] = idx;
f.origin = true;
}
for (int i = k; i < MAX_PTS; i++) add(f, lat[i], lon[i]);
if (f.n >= 2) push(f);
for (int i = k; i < MAX_PTS; i++) add(f, lat[i], lon[i], node[i]);
if (f.n < 2) { // only here, or nothing: a pulse here
if (!own) return;
f.n = 0;
add(f, ola, olo);
f.here = true;
}
push(f);
}
void onSent(const mesh::Packet* pkt) {
if (!enabled() || !pkt) return;
tally(K_SENT);
int32_t lat, lon;
if (!ownPosition(lat, lon)) return;
Flow f;
@@ -105,19 +158,42 @@ public:
f.kind = K_SENT;
f.n = 0;
f.origin = true;
f.here = false;
f.id = idOf(pkt);
add(f, lat, lon);
push(f);
}
void clear() { _n = 0; _head = 0; _seq++; }
void clear() { _n = 0; _head = 0; _seq++; memset(_cnt, 0, sizeof(_cnt)); }
private:
static void add(Flow& f, int32_t lat, int32_t lon) {
if (f.n > 0 && f.lat[f.n - 1] == lat && f.lon[f.n - 1] == lon) return; // the same spot twice
f.lat[f.n] = lat; f.lon[f.n] = lon; f.n++;
static const int BUCKETS = 6; // the minute, in 10 s steps
static const uint32_t BUCKET_MS = 10000;
void tally(uint8_t kind) {
const uint32_t now = millis() / BUCKET_MS;
const int b = now % BUCKETS;
if (_bucket_at[b] != now) { _bucket_at[b] = now; memset(_cnt[b], 0, sizeof(_cnt[b])); }
if (_cnt[b][kind] < 0xFFFF) _cnt[b][kind]++;
}
void push(const Flow& f) {
static void add(Flow& f, int32_t lat, int32_t lon, int16_t node = -1) {
if (f.n > 0 && f.lat[f.n - 1] == lat && f.lon[f.n - 1] == lon) return; // the same spot twice
f.lat[f.n] = lat; f.lon[f.n] = lon; f.node[f.n] = node; f.n++;
}
static uint32_t idOf(const mesh::Packet* pkt) {
uint8_t h[MAX_HASH_SIZE];
pkt->calculatePacketHash(h);
uint32_t id;
memcpy(&id, h, sizeof(id));
return id;
}
void push(Flow f) {
f.t_fade = f.t_ms;
for (int i = 0; i < _n; i++) // the earlier copies of it: their fade starts again with this one
if (_flows[i].id == f.id) _flows[i].t_fade = f.t_ms;
_flows[_head] = f;
_head = (_head + 1) % MAX_FLOWS;
if (_n < MAX_FLOWS) _n++;
@@ -134,9 +210,13 @@ private:
// Where the packet came from, when it says (a one-byte hash: the contact
// nearest the point after it, when there is one).
static bool origin(const mesh::Packet* pkt, bool near, int32_t nlat, int32_t nlon, int32_t& lat, int32_t& lon) {
static bool origin(const mesh::Packet* pkt, bool near, int32_t nlat, int32_t nlon, int32_t& lat, int32_t& lon,
int16_t& node) {
const uint8_t type = pkt->getPayloadType();
node = -1;
if (type == PAYLOAD_TYPE_ADVERT) {
ContactInfo* c = the_mesh.lookupContactByPubKey(pkt->payload, PUB_KEY_SIZE);
if (c) node = indexOf(c->id.pub_key);
const int app = PUB_KEY_SIZE + 4 + SIGNATURE_SIZE; // key, timestamp, signature, then the app data
if (pkt->payload_len > app) {
AdvertDataParser p(&pkt->payload[app], pkt->payload_len - app);
@@ -145,13 +225,12 @@ private:
return true;
}
}
ContactInfo* c = the_mesh.lookupContactByPubKey(pkt->payload, PUB_KEY_SIZE);
if (c && (c->gps_lat || c->gps_lon)) { lat = c->gps_lat; lon = c->gps_lon; return true; }
return false;
}
if ((type == PAYLOAD_TYPE_TXT_MSG || type == PAYLOAD_TYPE_REQ || type == PAYLOAD_TYPE_RESPONSE ||
type == PAYLOAD_TYPE_PATH) && pkt->payload_len >= 2)
return placeHash(&pkt->payload[1], 1, false, nlat, nlon, near, lat, lon); // [dest hash, src hash, ...]
return placeHash(&pkt->payload[1], 1, false, nlat, nlon, near, lat, lon, node); // [dest hash, src hash, ...]
return false;
}
@@ -159,8 +238,9 @@ private:
// repeaters only when `repeaters`; with several, the nearest to (nlat, nlon)
// when `near`, else the first.
static bool placeHash(const uint8_t* hash, uint8_t size, bool repeaters, int32_t nlat, int32_t nlon, bool near,
int32_t& lat, int32_t& lon) {
int32_t& lat, int32_t& lon, int16_t& node) {
bool found = false;
node = -1;
float best = 0;
for (int idx = MAX_ANON_CONTACTS; ; idx++) {
ContactInfo c;
@@ -168,16 +248,27 @@ private:
if (repeaters && c.type != ADV_TYPE_REPEATER) continue;
if ((!c.gps_lat && !c.gps_lon) || memcmp(c.id.pub_key, hash, size) != 0) continue;
float d = near ? geo::haversineKm(nlat, nlon, c.gps_lat, c.gps_lon) : 0;
if (!found || d < best) { best = d; lat = c.gps_lat; lon = c.gps_lon; found = true; }
if (!found || d < best) { best = d; lat = c.gps_lat; lon = c.gps_lon; node = (int16_t)idx; found = true; }
if (!near) break;
}
return found;
}
// The contact's index (getContactByIdx) by its key; -1 none.
static int16_t indexOf(const uint8_t* key) {
for (int idx = MAX_ANON_CONTACTS; ; idx++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(idx, c)) return -1;
if (memcmp(c.id.pub_key, key, PUB_KEY_SIZE) == 0) return (int16_t)idx;
}
}
NodePrefs* _prefs = nullptr;
CourseEngine* _course = nullptr;
SensorManager* _sensors = nullptr;
Flow _flows[MAX_FLOWS];
int _head = 0, _n = 0;
uint32_t _seq = 0;
uint16_t _cnt[BUCKETS][K_COUNT] = {};
uint32_t _bucket_at[BUCKETS] = {};
};
+105 -10
View File
@@ -271,19 +271,40 @@ 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 bool s_flow_nodes = false; // no node markers on this map: a flow draws its own, while it lasts
static lv_area_t s_flow_area; // what the flows covered at the last tick
static bool s_flow_area_on = false;
// Fixed colours, not the accent: a kind looks the same whatever the theme.
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_MSG: return 0x6FCF6F; // green
case PacketFlow::K_GROUP: return 0xFFB000; // amber
case PacketFlow::K_REQ: return 0xB39DDB; // violet
case PacketFlow::K_ACK: return 0x80CBC4; // teal
case PacketFlow::K_SENT: return theme::TEXT;
case PacketFlow::K_AHEAD: return theme::TEXT_MUTED;
default: return 0xB39DDB; // violet
default: return 0xF48FB1; // pink: trace, control ...
}
}
static const char* const FLOW_NAMES[PacketFlow::K_COUNT] = { "Adv", "DM", "Ch", "Req", "Ack", "Other", "Sent" };
static lv_obj_t* s_flow_legend = nullptr; // top centre: each kind's count over the last minute
// "Adv 3 Ch 5 ...", each in its colour; hidden with nothing to count.
static void flowLegendRefresh() {
if (!s_flow_legend) return;
char t[160];
int o = 0;
for (int k = 0; s_flow && s_flow->enabled() && k < PacketFlow::K_COUNT; k++) {
const int n = s_flow->count(k);
if (n > 0 && o < (int)sizeof(t) - 24)
o += snprintf(t + o, sizeof(t) - o, "%s#%06lX %s %d#", o ? " " : "", (unsigned long)flowColour(k), FLOW_NAMES[k], n);
}
t[o] = '\0';
if (!o) { lv_obj_add_flag(s_flow_legend, LV_OBJ_FLAG_HIDDEN); return; }
setText(s_flow_legend, t);
lv_obj_remove_flag(s_flow_legend, LV_OBJ_FLAG_HIDDEN);
}
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;
@@ -299,7 +320,7 @@ static bool flowArea(lv_area_t& r) {
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
if (now - f.t_fade >= FLOW_LIFE) continue; // (an older one may live on with a newer copy)
for (int k = 0; k < f.n; k++) {
int32_t x, y;
flowPx(f.lat[k], f.lon[k], x, y);
@@ -307,10 +328,71 @@ static bool flowArea(lv_area_t& r) {
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; }
if (any) { r.x1 -= 28; r.y1 -= 28; r.x2 += s_flow_nodes ? 120 : 28; r.y2 += 28; } // (a node's name to its right)
return any;
}
// On a map without node markers, the nodes a flow passes -- its sender and
// repeaters, a dot and the name -- shown while it lasts and fading with it,
// each once however many flows share it (the most visible one counts).
static void drawFlowNodes(lv_layer_t* layer, const lv_area_t& a, uint32_t now) {
struct Node { int16_t idx; lv_opa_t opa; int32_t x, y; uint8_t kind; };
Node nodes[24];
int nn = 0;
for (int i = 0; i < s_flow->count(); i++) { // newest first
const PacketFlow::Flow& f = s_flow->at(i);
const uint32_t age = now - (f.n == 1 ? f.t_ms : f.t_fade); // a pulse's node: as long as the pulse
const uint32_t life = f.n == 1 ? PULSE_MS : FLOW_LIFE;
if (age >= life) continue;
const lv_opa_t opa = (lv_opa_t)(age < FLOW_TRAVEL ? 255 : 255 - 255 * (age - FLOW_TRAVEL) / (life - FLOW_TRAVEL + 1));
for (int k = 0; k < f.n; k++) {
if (f.node[k] < 0) continue;
int j = 0;
while (j < nn && nodes[j].idx != f.node[k]) j++;
if (j < nn) { if (opa > nodes[j].opa) nodes[j].opa = opa; continue; }
if (nn == (int)(sizeof(nodes) / sizeof(nodes[0]))) continue;
int32_t x, y;
flowPx(f.lat[k], f.lon[k], x, y);
nodes[nn++] = { f.node[k], opa, a.x1 + x, a.y1 + y, f.kind };
}
}
lv_draw_rect_dsc_t dot, pill;
lv_draw_rect_dsc_init(&dot);
dot.radius = LV_RADIUS_CIRCLE;
dot.border_color = lv_color_hex(theme::BG);
dot.border_width = 2;
lv_draw_rect_dsc_init(&pill);
pill.bg_color = lv_color_hex(theme::BG);
pill.radius = 4;
lv_draw_label_dsc_t ld;
lv_draw_label_dsc_init(&ld);
ld.font = THEME_FONT_SMALL;
ld.color = lv_color_hex(theme::TEXT);
ld.text_local = true;
const int32_t lh = lv_font_get_line_height(THEME_FONT_SMALL);
for (int j = 0; j < nn; j++) {
const Node& n = nodes[j];
if (n.x < a.x1 - 120 || n.x > a.x2 + 8 || n.y < a.y1 - 20 || n.y > a.y2 + 20) continue;
dot.bg_color = lv_color_hex(flowColour(n.kind));
dot.bg_opa = dot.border_opa = n.opa;
lv_area_t da = { n.x - 5, n.y - 5, n.x + 5, n.y + 5 };
lv_draw_rect(layer, &dot, &da);
ContactInfo c;
if (!the_mesh.getContactByIdx(n.idx, c) || !c.name[0]) continue;
char name[20];
snprintf(name, sizeof(name), "%s", c.name);
lv_point_t sz;
lv_text_get_size(&sz, name, THEME_FONT_SMALL, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE);
lv_area_t pa = { n.x + 8, n.y - lh / 2 - 1, n.x + 8 + sz.x + 9, n.y + lh / 2 + 1 };
pill.bg_opa = (lv_opa_t)(n.opa * 4 / 5);
lv_draw_rect(layer, &pill, &pa);
lv_area_t ta = { pa.x1 + 5, pa.y1 + 1, pa.x2 - 4, pa.y2 };
ld.text = name;
ld.opa = n.opa;
lv_draw_label(layer, &ld, &ta);
}
}
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();
@@ -334,8 +416,9 @@ static void drawFlows(lv_layer_t* layer, const lv_area_t& a) {
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 uint32_t age = now - f.t_ms; // the dot's run
const uint32_t fade = now - f.t_fade; // the fading: with every copy of the packet
if (fade >= 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; }
@@ -359,7 +442,7 @@ static void drawFlows(lv_layer_t* layer, const lv_area_t& a) {
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));
ln.opa = (lv_opa_t)(fade < FLOW_TRAVEL ? 255 : 255 - 215 * (fade - 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];
@@ -391,6 +474,7 @@ static void drawFlows(lv_layer_t* layer, const lv_area_t& a) {
lv_draw_arc(layer, &ring);
}
}
if (s_flow_nodes) drawFlowNodes(layer, a, now);
}
static const int MARK_D = 12; // marker dot diameter
@@ -750,6 +834,14 @@ void UITask::buildMap() {
lv_obj_set_ext_click_area(_map_dl_pill, 6);
lv_obj_add_event_cb(_map_dl_pill, onMapDownload, LV_EVENT_CLICKED, NULL);
lv_obj_add_flag(_map_dl_pill, LV_OBJ_FLAG_HIDDEN);
mapview::s_flow_legend = mapPill(mapview::s_going, ""); // packet flow: what's been heard
lv_label_set_recolor(mapview::s_flow_legend, true);
lv_obj_set_style_max_width(mapview::s_flow_legend, 212, 0); // a busy minute wraps to a second line
lv_obj_set_style_text_align(mapview::s_flow_legend, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_radius(mapview::s_flow_legend, 8, 0);
lv_obj_add_flag(mapview::s_flow_legend, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_event_cb(mapview::s_flow_legend, [](lv_event_t*) { mapview::s_flow_legend = nullptr; }, LV_EVENT_DELETE, NULL);
mapview::flowLegendRefresh();
// Bottom left, one pill about the map itself: the map data credit (the
// licences require it on the map; a tap shows the full line, also in
// Settings > About), a bar one grid step long with its distance, the zoom.
@@ -981,7 +1073,9 @@ void UITask::rebuildMapMarkers() {
mapview::s_mark_prev = mapview::s_mark_count;
mapview::s_mark_count = 0;
if (_map_nav) rebuildNavMarkers();
if (_map_nodes || !_map_nav) rebuildNodeMarkers();
const bool nodes = _map_nodes || !_map_nav;
if (nodes) rebuildNodeMarkers();
mapview::s_flow_nodes = !nodes; // the flows bring theirs
mapview::s_mark_prev = 0;
}
@@ -1202,6 +1296,7 @@ void UITask::mapFlowTick() {
using namespace mapview;
if (!s_mark_layer || (int32_t)(millis() - _next_flow_ms) < 0) return;
_next_flow_ms = millis() + 50;
if (++_flow_legend_tick >= 10) { _flow_legend_tick = 0; flowLegendRefresh(); } // twice a second
lv_area_t r;
bool now_on = flowArea(r);
if (!now_on && !s_flow_area_on) return;
@@ -656,6 +656,7 @@ private:
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
uint8_t _flow_legend_tick = 0; // its legend, every 10th 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;
@@ -287,6 +287,7 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
bool _nav = false; // full-screen navigate-to-node view (over detail)
bool _map = false; // the nodes with a position on a map (under detail)
static const uint32_t FLOW_SHOW_MS = 60000; // packet flow lines stay this long
static const uint32_t FLOW_HERE_MS = 3000; // and a ring here, for one placed nowhere else
uint32_t _flow_seen = 0;
int _map_zoom = 0; // 0: all of them in view; each step halves it, on the selected
navview::EtaTracker _nav_eta; // closing-speed/ETA for the navigate view
@@ -945,7 +946,8 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
if (millis() - f.t_ms > FLOW_SHOW_MS) break; // newest first
int px[PacketFlow::MAX_PTS], py[PacketFlow::MAX_PTS];
for (int k = 0; k < f.n; k++) project(f.lat[k], f.lon[k], px[k], py[k]);
if (f.n == 1) {
if (f.n == 1) { // a packet nothing of which could be placed: a ring here, briefly
if (f.here && millis() - f.t_ms > FLOW_HERE_MS) continue;
if (inside(px[0], py[0])) gfx::drawCircle(d, px[0], py[0], 6 * s);
continue;
}