diff --git a/Base/res/apps/Maps.af b/Base/res/apps/Maps.af new file mode 100644 index 0000000000..286c0a20dc --- /dev/null +++ b/Base/res/apps/Maps.af @@ -0,0 +1,5 @@ +[App] +Name=Maps +Executable=/bin/Maps +Category=Internet +Description=Explore the world diff --git a/Userland/Applications/CMakeLists.txt b/Userland/Applications/CMakeLists.txt index fd5e5a0742..39191522cd 100644 --- a/Userland/Applications/CMakeLists.txt +++ b/Userland/Applications/CMakeLists.txt @@ -25,6 +25,7 @@ add_subdirectory(KeyboardSettings) add_subdirectory(Magnifier) add_subdirectory(Mail) add_subdirectory(MailSettings) +add_subdirectory(Maps) add_subdirectory(MouseSettings) add_subdirectory(NetworkSettings) add_subdirectory(PartitionEditor) diff --git a/Userland/Applications/Maps/CMakeLists.txt b/Userland/Applications/Maps/CMakeLists.txt new file mode 100644 index 0000000000..d54f0cdc77 --- /dev/null +++ b/Userland/Applications/Maps/CMakeLists.txt @@ -0,0 +1,13 @@ +serenity_component( + Maps + REQUIRED + TARGETS Maps +) + +set(SOURCES + main.cpp + MapWidget.cpp +) + +serenity_app(Maps ICON app-hello-world) # FIXME: Create Maps icon +target_link_libraries(Maps PRIVATE LibConfig LibCore LibDesktop LibGfx LibGUI LibMain LibProtocol) diff --git a/Userland/Applications/Maps/MapWidget.cpp b/Userland/Applications/Maps/MapWidget.cpp new file mode 100644 index 0000000000..88acb76aec --- /dev/null +++ b/Userland/Applications/Maps/MapWidget.cpp @@ -0,0 +1,301 @@ +/* + * Copyright (c) 2023, Bastiaan van der Plaat + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "MapWidget.h" +#include +#include +#include +#include + +// Math helpers +static double radians(double degrees) +{ + return degrees * M_PI / 180.0; +} + +static double degrees(double radians) +{ + return radians * 180.0 / M_PI; +} + +// https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Pseudo-code +static double longitude_to_tile_x(double longitude, int zoom) +{ + return pow(2, zoom) * ((longitude + 180.0) / 360.0); +} + +static double latitude_to_tile_y(double latitude, int zoom) +{ + return pow(2, zoom) * (1.0 - (log(tan(radians(latitude)) + (1.0 / cos(radians(latitude)))) / M_PI)) / 2.0; +} + +static double tile_x_to_longitude(double x, int zoom) +{ + return x / pow(2, zoom) * 360.0 - 180.0; +} + +static double tile_y_to_latitude(double y, int zoom) +{ + return degrees(atan(sinh(M_PI * (1.0 - 2.0 * y / pow(2, zoom))))); +} + +static double nice_round_number(double number) +{ + double pow10 = pow(10, floor(log10(floor(number)))); + double d = number / pow10; + return pow10 * (d >= 10 ? 10 : (d >= 5 ? 5 : (d >= 3 ? 3 : (d >= 2 ? 2 : 1)))); +} + +double MapWidget::LatLng::distance_to(LatLng const& other) const +{ + double const earth_radius = 6371000.0; + return earth_radius * 2.0 * asin(sqrt(pow(sin((radians(other.latitude) - radians(latitude)) / 2.0), 2.0) + cos(radians(latitude)) * cos(radians(other.latitude)) * pow(sin((radians(other.longitude) - radians(longitude)) / 2.0), 2.0))); +} + +// MapWidget class +MapWidget::MapWidget(Options const& options) + : m_tile_layer_url(options.tile_layer_url) + , m_center(options.center) + , m_zoom(options.zoom) + , m_scale_enabled(options.scale_enabled) + , m_scale_max_width(options.scale_max_width) + , m_attribution_enabled(options.attribution_enabled) + , m_attribution_text(options.attribution_text) + , m_attribution_url(options.attribution_url) +{ + m_request_client = Protocol::RequestClient::try_create().release_value_but_fixme_should_propagate_errors(); +} + +void MapWidget::mousedown_event(GUI::MouseEvent& event) +{ + if (m_connection_failed) + return; + + if (event.button() == GUI::MouseButton::Primary) { + // Ignore attribution click + if (m_attribution_enabled && static_cast(event.x()) > width() - m_attribution_width && static_cast(event.y()) > height() - m_attribution_height) { + return; + } + + // Start map tiles dragging + m_dragging = true; + m_last_mouse_x = event.x(); + m_last_mouse_y = event.y(); + set_override_cursor(Gfx::StandardCursor::Drag); + } +} + +void MapWidget::mousemove_event(GUI::MouseEvent& event) +{ + if (m_connection_failed) + return; + + if (m_dragging) { + // Adjust map center by mouse delta + double delta_x = event.x() - m_last_mouse_x; + double delta_y = event.y() - m_last_mouse_y; + set_center({ tile_y_to_latitude(latitude_to_tile_y(m_center.latitude, m_zoom) - delta_y / TILE_SIZE, m_zoom), + tile_x_to_longitude(longitude_to_tile_x(m_center.longitude, m_zoom) - delta_x / TILE_SIZE, m_zoom) }); + m_last_mouse_x = event.x(); + m_last_mouse_y = event.y(); + return; + } + + // Handle attribution hover + if (m_attribution_enabled) { + if (static_cast(event.x()) > width() - m_attribution_width && static_cast(event.y()) > height() - m_attribution_height) { + set_override_cursor(Gfx::StandardCursor::Hand); + } else { + set_override_cursor(Gfx::StandardCursor::Arrow); + } + } +} + +void MapWidget::mouseup_event(GUI::MouseEvent& event) +{ + if (m_connection_failed) + return; + + // Stop map tiles dragging + if (m_dragging) { + m_dragging = false; + set_override_cursor(Gfx::StandardCursor::Arrow); + return; + } + + if (event.button() == GUI::MouseButton::Primary) { + // Handle attribution click + if (m_attribution_enabled && static_cast(event.x()) > width() - m_attribution_width && static_cast(event.y()) > height() - m_attribution_height) { + Desktop::Launcher::open(m_attribution_url); + return; + } + } +} + +void MapWidget::mousewheel_event(GUI::MouseEvent& event) +{ + if (m_connection_failed) + return; + + int new_zoom = event.wheel_delta_y() > 0 ? m_zoom - 1 : m_zoom + 1; + if (new_zoom < ZOOM_MIN || new_zoom > ZOOM_MAX) + return; + if (event.wheel_delta_y() > 0) { + set_center({ tile_y_to_latitude(latitude_to_tile_y(m_center.latitude, m_zoom) - static_cast(event.y() - height() / 2) / TILE_SIZE, m_zoom), + tile_x_to_longitude(longitude_to_tile_x(m_center.longitude, m_zoom) - static_cast(event.x() - width() / 2) / TILE_SIZE, m_zoom) }); + } else { + set_center({ tile_y_to_latitude(latitude_to_tile_y(m_center.latitude, new_zoom) + static_cast(event.y() - height() / 2) / TILE_SIZE, new_zoom), + tile_x_to_longitude(longitude_to_tile_x(m_center.longitude, new_zoom) + static_cast(event.x() - width() / 2) / TILE_SIZE, new_zoom) }); + } + set_zoom(new_zoom); +} + +Optional> MapWidget::get_tile_image(int x, int y) +{ + // Get the right tile from tiles cache + TileKey key = { x, y, m_zoom }; + if (auto it = m_tiles.find(key); it != m_tiles.end()) { + if (it->value) + return it->value; + return {}; + } + + // Add tile when not in tiles + if (m_tiles.size() >= TILES_CACHE_MAX) + m_tiles.remove(m_tiles.begin()); + m_tiles.set(key, nullptr); + + // Start HTTP GET request to load image + HashMap headers; + headers.set("User-Agent", "SerenityOS Maps"); + headers.set("Accept", "image/png"); + URL url(MUST(String::formatted(m_tile_layer_url, m_zoom, x, y))); + auto request = m_request_client->start_request("GET", url, headers, {}); + m_active_requests.append(request); + request->on_buffered_request_finish = [this, request, url, key](bool success, auto, auto&, auto, ReadonlyBytes payload) { + m_active_requests.remove_all_matching([request](auto const& other_request) { return other_request->id() == request->id(); }); + if (!success) { + // When first image load fails set connection failed + if (!m_first_image_loaded) { + m_first_image_loaded = true; + m_connection_failed = true; + } + dbgln("Maps: Can't load image: {}", url); + return; + } + m_first_image_loaded = true; + + // Decode loaded PNG image data + auto decoder = Gfx::ImageDecoder::try_create_for_raw_bytes(payload, "image/png"); + if (!decoder || (decoder->frame_count() == 0)) { + dbgln("Maps: Can't decode image: {}", url); + return; + } + m_tiles.set(key, decoder->frame(0).release_value_but_fixme_should_propagate_errors().image); + update(); + }; + request->set_should_buffer_all_input(true); + request->on_certificate_requested = []() -> Protocol::Request::CertificateAndKey { return {}; }; + + // Return no image for now + return {}; +} + +void MapWidget::paint_tiles(GUI::Painter& painter) +{ + int center_tile_x = floor(longitude_to_tile_x(m_center.longitude, m_zoom)); + int center_tile_y = floor(latitude_to_tile_y(m_center.latitude, m_zoom)); + double offset_x = (longitude_to_tile_x(m_center.longitude, m_zoom) - center_tile_x) * TILE_SIZE; + double offset_y = (latitude_to_tile_y(m_center.latitude, m_zoom) - center_tile_y) * TILE_SIZE; + + // Draw grid around center tile + int grid_width = ceil(static_cast(width()) / TILE_SIZE); + int grid_height = ceil(static_cast(height()) / TILE_SIZE); + for (int dy = -(grid_height / 2) - 1; dy < (grid_height / 2) + 2; dy++) { + for (int dx = -(grid_width / 2) - 1; dx < (grid_width / 2) + 2; dx++) { + int tile_x = center_tile_x + dx; + int tile_y = center_tile_y + dy; + + // Only draw tiles that exist + if (tile_x < 0 || tile_y < 0 || tile_x > pow(2, m_zoom) - 1 || tile_y > pow(2, m_zoom) - 1) + continue; + + // Get tile, when it has a loaded image draw it at the right position + auto tile_image = get_tile_image(tile_x, tile_y); + if (tile_image.has_value()) + painter.blit({ static_cast(width() / 2 + dx * TILE_SIZE - offset_x), static_cast(height() / 2 + dy * TILE_SIZE - offset_y) }, *tile_image.release_value(), { 0, 0, TILE_SIZE, TILE_SIZE }, 1); + } + } +} + +void MapWidget::paint_scale_line(GUI::Painter& painter, String label, Gfx::IntRect rect) +{ + painter.fill_rect(rect, panel_background_color); + painter.fill_rect({ rect.x(), rect.y(), 1, rect.height() }, panel_foreground_color); + painter.fill_rect({ rect.x() + rect.width() - 1, rect.y(), 1, rect.height() }, panel_foreground_color); + Gfx::FloatRect label_rect { rect.x() + PANEL_PADDING_X, rect.y() + PANEL_PADDING_Y, rect.width() - PANEL_PADDING_X * 2, rect.height() - PANEL_PADDING_Y * 2 }; + painter.draw_text(label_rect, label, Gfx::TextAlignment::TopLeft, panel_foreground_color); +} + +void MapWidget::paint_scale(GUI::Painter& painter) +{ + double max_meters = m_center.distance_to({ m_center.latitude, tile_x_to_longitude(longitude_to_tile_x(m_center.longitude, m_zoom) + static_cast(m_scale_max_width) / TILE_SIZE, m_zoom) }); + float margin_x = 8; + float margin_y = 8; + float line_height = PANEL_PADDING_Y + painter.font().pixel_size() + PANEL_PADDING_Y; + + // Metric line + double meters = nice_round_number(max_meters); + float metric_width = m_scale_max_width * (meters / max_meters); + Gfx::IntRect metric_rect = { margin_x, height() - margin_y - line_height * 2, metric_width, line_height }; + if (meters < 1000) { + paint_scale_line(painter, MUST(String::formatted("{} m", meters)), metric_rect); + } else { + paint_scale_line(painter, MUST(String::formatted("{} km", meters / 1000)), metric_rect); + } + + // Imperial line + double max_feet = max_meters * 3.28084; + double feet = nice_round_number(max_feet); + double max_miles = max_feet / 5280; + double miles = nice_round_number(max_miles); + float imperial_width = m_scale_max_width * (feet < 5280 ? feet / max_feet : miles / max_miles); + Gfx::IntRect imperial_rect = { margin_x, height() - margin_y - line_height, imperial_width, line_height }; + if (feet < 5280) { + paint_scale_line(painter, MUST(String::formatted("{} ft", feet)), imperial_rect); + } else { + paint_scale_line(painter, MUST(String::formatted("{} mi", miles)), imperial_rect); + } + + // Border between + painter.fill_rect({ margin_x, height() - margin_y - line_height, max(metric_width, imperial_width), 1.0f }, panel_foreground_color); +} + +void MapWidget::paint_attribution(GUI::Painter& painter) +{ + m_attribution_width = PANEL_PADDING_X + painter.font().width(m_attribution_text) + PANEL_PADDING_X; + m_attribution_height = PANEL_PADDING_Y + painter.font().pixel_size() + PANEL_PADDING_Y; + painter.fill_rect({ width() - m_attribution_width, height() - m_attribution_height, m_attribution_width, m_attribution_height }, panel_background_color); + Gfx::FloatRect attribution_text_rect { 0.0f, 0.0f, width() - PANEL_PADDING_X, height() - PANEL_PADDING_Y }; + painter.draw_text(attribution_text_rect, m_attribution_text, Gfx::TextAlignment::BottomRight, panel_foreground_color); +} + +void MapWidget::paint_event(GUI::PaintEvent&) +{ + GUI::Painter painter(*this); + painter.fill_rect(rect(), map_background_color); + + if (m_connection_failed) { + painter.draw_text(rect(), "Failed to fetch map tiles :^("sv, Gfx::TextAlignment::Center, panel_foreground_color); + return; + } + + paint_tiles(painter); + if (m_scale_enabled) + paint_scale(painter); + if (m_attribution_enabled) + paint_attribution(painter); +} diff --git a/Userland/Applications/Maps/MapWidget.h b/Userland/Applications/Maps/MapWidget.h new file mode 100644 index 0000000000..300de7246a --- /dev/null +++ b/Userland/Applications/Maps/MapWidget.h @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2023, Bastiaan van der Plaat + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include + +class MapWidget final : public GUI::Widget { + C_OBJECT(MapWidget); + +public: + struct LatLng { + double latitude; + double longitude; + + double distance_to(LatLng const& other) const; + }; + + struct Options { + String tile_layer_url { "https://tile.openstreetmap.org/{}/{}/{}.png"_string }; + LatLng center; + int zoom; + bool scale_enabled { true }; + int scale_max_width { 100 }; + bool attribution_enabled { true }; + String attribution_text { "© OpenStreetMap contributors"_string }; + URL attribution_url { "https://www.openstreetmap.org/copyright"sv }; + }; + + LatLng center() const { return m_center; } + void set_center(LatLng const& center) + { + m_center = { + min(max(center.latitude, -LATITUDE_MAX), LATITUDE_MAX), + min(max(center.longitude, -180.0), 180.0) + }; + update(); + } + + int zoom() const { return m_zoom; } + void set_zoom(int zoom) + { + m_zoom = min(max(zoom, ZOOM_MIN), ZOOM_MAX); + update(); + } + + struct TileKey { + int x; + int y; + int zoom; + + unsigned hash() const + { + return pair_int_hash(x, pair_int_hash(y, zoom)); + } + + bool operator==(TileKey const& other) const + { + return x == other.x && y == other.y && zoom == other.zoom; + } + }; + +private: + MapWidget(Options const&); + + virtual void mousemove_event(GUI::MouseEvent&) override; + + virtual void mousedown_event(GUI::MouseEvent&) override; + + virtual void mouseup_event(GUI::MouseEvent&) override; + + virtual void mousewheel_event(GUI::MouseEvent&) override; + + virtual void paint_event(GUI::PaintEvent&) override; + + Optional> get_tile_image(int x, int y); + + void paint_tiles(GUI::Painter&); + + void paint_scale_line(GUI::Painter&, String label, Gfx::IntRect rect); + + void paint_scale(GUI::Painter&); + + void paint_attribution(GUI::Painter&); + + static int constexpr TILE_SIZE = 256; + static double constexpr LATITUDE_MAX = 85.0511287798066; + static size_t constexpr TILES_CACHE_MAX = 256; + static int constexpr ZOOM_MIN = 2; + static int constexpr ZOOM_MAX = 19; + static float constexpr PANEL_PADDING_X = 6; + static float constexpr PANEL_PADDING_Y = 4; + + // These colors match the default OpenStreetMap map tiles style, so they don't depend on any system theme colors + static Gfx::Color constexpr map_background_color = { 200, 200, 200 }; + static Gfx::Color constexpr panel_background_color = { 255, 255, 255, 204 }; + static Gfx::Color constexpr panel_foreground_color = { 51, 51, 51 }; + + RefPtr m_request_client; + Vector> m_active_requests; + String m_tile_layer_url; + LatLng m_center; + int m_zoom {}; + bool m_scale_enabled {}; + int m_scale_max_width {}; + bool m_attribution_enabled {}; + String m_attribution_text; + float m_attribution_width {}; + float m_attribution_height {}; + URL m_attribution_url; + bool m_dragging { false }; + int m_last_mouse_x { 0 }; + int m_last_mouse_y { 0 }; + bool m_first_image_loaded { false }; + bool m_connection_failed { false }; + OrderedHashMap> m_tiles; +}; + +template<> +struct AK::Traits : public GenericTraits { + static unsigned hash(MapWidget::TileKey const& t) { return t.hash(); } +}; diff --git a/Userland/Applications/Maps/main.cpp b/Userland/Applications/Maps/main.cpp new file mode 100644 index 0000000000..dbda381666 --- /dev/null +++ b/Userland/Applications/Maps/main.cpp @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023, Bastiaan van der Plaat + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "MapWidget.h" +#include +#include +#include +#include +#include +#include + +ErrorOr serenity_main(Main::Arguments arguments) +{ + auto app = TRY(GUI::Application::create(arguments)); + auto app_icon = TRY(GUI::Icon::try_create_default_icon("app-hello-world"sv)); // FIXME: Create Maps icon + auto window = TRY(GUI::Window::try_create()); + window->set_title("Maps"); + window->set_icon(app_icon.bitmap_for_size(16)); + window->resize(640, 480); + + // Map widget + MapWidget::Options options {}; + options.center.latitude = Config::read_string("Maps"sv, "MapView"sv, "CenterLatitude"sv, "30"sv).to_double().value_or(30.0); + options.center.longitude = Config::read_string("Maps"sv, "MapView"sv, "CenterLongitude"sv, "0"sv).to_double().value_or(0.0); + options.zoom = Config::read_i32("Maps"sv, "MapView"sv, "Zoom"sv, 3); + auto maps = TRY(MapWidget::try_create(options)); + window->set_main_widget(maps); + + // Main menu + auto file_menu = window->add_menu("&File"_string); + file_menu->add_action(GUI::CommonActions::make_quit_action([](auto&) { GUI::Application::the()->quit(); })); + + auto view_menu = window->add_menu("&View"_string); + view_menu->add_action(GUI::CommonActions::make_zoom_in_action([maps](auto&) { maps->set_zoom(maps->zoom() + 1); }, window)); + view_menu->add_action(GUI::CommonActions::make_zoom_out_action([maps](auto&) { maps->set_zoom(maps->zoom() - 1); }, window)); + view_menu->add_action(GUI::CommonActions::make_reset_zoom_action([maps](auto&) { maps->set_zoom(3); }, window)); + view_menu->add_separator(); + view_menu->add_action(GUI::CommonActions::make_fullscreen_action([window](auto&) { window->set_fullscreen(!window->is_fullscreen()); }, window)); + + auto help_menu = window->add_menu("&Help"_string); + help_menu->add_action(GUI::CommonActions::make_command_palette_action(window)); + help_menu->add_action(GUI::CommonActions::make_about_action("Maps", app_icon, window)); + + window->show(); + + // Remember last map position + int exec = app->exec(); + Config::write_string("Maps"sv, "MapView"sv, "CenterLatitude"sv, TRY(String::number(maps->center().latitude))); + Config::write_string("Maps"sv, "MapView"sv, "CenterLongitude"sv, TRY(String::number(maps->center().longitude))); + Config::write_i32("Maps"sv, "MapView"sv, "Zoom"sv, maps->zoom()); + return exec; +}