Files
sunnypilot/tools/jotpluggler/map.cc
2026-05-10 17:24:53 -07:00

1329 lines
47 KiB
C++

#include "tools/jotpluggler/app.h"
#include "tools/jotpluggler/common.h"
#include "tools/jotpluggler/map.h"
#include <GLFW/glfw3.h>
extern "C" {
#include <zstd.h>
}
#include <array>
#include <algorithm>
#include <cmath>
#include <condition_variable>
#include <cstdint>
#include <cstring>
#include <filesystem>
#include <limits>
#include <mutex>
#include <optional>
#include <string_view>
#include <thread>
#include <unordered_set>
#include <utility>
#include <vector>
#include "common/util.h"
#include "json11/json11.hpp"
namespace fs = std::filesystem;
namespace {
constexpr int MAP_MIN_ZOOM = 1;
constexpr int MAP_MAX_ZOOM = 18;
constexpr int MAP_SINGLE_POINT_MIN_ZOOM = 14;
constexpr float MAP_WHEEL_ZOOM_STEP = 0.25f;
constexpr double MAP_TRACE_PAD_FRAC = 0.45;
constexpr double MAP_TRACE_MIN_LAT_PAD = 0.01;
constexpr double MAP_BOUNDS_GRID = 0.005;
constexpr double MAP_CORRIDOR_LAT_PAD = 0.010;
constexpr double MAP_CORRIDOR_MIN_STEP_S = 1.5;
constexpr size_t MAP_CORRIDOR_MAX_BOXES = 36;
constexpr float MAP_INITIAL_FIT_FILL = 0.88f;
constexpr float MAP_MIN_ZOOM_FILL = 0.98f;
constexpr float MAP_EDGE_FADE_FRAC = 0.28f;
constexpr const char *MAP_QUERY_ENDPOINTS[] = {
"https://overpass-api.de/api/interpreter",
"https://overpass.private.coffee/api/interpreter",
};
struct GeoPoint {
double lat = 0.0;
double lon = 0.0;
};
struct ProjectedPoint {
float x = 0.0f;
float y = 0.0f;
};
struct ProjectedBounds {
float min_x = 0.0f;
float min_y = 0.0f;
float max_x = 0.0f;
float max_y = 0.0f;
bool valid() const {
return max_x >= min_x && max_y >= min_y;
}
};
enum class RoadClass : uint8_t {
Motorway,
Primary,
Secondary,
Local,
};
struct RoadFeature {
RoadClass road_class = RoadClass::Local;
ProjectedBounds bounds;
std::vector<ProjectedPoint> points;
};
struct WaterLineFeature {
ProjectedBounds bounds;
std::vector<ProjectedPoint> points;
};
struct WaterPolygonFeature {
ProjectedBounds bounds;
std::vector<ProjectedPoint> ring;
};
} // namespace
struct RouteBasemap {
std::string key;
GeoBounds bounds;
ProjectedBounds projected_bounds;
std::vector<RoadFeature> roads;
std::vector<WaterLineFeature> water_lines;
std::vector<WaterPolygonFeature> water_polygons;
};
struct MapRequestSpec {
std::string key;
GeoBounds bounds;
std::string query;
};
namespace {
double lon_to_world_x(double lon, double zoom) {
return (lon + 180.0) / 360.0 * 256.0 * std::exp2(zoom);
}
double lat_to_world_y(double lat, double zoom) {
const double lat_rad = lat * M_PI / 180.0;
return (1.0 - std::log(std::tan(lat_rad) + 1.0 / std::cos(lat_rad)) / M_PI) / 2.0 * 256.0 * std::exp2(zoom);
}
double world_x_to_lon(double x, double zoom) {
return x / std::exp2(zoom) / 256.0 * 360.0 - 180.0;
}
double world_y_to_lat(double y, double zoom) {
const double n = M_PI - (2.0 * M_PI * (y / std::exp2(zoom))) / 256.0;
return 180.0 / M_PI * std::atan(std::sinh(n));
}
double map_trace_center_lat(const GpsTrace &trace) {
return (trace.min_lat + trace.max_lat) * 0.5;
}
double map_trace_center_lon(const GpsTrace &trace) {
return (trace.min_lon + trace.max_lon) * 0.5;
}
double clamp_lat(double lat) {
return std::clamp(lat, -85.0, 85.0);
}
double clamp_lon(double lon) {
return std::clamp(lon, -179.999, 179.999);
}
float project_lon0(double lon) {
return static_cast<float>((lon + 180.0) / 360.0 * 256.0);
}
float project_lat0(double lat) {
const double lat_rad = lat * M_PI / 180.0;
return static_cast<float>((1.0 - std::log(std::tan(lat_rad) + 1.0 / std::cos(lat_rad)) / M_PI) / 2.0 * 256.0);
}
double cos_lat_scale(double lat) {
return std::max(0.2, std::cos(lat * M_PI / 180.0));
}
double quantize_down(double value, double step) {
return std::floor(value / step) * step;
}
double quantize_up(double value, double step) {
return std::ceil(value / step) * step;
}
ProjectedBounds compute_projected_bounds(const std::vector<ProjectedPoint> &points) {
ProjectedBounds bounds;
if (points.empty()) {
return bounds;
}
bounds.min_x = bounds.max_x = points.front().x;
bounds.min_y = bounds.max_y = points.front().y;
for (const ProjectedPoint &point : points) {
bounds.min_x = std::min(bounds.min_x, point.x);
bounds.max_x = std::max(bounds.max_x, point.x);
bounds.min_y = std::min(bounds.min_y, point.y);
bounds.max_y = std::max(bounds.max_y, point.y);
}
return bounds;
}
ProjectedBounds project_bounds0(const GeoBounds &bounds) {
if (!bounds.valid()) {
return {};
}
return ProjectedBounds{
.min_x = project_lon0(bounds.west),
.min_y = project_lat0(bounds.north),
.max_x = project_lon0(bounds.east),
.max_y = project_lat0(bounds.south),
};
}
bool feature_intersects_view(const ProjectedBounds &feature, const ProjectedBounds &view, float zoom_scale) {
const float min_x = feature.min_x * zoom_scale;
const float max_x = feature.max_x * zoom_scale;
const float min_y = feature.min_y * zoom_scale;
const float max_y = feature.max_y * zoom_scale;
return !(max_x < view.min_x || min_x > view.max_x
|| max_y < view.min_y || min_y > view.max_y);
}
GeoBounds requested_bounds_for_trace(const GpsTrace &trace) {
if (trace.points.empty()) {
return {};
}
const double center_lat = map_trace_center_lat(trace);
const double lat_span = std::max(trace.max_lat - trace.min_lat, 0.002);
const double lon_span = std::max(trace.max_lon - trace.min_lon, 0.002 / cos_lat_scale(center_lat));
const double lat_pad = std::max(lat_span * MAP_TRACE_PAD_FRAC, MAP_TRACE_MIN_LAT_PAD);
const double lon_pad = std::max(lon_span * MAP_TRACE_PAD_FRAC, MAP_TRACE_MIN_LAT_PAD / cos_lat_scale(center_lat));
GeoBounds bounds;
bounds.south = clamp_lat(quantize_down(trace.min_lat - lat_pad, MAP_BOUNDS_GRID));
bounds.north = clamp_lat(quantize_up(trace.max_lat + lat_pad, MAP_BOUNDS_GRID));
bounds.west = clamp_lon(quantize_down(trace.min_lon - lon_pad, MAP_BOUNDS_GRID));
bounds.east = clamp_lon(quantize_up(trace.max_lon + lon_pad, MAP_BOUNDS_GRID));
return bounds;
}
GeoBounds merge_bounds(const GeoBounds &a, const GeoBounds &b) {
if (!a.valid()) return b;
if (!b.valid()) return a;
return GeoBounds{
.south = std::min(a.south, b.south),
.west = std::min(a.west, b.west),
.north = std::max(a.north, b.north),
.east = std::max(a.east, b.east),
};
}
bool bounds_overlap_or_touch(const GeoBounds &a, const GeoBounds &b) {
return !(a.east < b.west || b.east < a.west || a.north < b.south || b.north < a.south);
}
std::vector<GeoBounds> corridor_boxes_for_trace(const GpsTrace &trace) {
std::vector<GeoBounds> boxes;
if (trace.points.empty()) {
return boxes;
}
const double center_lat = map_trace_center_lat(trace);
const double lon_pad = MAP_CORRIDOR_LAT_PAD / cos_lat_scale(center_lat);
const double total_time = trace.points.back().time - trace.points.front().time;
const double target_boxes = std::min<double>(MAP_CORRIDOR_MAX_BOXES, std::max<double>(8.0, total_time / MAP_CORRIDOR_MIN_STEP_S));
const size_t stride = std::max<size_t>(1, static_cast<size_t>(std::ceil(trace.points.size() / target_boxes)));
auto add_box = [&](double lat, double lon) {
GeoBounds box{
.south = clamp_lat(quantize_down(lat - MAP_CORRIDOR_LAT_PAD, MAP_BOUNDS_GRID)),
.west = clamp_lon(quantize_down(lon - lon_pad, MAP_BOUNDS_GRID)),
.north = clamp_lat(quantize_up(lat + MAP_CORRIDOR_LAT_PAD, MAP_BOUNDS_GRID)),
.east = clamp_lon(quantize_up(lon + lon_pad, MAP_BOUNDS_GRID)),
};
if (!box.valid()) {
return;
}
for (GeoBounds &existing : boxes) {
if (bounds_overlap_or_touch(existing, box)) {
existing = merge_bounds(existing, box);
return;
}
}
boxes.push_back(box);
};
add_box(trace.points.front().lat, trace.points.front().lon);
for (size_t i = stride; i < trace.points.size(); i += stride) {
add_box(trace.points[i].lat, trace.points[i].lon);
}
add_box(trace.points.back().lat, trace.points.back().lon);
bool merged = true;
while (merged) {
merged = false;
for (size_t i = 0; i < boxes.size() && !merged; ++i) {
for (size_t j = i + 1; j < boxes.size(); ++j) {
if (bounds_overlap_or_touch(boxes[i], boxes[j])) {
boxes[i] = merge_bounds(boxes[i], boxes[j]);
boxes.erase(boxes.begin() + static_cast<std::ptrdiff_t>(j));
merged = true;
break;
}
}
}
}
return boxes;
}
ProjectedBounds view_bounds(double top_left_x, double top_left_y, float width, float height) {
return ProjectedBounds{
.min_x = static_cast<float>(top_left_x),
.min_y = static_cast<float>(top_left_y),
.max_x = static_cast<float>(top_left_x + width),
.max_y = static_cast<float>(top_left_y + height),
};
}
int fit_map_zoom_for_bounds(const GeoBounds &bounds, float width, float height, float fill_fraction) {
if (!bounds.valid()) {
return MAP_MIN_ZOOM;
}
const double max_width = std::max(1.0f, width * fill_fraction);
const double max_height = std::max(1.0f, height * fill_fraction);
for (int z = MAP_MAX_ZOOM; z >= MAP_MIN_ZOOM; --z) {
const double pixel_width = std::abs(lon_to_world_x(bounds.east, z) - lon_to_world_x(bounds.west, z));
const double pixel_height = std::abs(lat_to_world_y(bounds.south, z) - lat_to_world_y(bounds.north, z));
if (pixel_width <= max_width && pixel_height <= max_height) {
return z;
}
}
return MAP_MIN_ZOOM;
}
int fit_map_zoom_for_trace(const GpsTrace &trace, float width, float height) {
return fit_map_zoom_for_bounds(requested_bounds_for_trace(trace), width, height, MAP_INITIAL_FIT_FILL);
}
int minimum_allowed_map_zoom(const GeoBounds &bounds, const GpsTrace &trace, ImVec2 size) {
if (trace.points.size() <= 1) {
return MAP_SINGLE_POINT_MIN_ZOOM;
}
const int fit_zoom = fit_map_zoom_for_bounds(bounds.valid() ? bounds : requested_bounds_for_trace(trace),
size.x, size.y, MAP_MIN_ZOOM_FILL);
return std::clamp(fit_zoom, MAP_MIN_ZOOM, MAP_MAX_ZOOM);
}
std::optional<GpsPoint> interpolate_gps(const GpsTrace &trace, double time_value) {
if (trace.points.empty()) {
return std::nullopt;
}
if (time_value <= trace.points.front().time) {
return trace.points.front();
}
if (time_value >= trace.points.back().time) {
return trace.points.back();
}
auto upper = std::lower_bound(trace.points.begin(), trace.points.end(), time_value,
[](const GpsPoint &point, double target) {
return point.time < target;
});
if (upper == trace.points.begin()) {
return trace.points.front();
}
const GpsPoint &p1 = *upper;
const GpsPoint &p0 = *(upper - 1);
const double dt = p1.time - p0.time;
if (dt <= 1.0e-9) {
return p0;
}
const double alpha = (time_value - p0.time) / dt;
GpsPoint out;
out.time = time_value;
out.lat = p0.lat + (p1.lat - p0.lat) * alpha;
out.lon = p0.lon + (p1.lon - p0.lon) * alpha;
out.bearing = static_cast<float>(p0.bearing + (p1.bearing - p0.bearing) * alpha);
out.type = alpha < 0.5 ? p0.type : p1.type;
return out;
}
ImU32 map_timeline_color(TimelineEntry::Type type, float alpha = 1.0f) {
return timeline_entry_color(type, alpha, {140, 150, 165});
}
ImVec2 gps_to_screen(double lat, double lon, double zoom, double top_left_x, double top_left_y, const ImVec2 &rect_min) {
return ImVec2(rect_min.x + static_cast<float>(lon_to_world_x(lon, zoom) - top_left_x),
rect_min.y + static_cast<float>(lat_to_world_y(lat, zoom) - top_left_y));
}
bool point_in_rect_with_margin(const ImVec2 &point, const ImVec2 &rect_min, const ImVec2 &rect_max,
float margin_fraction) {
const float width = rect_max.x - rect_min.x;
const float height = rect_max.y - rect_min.y;
const float margin_x = width * margin_fraction;
const float margin_y = height * margin_fraction;
return point.x >= rect_min.x + margin_x && point.x <= rect_max.x - margin_x
&& point.y >= rect_min.y + margin_y && point.y <= rect_max.y - margin_y;
}
void draw_car_marker(ImDrawList *draw_list, ImVec2 center, float bearing_deg, ImU32 color, float size) {
const float rad = bearing_deg * static_cast<float>(M_PI / 180.0);
const ImVec2 forward(std::sin(rad), -std::cos(rad));
const ImVec2 perp(-forward.y, forward.x);
const ImVec2 tip(center.x + forward.x * size, center.y + forward.y * size);
const ImVec2 base(center.x - forward.x * size * 0.45f, center.y - forward.y * size * 0.45f);
const ImVec2 left(base.x + perp.x * size * 0.6f, base.y + perp.y * size * 0.6f);
const ImVec2 right(base.x - perp.x * size * 0.6f, base.y - perp.y * size * 0.6f);
draw_list->AddTriangleFilled(tip, left, right, color);
draw_list->AddTriangle(tip, left, right, IM_COL32(255, 255, 255, 210), 2.0f);
}
bool is_convex_ring(const std::vector<ImVec2> &points) {
if (points.size() < 4) {
return false;
}
float sign = 0.0f;
const size_t n = points.size();
for (size_t i = 0; i < n; ++i) {
const ImVec2 &a = points[i];
const ImVec2 &b = points[(i + 1) % n];
const ImVec2 &c = points[(i + 2) % n];
const float cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x);
if (std::abs(cross) < 1.0e-3f) {
continue;
}
if (sign == 0.0f) {
sign = cross;
} else if ((cross > 0.0f) != (sign > 0.0f)) {
return false;
}
}
return sign != 0.0f;
}
uint64_t fnv1a64(std::string_view text) {
uint64_t value = 1469598103934665603ULL;
for (unsigned char c : text) {
value ^= static_cast<uint64_t>(c);
value *= 1099511628211ULL;
}
return value;
}
fs::path basemap_cache_root() {
const char *home = std::getenv("HOME");
fs::path root = home != nullptr ? fs::path(home) / ".comma" : fs::temp_directory_path();
root /= "jotpluggler_vector_map";
fs::create_directories(root);
return root;
}
std::string bounds_key(const GeoBounds &bounds) {
return util::string_format("v2_%.5f_%.5f_%.5f_%.5f",
bounds.south, bounds.west, bounds.north, bounds.east);
}
fs::path basemap_cache_path(const std::string &key) {
const uint64_t hash = fnv1a64(key);
return basemap_cache_root() / util::string_format("%016llx.bin.zst", static_cast<unsigned long long>(hash));
}
uint64_t cache_directory_size_bytes() {
uint64_t total = 0;
const fs::path root = basemap_cache_root();
if (!fs::exists(root)) {
return 0;
}
for (const fs::directory_entry &entry : fs::directory_iterator(root)) {
if (entry.is_regular_file()) {
total += static_cast<uint64_t>(entry.file_size());
}
}
return total;
}
size_t cache_directory_file_count() {
size_t count = 0;
const fs::path root = basemap_cache_root();
if (!fs::exists(root)) {
return 0;
}
for (const fs::directory_entry &entry : fs::directory_iterator(root)) {
if (entry.is_regular_file()) {
++count;
}
}
return count;
}
void clear_cache_directory() {
const fs::path root = basemap_cache_root();
if (!fs::exists(root)) {
return;
}
for (const fs::directory_entry &entry : fs::directory_iterator(root)) {
if (entry.is_regular_file()) {
std::error_code ec;
fs::remove(entry.path(), ec);
}
}
}
std::string percent_encode(std::string_view text) {
std::string out;
out.reserve(text.size() * 3);
for (unsigned char c : text) {
if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
|| c == '-' || c == '_' || c == '.' || c == '~') {
out.push_back(static_cast<char>(c));
} else {
out += util::string_format("%%%02X", static_cast<unsigned int>(c));
}
}
return out;
}
std::string bbox_string(const GeoBounds &bounds) {
return util::string_format("%.6f,%.6f,%.6f,%.6f",
bounds.south, bounds.west, bounds.north, bounds.east);
}
MapRequestSpec build_request_for_trace(const GpsTrace &trace) {
const std::vector<GeoBounds> boxes = corridor_boxes_for_trace(trace);
GeoBounds union_bounds;
std::string query = "[out:json][timeout:25];(";
for (const GeoBounds &box : boxes) {
union_bounds = merge_bounds(union_bounds, box);
const std::string bbox = bbox_string(box);
query += "way[\"highway\"][\"area\"!=\"yes\"](" + bbox + ");";
query += "way[\"natural\"=\"water\"](" + bbox + ");";
query += "way[\"waterway\"=\"riverbank\"](" + bbox + ");";
query += "way[\"waterway\"~\"river|stream|canal\"](" + bbox + ");";
}
query += ");out tags geom;";
std::string key = bounds_key(union_bounds);
key += ":";
key += std::to_string(boxes.size());
for (const GeoBounds &box : boxes) {
key += ":";
key += bbox_string(box);
}
return MapRequestSpec{
.key = std::move(key),
.bounds = union_bounds,
.query = std::move(query),
};
}
bool fetch_overpass_json(std::string_view query, std::string *out) {
const std::string body = std::string("data=") + percent_encode(query);
for (const char *endpoint : MAP_QUERY_ENDPOINTS) {
const std::string command = "curl -fsSL --compressed --connect-timeout 8 --max-time 30 "
"-A 'jotpluggler-vector-map/1.0' "
"-H 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' "
"--data-raw " + shell_quote(body) + " "
+ shell_quote(endpoint);
const std::string response = util::check_output(command);
if (!response.empty() && response.front() == '{') {
*out = response;
return true;
}
}
return false;
}
std::string load_overpass_json(std::string_view query) {
std::string response;
if (!fetch_overpass_json(query, &response)) {
return {};
}
return response;
}
template <typename T>
void append_pod(std::string *out, const T &value) {
const size_t start = out->size();
out->resize(start + sizeof(T));
std::memcpy(out->data() + start, &value, sizeof(T));
}
template <typename T>
bool read_pod(std::string_view data, size_t *offset, T *value) {
if (*offset + sizeof(T) > data.size()) {
return false;
}
std::memcpy(value, data.data() + *offset, sizeof(T));
*offset += sizeof(T);
return true;
}
void append_points(std::string *out, const std::vector<ProjectedPoint> &points) {
const uint32_t count = static_cast<uint32_t>(points.size());
append_pod(out, count);
for (const ProjectedPoint &point : points) {
append_pod(out, point.x);
append_pod(out, point.y);
}
}
bool read_points(std::string_view data, size_t *offset, std::vector<ProjectedPoint> *points) {
uint32_t count = 0;
if (!read_pod(data, offset, &count)) {
return false;
}
points->clear();
points->reserve(count);
for (uint32_t i = 0; i < count; ++i) {
ProjectedPoint point;
if (!read_pod(data, offset, &point.x) || !read_pod(data, offset, &point.y)) {
return false;
}
points->push_back(point);
}
return true;
}
std::string serialize_basemap_payload(const RouteBasemap &basemap) {
std::string raw;
raw.reserve(1024 + basemap.roads.size() * 48);
raw.append("JBM2", 4);
append_pod(&raw, basemap.bounds.south);
append_pod(&raw, basemap.bounds.west);
append_pod(&raw, basemap.bounds.north);
append_pod(&raw, basemap.bounds.east);
const uint32_t road_count = static_cast<uint32_t>(basemap.roads.size());
const uint32_t water_line_count = static_cast<uint32_t>(basemap.water_lines.size());
const uint32_t water_polygon_count = static_cast<uint32_t>(basemap.water_polygons.size());
append_pod(&raw, road_count);
append_pod(&raw, water_line_count);
append_pod(&raw, water_polygon_count);
for (const RoadFeature &road : basemap.roads) {
const uint8_t kind = static_cast<uint8_t>(road.road_class);
append_pod(&raw, kind);
append_points(&raw, road.points);
}
for (const WaterLineFeature &water : basemap.water_lines) {
append_points(&raw, water.points);
}
for (const WaterPolygonFeature &water : basemap.water_polygons) {
append_points(&raw, water.ring);
}
return raw;
}
std::optional<RouteBasemap> deserialize_basemap_payload(std::string_view raw, const std::string &key) {
if (!util::starts_with(std::string(raw), "JBM2")) {
return std::nullopt;
}
size_t offset = 4;
RouteBasemap basemap;
basemap.key = key;
if (!read_pod(raw, &offset, &basemap.bounds.south)
|| !read_pod(raw, &offset, &basemap.bounds.west)
|| !read_pod(raw, &offset, &basemap.bounds.north)
|| !read_pod(raw, &offset, &basemap.bounds.east)) {
return std::nullopt;
}
basemap.projected_bounds = project_bounds0(basemap.bounds);
uint32_t road_count = 0;
uint32_t water_line_count = 0;
uint32_t water_polygon_count = 0;
if (!read_pod(raw, &offset, &road_count)
|| !read_pod(raw, &offset, &water_line_count)
|| !read_pod(raw, &offset, &water_polygon_count)) {
return std::nullopt;
}
basemap.roads.reserve(road_count);
for (uint32_t i = 0; i < road_count; ++i) {
uint8_t kind = 0;
std::vector<ProjectedPoint> points;
if (!read_pod(raw, &offset, &kind) || !read_points(raw, &offset, &points)) {
return std::nullopt;
}
basemap.roads.push_back(RoadFeature{
.road_class = static_cast<RoadClass>(kind),
.bounds = compute_projected_bounds(points),
.points = std::move(points),
});
}
basemap.water_lines.reserve(water_line_count);
for (uint32_t i = 0; i < water_line_count; ++i) {
std::vector<ProjectedPoint> points;
if (!read_points(raw, &offset, &points)) {
return std::nullopt;
}
basemap.water_lines.push_back(WaterLineFeature{
.bounds = compute_projected_bounds(points),
.points = std::move(points),
});
}
basemap.water_polygons.reserve(water_polygon_count);
for (uint32_t i = 0; i < water_polygon_count; ++i) {
std::vector<ProjectedPoint> ring;
if (!read_points(raw, &offset, &ring)) {
return std::nullopt;
}
basemap.water_polygons.push_back(WaterPolygonFeature{
.bounds = compute_projected_bounds(ring),
.ring = std::move(ring),
});
}
return basemap;
}
bool save_compressed_basemap(const fs::path &path, const RouteBasemap &basemap) {
const std::string raw = serialize_basemap_payload(basemap);
const size_t bound = ZSTD_compressBound(raw.size());
std::string compressed(bound, '\0');
const size_t size = ZSTD_compress(compressed.data(), compressed.size(), raw.data(), raw.size(), 5);
if (ZSTD_isError(size)) {
return false;
}
compressed.resize(size);
ensure_parent_dir(path);
const std::string path_string = path.string();
return util::write_file(path_string.c_str(), compressed.data(), compressed.size(), O_WRONLY | O_CREAT | O_TRUNC) == 0;
}
std::optional<RouteBasemap> load_compressed_basemap(const fs::path &path, const std::string &key) {
const std::string compressed = util::read_file(path.string());
if (compressed.empty()) {
return std::nullopt;
}
const unsigned long long raw_size = ZSTD_getFrameContentSize(compressed.data(), compressed.size());
if (raw_size == ZSTD_CONTENTSIZE_ERROR || raw_size == ZSTD_CONTENTSIZE_UNKNOWN || raw_size > (1ULL << 31)) {
return std::nullopt;
}
std::string raw(static_cast<size_t>(raw_size), '\0');
const size_t actual = ZSTD_decompress(raw.data(), raw.size(), compressed.data(), compressed.size());
if (ZSTD_isError(actual)) {
return std::nullopt;
}
raw.resize(actual);
return deserialize_basemap_payload(raw, key);
}
std::vector<ProjectedPoint> geometry_points(const json11::Json &geometry_json) {
std::vector<ProjectedPoint> points;
const auto items = geometry_json.array_items();
points.reserve(items.size());
for (const json11::Json &point : items) {
if (!point["lat"].is_number() || !point["lon"].is_number()) {
continue;
}
points.push_back(ProjectedPoint{
.x = project_lon0(point["lon"].number_value()),
.y = project_lat0(point["lat"].number_value()),
});
}
return points;
}
std::optional<RoadClass> classify_road(std::string_view highway) {
if (highway == "motorway" || highway == "motorway_link" || highway == "trunk" || highway == "trunk_link") {
return RoadClass::Motorway;
}
if (highway == "primary" || highway == "primary_link") {
return RoadClass::Primary;
}
if (highway == "secondary" || highway == "secondary_link" || highway == "tertiary" || highway == "tertiary_link") {
return RoadClass::Secondary;
}
if (highway == "residential" || highway == "unclassified" || highway == "living_street" || highway == "road") {
return RoadClass::Local;
}
return std::nullopt;
}
std::optional<RouteBasemap> parse_basemap_json(const std::string &raw, const GeoBounds &bounds, const std::string &key) {
std::string parse_error;
const json11::Json root = json11::Json::parse(raw, parse_error);
if (!parse_error.empty() || !root.is_object()) {
return std::nullopt;
}
RouteBasemap basemap;
basemap.key = key;
basemap.bounds = bounds;
basemap.projected_bounds = project_bounds0(bounds);
for (const json11::Json &element : root["elements"].array_items()) {
if (element["type"].string_value() != "way") {
continue;
}
const json11::Json &tags = element["tags"];
const std::vector<ProjectedPoint> points = geometry_points(element["geometry"]);
if (points.size() < 2) {
continue;
}
const std::string highway = tags["highway"].string_value();
if (!highway.empty()) {
const std::optional<RoadClass> road_class = classify_road(highway);
if (!road_class.has_value()) {
continue;
}
basemap.roads.push_back(RoadFeature{
.road_class = *road_class,
.bounds = compute_projected_bounds(points),
.points = points,
});
continue;
}
const std::string natural = tags["natural"].string_value();
const std::string waterway = tags["waterway"].string_value();
const bool closed = points.size() >= 4
&& std::abs(points.front().x - points.back().x) < 1.0e-6f
&& std::abs(points.front().y - points.back().y) < 1.0e-6f;
if ((natural == "water" || waterway == "riverbank") && closed) {
basemap.water_polygons.push_back(WaterPolygonFeature{
.bounds = compute_projected_bounds(points),
.ring = points,
});
continue;
}
if (waterway == "river" || waterway == "stream" || waterway == "canal") {
basemap.water_lines.push_back(WaterLineFeature{
.bounds = compute_projected_bounds(points),
.points = points,
});
}
}
return basemap;
}
struct RoadPaint {
ImU32 casing = 0;
ImU32 fill = 0;
float casing_width = 1.0f;
float fill_width = 1.0f;
};
constexpr ImU32 MAP_BG_COLOR = IM_COL32(244, 243, 238, 255);
constexpr ImU32 MAP_WATER_FILL = IM_COL32(193, 216, 235, 185);
constexpr ImU32 MAP_WATER_OUTLINE = IM_COL32(143, 173, 201, 220);
constexpr ImU32 MAP_WATER_LINE = IM_COL32(156, 186, 214, 205);
constexpr ImU32 MAP_ROUTE_HALO = IM_COL32(31, 40, 50, 92);
RoadPaint road_paint(RoadClass road_class, float zoom) {
const float scale = std::clamp(0.88f + 0.12f * (zoom - 12.0f), 0.76f, 1.95f);
switch (road_class) {
case RoadClass::Motorway:
return {
.casing = IM_COL32(163, 157, 149, 235),
.fill = IM_COL32(245, 235, 215, 255),
.casing_width = 5.6f * scale,
.fill_width = 3.7f * scale,
};
case RoadClass::Primary:
return {
.casing = IM_COL32(171, 171, 168, 220),
.fill = IM_COL32(249, 246, 237, 248),
.casing_width = 4.6f * scale,
.fill_width = 2.95f * scale,
};
case RoadClass::Secondary:
return {
.casing = IM_COL32(183, 186, 189, 210),
.fill = IM_COL32(252, 251, 247, 240),
.casing_width = 3.5f * scale,
.fill_width = 2.15f * scale,
};
case RoadClass::Local:
default:
return {
.casing = IM_COL32(200, 202, 205, 195),
.fill = IM_COL32(255, 255, 254, 230),
.casing_width = 2.5f * scale,
.fill_width = 1.5f * scale,
};
}
}
void clamp_map_center(TabUiState::MapPaneState *map_state, const GeoBounds &bounds, const ImVec2 &size) {
if (!bounds.valid() || size.x <= 1.0f || size.y <= 1.0f) {
return;
}
const double zoom = map_state->zoom;
const double min_x = lon_to_world_x(bounds.west, zoom);
const double max_x = lon_to_world_x(bounds.east, zoom);
const double min_y = lat_to_world_y(bounds.north, zoom);
const double max_y = lat_to_world_y(bounds.south, zoom);
const double half_w = size.x * 0.5;
const double half_h = size.y * 0.5;
double center_x = lon_to_world_x(map_state->center_lon, zoom);
double center_y = lat_to_world_y(map_state->center_lat, zoom);
if (max_x - min_x <= size.x) {
center_x = (min_x + max_x) * 0.5;
} else {
center_x = std::clamp(center_x, min_x + half_w, max_x - half_w);
}
if (max_y - min_y <= size.y) {
center_y = (min_y + max_y) * 0.5;
} else {
center_y = std::clamp(center_y, min_y + half_h, max_y - half_h);
}
map_state->center_lon = world_x_to_lon(center_x, zoom);
map_state->center_lat = world_y_to_lat(center_y, zoom);
}
void initialize_map_pane_state(TabUiState::MapPaneState *map_state,
const GpsTrace &trace,
const GeoBounds &bounds,
ImVec2 size,
SessionDataMode mode,
std::optional<GpsPoint> cursor_point) {
if (trace.points.empty()) {
return;
}
map_state->initialized = true;
map_state->follow = mode == SessionDataMode::Stream;
const int min_zoom = minimum_allowed_map_zoom(bounds, trace, size);
if (mode == SessionDataMode::Stream && cursor_point.has_value()) {
map_state->zoom = std::max(16.0f, static_cast<float>(min_zoom));
map_state->center_lat = cursor_point->lat;
map_state->center_lon = cursor_point->lon;
} else {
map_state->zoom = std::max(static_cast<float>(fit_map_zoom_for_trace(trace, size.x, size.y)),
static_cast<float>(min_zoom));
map_state->center_lat = map_trace_center_lat(trace);
map_state->center_lon = map_trace_center_lon(trace);
}
clamp_map_center(map_state, bounds, size);
}
void draw_feature_polyline(ImDrawList *draw_list,
const std::vector<ProjectedPoint> &points,
float zoom_scale,
double top_left_x,
double top_left_y,
const ImVec2 &rect_min,
ImU32 color,
float thickness,
bool closed = false) {
if (points.size() < 2) {
return;
}
std::vector<ImVec2> screen;
screen.reserve(points.size());
for (const ProjectedPoint &point : points) {
screen.push_back(ImVec2(rect_min.x + point.x * zoom_scale - static_cast<float>(top_left_x),
rect_min.y + point.y * zoom_scale - static_cast<float>(top_left_y)));
}
draw_list->AddPolyline(screen.data(), static_cast<int>(screen.size()), color,
closed ? ImDrawFlags_Closed : ImDrawFlags_None, thickness);
}
void draw_water_polygon(ImDrawList *draw_list,
const WaterPolygonFeature &feature,
float zoom_scale,
double top_left_x,
double top_left_y,
const ImVec2 &rect_min) {
if (feature.ring.size() < 3) {
return;
}
std::vector<ImVec2> screen;
screen.reserve(feature.ring.size());
for (const ProjectedPoint &point : feature.ring) {
screen.push_back(ImVec2(rect_min.x + point.x * zoom_scale - static_cast<float>(top_left_x),
rect_min.y + point.y * zoom_scale - static_cast<float>(top_left_y)));
}
if (screen.size() >= 3 && is_convex_ring(screen)) {
draw_list->AddConvexPolyFilled(screen.data(), static_cast<int>(screen.size()), MAP_WATER_FILL);
}
draw_list->AddPolyline(screen.data(), static_cast<int>(screen.size()), MAP_WATER_OUTLINE,
ImDrawFlags_Closed, 1.8f);
}
void draw_edge_fade(ImDrawList *draw_list,
const GeoBounds &bounds,
double zoom,
double top_left_x,
double top_left_y,
const ImVec2 &rect_min,
const ImVec2 &rect_max) {
if (!bounds.valid()) {
return;
}
const float west_x = rect_min.x + static_cast<float>(lon_to_world_x(bounds.west, zoom) - top_left_x);
const float east_x = rect_min.x + static_cast<float>(lon_to_world_x(bounds.east, zoom) - top_left_x);
const float north_y = rect_min.y + static_cast<float>(lat_to_world_y(bounds.north, zoom) - top_left_y);
const float south_y = rect_min.y + static_cast<float>(lat_to_world_y(bounds.south, zoom) - top_left_y);
const float fade_x = std::max(28.0f, (rect_max.x - rect_min.x) * MAP_EDGE_FADE_FRAC);
const float fade_y = std::max(28.0f, (rect_max.y - rect_min.y) * MAP_EDGE_FADE_FRAC);
const ImU32 solid = MAP_BG_COLOR;
const ImU32 clear = IM_COL32(244, 243, 238, 6);
if (west_x > rect_min.x) {
const float x0 = rect_min.x;
const float x1 = std::min(rect_max.x, west_x);
const float xfade = std::max(x0, x1 - fade_x);
draw_list->AddRectFilledMultiColor(ImVec2(x0, rect_min.y), ImVec2(xfade, rect_max.y), solid, solid, solid, solid);
draw_list->AddRectFilledMultiColor(ImVec2(xfade, rect_min.y), ImVec2(x1, rect_max.y), solid, clear, clear, solid);
}
if (east_x < rect_max.x) {
const float x0 = std::max(rect_min.x, east_x);
const float x1 = rect_max.x;
const float xfade = std::min(x1, x0 + fade_x);
draw_list->AddRectFilledMultiColor(ImVec2(x0, rect_min.y), ImVec2(xfade, rect_max.y), clear, solid, solid, clear);
draw_list->AddRectFilledMultiColor(ImVec2(xfade, rect_min.y), ImVec2(x1, rect_max.y), solid, solid, solid, solid);
}
if (north_y > rect_min.y) {
const float y0 = rect_min.y;
const float y1 = std::min(rect_max.y, north_y);
const float yfade = std::max(y0, y1 - fade_y);
draw_list->AddRectFilledMultiColor(ImVec2(rect_min.x, y0), ImVec2(rect_max.x, yfade), solid, solid, solid, solid);
draw_list->AddRectFilledMultiColor(ImVec2(rect_min.x, yfade), ImVec2(rect_max.x, y1), solid, solid, clear, clear);
}
if (south_y < rect_max.y) {
const float y0 = std::max(rect_min.y, south_y);
const float y1 = rect_max.y;
const float yfade = std::min(y1, y0 + fade_y);
draw_list->AddRectFilledMultiColor(ImVec2(rect_min.x, y0), ImVec2(rect_max.x, yfade), clear, clear, solid, solid);
draw_list->AddRectFilledMultiColor(ImVec2(rect_min.x, yfade), ImVec2(rect_max.x, y1), solid, solid, solid, solid);
}
}
} // namespace
MapDataManager::MapDataManager() : worker_([this]() { run(); }) {}
MapDataManager::~MapDataManager() {
{
std::lock_guard<std::mutex> lock(mutex_);
stopping_ = true;
}
cv_.notify_all();
if (worker_.joinable()) {
worker_.join();
}
}
void MapDataManager::pump() {
std::unique_ptr<RouteBasemap> ready;
{
std::lock_guard<std::mutex> lock(mutex_);
ready = std::move(completed_);
}
if (ready) {
current_ = std::move(ready);
}
}
void MapDataManager::ensureTrace(const GpsTrace &trace) {
if (trace.points.empty()) {
return;
}
const MapRequestSpec wanted = build_request_for_trace(trace);
if (!wanted.bounds.valid()) {
return;
}
std::lock_guard<std::mutex> lock(mutex_);
if ((current_ && current_->key == wanted.key) || (pending_ && pending_->key == wanted.key)) {
return;
}
if (const auto cached = load_compressed_basemap(basemap_cache_path(wanted.key), wanted.key)) {
current_ = std::make_unique<RouteBasemap>(std::move(*cached));
completed_.reset();
pending_.reset();
active_.reset();
return;
}
pending_ = std::make_unique<Request>(Request{
.key = wanted.key,
.bounds = wanted.bounds,
.query = wanted.query,
});
cv_.notify_one();
}
bool MapDataManager::loading() const {
std::lock_guard<std::mutex> lock(mutex_);
return active_ || pending_;
}
const RouteBasemap *MapDataManager::current() const {
return current_.get();
}
void MapDataManager::clearCache() {
std::lock_guard<std::mutex> lock(mutex_);
clear_cache_directory();
}
MapCacheStats MapDataManager::cacheStats() const {
return MapCacheStats{
.bytes = cache_directory_size_bytes(),
.files = cache_directory_file_count(),
};
}
void MapDataManager::run() {
while (true) {
Request request;
{
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock, [&]() { return stopping_ || pending_ != nullptr; });
if (stopping_) {
return;
}
request = *pending_;
active_ = std::move(pending_);
}
std::unique_ptr<RouteBasemap> parsed;
const std::string raw = load_overpass_json(request.query);
if (!raw.empty()) {
if (auto basemap = parse_basemap_json(raw, request.bounds, request.key)) {
save_compressed_basemap(basemap_cache_path(request.key), *basemap);
parsed = std::make_unique<RouteBasemap>(std::move(*basemap));
}
}
{
std::lock_guard<std::mutex> lock(mutex_);
if (active_ && active_->key == request.key) {
completed_ = std::move(parsed);
active_.reset();
}
}
}
}
void draw_map_pane(AppSession *session, UiState *state, Pane *, int pane_index) {
TabUiState *tab_state = app_active_tab_state(state);
if (tab_state == nullptr || pane_index < 0 || pane_index >= static_cast<int>(tab_state->map_panes.size())) {
ImGui::TextUnformatted("Map unavailable");
return;
}
if (!session->map_data) {
ImGui::TextUnformatted("Map unavailable");
return;
}
session->map_data->ensureTrace(session->route_data.gps_trace);
session->map_data->pump();
TabUiState::MapPaneState &map_state = tab_state->map_panes[static_cast<size_t>(pane_index)];
const GpsTrace &trace = session->route_data.gps_trace;
const RouteBasemap *basemap = session->map_data->current();
const GeoBounds map_bounds = basemap != nullptr ? basemap->bounds : requested_bounds_for_trace(trace);
const ImVec2 rect_min = ImGui::GetCursorScreenPos();
const ImVec2 size = ImGui::GetContentRegionAvail();
const ImVec2 input_size(std::max(1.0f, size.x - 22.0f), std::max(1.0f, size.y));
ImGui::SetNextItemAllowOverlap();
ImGui::InvisibleButton("##map_canvas", input_size);
const ImVec2 rect_max(rect_min.x + size.x, rect_min.y + size.y);
const float rect_width = rect_max.x - rect_min.x;
const float rect_height = rect_max.y - rect_min.y;
ImDrawList *draw_list = ImGui::GetWindowDrawList();
draw_list->PushClipRect(rect_min, rect_max, true);
draw_list->AddRectFilled(rect_min, rect_max, MAP_BG_COLOR);
if (trace.points.empty()) {
const char *label = session->async_route_loading ? "Loading map..." : "No GPS trace";
const ImVec2 text = ImGui::CalcTextSize(label);
draw_list->AddText(ImVec2(rect_min.x + (rect_width - text.x) * 0.5f,
rect_min.y + (rect_height - text.y) * 0.5f),
IM_COL32(110, 118, 128, 255), label);
draw_list->PopClipRect();
return;
}
const std::optional<GpsPoint> cursor_point = state->has_tracker_time
? interpolate_gps(trace, state->tracker_time)
: std::optional<GpsPoint>{};
if (!map_state.initialized) {
initialize_map_pane_state(&map_state, trace, map_bounds, size, session->data_mode, cursor_point);
}
const int min_zoom = minimum_allowed_map_zoom(map_bounds, trace, size);
if (map_state.follow && cursor_point.has_value()) {
const float follow_zoom = std::clamp(map_state.zoom, static_cast<float>(min_zoom), static_cast<float>(MAP_MAX_ZOOM));
const double center_x = lon_to_world_x(map_state.center_lon, follow_zoom);
const double center_y = lat_to_world_y(map_state.center_lat, follow_zoom);
const double top_left_x = center_x - rect_width * 0.5;
const double top_left_y = center_y - rect_height * 0.5;
const ImVec2 car_screen = gps_to_screen(cursor_point->lat, cursor_point->lon, follow_zoom, top_left_x, top_left_y, rect_min);
if (!point_in_rect_with_margin(car_screen, rect_min, rect_max, 0.22f)) {
map_state.center_lat = cursor_point->lat;
map_state.center_lon = cursor_point->lon;
}
}
map_state.zoom = std::clamp(map_state.zoom, static_cast<float>(min_zoom), static_cast<float>(MAP_MAX_ZOOM));
clamp_map_center(&map_state, map_bounds, size);
const double zoom = map_state.zoom;
const float zoom_scale = static_cast<float>(std::exp2(zoom));
const double center_x = lon_to_world_x(map_state.center_lon, zoom);
const double center_y = lat_to_world_y(map_state.center_lat, zoom);
const double top_left_x = center_x - rect_width * 0.5;
const double top_left_y = center_y - rect_height * 0.5;
const ProjectedBounds current_view = view_bounds(top_left_x, top_left_y, rect_width, rect_height);
if (basemap != nullptr) {
for (const WaterPolygonFeature &water : basemap->water_polygons) {
if (feature_intersects_view(water.bounds, current_view, zoom_scale)) {
draw_water_polygon(draw_list, water, zoom_scale, top_left_x, top_left_y, rect_min);
}
}
for (const WaterLineFeature &water : basemap->water_lines) {
if (feature_intersects_view(water.bounds, current_view, zoom_scale)) {
draw_feature_polyline(draw_list, water.points, zoom_scale, top_left_x, top_left_y, rect_min,
MAP_WATER_LINE, 2.4f);
}
}
std::array<RoadClass, 4> order = {
RoadClass::Local,
RoadClass::Secondary,
RoadClass::Primary,
RoadClass::Motorway,
};
for (RoadClass road_class : order) {
const RoadPaint paint = road_paint(road_class, static_cast<float>(zoom));
for (const RoadFeature &road : basemap->roads) {
if (road.road_class != road_class || !feature_intersects_view(road.bounds, current_view, zoom_scale)) {
continue;
}
draw_feature_polyline(draw_list, road.points, zoom_scale, top_left_x, top_left_y, rect_min,
paint.casing, paint.casing_width);
draw_feature_polyline(draw_list, road.points, zoom_scale, top_left_x, top_left_y, rect_min,
paint.fill, paint.fill_width);
}
}
}
if (basemap != nullptr) {
draw_edge_fade(draw_list, basemap->bounds, zoom, top_left_x, top_left_y, rect_min, rect_max);
}
for (size_t i = 1; i < trace.points.size(); ++i) {
const GpsPoint &p0 = trace.points[i - 1];
const GpsPoint &p1 = trace.points[i];
const ImVec2 s0 = gps_to_screen(p0.lat, p0.lon, zoom, top_left_x, top_left_y, rect_min);
const ImVec2 s1 = gps_to_screen(p1.lat, p1.lon, zoom, top_left_x, top_left_y, rect_min);
draw_list->AddLine(s0, s1, MAP_ROUTE_HALO, 5.8f);
draw_list->AddLine(s0, s1, map_timeline_color(p1.type, 1.0f), 3.25f);
}
if (cursor_point.has_value()) {
const ImVec2 marker = gps_to_screen(cursor_point->lat, cursor_point->lon, zoom, top_left_x, top_left_y, rect_min);
const float marker_size = std::clamp(9.0f + 1.0f * static_cast<float>(zoom - min_zoom), 9.0f, 20.0f);
draw_car_marker(draw_list, marker, cursor_point->bearing, map_timeline_color(cursor_point->type, 1.0f), marker_size);
}
if (session->map_data->loading()) {
const char *label = basemap != nullptr ? "Refreshing roads..." : "Loading roads...";
const ImVec2 text = ImGui::CalcTextSize(label);
const ImVec2 pos(rect_min.x + 12.0f, rect_max.y - text.y - 12.0f);
draw_list->AddRectFilled(ImVec2(pos.x - 6.0f, pos.y - 4.0f),
ImVec2(pos.x + text.x + 6.0f, pos.y + text.y + 4.0f),
IM_COL32(255, 255, 255, 180), 4.0f);
draw_list->AddText(pos, IM_COL32(84, 93, 105, 255), label);
}
draw_list->PopClipRect();
const bool canvas_hovered = ImGui::IsItemHovered();
const bool double_clicked = canvas_hovered && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left);
bool overlay_hovered = false;
if (const std::string google_maps_url = route_google_maps_url(trace); !google_maps_url.empty()) {
std::string label = std::string("Google Maps ") + icon::BOX_ARROW_UP_RIGHT;
const ImVec2 text_size = ImGui::CalcTextSize(label.c_str());
const ImVec2 button_size(text_size.x + 20.0f, text_size.y + 10.0f);
const ImVec2 button_pos(rect_max.x - button_size.x - 28.0f, rect_min.y + 10.0f);
ImGui::SetCursorScreenPos(button_pos);
ImGui::SetNextItemAllowOverlap();
if (ImGui::Button("##open_google_maps", button_size)) {
open_external_url(google_maps_url);
state->status_text = "Opened Google Maps";
}
overlay_hovered = ImGui::IsItemHovered();
draw_list->AddText(ImVec2(button_pos.x + 10.0f, button_pos.y + (button_size.y - text_size.y) * 0.5f),
ImGui::GetColorU32(ImGuiCol_Text), label.c_str());
}
const bool hovered = canvas_hovered && !overlay_hovered;
if (hovered && ImGui::GetIO().MouseWheel != 0.0f) {
const float next_zoom = std::clamp(static_cast<float>(zoom) + ImGui::GetIO().MouseWheel * MAP_WHEEL_ZOOM_STEP,
static_cast<float>(min_zoom), static_cast<float>(MAP_MAX_ZOOM));
if (std::abs(next_zoom - zoom) > 1.0e-4f) {
const ImVec2 mouse = ImGui::GetIO().MousePos;
const double mouse_world_x = top_left_x + (mouse.x - rect_min.x);
const double mouse_world_y = top_left_y + (mouse.y - rect_min.y);
const double mouse_lon = world_x_to_lon(mouse_world_x, zoom);
const double mouse_lat = world_y_to_lat(mouse_world_y, zoom);
const double next_center_x = lon_to_world_x(mouse_lon, next_zoom) - (mouse.x - rect_min.x) + rect_width * 0.5;
const double next_center_y = lat_to_world_y(mouse_lat, next_zoom) - (mouse.y - rect_min.y) + rect_height * 0.5;
map_state.zoom = next_zoom;
map_state.center_lon = world_x_to_lon(next_center_x, next_zoom);
map_state.center_lat = world_y_to_lat(next_center_y, next_zoom);
map_state.follow = false;
clamp_map_center(&map_state, map_bounds, size);
}
}
if (hovered && ImGui::IsMouseDragging(ImGuiMouseButton_Left, 2.0f)) {
const ImVec2 delta = ImGui::GetIO().MouseDelta;
const double next_center_x = center_x - delta.x;
const double next_center_y = center_y - delta.y;
map_state.center_lon = world_x_to_lon(next_center_x, zoom);
map_state.center_lat = world_y_to_lat(next_center_y, zoom);
map_state.follow = false;
clamp_map_center(&map_state, map_bounds, size);
} else if (hovered && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
const ImVec2 drag_delta = ImGui::GetMouseDragDelta(ImGuiMouseButton_Left);
if (drag_delta.x * drag_delta.x + drag_delta.y * drag_delta.y < 16.0f) {
const ImVec2 mouse = ImGui::GetIO().MousePos;
double best_dist = std::numeric_limits<double>::max();
double best_time = state->tracker_time;
for (const GpsPoint &point : trace.points) {
const ImVec2 screen = gps_to_screen(point.lat, point.lon, zoom, top_left_x, top_left_y, rect_min);
const double dx = static_cast<double>(screen.x - mouse.x);
const double dy = static_cast<double>(screen.y - mouse.y);
const double dist = dx * dx + dy * dy;
if (dist < best_dist) {
best_dist = dist;
best_time = point.time;
}
}
state->tracker_time = best_time;
state->has_tracker_time = true;
}
ImGui::ResetMouseDragDelta(ImGuiMouseButton_Left);
}
if (double_clicked) {
map_state.initialized = false;
}
}