/* * Copyright (C) 2015 Jared Boone, ShareBrained Technology, Inc. * Copyright (C) 2017 Furrtek * * This file is part of PortaPack. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2, or (at your option) * any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; see the file COPYING. If not, write to * the Free Software Foundation, Inc., 51 Franklin Street, * Boston, MA 02110-1301, USA. */ #include "ui_geomap.hpp" #include "portapack.hpp" #include #include using namespace portapack; #include "string_format.hpp" #include "complex.hpp" #include "ui_styles.hpp" #include "ui_font_fixed_5x8.hpp" namespace ui { GeoPos::GeoPos( const Point pos, const alt_unit altitude_unit, const spd_unit speed_unit) : altitude_unit_(altitude_unit), speed_unit_(speed_unit) { set_parent_rect({pos, {30 * 8, 3 * 16}}); add_children({&labels_position, &label_spd_position, &field_altitude, &field_speed, &text_alt_unit, &text_speed_unit, &field_lat_degrees, &field_lat_minutes, &field_lat_seconds, &text_lat_decimal, &field_lon_degrees, &field_lon_minutes, &field_lon_seconds, &text_lon_decimal}); // Defaults set_altitude(0); set_speed(0); set_lat(0); set_lon(0); const auto changed_fn = [this](int32_t) { // Convert degrees/minutes/seconds fields to decimal (floating point) lat/lon degree float lat_value = lat(); float lon_value = lon(); text_lat_decimal.set(to_string_decimal(lat_value, 5)); text_lon_decimal.set(to_string_decimal(lon_value, 5)); if (on_change && report_change) on_change(altitude(), lat_value, lon_value, speed()); }; field_altitude.on_change = changed_fn; field_speed.on_change = changed_fn; field_lat_degrees.on_change = changed_fn; field_lat_minutes.on_change = changed_fn; field_lat_seconds.on_change = changed_fn; field_lon_degrees.on_change = changed_fn; field_lon_minutes.on_change = changed_fn; field_lon_seconds.on_change = changed_fn; const auto wrapped_lat_seconds = [this](int32_t v) { field_lat_minutes.on_encoder(v); }; const auto wrapped_lat_minutes = [this](int32_t v) { field_lat_degrees.on_encoder((field_lat_degrees.value() >= 0) ? v : -v); }; const auto wrapped_lon_seconds = [this](int32_t v) { field_lon_minutes.on_encoder(v); }; const auto wrapped_lon_minutes = [this](int32_t v) { field_lon_degrees.on_encoder((field_lon_degrees.value() >= 0) ? v : -v); }; field_lat_seconds.on_wrap = wrapped_lat_seconds; field_lat_minutes.on_wrap = wrapped_lat_minutes; field_lon_seconds.on_wrap = wrapped_lon_seconds; field_lon_minutes.on_wrap = wrapped_lon_minutes; text_alt_unit.set(altitude_unit_ ? "m" : "ft"); if (speed_unit_ == KMPH) text_speed_unit.set("kmph"); if (speed_unit_ == MPH) text_speed_unit.set("mph"); if (speed_unit_ == HIDDEN) { text_speed_unit.hidden(true); label_spd_position.hidden(true); field_speed.hidden(true); } } void GeoPos::set_read_only(bool v) { // only setting altitude to read-only (allow manual panning via lat/lon fields) field_altitude.set_focusable(!v); field_speed.set_focusable(!v); } // Stupid hack to avoid an event loop void GeoPos::set_report_change(bool v) { report_change = v; } void GeoPos::focus() { if (field_altitude.focusable()) field_altitude.focus(); else field_lat_degrees.focus(); } void GeoPos::hide_altandspeed() { // Color altitude grey to indicate it's not updated in manual panning mode field_altitude.set_style(&Styles::grey); field_speed.set_style(&Styles::grey); } void GeoPos::set_altitude(int32_t altitude) { field_altitude.set_value(altitude); } void GeoPos::set_speed(int32_t speed) { field_speed.set_value(speed); } void GeoPos::set_lat(float lat) { field_lat_degrees.set_value(lat); field_lat_minutes.set_value((uint32_t)abs(lat / (1.0 / 60)) % 60); field_lat_seconds.set_value((uint32_t)abs(lat / (1.0 / 3600)) % 60); } void GeoPos::set_lon(float lon) { field_lon_degrees.set_value(lon); field_lon_minutes.set_value((uint32_t)abs(lon / (1.0 / 60)) % 60); field_lon_seconds.set_value((uint32_t)abs(lon / (1.0 / 3600)) % 60); } float GeoPos::lat() { if (field_lat_degrees.value() < 0) { return -1 * (-1 * field_lat_degrees.value() + (field_lat_minutes.value() / 60.0) + (field_lat_seconds.value() / 3600.0)); } else { return field_lat_degrees.value() + (field_lat_minutes.value() / 60.0) + (field_lat_seconds.value() / 3600.0); } }; float GeoPos::lon() { if (field_lon_degrees.value() < 0) { return -1 * (-1 * field_lon_degrees.value() + (field_lon_minutes.value() / 60.0) + (field_lon_seconds.value() / 3600.0)); } else { return field_lon_degrees.value() + (field_lon_minutes.value() / 60.0) + (field_lon_seconds.value() / 3600.0); } }; int32_t GeoPos::altitude() { return field_altitude.value(); }; int32_t GeoPos::speed() { return field_speed.value(); }; GeoMap::GeoMap( Rect parent_rect) : Widget{parent_rect}, markerListLen(0) { } bool GeoMap::on_encoder(const EncoderEvent delta) { // Valid map_zoom values are -2 to -MAX_MAP_ZOOM_OUT, and +1 to +MAX_MAP_ZOOM_IN (values of 0 and -1 are not permitted) if (delta > 0) { if (map_zoom < MAX_MAP_ZOOM_IN) { if (map_zoom == -2) { map_zoom = 1; } else { // zoom in faster after exceeding the map resolution limit map_zoom += (map_zoom >= MAP_ZOOM_RESOLUTION_LIMIT) ? map_zoom : 1; } } } else if (delta < 0) { if (map_zoom > -MAX_MAP_ZOOM_OUT) { if (map_zoom == 1) { map_zoom = -2; } else { if (map_zoom > MAP_ZOOM_RESOLUTION_LIMIT) map_zoom /= 2; else map_zoom--; } } } else { return false; } map_visible = map_opened && (map_zoom <= MAP_ZOOM_RESOLUTION_LIMIT); zoom_pixel_offset = (map_visible && (map_zoom > 1)) ? (float)map_zoom / 2 : 0.0f; // Trigger map redraw redraw_map = true; set_dirty(); return true; } void GeoMap::map_read_line(ui::Color* buffer, uint16_t pixels) { if (map_zoom == 1) { map_file.read(buffer, pixels << 1); } else if (map_zoom > 1) { map_file.read(buffer, (pixels / map_zoom) << 1); // Zoom in: Expand each pixel to "map_zoom" number of pixels. // Future TODO: Add dithering to smooth out the pixelation. // As long as MOD(width,map_zoom)==0 then we don't need to check buffer overflow case when stretching last pixel; // For 240 width, than means no check is needed for map_zoom values up to 6. // (Rectangle height must also divide evenly into map_zoom or we get black lines at end of screen) // Note that zooming in results in a map offset of (1/map_zoom) pixels to the right & downward directions (see zoom_pixel_offset). for (int i = (geomap_rect_width / map_zoom) - 1; i >= 0; i--) { for (int j = 0; j < map_zoom; j++) { buffer[(i * map_zoom) + j] = buffer[i]; } } } else { ui::Color* zoom_out_buffer = new ui::Color[(pixels * (-map_zoom))]; map_file.read(zoom_out_buffer, (pixels * (-map_zoom)) << 1); // Zoom out: Collapse each group of "-map_zoom" pixels into one pixel. // Future TODO: Average each group of pixels (in both X & Y directions if possible). for (int i = 0; i < geomap_rect_width; i++) { buffer[i] = zoom_out_buffer[i * (-map_zoom)]; } delete zoom_out_buffer; } } void GeoMap::draw_markers(Painter& painter) { for (int i = 0; i < markerListLen; ++i) { draw_marker_item(painter, markerList[i], Color::blue(), Color::blue(), Color::magenta()); } } void GeoMap::draw_marker_item(Painter& painter, GeoMarker& item, const Color color, const Color fontColor, const Color backColor) { const auto r = screen_rect(); const ui::Point itemPoint = item_rect_pixel(item); if ((itemPoint.x() >= 0) && (itemPoint.x() < r.width()) && (itemPoint.y() > 10) && (itemPoint.y() < r.height())) // Dont draw within symbol size of top { draw_marker(painter, {itemPoint.x(), itemPoint.y() + r.top()}, item.angle, item.tag, color, fontColor, backColor); } } // Calculate screen position of item, adjusted for zoom factor. ui::Point GeoMap::item_rect_pixel(GeoMarker& item) { const auto r = screen_rect(); const auto geomap_rect_half_width = r.width() / 2; const auto geomap_rect_half_height = r.height() / 2; GeoPoint mapPoint = lat_lon_to_map_pixel(item.lat, item.lon); float x = mapPoint.x - x_pos; float y = mapPoint.y - y_pos; if (map_zoom > 1) { x = x * map_zoom + zoom_pixel_offset; y = y * map_zoom + zoom_pixel_offset; } else if (map_zoom < 0) { x = x / (-map_zoom); y = y / (-map_zoom); } x += geomap_rect_half_width; y += geomap_rect_half_height; return {(int16_t)x, (int16_t)y}; } // Converts latitude/longitude to pixel coordinates in map file. // (Note that when map_zoom==1, one pixel in map file corresponds to 1 pixel on screen) GeoPoint GeoMap::lat_lon_to_map_pixel(float lat, float lon) { // Using WGS 84/Pseudo-Mercator projection float x = (map_width * (lon + 180) / 360); // Latitude calculation based on https://stackoverflow.com/a/10401734/2278659 double lat_rad = sin(lat * pi / 180); float y = (map_height - ((map_world_lon / 2 * log((1 + lat_rad) / (1 - lat_rad))) - map_offset)); return {x, y}; } // Draw grid in place of map (when zoom-in level is too high). void GeoMap::draw_map_grid() { const auto r = screen_rect(); // Grid spacing is just based on zoom at the moment, and centered on screen. // TODO: Maybe align with latitude/longitude seconds instead? int grid_spacing = map_zoom * 2; int x = (r.width() / 2) % grid_spacing; int y = (r.height() / 2) % grid_spacing; if (map_zoom <= MAP_ZOOM_RESOLUTION_LIMIT) return; display.fill_rectangle({{0, r.top()}, {r.width(), r.height()}}, Color::black()); for (uint16_t line = y; line < r.height(); line += grid_spacing) { display.fill_rectangle({{0, r.top() + line}, {r.width(), 1}}, Color::darker_grey()); } for (uint16_t column = x; column < r.width(); column += grid_spacing) { display.fill_rectangle({{column, r.top()}, {1, r.height()}}, Color::darker_grey()); } } void GeoMap::paint(Painter& painter) { const auto r = screen_rect(); std::array map_line_buffer; int16_t zoom_seek_x, zoom_seek_y; // Ony redraw map if it moved by at least 1 pixel or the markers list was updated if (map_zoom <= 1) { // Zooming out, or no zoom const int min_diff = abs(map_zoom); if ((int)abs(x_pos - prev_x_pos) >= min_diff) redraw_map = true; else if ((int)abs(y_pos - prev_y_pos) >= min_diff) redraw_map = true; } else { // Zooming in; magnify position differences (utilizing zoom_pixel_offset) if ((int)(abs(x_pos - prev_x_pos) * map_zoom) >= 1) redraw_map = true; else if ((int)(abs(y_pos - prev_y_pos) * map_zoom) >= 1) redraw_map = true; } if (redraw_map) { prev_x_pos = x_pos; // Note x_pos/y_pos pixel position in map file now correspond to screen rect CENTER pixel prev_y_pos = y_pos; redraw_map = false; // Adjust starting corner position of map per zoom setting; // When zooming in the map should technically by shifted left & up by another map_zoom/2 pixels but // the map_read_line() function doesn't handle that yet so we're adjusting markers instead (see zoom_pixel_offset). if (map_zoom > 1) { zoom_seek_x = x_pos - (float)r.width() / (2 * map_zoom); zoom_seek_y = y_pos - (float)r.height() / (2 * map_zoom); } else { zoom_seek_x = x_pos - (r.width() * abs(map_zoom)) / 2; zoom_seek_y = y_pos - (r.height() * abs(map_zoom)) / 2; } if (map_visible) { // Read from map file and display to zoomed scale int duplicate_lines = (map_zoom < 0) ? 1 : map_zoom; for (uint16_t line = 0; line < (r.height() / duplicate_lines); line++) { uint16_t seek_line = zoom_seek_y + ((map_zoom >= 0) ? line : line * (-map_zoom)); map_file.seek(4 + ((zoom_seek_x + (map_width * seek_line)) << 1)); map_read_line(map_line_buffer.data(), r.width()); for (uint16_t j = 0; j < duplicate_lines; j++) { display.draw_pixels({0, r.top() + (line * duplicate_lines) + j, r.width(), 1}, map_line_buffer); } } } else { // No map data or excessive zoom; just draw a grid draw_map_grid(); } // Draw crosshairs in center in manual panning mode if (manual_panning_) { display.fill_rectangle({r.center() - Point(16, 1) + Point(zoom_pixel_offset, zoom_pixel_offset), {32, 2}}, Color::red()); display.fill_rectangle({r.center() - Point(1, 16) + Point(zoom_pixel_offset, zoom_pixel_offset), {2, 32}}, Color::red()); } // Draw the other markers draw_markers(painter); draw_scale(painter); draw_mypos(painter); set_clean(); } // Draw the marker in the center if (!manual_panning_ && !hide_center_marker_) { draw_marker(painter, r.center() + Point(zoom_pixel_offset, zoom_pixel_offset), angle_, tag_, Color::red(), Color::white(), Color::black()); } } bool GeoMap::on_keyboard(KeyboardEvent key) { if (key == '+' || key == ' ') return on_encoder(1); if (key == '-') return on_encoder(-1); return false; } bool GeoMap::on_touch(const TouchEvent event) { if ((event.type == TouchEvent::Type::Start) && (mode_ == PROMPT)) { set_highlighted(true); if (on_move) { Point p = event.point - screen_rect().center(); on_move(p.x() / 2.0 * lon_ratio, p.y() / 2.0 * lat_ratio); return true; } } return false; } void GeoMap::move(const float lon, const float lat) { const auto r = screen_rect(); lon_ = lon; lat_ = lat; // Calculate x_pos/y_pos in map file corresponding to CENTER pixel of screen rect // (Note there is a 1:1 correspondence between map file pixels and screen pixels when map_zoom=1) GeoPoint mapPoint = lat_lon_to_map_pixel(lat_, lon_); x_pos = mapPoint.x; y_pos = mapPoint.y; // Cap position if (x_pos > (map_width - r.width() / 2)) x_pos = map_width - r.width() / 2; if (y_pos > (map_height + r.height() / 2)) y_pos = map_height - r.height() / 2; // Scale calculation float km_per_deg_lon = cos(lat * pi / 180) * 111.321; // 111.321 km/deg longitude at equator, and 0 km at poles pixels_per_km = (r.width() / 2) / km_per_deg_lon; } bool GeoMap::init() { auto result = map_file.open("ADSB/world_map.bin"); map_opened = !result.is_valid(); if (map_opened) { map_file.read(&map_width, 2); map_file.read(&map_height, 2); } else { map_width = 32768; map_height = 32768; } map_visible = map_opened; map_center_x = map_width >> 1; map_center_y = map_height >> 1; lon_ratio = 180.0 / map_center_x; lat_ratio = -90.0 / map_center_y; map_bottom = sin(-85.05 * pi / 180); // Map bitmap only goes from about -85 to 85 lat map_world_lon = map_width / (2 * pi); map_offset = (map_world_lon / 2 * log((1 + map_bottom) / (1 - map_bottom))); return map_opened; } void GeoMap::set_mode(GeoMapMode mode) { mode_ = mode; } void GeoMap::set_manual_panning(bool v) { manual_panning_ = v; } bool GeoMap::manual_panning() { return manual_panning_; } void GeoMap::draw_scale(Painter& painter) { const auto r = screen_rect(); uint32_t m = 800000; uint32_t scale_width = (map_zoom > 0) ? m * map_zoom * pixels_per_km : m * pixels_per_km / (-map_zoom); ui::Color scale_color = (map_visible) ? Color::black() : Color::white(); std::string km_string; while (scale_width > (uint32_t)r.width() * (1000 / 2)) { scale_width /= 2; m /= 2; } scale_width /= 1000; if (m < 1000) { km_string = to_string_dec_uint(m) + "m"; } else { uint32_t km = m / 1000; m -= km * 1000; if (m == 0) { km_string = to_string_dec_uint(km) + " km"; } else { km_string = to_string_dec_uint(km) + "." + to_string_dec_uint((m + 50) / 100, 1) + "km"; } } display.fill_rectangle({{r.right() - 5 - (uint16_t)scale_width, r.bottom() - 4}, {(uint16_t)scale_width, 2}}, scale_color); display.fill_rectangle({{r.right() - 5, r.bottom() - 8}, {2, 6}}, scale_color); display.fill_rectangle({{r.right() - 5 - (uint16_t)scale_width, r.bottom() - 8}, {2, 6}}, scale_color); painter.draw_string({(uint16_t)(r.right() - 25 - scale_width - km_string.length() * 5 / 2), r.bottom() - 10}, ui::font::fixed_5x8, Color::black(), Color::white(), km_string); } void GeoMap::draw_bearing(const Point origin, const uint16_t angle, uint32_t size, const Color color) { Point arrow_a, arrow_b, arrow_c; for (size_t thickness = 0; thickness < 3; thickness++) { arrow_a = fast_polar_to_point((int)angle, size) + origin; arrow_b = fast_polar_to_point((int)(angle + 180 - 35), size) + origin; arrow_c = fast_polar_to_point((int)(angle + 180 + 35), size) + origin; display.draw_line(arrow_a, arrow_b, color); display.draw_line(arrow_b, arrow_c, color); display.draw_line(arrow_c, arrow_a, color); size--; } display.draw_pixel(origin, color); // 1 pixel indicating center pivot point of bearing symbol } void GeoMap::draw_marker(Painter& painter, const ui::Point itemPoint, const uint16_t itemAngle, const std::string itemTag, const Color color, const Color fontColor, const Color backColor) { const auto r = screen_rect(); int tagOffset = 10; if (mode_ == PROMPT) { // Cross display.fill_rectangle({itemPoint - Point(16, 1), {32, 2}}, color); display.fill_rectangle({itemPoint - Point(1, 16), {2, 32}}, color); tagOffset = 16; } else if (angle_ < 360) { // if we have a valid angle draw bearing draw_bearing(itemPoint, itemAngle, 10, color); tagOffset = 10; } else { // draw a small cross display.fill_rectangle({itemPoint - Point(8, 1), {16, 2}}, color); display.fill_rectangle({itemPoint - Point(1, 8), {2, 16}}, color); tagOffset = 8; } // center tag above point if ((itemPoint.y() - r.top() >= 32) && (itemTag.find_first_not_of(' ') != itemTag.npos)) { // only draw tag if doesn't overlap top & not just spaces painter.draw_string(itemPoint - Point(((int)itemTag.length() * 8 / 2), 14 + tagOffset), style().font, fontColor, backColor, itemTag); } } void GeoMap::draw_mypos(Painter& painter) { if ((my_pos.lat < INVALID_LAT_LON) && (my_pos.lon < INVALID_LAT_LON)) draw_marker_item(painter, my_pos, Color::yellow()); } void GeoMap::clear_markers() { markerListLen = 0; } MapMarkerStored GeoMap::store_marker(GeoMarker& marker) { const auto r = screen_rect(); MapMarkerStored ret; // Check if it could be on screen // (Shows more distant planes when zoomed out) GeoPoint mapPoint = lat_lon_to_map_pixel(marker.lat, marker.lon); int x_dist = abs((int)mapPoint.x - (int)x_pos); int y_dist = abs((int)mapPoint.y - (int)y_pos); int zoom_out = (map_zoom < 0) ? -map_zoom : 1; if ((x_dist >= (zoom_out * r.width() / 2)) || (y_dist >= (zoom_out * r.height() / 2))) { ret = MARKER_NOT_STORED; } else if (markerListLen < NumMarkerListElements) { markerList[markerListLen] = marker; markerListLen++; redraw_map = true; ret = MARKER_STORED; } else { ret = MARKER_LIST_FULL; } return ret; } void GeoMap::update_my_position(float lat, float lon, int32_t altitude) { my_pos.lat = lat; my_pos.lon = lon; my_altitude = altitude; redraw_map = true; set_dirty(); } void GeoMap::update_my_orientation(uint16_t angle, bool refresh) { my_pos.angle = angle; if (refresh) { redraw_map = true; set_dirty(); } } void GeoMapView::focus() { geopos.focus(); if (!geomap.map_file_opened()) nav_.display_modal("No map", "No world_map.bin file in\n/ADSB/ directory", ABORT); } void GeoMapView::update_my_position(float lat, float lon, int32_t altitude) { geomap.update_my_position(lat, lon, altitude); } void GeoMapView::update_my_orientation(uint16_t angle, bool refresh) { geomap.update_my_orientation(angle, refresh); } void GeoMapView::update_position(float lat, float lon, uint16_t angle, int32_t altitude, int32_t speed) { if (geomap.manual_panning()) { geomap.set_dirty(); return; } lat_ = lat; lon_ = lon; altitude_ = altitude; speed_ = speed; // Stupid hack to avoid an event loop geopos.set_report_change(false); geopos.set_lat(lat_); geopos.set_lon(lon_); geopos.set_altitude(altitude_); geopos.set_speed(speed_); geopos.set_report_change(true); geomap.set_angle(angle); geomap.move(lon_, lat_); geomap.set_dirty(); } void GeoMapView::update_tag(const std::string tag) { geomap.set_tag(tag); } void GeoMapView::setup() { add_child(&geomap); geopos.set_altitude(altitude_); geopos.set_lat(lat_); geopos.set_lon(lon_); geopos.on_change = [this](int32_t altitude, float lat, float lon, int32_t speed) { altitude_ = altitude; lat_ = lat; lon_ = lon; speed_ = speed; geopos.hide_altandspeed(); geomap.set_manual_panning(true); geomap.move(lon_, lat_); geomap.set_dirty(); }; geomap.on_move = [this](float move_x, float move_y) { lon_ += move_x; lat_ += move_y; // Stupid hack to avoid an event loop geopos.set_report_change(false); geopos.set_lon(lon_); geopos.set_lat(lat_); geopos.set_report_change(true); geomap.move(lon_, lat_); geomap.set_dirty(); }; } GeoMapView::~GeoMapView() { if (on_close_) on_close_(); } // Display mode GeoMapView::GeoMapView( NavigationView& nav, const std::string& tag, int32_t altitude, GeoPos::alt_unit altitude_unit, GeoPos::spd_unit speed_unit, float lat, float lon, uint16_t angle, const std::function on_close) : nav_(nav), altitude_(altitude), altitude_unit_(altitude_unit), speed_unit_(speed_unit), lat_(lat), lon_(lon), angle_(angle), on_close_(on_close) { mode_ = DISPLAY; add_child(&geopos); geomap.init(); setup(); geomap.set_mode(mode_); geomap.set_tag(tag); geomap.set_angle(angle); geomap.move(lon_, lat_); geomap.set_focusable(true); geopos.set_read_only(true); } // Prompt mode GeoMapView::GeoMapView( NavigationView& nav, int32_t altitude, GeoPos::alt_unit altitude_unit, GeoPos::spd_unit speed_unit, float lat, float lon, const std::function on_done) : nav_(nav), altitude_(altitude), altitude_unit_(altitude_unit), speed_unit_(speed_unit), lat_(lat), lon_(lon) { mode_ = PROMPT; add_child(&geopos); geomap.init(); setup(); add_child(&button_ok); geomap.set_mode(mode_); geomap.move(lon_, lat_); geomap.set_focusable(true); button_ok.on_select = [this, on_done, &nav](Button&) { if (on_done) on_done(altitude_, lat_, lon_, speed_); nav.pop(); }; } void GeoMapView::clear_markers() { geomap.clear_markers(); } MapMarkerStored GeoMapView::store_marker(GeoMarker& marker) { return geomap.store_marker(marker); } } /* namespace ui */