// Copyright (c) 2024 microReticulum contributors // SPDX-License-Identifier: MIT #include "MapScreen.h" #ifdef ARDUINO #include "Theme.h" #include "TileMath.h" #include "Log.h" #include "../LVGL/LVGLInit.h" #include "../LVGL/LVGLLock.h" #include "../../Hardware/TDeck/TileDownloader.h" #include using namespace RNS; namespace UI { namespace LXMF { MapScreen::MapScreen(lv_obj_t* parent) : _screen(nullptr), _header(nullptr), _viewport(nullptr), _gps_marker(nullptr), _zoom_label(nullptr), _peer_marker_count(0), _gps(nullptr), _center_lat(0.0), _center_lon(0.0), _zoom(14), _follow_gps(true), _has_gps_fix(false), _loaded_zoom(-1) { memset(_tile_imgs, 0, sizeof(_tile_imgs)); memset(_peer_markers, 0, sizeof(_peer_markers)); memset(_peer_labels, 0, sizeof(_peer_labels)); memset(_loaded_tile_x, -1, sizeof(_loaded_tile_x)); memset(_loaded_tile_y, -1, sizeof(_loaded_tile_y)); memset(_pending_tiles, 0, sizeof(_pending_tiles)); _pending_count = 0; _last_touch_x = 0; _last_touch_y = 0; // Download queue created once; task started/stopped on show/hide _download_queue = xQueueCreate(8, sizeof(TileRequest)); _download_complete = false; _download_task = nullptr; _task_should_stop = false; LVGL_LOCK(); if (parent) { _screen = lv_obj_create(parent); } else { _screen = lv_obj_create(lv_scr_act()); } lv_obj_set_size(_screen, LV_PCT(100), LV_PCT(100)); lv_obj_clear_flag(_screen, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_style_bg_color(_screen, Theme::surface(), 0); lv_obj_set_style_bg_opa(_screen, LV_OPA_COVER, 0); lv_obj_set_style_pad_all(_screen, 0, 0); lv_obj_set_style_border_width(_screen, 0, 0); lv_obj_set_style_radius(_screen, 0, 0); create_header(); create_viewport(); hide(); TRACE("MapScreen created"); } MapScreen::~MapScreen() { // Stop download task if running if (_download_task) { _task_should_stop = true; TileRequest dummy = {0, 0, 0}; xQueueSend(_download_queue, &dummy, 0); vTaskDelay(pdMS_TO_TICKS(100)); } if (_download_queue) { vQueueDelete(_download_queue); } LVGL_LOCK(); if (_screen) { lv_obj_del(_screen); } } void MapScreen::create_header() { _header = lv_obj_create(_screen); lv_obj_set_size(_header, LV_PCT(100), 36); lv_obj_align(_header, LV_ALIGN_TOP_MID, 0, 0); lv_obj_set_style_bg_color(_header, Theme::surfaceHeader(), 0); lv_obj_set_style_border_width(_header, 0, 0); lv_obj_set_style_radius(_header, 0, 0); lv_obj_set_style_pad_all(_header, 0, 0); // Back button lv_obj_t* btn_back = lv_btn_create(_header); lv_obj_set_size(btn_back, 50, 28); lv_obj_align(btn_back, LV_ALIGN_LEFT_MID, 4, 0); lv_obj_set_style_bg_color(btn_back, Theme::btnSecondary(), 0); lv_obj_set_style_bg_color(btn_back, Theme::btnSecondaryPressed(), LV_STATE_PRESSED); lv_obj_add_event_cb(btn_back, on_back_clicked, LV_EVENT_CLICKED, this); lv_obj_t* label_back = lv_label_create(btn_back); lv_label_set_text(label_back, LV_SYMBOL_LEFT); lv_obj_center(label_back); lv_obj_set_style_text_color(label_back, Theme::textSecondary(), 0); // Zoom label _zoom_label = lv_label_create(_header); lv_label_set_text(_zoom_label, "z:14"); lv_obj_align(_zoom_label, LV_ALIGN_LEFT_MID, 62, 0); lv_obj_set_style_text_color(_zoom_label, Theme::textPrimary(), 0); lv_obj_set_style_text_font(_zoom_label, &lv_font_montserrat_14, 0); // Zoom in button lv_obj_t* btn_zin = lv_btn_create(_header); lv_obj_set_size(btn_zin, 40, 28); lv_obj_align(btn_zin, LV_ALIGN_RIGHT_MID, -48, 0); lv_obj_set_style_bg_color(btn_zin, Theme::btnSecondary(), 0); lv_obj_set_style_bg_color(btn_zin, Theme::btnSecondaryPressed(), LV_STATE_PRESSED); lv_obj_add_event_cb(btn_zin, on_zoom_in_clicked, LV_EVENT_CLICKED, this); lv_obj_t* label_zin = lv_label_create(btn_zin); lv_label_set_text(label_zin, "+"); lv_obj_center(label_zin); lv_obj_set_style_text_color(label_zin, Theme::textPrimary(), 0); // Zoom out button lv_obj_t* btn_zout = lv_btn_create(_header); lv_obj_set_size(btn_zout, 40, 28); lv_obj_align(btn_zout, LV_ALIGN_RIGHT_MID, -4, 0); lv_obj_set_style_bg_color(btn_zout, Theme::btnSecondary(), 0); lv_obj_set_style_bg_color(btn_zout, Theme::btnSecondaryPressed(), LV_STATE_PRESSED); lv_obj_add_event_cb(btn_zout, on_zoom_out_clicked, LV_EVENT_CLICKED, this); lv_obj_t* label_zout = lv_label_create(btn_zout); lv_label_set_text(label_zout, "-"); lv_obj_center(label_zout); lv_obj_set_style_text_color(label_zout, Theme::textPrimary(), 0); } void MapScreen::create_viewport() { _viewport = lv_obj_create(_screen); lv_obj_set_size(_viewport, VIEWPORT_W, VIEWPORT_H); lv_obj_align(_viewport, LV_ALIGN_BOTTOM_MID, 0, 0); lv_obj_set_style_bg_color(_viewport, lv_color_hex(0x1a1a2e), 0); // Dark blue for missing tiles lv_obj_set_style_border_width(_viewport, 0, 0); lv_obj_set_style_radius(_viewport, 0, 0); lv_obj_set_style_pad_all(_viewport, 0, 0); lv_obj_clear_flag(_viewport, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_style_clip_corner(_viewport, true, 0); // Create 4 tile image objects (2x2 grid) for (int i = 0; i < 4; i++) { _tile_imgs[i] = lv_img_create(_viewport); lv_obj_set_size(_tile_imgs[i], TILE_SIZE, TILE_SIZE); // Position will be set in update_tiles() } // GPS marker: colored circle on top of tiles _gps_marker = lv_obj_create(_viewport); lv_obj_set_size(_gps_marker, 12, 12); lv_obj_set_style_radius(_gps_marker, LV_RADIUS_CIRCLE, 0); lv_obj_set_style_bg_color(_gps_marker, Theme::primary(), 0); lv_obj_set_style_bg_opa(_gps_marker, LV_OPA_COVER, 0); lv_obj_set_style_border_width(_gps_marker, 2, 0); lv_obj_set_style_border_color(_gps_marker, lv_color_white(), 0); lv_obj_set_style_pad_all(_gps_marker, 0, 0); lv_obj_add_flag(_gps_marker, LV_OBJ_FLAG_HIDDEN); // Register key event on viewport for pan/zoom lv_obj_add_flag(_viewport, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(_viewport, on_key_event, LV_EVENT_KEY, this); // Touch drag for panning lv_obj_add_event_cb(_viewport, on_touch_event, LV_EVENT_PRESSED, this); lv_obj_add_event_cb(_viewport, on_touch_event, LV_EVENT_PRESSING, this); } void MapScreen::show() { // Start download task (16KB stack only while map is visible) if (!_download_task) { _task_should_stop = false; xQueueReset(_download_queue); xTaskCreatePinnedToCore(download_task_func, "tile_dl", 16384, this, 1, &_download_task, 0); } LVGL_LOCK(); lv_obj_clear_flag(_screen, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(_screen); // Register viewport in focus group for key events lv_group_t* group = LVGL::LVGLInit::get_default_group(); if (group) { lv_group_add_obj(group, _viewport); lv_group_focus_obj(_viewport); } // If we have GPS fix and follow mode, center on GPS if (_follow_gps && _gps && _gps->location.isValid()) { _center_lat = _gps->location.lat(); _center_lon = _gps->location.lng(); _has_gps_fix = true; } update_tiles(); position_gps_marker(); position_peer_markers(); } void MapScreen::hide() { // Stop download task to free 16KB internal RAM if (_download_task) { _task_should_stop = true; // Send dummy request to wake the task from xQueueReceive TileRequest dummy = {0, 0, 0}; xQueueSend(_download_queue, &dummy, 0); // Wait for task to exit (up to 2s) for (int i = 0; i < 200 && _download_task; i++) { vTaskDelay(pdMS_TO_TICKS(10)); } _download_task = nullptr; } LVGL_LOCK(); lv_group_t* group = LVGL::LVGLInit::get_default_group(); if (group) { lv_group_remove_obj(_viewport); } lv_obj_add_flag(_screen, LV_OBJ_FLAG_HIDDEN); } void MapScreen::update_gps_position() { // Reload tiles after background downloads complete if (_download_complete) { _download_complete = false; _loaded_zoom = -1; // Force tile reload update_tiles(); } // Incremental tile loading: process one pending tile per cycle // This prevents LVGL mutex timeout from decoding multiple PNGs at once if (_pending_count > 0) { PendingTile& pt = _pending_tiles[_pending_count - 1]; load_tile(pt.slot, pt.x, pt.y, pt.z); _loaded_tile_x[pt.slot] = pt.x; _loaded_tile_y[pt.slot] = pt.y; _pending_count--; } if (!_gps) return; if (_gps->location.isValid()) { _has_gps_fix = true; if (_follow_gps) { _center_lat = _gps->location.lat(); _center_lon = _gps->location.lng(); update_tiles(); } position_gps_marker(); } } void MapScreen::update_peer_locations(const PeerLocation* locations, size_t count) { LVGL_LOCK(); // Clean up old markers for (int i = 0; i < _peer_marker_count; i++) { if (_peer_markers[i]) lv_obj_del(_peer_markers[i]); if (_peer_labels[i]) lv_obj_del(_peer_labels[i]); _peer_markers[i] = nullptr; _peer_labels[i] = nullptr; } _peer_marker_count = (count > MAX_PEER_MARKERS) ? MAX_PEER_MARKERS : (int)count; // Store locations for repositioning on pan/zoom _peer_locations.clear(); _peer_locations.reserve(_peer_marker_count); for (int i = 0; i < _peer_marker_count; i++) { _peer_locations.push_back(locations[i]); // Create marker dot _peer_markers[i] = lv_obj_create(_viewport); lv_obj_set_size(_peer_markers[i], 10, 10); lv_obj_set_style_radius(_peer_markers[i], LV_RADIUS_CIRCLE, 0); lv_obj_set_style_bg_color(_peer_markers[i], Theme::success(), 0); lv_obj_set_style_bg_opa(_peer_markers[i], LV_OPA_COVER, 0); lv_obj_set_style_border_width(_peer_markers[i], 1, 0); lv_obj_set_style_border_color(_peer_markers[i], lv_color_white(), 0); lv_obj_set_style_pad_all(_peer_markers[i], 0, 0); // Create name label — use display name if available, else truncated hash _peer_labels[i] = lv_label_create(_viewport); if (!locations[i].name.empty()) { lv_label_set_text(_peer_labels[i], locations[i].name.c_str()); } else { char hash_str[12]; snprintf(hash_str, sizeof(hash_str), "%.6s", locations[i].peer_hash.toHex().c_str()); lv_label_set_text(_peer_labels[i], hash_str); } lv_obj_set_style_text_color(_peer_labels[i], Theme::textPrimary(), 0); lv_obj_set_style_text_font(_peer_labels[i], &lv_font_montserrat_12, 0); } position_peer_markers(); } void MapScreen::update_tiles() { // Convert center lat/lon to global pixel coordinates double center_px, center_py; TileMath::latlon_to_pixel(_center_lat, _center_lon, _zoom, center_px, center_py); // Top-left corner of viewport in global pixels double vp_left = center_px - VIEWPORT_W / 2.0; double vp_top = center_py - VIEWPORT_H / 2.0; // Which tile contains the top-left corner int base_tile_x = (int)floor(vp_left / TILE_SIZE); int base_tile_y = (int)floor(vp_top / TILE_SIZE); // Pixel offset of base tile relative to viewport int offset_x = (int)(base_tile_x * TILE_SIZE - vp_left); int offset_y = (int)(base_tile_y * TILE_SIZE - vp_top); // Position the 2x2 tile grid // Slot layout: [0]=top-left, [1]=top-right, [2]=bottom-left, [3]=bottom-right int tile_coords[4][2] = { {base_tile_x, base_tile_y}, {base_tile_x + 1, base_tile_y}, {base_tile_x, base_tile_y + 1}, {base_tile_x + 1, base_tile_y + 1} }; _pending_count = 0; for (int i = 0; i < 4; i++) { int col = i % 2; int row = i / 2; int px = offset_x + col * TILE_SIZE; int py = offset_y + row * TILE_SIZE; lv_obj_set_pos(_tile_imgs[i], px, py); // Only reload if tile changed if (_loaded_zoom != _zoom || _loaded_tile_x[i] != tile_coords[i][0] || _loaded_tile_y[i] != tile_coords[i][1]) { int tx = tile_coords[i][0]; int ty = tile_coords[i][1]; // Always queue for incremental loading (one per update cycle) // to prevent multiple simultaneous PNG decodes from crashing _pending_tiles[_pending_count++] = {i, _zoom, tx, ty}; if (!Hardware::TDeck::TileDownloader::tile_exists(_zoom, tx, ty)) { // Queue background download for missing tiles TileRequest req = {_zoom, tx, ty}; xQueueSend(_download_queue, &req, 0); } } } _loaded_zoom = _zoom; } void MapScreen::position_gps_marker() { if (!_gps || !_gps->location.isValid()) { lv_obj_add_flag(_gps_marker, LV_OBJ_FLAG_HIDDEN); return; } double gps_px, gps_py; TileMath::latlon_to_pixel(_gps->location.lat(), _gps->location.lng(), _zoom, gps_px, gps_py); double center_px, center_py; TileMath::latlon_to_pixel(_center_lat, _center_lon, _zoom, center_px, center_py); // Position relative to viewport center int screen_x = (int)(gps_px - center_px) + VIEWPORT_W / 2 - 6; // -6 for marker center int screen_y = (int)(gps_py - center_py) + VIEWPORT_H / 2 - 6; // Only show if within viewport if (screen_x >= -12 && screen_x <= VIEWPORT_W && screen_y >= -12 && screen_y <= VIEWPORT_H) { lv_obj_set_pos(_gps_marker, screen_x, screen_y); lv_obj_clear_flag(_gps_marker, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(_gps_marker); } else { lv_obj_add_flag(_gps_marker, LV_OBJ_FLAG_HIDDEN); } } void MapScreen::position_peer_markers() { double center_px, center_py; TileMath::latlon_to_pixel(_center_lat, _center_lon, _zoom, center_px, center_py); for (int i = 0; i < _peer_marker_count && i < (int)_peer_locations.size(); i++) { double peer_px, peer_py; TileMath::latlon_to_pixel(_peer_locations[i].lat, _peer_locations[i].lon, _zoom, peer_px, peer_py); int screen_x = (int)(peer_px - center_px) + VIEWPORT_W / 2 - 5; // -5 for marker center (10px dot) int screen_y = (int)(peer_py - center_py) + VIEWPORT_H / 2 - 5; if (screen_x < -50 || screen_x > VIEWPORT_W + 50 || screen_y < -50 || screen_y > VIEWPORT_H + 50) { lv_obj_add_flag(_peer_markers[i], LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(_peer_labels[i], LV_OBJ_FLAG_HIDDEN); } else { lv_obj_clear_flag(_peer_markers[i], LV_OBJ_FLAG_HIDDEN); lv_obj_clear_flag(_peer_labels[i], LV_OBJ_FLAG_HIDDEN); lv_obj_set_pos(_peer_markers[i], screen_x, screen_y); lv_obj_set_pos(_peer_labels[i], screen_x + 12, screen_y - 2); // label right of dot } } } void MapScreen::update_zoom_label() { char buf[8]; snprintf(buf, sizeof(buf), "z:%d", _zoom); lv_label_set_text(_zoom_label, buf); } void MapScreen::pan(int dx, int dy) { _follow_gps = false; // Manual pan disables follow mode double center_px, center_py; TileMath::latlon_to_pixel(_center_lat, _center_lon, _zoom, center_px, center_py); center_px += dx; center_py += dy; TileMath::pixel_to_latlon(center_px, center_py, _zoom, _center_lat, _center_lon); update_tiles(); position_gps_marker(); position_peer_markers(); } void MapScreen::load_tile(int slot, int tile_x, int tile_y, int z) { char path[64]; snprintf(path, sizeof(path), "S:tiles/%d/%d/%d.png", z, tile_x, tile_y); lv_img_set_src(_tile_imgs[slot], path); } void MapScreen::download_task_func(void* param) { MapScreen* self = (MapScreen*)param; TileRequest req; while (!self->_task_should_stop) { if (xQueueReceive(self->_download_queue, &req, pdMS_TO_TICKS(500)) == pdTRUE) { if (self->_task_should_stop) break; if (Hardware::TDeck::TileDownloader::download_tile(req.z, req.x, req.y)) { self->_download_complete = true; } } } self->_download_task = nullptr; vTaskDelete(NULL); } void MapScreen::on_touch_event(lv_event_t* event) { MapScreen* screen = (MapScreen*)lv_event_get_user_data(event); lv_event_code_t code = lv_event_get_code(event); lv_indev_t* indev = lv_indev_get_act(); if (!indev) return; lv_point_t point; lv_indev_get_point(indev, &point); if (code == LV_EVENT_PRESSED) { screen->_last_touch_x = point.x; screen->_last_touch_y = point.y; } else if (code == LV_EVENT_PRESSING) { int dx = screen->_last_touch_x - point.x; int dy = screen->_last_touch_y - point.y; if (dx != 0 || dy != 0) { screen->pan(dx, dy); screen->_last_touch_x = point.x; screen->_last_touch_y = point.y; } } } void MapScreen::on_back_clicked(lv_event_t* event) { MapScreen* screen = (MapScreen*)lv_event_get_user_data(event); if (screen->_back_callback) { screen->_back_callback(); } } void MapScreen::on_zoom_in_clicked(lv_event_t* event) { MapScreen* screen = (MapScreen*)lv_event_get_user_data(event); if (screen->_zoom < 19) { screen->_zoom++; screen->update_zoom_label(); // Invalidate loaded tiles to force reload screen->_loaded_zoom = -1; screen->update_tiles(); screen->position_gps_marker(); screen->position_peer_markers(); } } void MapScreen::on_zoom_out_clicked(lv_event_t* event) { MapScreen* screen = (MapScreen*)lv_event_get_user_data(event); if (screen->_zoom > 1) { screen->_zoom--; screen->update_zoom_label(); screen->_loaded_zoom = -1; screen->update_tiles(); screen->position_gps_marker(); screen->position_peer_markers(); } } void MapScreen::on_key_event(lv_event_t* event) { MapScreen* screen = (MapScreen*)lv_event_get_user_data(event); uint32_t key = lv_event_get_key(event); const int PAN_STEP = 32; // pixels per key press switch (key) { case LV_KEY_UP: screen->pan(0, -PAN_STEP); break; case LV_KEY_DOWN: screen->pan(0, PAN_STEP); break; case LV_KEY_LEFT: screen->pan(-PAN_STEP, 0); break; case LV_KEY_RIGHT: screen->pan(PAN_STEP, 0); break; case '+': case '=': if (screen->_zoom < 19) { screen->_zoom++; screen->update_zoom_label(); screen->_loaded_zoom = -1; screen->update_tiles(); screen->position_gps_marker(); screen->position_peer_markers(); } break; case '-': if (screen->_zoom > 1) { screen->_zoom--; screen->update_zoom_label(); screen->_loaded_zoom = -1; screen->update_tiles(); screen->position_gps_marker(); screen->position_peer_markers(); } break; case 'c': case 'C': // Re-center on GPS screen->_follow_gps = true; if (screen->_gps && screen->_gps->location.isValid()) { screen->_center_lat = screen->_gps->location.lat(); screen->_center_lon = screen->_gps->location.lng(); screen->update_tiles(); screen->position_gps_marker(); screen->position_peer_markers(); } break; default: break; } } } // namespace LXMF } // namespace UI #endif // ARDUINO