|
@@ -0,0 +1,1207 @@
|
|
|
|
|
+/*
|
|
|
|
|
+ * GPS Test Tool - GUI front-end
|
|
|
|
|
+ *
|
|
|
|
|
+ * Plots an NMEA log (input) and the GPX track the embedded code produces
|
|
|
|
|
+ * from it (output) on an OpenStreetMap background. The GPX is generated by
|
|
|
|
|
+ * running the command-line gps-test-tool (-o single-run mode) with the
|
|
|
|
|
+ * settings chosen in the side panel; every change re-runs it.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Usage: ./gps-test-tool-gui [input.nmea]
|
|
|
|
|
+ */
|
|
|
|
|
+
|
|
|
|
|
+#include <gtk/gtk.h>
|
|
|
|
|
+#include <libsoup/soup.h>
|
|
|
|
|
+#include <glib/gstdio.h>
|
|
|
|
|
+#include <glib-unix.h>
|
|
|
|
|
+#include <signal.h>
|
|
|
|
|
+#include <string.h>
|
|
|
|
|
+#include <math.h>
|
|
|
|
|
+
|
|
|
|
|
+#define TILE_SIZE 256
|
|
|
|
|
+#define MIN_ZOOM 2
|
|
|
|
|
+#define MAX_ZOOM 19
|
|
|
|
|
+#define TILE_URL "https://tile.openstreetmap.org/%d/%d/%d.png"
|
|
|
|
|
+#define USER_AGENT "GPStracker-gps-test-tool-gui/1.0"
|
|
|
|
|
+#define MAX_DOWNLOADS 2 /* OSM tile usage policy: at most 2 connections */
|
|
|
|
|
+#define MEM_CACHE_MAX 600 /* tiles kept in memory before evicting unused ones */
|
|
|
|
|
+#define TILE_MAX_AGE (30 * 24 * 3600) /* re-download disk-cached tiles older than this */
|
|
|
|
|
+#define FALLBACK_LEVELS 6 /* how many zoom levels up to look for a placeholder tile */
|
|
|
|
|
+#define CLICK_SLOP 4 /* pixels a press may move and still count as a click */
|
|
|
|
|
+#define DOTS_ZOOM 16 /* draw individual points from this zoom level up */
|
|
|
|
|
+#define REPROCESS_DELAY_MS 150
|
|
|
|
|
+
|
|
|
|
|
+/* A track point in normalized Web Mercator coordinates (0..1 both ways) */
|
|
|
|
|
+struct mpoint {
|
|
|
|
|
+ double x, y;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+enum {
|
|
|
|
|
+ DL_IDLE,
|
|
|
|
|
+ DL_QUEUED,
|
|
|
|
|
+ DL_LOADING,
|
|
|
|
|
+ DL_FAILED,
|
|
|
|
|
+ DL_DONE,
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+struct tile {
|
|
|
|
|
+ int z, x, y;
|
|
|
|
|
+ cairo_surface_t *surface;
|
|
|
|
|
+ unsigned char dl_state;
|
|
|
|
|
+ unsigned char stale;
|
|
|
|
|
+ guint last_used;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+struct dl_request {
|
|
|
|
|
+ struct tile *tile;
|
|
|
|
|
+ SoupMessage *msg;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+struct setting_spin {
|
|
|
|
|
+ const char *key;
|
|
|
|
|
+ const char *label;
|
|
|
|
|
+ const char *cli_opt;
|
|
|
|
|
+ int min, max, def;
|
|
|
|
|
+ GtkWidget *widget;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+/* Ranges match soft/settings.c's limits_min_u8/limits_max_u8; defaults
|
|
|
|
|
+ * match the command-line tool's. */
|
|
|
|
|
+static struct setting_spin spins[] = {
|
|
|
|
|
+ { "skip_points", "Skip points", "-k", 0, 120, 0, NULL },
|
|
|
|
|
+ { "min_sats", "Min. satellites", "-m", 4, 12, 4, NULL },
|
|
|
|
|
+ { "auto_pause_time", "Time (s)", "-t", 10, 120, 30, NULL },
|
|
|
|
|
+ { "auto_pause_dist", "Distance (m)", "-d", 2, 100, 10, NULL },
|
|
|
|
|
+ { "auto_pause_speed", "Speed (km/h)", "-s", 0, 20, 3, NULL },
|
|
|
|
|
+};
|
|
|
|
|
+#define SPIN_SKIP 0
|
|
|
|
|
+#define SPIN_MIN_SATS 1
|
|
|
|
|
+#define SPIN_AP_FIRST 2
|
|
|
|
|
+#define SPIN_COUNT (sizeof(spins) / sizeof(spins[0]))
|
|
|
|
|
+
|
|
|
|
|
+/* Per-segment colours for the GPX output */
|
|
|
|
|
+static const double seg_colors[][3] = {
|
|
|
|
|
+ { 0.12, 0.47, 0.71 },
|
|
|
|
|
+ { 1.00, 0.50, 0.05 },
|
|
|
|
|
+ { 0.17, 0.63, 0.17 },
|
|
|
|
|
+ { 0.84, 0.15, 0.16 },
|
|
|
|
|
+ { 0.58, 0.40, 0.74 },
|
|
|
|
|
+ { 0.09, 0.75, 0.81 },
|
|
|
|
|
+ { 0.89, 0.47, 0.76 },
|
|
|
|
|
+ { 0.55, 0.34, 0.29 },
|
|
|
|
|
+ { 0.74, 0.74, 0.13 },
|
|
|
|
|
+};
|
|
|
|
|
+#define SEG_COLOR_COUNT (sizeof(seg_colors) / sizeof(seg_colors[0]))
|
|
|
|
|
+
|
|
|
|
|
+static struct {
|
|
|
|
|
+ GtkWidget *window, *map, *coord_label, *status_label, *summary_label;
|
|
|
|
|
+ GtkWidget *save_button;
|
|
|
|
|
+ GtkWidget *chk_disable_filters, *chk_auto_pause, *chk_show_nmea, *chk_show_gpx;
|
|
|
|
|
+ gboolean loading_settings;
|
|
|
|
|
+
|
|
|
|
|
+ char *input_file;
|
|
|
|
|
+ GArray *nmea; /* struct mpoint */
|
|
|
|
|
+ GPtrArray *segments; /* GArray of struct mpoint, one per <trkseg> */
|
|
|
|
|
+ gsize gpx_points;
|
|
|
|
|
+
|
|
|
|
|
+ char *cli_path;
|
|
|
|
|
+ char *tmpdir, *out_gpx, *out_log;
|
|
|
|
|
+ gboolean have_result;
|
|
|
|
|
+ GPid child;
|
|
|
|
|
+ gboolean running, pending;
|
|
|
|
|
+ guint reprocess_id;
|
|
|
|
|
+
|
|
|
|
|
+ int zoom;
|
|
|
|
|
+ double cx, cy; /* view centre in world pixels at the current zoom */
|
|
|
|
|
+ gboolean need_fit;
|
|
|
|
|
+ gboolean pressed, dragging;
|
|
|
|
|
+ double press_x, press_y, last_x, last_y;
|
|
|
|
|
+ double scroll_accum;
|
|
|
|
|
+ gboolean have_cursor, frozen;
|
|
|
|
|
+ double cursor_mx, cursor_my, frozen_mx, frozen_my;
|
|
|
|
|
+
|
|
|
|
|
+ GHashTable *tiles; /* "z/x/y" -> struct tile */
|
|
|
|
|
+ GQueue dl_queue; /* struct tile, DL_QUEUED */
|
|
|
|
|
+ int dl_active;
|
|
|
|
|
+ guint frame;
|
|
|
|
|
+ SoupSession *soup;
|
|
|
|
|
+ char *cache_dir;
|
|
|
|
|
+
|
|
|
|
|
+ GKeyFile *config;
|
|
|
|
|
+ char *config_path;
|
|
|
|
|
+} app;
|
|
|
|
|
+
|
|
|
|
|
+/* ===== Projection ===== */
|
|
|
|
|
+
|
|
|
|
|
+static double world_size(int zoom) {
|
|
|
|
|
+ return TILE_SIZE * (double)(1 << zoom);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void latlon_to_merc(double lat, double lon, struct mpoint *p) {
|
|
|
|
|
+ double rlat = lat * M_PI / 180.0;
|
|
|
|
|
+ p->x = (lon + 180.0) / 360.0;
|
|
|
|
|
+ p->y = (1.0 - log(tan(rlat) + 1.0 / cos(rlat)) / M_PI) / 2.0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void merc_to_latlon(double x, double y, double *lat, double *lon) {
|
|
|
|
|
+ *lon = x * 360.0 - 180.0;
|
|
|
|
|
+ *lat = atan(sinh(M_PI * (1.0 - 2.0 * y))) * 180.0 / M_PI;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void screen_to_merc(double sx, double sy, double *mx, double *my) {
|
|
|
|
|
+ double ws = world_size(app.zoom);
|
|
|
|
|
+ /* Same whole-pixel origin as map_draw() */
|
|
|
|
|
+ *mx = (floor(app.cx - gtk_widget_get_allocated_width(app.map) / 2.0) + sx) / ws;
|
|
|
|
|
+ *my = (floor(app.cy - gtk_widget_get_allocated_height(app.map) / 2.0) + sy) / ws;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void clamp_view(void) {
|
|
|
|
|
+ double ws = world_size(app.zoom);
|
|
|
|
|
+ if (app.cy < 0)
|
|
|
|
|
+ app.cy = 0;
|
|
|
|
|
+ if (app.cy > ws)
|
|
|
|
|
+ app.cy = ws;
|
|
|
|
|
+ app.cx = fmod(app.cx, ws);
|
|
|
|
|
+ if (app.cx < 0)
|
|
|
|
|
+ app.cx += ws;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ===== Config ===== */
|
|
|
|
|
+
|
|
|
|
|
+static void config_save(void) {
|
|
|
|
|
+ char *dir = g_path_get_dirname(app.config_path);
|
|
|
|
|
+ g_mkdir_with_parents(dir, 0755);
|
|
|
|
|
+ g_free(dir);
|
|
|
|
|
+ g_key_file_save_to_file(app.config, app.config_path, NULL);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static int config_get_int(const char *group, const char *key, int def) {
|
|
|
|
|
+ GError *err = NULL;
|
|
|
|
|
+ int v = g_key_file_get_integer(app.config, group, key, &err);
|
|
|
|
|
+ if (err) {
|
|
|
|
|
+ g_error_free(err);
|
|
|
|
|
+ return def;
|
|
|
|
|
+ }
|
|
|
|
|
+ return v;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void settings_store(void) {
|
|
|
|
|
+ unsigned i;
|
|
|
|
|
+ if (app.loading_settings)
|
|
|
|
|
+ return;
|
|
|
|
|
+ for (i = 0; i < SPIN_COUNT; i++)
|
|
|
|
|
+ g_key_file_set_integer(app.config, "settings", spins[i].key,
|
|
|
|
|
+ gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(spins[i].widget)));
|
|
|
|
|
+ g_key_file_set_boolean(app.config, "settings", "disable_filters",
|
|
|
|
|
+ gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_disable_filters)));
|
|
|
|
|
+ g_key_file_set_boolean(app.config, "settings", "auto_pause",
|
|
|
|
|
+ gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_auto_pause)));
|
|
|
|
|
+ g_key_file_set_boolean(app.config, "view", "show_nmea",
|
|
|
|
|
+ gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_show_nmea)));
|
|
|
|
|
+ g_key_file_set_boolean(app.config, "view", "show_gpx",
|
|
|
|
|
+ gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_show_gpx)));
|
|
|
|
|
+ config_save();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ===== Tiles ===== */
|
|
|
|
|
+
|
|
|
|
|
+static char *tile_cache_path(int z, int x, int y) {
|
|
|
|
|
+ return g_strdup_printf("%s/%d/%d/%d.png", app.cache_dir, z, x, y);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static cairo_surface_t *surface_from_pixbuf(GdkPixbuf *pix) {
|
|
|
|
|
+ cairo_surface_t *s;
|
|
|
|
|
+ cairo_t *cr;
|
|
|
|
|
+ if (!pix)
|
|
|
|
|
+ return NULL;
|
|
|
|
|
+ s = cairo_image_surface_create(CAIRO_FORMAT_RGB24, TILE_SIZE, TILE_SIZE);
|
|
|
|
|
+ cr = cairo_create(s);
|
|
|
|
|
+ gdk_cairo_set_source_pixbuf(cr, pix, 0, 0);
|
|
|
|
|
+ cairo_paint(cr);
|
|
|
|
|
+ cairo_destroy(cr);
|
|
|
|
|
+ g_object_unref(pix);
|
|
|
|
|
+ return s;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void tile_free(gpointer data) {
|
|
|
|
|
+ struct tile *t = data;
|
|
|
|
|
+ if (t->surface)
|
|
|
|
|
+ cairo_surface_destroy(t->surface);
|
|
|
|
|
+ g_free(t);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static gboolean tile_visible(struct tile *t) {
|
|
|
|
|
+ int w = gtk_widget_get_allocated_width(app.map);
|
|
|
|
|
+ int h = gtk_widget_get_allocated_height(app.map);
|
|
|
|
|
+ int n = 1 << app.zoom;
|
|
|
|
|
+ int x0, x1, y0, y1, x;
|
|
|
|
|
+ if (t->z != app.zoom)
|
|
|
|
|
+ return FALSE;
|
|
|
|
|
+ x0 = (int)floor((app.cx - w / 2.0) / TILE_SIZE);
|
|
|
|
|
+ x1 = (int)floor((app.cx + w / 2.0) / TILE_SIZE);
|
|
|
|
|
+ y0 = (int)floor((app.cy - h / 2.0) / TILE_SIZE);
|
|
|
|
|
+ y1 = (int)floor((app.cy + h / 2.0) / TILE_SIZE);
|
|
|
|
|
+ if (t->y < y0 || t->y > y1)
|
|
|
|
|
+ return FALSE;
|
|
|
|
|
+ for (x = x0; x <= x1; x++)
|
|
|
|
|
+ if (((x % n) + n) % n == t->x)
|
|
|
|
|
+ return TRUE;
|
|
|
|
|
+ return FALSE;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void dl_pump(void);
|
|
|
|
|
+
|
|
|
|
|
+static void dl_finished(GObject *source, GAsyncResult *res, gpointer data) {
|
|
|
|
|
+ struct dl_request *req = data;
|
|
|
|
|
+ struct tile *t = req->tile;
|
|
|
|
|
+ GError *err = NULL;
|
|
|
|
|
+ GBytes *bytes = soup_session_send_and_read_finish(SOUP_SESSION(source), res, &err);
|
|
|
|
|
+
|
|
|
|
|
+ app.dl_active--;
|
|
|
|
|
+ t->dl_state = DL_FAILED;
|
|
|
|
|
+ if (bytes && soup_message_get_status(req->msg) == SOUP_STATUS_OK) {
|
|
|
|
|
+ GInputStream *st = g_memory_input_stream_new_from_bytes(bytes);
|
|
|
|
|
+ GdkPixbuf *pix = gdk_pixbuf_new_from_stream(st, NULL, NULL);
|
|
|
|
|
+ g_object_unref(st);
|
|
|
|
|
+ if (pix) {
|
|
|
|
|
+ char *path = tile_cache_path(t->z, t->x, t->y);
|
|
|
|
|
+ char *dir = g_path_get_dirname(path);
|
|
|
|
|
+ g_mkdir_with_parents(dir, 0755);
|
|
|
|
|
+ g_file_set_contents(path, g_bytes_get_data(bytes, NULL), g_bytes_get_size(bytes), NULL);
|
|
|
|
|
+ g_free(dir);
|
|
|
|
|
+ g_free(path);
|
|
|
|
|
+ if (t->surface)
|
|
|
|
|
+ cairo_surface_destroy(t->surface);
|
|
|
|
|
+ t->surface = surface_from_pixbuf(pix);
|
|
|
|
|
+ t->stale = 0;
|
|
|
|
|
+ t->dl_state = DL_DONE;
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if (err) {
|
|
|
|
|
+ g_warning("Tile %d/%d/%d: %s", t->z, t->x, t->y, err->message);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ g_warning("Tile %d/%d/%d: HTTP %u", t->z, t->x, t->y, soup_message_get_status(req->msg));
|
|
|
|
|
+ }
|
|
|
|
|
+ if (err)
|
|
|
|
|
+ g_error_free(err);
|
|
|
|
|
+ if (bytes)
|
|
|
|
|
+ g_bytes_unref(bytes);
|
|
|
|
|
+ g_object_unref(req->msg);
|
|
|
|
|
+ g_free(req);
|
|
|
|
|
+ gtk_widget_queue_draw(app.map);
|
|
|
|
|
+ dl_pump();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void dl_pump(void) {
|
|
|
|
|
+ while (app.dl_active < MAX_DOWNLOADS && !g_queue_is_empty(&app.dl_queue)) {
|
|
|
|
|
+ struct tile *t = g_queue_pop_head(&app.dl_queue);
|
|
|
|
|
+ struct dl_request *req;
|
|
|
|
|
+ char *url;
|
|
|
|
|
+ if (!tile_visible(t)) {
|
|
|
|
|
+ t->dl_state = DL_IDLE; /* may be queued again once it's back in view */
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ url = g_strdup_printf(TILE_URL, t->z, t->x, t->y);
|
|
|
|
|
+ req = g_new0(struct dl_request, 1);
|
|
|
|
|
+ req->tile = t;
|
|
|
|
|
+ req->msg = soup_message_new("GET", url);
|
|
|
|
|
+ g_free(url);
|
|
|
|
|
+ t->dl_state = DL_LOADING;
|
|
|
|
|
+ app.dl_active++;
|
|
|
|
|
+ soup_session_send_and_read_async(app.soup, req->msg, G_PRIORITY_DEFAULT, NULL, dl_finished, req);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* Look up a tile, loading it from the disk cache if needed. With fetch set,
|
|
|
|
|
+ * a missing or stale tile is queued for download. */
|
|
|
|
|
+static struct tile *tile_get(int z, int x, int y, gboolean fetch) {
|
|
|
|
|
+ char key[48];
|
|
|
|
|
+ struct tile *t;
|
|
|
|
|
+ g_snprintf(key, sizeof(key), "%d/%d/%d", z, x, y);
|
|
|
|
|
+ t = g_hash_table_lookup(app.tiles, key);
|
|
|
|
|
+ if (!t) {
|
|
|
|
|
+ char *path = tile_cache_path(z, x, y);
|
|
|
|
|
+ GStatBuf st;
|
|
|
|
|
+ t = g_new0(struct tile, 1);
|
|
|
|
|
+ t->z = z;
|
|
|
|
|
+ t->x = x;
|
|
|
|
|
+ t->y = y;
|
|
|
|
|
+ if (g_stat(path, &st) == 0) {
|
|
|
|
|
+ t->surface = surface_from_pixbuf(gdk_pixbuf_new_from_file(path, NULL));
|
|
|
|
|
+ t->stale = time(NULL) - st.st_mtime > TILE_MAX_AGE;
|
|
|
|
|
+ }
|
|
|
|
|
+ g_free(path);
|
|
|
|
|
+ g_hash_table_insert(app.tiles, g_strdup(key), t);
|
|
|
|
|
+ }
|
|
|
|
|
+ t->last_used = app.frame;
|
|
|
|
|
+ if (fetch && (!t->surface || t->stale) && t->dl_state == DL_IDLE) {
|
|
|
|
|
+ t->dl_state = DL_QUEUED;
|
|
|
|
|
+ g_queue_push_tail(&app.dl_queue, t);
|
|
|
|
|
+ }
|
|
|
|
|
+ return t;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static gboolean tile_evictable(gpointer key, gpointer value, gpointer data) {
|
|
|
|
|
+ struct tile *t = value;
|
|
|
|
|
+ (void)key;
|
|
|
|
|
+ (void)data;
|
|
|
|
|
+ return t->dl_state != DL_QUEUED && t->dl_state != DL_LOADING && t->last_used + 1 < app.frame;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void draw_tile(cairo_t *cr, int z, int x, int y, double sx, double sy) {
|
|
|
|
|
+ struct tile *t = tile_get(z, x, y, TRUE);
|
|
|
|
|
+ int d;
|
|
|
|
|
+ if (t->surface) {
|
|
|
|
|
+ cairo_set_source_surface(cr, t->surface, sx, sy);
|
|
|
|
|
+ cairo_rectangle(cr, sx, sy, TILE_SIZE, TILE_SIZE);
|
|
|
|
|
+ cairo_fill(cr);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ /* Not available (yet): stretch a lower-zoom tile we already have */
|
|
|
|
|
+ for (d = 1; d <= FALLBACK_LEVELS && z - d >= 0; d++) {
|
|
|
|
|
+ struct tile *p = tile_get(z - d, x >> d, y >> d, FALSE);
|
|
|
|
|
+ if (p->surface) {
|
|
|
|
|
+ double scale = 1 << d;
|
|
|
|
|
+ cairo_save(cr);
|
|
|
|
|
+ cairo_rectangle(cr, sx, sy, TILE_SIZE, TILE_SIZE);
|
|
|
|
|
+ cairo_clip(cr);
|
|
|
|
|
+ cairo_translate(cr, sx - (x - ((x >> d) << d)) * TILE_SIZE,
|
|
|
|
|
+ sy - (y - ((y >> d) << d)) * TILE_SIZE);
|
|
|
|
|
+ cairo_scale(cr, scale, scale);
|
|
|
|
|
+ cairo_set_source_surface(cr, p->surface, 0, 0);
|
|
|
|
|
+ cairo_pattern_set_filter(cairo_get_source(cr), CAIRO_FILTER_BILINEAR);
|
|
|
|
|
+ cairo_paint(cr);
|
|
|
|
|
+ cairo_restore(cr);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ===== Drawing ===== */
|
|
|
|
|
+
|
|
|
|
|
+static void draw_track(cairo_t *cr, GArray *pts, double ox, double oy, double ws,
|
|
|
|
|
+ double r, double g, double b, double a, double width, gboolean dots) {
|
|
|
|
|
+ guint i;
|
|
|
|
|
+ if (!pts || !pts->len)
|
|
|
|
|
+ return;
|
|
|
|
|
+ cairo_set_source_rgba(cr, r, g, b, a);
|
|
|
|
|
+ cairo_set_line_width(cr, width);
|
|
|
|
|
+ cairo_set_line_join(cr, CAIRO_LINE_JOIN_ROUND);
|
|
|
|
|
+ cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND);
|
|
|
|
|
+ for (i = 0; i < pts->len; i++) {
|
|
|
|
|
+ struct mpoint *p = &g_array_index(pts, struct mpoint, i);
|
|
|
|
|
+ double sx = p->x * ws - ox, sy = p->y * ws - oy;
|
|
|
|
|
+ if (i)
|
|
|
|
|
+ cairo_line_to(cr, sx, sy);
|
|
|
|
|
+ else
|
|
|
|
|
+ cairo_move_to(cr, sx, sy);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (pts->len == 1)
|
|
|
|
|
+ cairo_rel_line_to(cr, 0, 0); /* round cap makes it a dot */
|
|
|
|
|
+ cairo_stroke(cr);
|
|
|
|
|
+ if (!dots)
|
|
|
|
|
+ return;
|
|
|
|
|
+ for (i = 0; i < pts->len; i++) {
|
|
|
|
|
+ struct mpoint *p = &g_array_index(pts, struct mpoint, i);
|
|
|
|
|
+ cairo_new_sub_path(cr);
|
|
|
|
|
+ cairo_arc(cr, p->x * ws - ox, p->y * ws - oy, width * 0.9, 0, 2 * M_PI);
|
|
|
|
|
+ }
|
|
|
|
|
+ cairo_fill(cr);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void fit_view(void);
|
|
|
|
|
+
|
|
|
|
|
+static gboolean map_draw(GtkWidget *w, cairo_t *cr, gpointer data) {
|
|
|
|
|
+ int width = gtk_widget_get_allocated_width(w);
|
|
|
|
|
+ int height = gtk_widget_get_allocated_height(w);
|
|
|
|
|
+ double ws, ox, oy;
|
|
|
|
|
+ int n, tx0, tx1, ty0, ty1, tx, ty;
|
|
|
|
|
+ gboolean dots;
|
|
|
|
|
+ cairo_text_extents_t ext;
|
|
|
|
|
+ const char *attrib = "© OpenStreetMap contributors";
|
|
|
|
|
+ (void)data;
|
|
|
|
|
+
|
|
|
|
|
+ if (app.need_fit)
|
|
|
|
|
+ fit_view();
|
|
|
|
|
+
|
|
|
|
|
+ app.frame++;
|
|
|
|
|
+ if (g_hash_table_size(app.tiles) > MEM_CACHE_MAX)
|
|
|
|
|
+ g_hash_table_foreach_remove(app.tiles, tile_evictable, NULL);
|
|
|
|
|
+
|
|
|
|
|
+ ws = world_size(app.zoom);
|
|
|
|
|
+ n = 1 << app.zoom;
|
|
|
|
|
+ /* Whole pixels: tiles drawn at fractional offsets show seams */
|
|
|
|
|
+ ox = floor(app.cx - width / 2.0);
|
|
|
|
|
+ oy = floor(app.cy - height / 2.0);
|
|
|
|
|
+
|
|
|
|
|
+ cairo_set_source_rgb(cr, 0.85, 0.85, 0.85);
|
|
|
|
|
+ cairo_paint(cr);
|
|
|
|
|
+
|
|
|
|
|
+ tx0 = (int)floor(ox / TILE_SIZE);
|
|
|
|
|
+ tx1 = (int)floor((ox + width) / TILE_SIZE);
|
|
|
|
|
+ ty0 = MAX(0, (int)floor(oy / TILE_SIZE));
|
|
|
|
|
+ ty1 = MIN(n - 1, (int)floor((oy + height) / TILE_SIZE));
|
|
|
|
|
+ for (ty = ty0; ty <= ty1; ty++)
|
|
|
|
|
+ for (tx = tx0; tx <= tx1; tx++)
|
|
|
|
|
+ draw_tile(cr, app.zoom, ((tx % n) + n) % n, ty, tx * TILE_SIZE - ox, ty * TILE_SIZE - oy);
|
|
|
|
|
+ dl_pump();
|
|
|
|
|
+
|
|
|
|
|
+ dots = app.zoom >= DOTS_ZOOM;
|
|
|
|
|
+ if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_show_gpx)) && app.segments) {
|
|
|
|
|
+ guint i;
|
|
|
|
|
+ for (i = 0; i < app.segments->len; i++) {
|
|
|
|
|
+ const double *c = seg_colors[i % SEG_COLOR_COUNT];
|
|
|
|
|
+ draw_track(cr, g_ptr_array_index(app.segments, i), ox, oy, ws,
|
|
|
|
|
+ c[0], c[1], c[2], 0.85, 4.0, dots);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ /* Input on top, thin, so it stays visible where the output follows it */
|
|
|
|
|
+ if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_show_nmea)))
|
|
|
|
|
+ draw_track(cr, app.nmea, ox, oy, ws, 0.1, 0.1, 0.1, 0.75, 1.2, dots);
|
|
|
|
|
+
|
|
|
|
|
+ if (app.frozen) {
|
|
|
|
|
+ double sx = app.frozen_mx * ws - ox, sy = app.frozen_my * ws - oy;
|
|
|
|
|
+ cairo_set_line_width(cr, 2.0);
|
|
|
|
|
+ cairo_set_source_rgb(cr, 1, 1, 1);
|
|
|
|
|
+ cairo_arc(cr, sx, sy, 7, 0, 2 * M_PI);
|
|
|
|
|
+ cairo_stroke(cr);
|
|
|
|
|
+ cairo_set_line_width(cr, 1.5);
|
|
|
|
|
+ cairo_set_source_rgb(cr, 0.8, 0, 0);
|
|
|
|
|
+ cairo_arc(cr, sx, sy, 7, 0, 2 * M_PI);
|
|
|
|
|
+ cairo_move_to(cr, sx - 11, sy);
|
|
|
|
|
+ cairo_line_to(cr, sx + 11, sy);
|
|
|
|
|
+ cairo_move_to(cr, sx, sy - 11);
|
|
|
|
|
+ cairo_line_to(cr, sx, sy + 11);
|
|
|
|
|
+ cairo_stroke(cr);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* Attribution, required by the OSM tile usage policy */
|
|
|
|
|
+ cairo_set_font_size(cr, 11);
|
|
|
|
|
+ cairo_text_extents(cr, attrib, &ext);
|
|
|
|
|
+ cairo_set_source_rgba(cr, 1, 1, 1, 0.75);
|
|
|
|
|
+ cairo_rectangle(cr, width - ext.x_advance - 8, height - 17, ext.x_advance + 8, 17);
|
|
|
|
|
+ cairo_fill(cr);
|
|
|
|
|
+ cairo_set_source_rgb(cr, 0.2, 0.2, 0.2);
|
|
|
|
|
+ cairo_move_to(cr, width - ext.x_advance - 4, height - 5);
|
|
|
|
|
+ cairo_show_text(cr, attrib);
|
|
|
|
|
+ return TRUE;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ===== Coordinate bar ===== */
|
|
|
|
|
+
|
|
|
|
|
+static void update_coord_label(void) {
|
|
|
|
|
+ double mx, my, lat, lon;
|
|
|
|
|
+ char slat[G_ASCII_DTOSTR_BUF_SIZE], slon[G_ASCII_DTOSTR_BUF_SIZE];
|
|
|
|
|
+ char *text;
|
|
|
|
|
+ if (app.frozen || app.have_cursor) {
|
|
|
|
|
+ mx = app.frozen ? app.frozen_mx : app.cursor_mx;
|
|
|
|
|
+ my = app.frozen ? app.frozen_my : app.cursor_my;
|
|
|
|
|
+ merc_to_latlon(mx, my, &lat, &lon);
|
|
|
|
|
+ /* Always '.' as the decimal separator, whatever the locale */
|
|
|
|
|
+ g_ascii_formatd(slat, sizeof(slat), "%.6f", lat);
|
|
|
|
|
+ g_ascii_formatd(slon, sizeof(slon), "%.6f", lon);
|
|
|
|
|
+ if (app.frozen)
|
|
|
|
|
+ text = g_strdup_printf("<b>%s, %s</b> (frozen \u2013 right-click to release) zoom %d", slat, slon, app.zoom);
|
|
|
|
|
+ else
|
|
|
|
|
+ text = g_strdup_printf("%s, %s zoom %d", slat, slon, app.zoom);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ text = g_strdup_printf("\u2013 zoom %d", app.zoom);
|
|
|
|
|
+ }
|
|
|
|
|
+ gtk_label_set_markup(GTK_LABEL(app.coord_label), text);
|
|
|
|
|
+ g_free(text);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ===== View ===== */
|
|
|
|
|
+
|
|
|
|
|
+static gboolean bbox_add(GArray *a, double *x0, double *y0, double *x1, double *y1) {
|
|
|
|
|
+ guint i;
|
|
|
|
|
+ for (i = 0; a && i < a->len; i++) {
|
|
|
|
|
+ struct mpoint *p = &g_array_index(a, struct mpoint, i);
|
|
|
|
|
+ *x0 = MIN(*x0, p->x);
|
|
|
|
|
+ *y0 = MIN(*y0, p->y);
|
|
|
|
|
+ *x1 = MAX(*x1, p->x);
|
|
|
|
|
+ *y1 = MAX(*y1, p->y);
|
|
|
|
|
+ }
|
|
|
|
|
+ return a && a->len;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static gboolean track_bbox(double *x0, double *y0, double *x1, double *y1) {
|
|
|
|
|
+ gboolean any;
|
|
|
|
|
+ guint i;
|
|
|
|
|
+ *x0 = *y0 = 1.0;
|
|
|
|
|
+ *x1 = *y1 = 0.0;
|
|
|
|
|
+ any = bbox_add(app.nmea, x0, y0, x1, y1);
|
|
|
|
|
+ for (i = 0; app.segments && i < app.segments->len; i++)
|
|
|
|
|
+ any |= bbox_add(g_ptr_array_index(app.segments, i), x0, y0, x1, y1);
|
|
|
|
|
+ return any;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void fit_view(void) {
|
|
|
|
|
+ int w = gtk_widget_get_allocated_width(app.map);
|
|
|
|
|
+ int h = gtk_widget_get_allocated_height(app.map);
|
|
|
|
|
+ double x0, y0, x1, y1;
|
|
|
|
|
+ if (w <= 1 || h <= 1)
|
|
|
|
|
+ return; /* not realized yet; retried on the next draw */
|
|
|
|
|
+ app.need_fit = FALSE;
|
|
|
|
|
+ if (!track_bbox(&x0, &y0, &x1, &y1))
|
|
|
|
|
+ return;
|
|
|
|
|
+ for (app.zoom = MAX_ZOOM; app.zoom > MIN_ZOOM; app.zoom--) {
|
|
|
|
|
+ double ws = world_size(app.zoom);
|
|
|
|
|
+ if ((x1 - x0) * ws <= w - 40 && (y1 - y0) * ws <= h - 40)
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ app.cx = (x0 + x1) / 2.0 * world_size(app.zoom);
|
|
|
|
|
+ app.cy = (y0 + y1) / 2.0 * world_size(app.zoom);
|
|
|
|
|
+ update_coord_label();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void zoom_at(int new_zoom, double sx, double sy) {
|
|
|
|
|
+ int w = gtk_widget_get_allocated_width(app.map);
|
|
|
|
|
+ int h = gtk_widget_get_allocated_height(app.map);
|
|
|
|
|
+ double f;
|
|
|
|
|
+ new_zoom = CLAMP(new_zoom, MIN_ZOOM, MAX_ZOOM);
|
|
|
|
|
+ if (new_zoom == app.zoom)
|
|
|
|
|
+ return;
|
|
|
|
|
+ f = ldexp(1.0, new_zoom - app.zoom);
|
|
|
|
|
+ app.cx = (app.cx + sx - w / 2.0) * f - (sx - w / 2.0);
|
|
|
|
|
+ app.cy = (app.cy + sy - h / 2.0) * f - (sy - h / 2.0);
|
|
|
|
|
+ app.zoom = new_zoom;
|
|
|
|
|
+ clamp_view();
|
|
|
|
|
+ update_coord_label();
|
|
|
|
|
+ gtk_widget_queue_draw(app.map);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static gboolean map_scroll(GtkWidget *w, GdkEventScroll *ev, gpointer data) {
|
|
|
|
|
+ double dx, dy;
|
|
|
|
|
+ (void)w;
|
|
|
|
|
+ (void)data;
|
|
|
|
|
+ switch (ev->direction) {
|
|
|
|
|
+ case GDK_SCROLL_UP:
|
|
|
|
|
+ zoom_at(app.zoom + 1, ev->x, ev->y);
|
|
|
|
|
+ break;
|
|
|
|
|
+ case GDK_SCROLL_DOWN:
|
|
|
|
|
+ zoom_at(app.zoom - 1, ev->x, ev->y);
|
|
|
|
|
+ break;
|
|
|
|
|
+ case GDK_SCROLL_SMOOTH:
|
|
|
|
|
+ if (!gdk_event_get_scroll_deltas((GdkEvent *)ev, &dx, &dy))
|
|
|
|
|
+ break;
|
|
|
|
|
+ app.scroll_accum += dy;
|
|
|
|
|
+ while (app.scroll_accum <= -1.0) {
|
|
|
|
|
+ app.scroll_accum += 1.0;
|
|
|
|
|
+ zoom_at(app.zoom + 1, ev->x, ev->y);
|
|
|
|
|
+ }
|
|
|
|
|
+ while (app.scroll_accum >= 1.0) {
|
|
|
|
|
+ app.scroll_accum -= 1.0;
|
|
|
|
|
+ zoom_at(app.zoom - 1, ev->x, ev->y);
|
|
|
|
|
+ }
|
|
|
|
|
+ break;
|
|
|
|
|
+ default:
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ return TRUE;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static gboolean map_button_press(GtkWidget *w, GdkEventButton *ev, gpointer data) {
|
|
|
|
|
+ (void)w;
|
|
|
|
|
+ (void)data;
|
|
|
|
|
+ if (ev->type != GDK_BUTTON_PRESS)
|
|
|
|
|
+ return FALSE;
|
|
|
|
|
+ if (ev->button == 1) {
|
|
|
|
|
+ app.pressed = TRUE;
|
|
|
|
|
+ app.dragging = FALSE;
|
|
|
|
|
+ app.press_x = app.last_x = ev->x;
|
|
|
|
|
+ app.press_y = app.last_y = ev->y;
|
|
|
|
|
+ } else if (ev->button == 3 && app.frozen) {
|
|
|
|
|
+ app.frozen = FALSE;
|
|
|
|
|
+ update_coord_label();
|
|
|
|
|
+ gtk_widget_queue_draw(app.map);
|
|
|
|
|
+ }
|
|
|
|
|
+ return TRUE;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static gboolean map_button_release(GtkWidget *w, GdkEventButton *ev, gpointer data) {
|
|
|
|
|
+ (void)w;
|
|
|
|
|
+ (void)data;
|
|
|
|
|
+ if (ev->button != 1 || !app.pressed)
|
|
|
|
|
+ return FALSE;
|
|
|
|
|
+ app.pressed = FALSE;
|
|
|
|
|
+ if (!app.dragging) {
|
|
|
|
|
+ screen_to_merc(ev->x, ev->y, &app.frozen_mx, &app.frozen_my);
|
|
|
|
|
+ app.frozen = TRUE;
|
|
|
|
|
+ update_coord_label();
|
|
|
|
|
+ gtk_widget_queue_draw(app.map);
|
|
|
|
|
+ }
|
|
|
|
|
+ app.dragging = FALSE;
|
|
|
|
|
+ return TRUE;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static gboolean map_motion(GtkWidget *w, GdkEventMotion *ev, gpointer data) {
|
|
|
|
|
+ (void)w;
|
|
|
|
|
+ (void)data;
|
|
|
|
|
+ if (app.pressed) {
|
|
|
|
|
+ if (!app.dragging && (fabs(ev->x - app.press_x) > CLICK_SLOP || fabs(ev->y - app.press_y) > CLICK_SLOP))
|
|
|
|
|
+ app.dragging = TRUE;
|
|
|
|
|
+ if (app.dragging) {
|
|
|
|
|
+ app.cx -= ev->x - app.last_x;
|
|
|
|
|
+ app.cy -= ev->y - app.last_y;
|
|
|
|
|
+ clamp_view();
|
|
|
|
|
+ gtk_widget_queue_draw(app.map);
|
|
|
|
|
+ }
|
|
|
|
|
+ app.last_x = ev->x;
|
|
|
|
|
+ app.last_y = ev->y;
|
|
|
|
|
+ }
|
|
|
|
|
+ screen_to_merc(ev->x, ev->y, &app.cursor_mx, &app.cursor_my);
|
|
|
|
|
+ app.have_cursor = TRUE;
|
|
|
|
|
+ if (!app.frozen)
|
|
|
|
|
+ update_coord_label();
|
|
|
|
|
+ return TRUE;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static gboolean map_leave(GtkWidget *w, GdkEventCrossing *ev, gpointer data) {
|
|
|
|
|
+ (void)w;
|
|
|
|
|
+ (void)ev;
|
|
|
|
|
+ (void)data;
|
|
|
|
|
+ app.have_cursor = FALSE;
|
|
|
|
|
+ if (!app.frozen)
|
|
|
|
|
+ update_coord_label();
|
|
|
|
|
+ return FALSE;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ===== Input / output parsing ===== */
|
|
|
|
|
+
|
|
|
|
|
+static double nmea_coord(const char *val, const char *hemi) {
|
|
|
|
|
+ double v = g_ascii_strtod(val, NULL);
|
|
|
|
|
+ double deg = floor(v / 100.0);
|
|
|
|
|
+ v = deg + (v - deg * 100.0) / 60.0;
|
|
|
|
|
+ return (*hemi == 'S' || *hemi == 'W') ? -v : v;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* Extract a position from a valid, checksummed $..RMC sentence */
|
|
|
|
|
+static gboolean nmea_rmc_point(const char *line, struct mpoint *p) {
|
|
|
|
|
+ const char *s = strchr(line, '$'), *c;
|
|
|
|
|
+ char **f;
|
|
|
|
|
+ unsigned char sum = 0;
|
|
|
|
|
+ unsigned int want;
|
|
|
|
|
+ gboolean ok = FALSE;
|
|
|
|
|
+ char *body;
|
|
|
|
|
+
|
|
|
|
|
+ if (!s || strncmp(s + 3, "RMC,", 4))
|
|
|
|
|
+ return FALSE;
|
|
|
|
|
+ for (c = s + 1; *c && *c != '*'; c++)
|
|
|
|
|
+ sum ^= (unsigned char)*c;
|
|
|
|
|
+ if (*c != '*' || sscanf(c + 1, "%2x", &want) != 1 || want != sum)
|
|
|
|
|
+ return FALSE;
|
|
|
|
|
+ body = g_strndup(s + 1, c - s - 1);
|
|
|
|
|
+ f = g_strsplit(body, ",", 0);
|
|
|
|
|
+ if (g_strv_length(f) >= 7 && !strcmp(f[2], "A") && *f[3] && *f[5]) {
|
|
|
|
|
+ latlon_to_merc(nmea_coord(f[3], f[4]), nmea_coord(f[5], f[6]), p);
|
|
|
|
|
+ ok = TRUE;
|
|
|
|
|
+ }
|
|
|
|
|
+ g_strfreev(f);
|
|
|
|
|
+ g_free(body);
|
|
|
|
|
+ return ok;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static gboolean load_nmea(const char *path) {
|
|
|
|
|
+ char *data, **lines;
|
|
|
|
|
+ guint i;
|
|
|
|
|
+ GError *err = NULL;
|
|
|
|
|
+ if (!g_file_get_contents(path, &data, NULL, &err)) {
|
|
|
|
|
+ GtkWidget *d = gtk_message_dialog_new(GTK_WINDOW(app.window), GTK_DIALOG_MODAL,
|
|
|
|
|
+ GTK_MESSAGE_ERROR, GTK_BUTTONS_CLOSE, "Cannot open %s: %s", path, err->message);
|
|
|
|
|
+ gtk_dialog_run(GTK_DIALOG(d));
|
|
|
|
|
+ gtk_widget_destroy(d);
|
|
|
|
|
+ g_error_free(err);
|
|
|
|
|
+ return FALSE;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (app.nmea)
|
|
|
|
|
+ g_array_free(app.nmea, TRUE);
|
|
|
|
|
+ app.nmea = g_array_new(FALSE, FALSE, sizeof(struct mpoint));
|
|
|
|
|
+ lines = g_strsplit(data, "\n", 0);
|
|
|
|
|
+ for (i = 0; lines[i]; i++) {
|
|
|
|
|
+ struct mpoint p;
|
|
|
|
|
+ if (nmea_rmc_point(lines[i], &p))
|
|
|
|
|
+ g_array_append_val(app.nmea, p);
|
|
|
|
|
+ }
|
|
|
|
|
+ g_strfreev(lines);
|
|
|
|
|
+ g_free(data);
|
|
|
|
|
+ return TRUE;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void free_segments(void) {
|
|
|
|
|
+ if (app.segments)
|
|
|
|
|
+ g_ptr_array_free(app.segments, TRUE);
|
|
|
|
|
+ app.segments = NULL;
|
|
|
|
|
+ app.gpx_points = 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void load_gpx(const char *path) {
|
|
|
|
|
+ char *data, *s;
|
|
|
|
|
+ GArray *seg = NULL;
|
|
|
|
|
+ free_segments();
|
|
|
|
|
+ app.segments = g_ptr_array_new_with_free_func((GDestroyNotify)g_array_unref);
|
|
|
|
|
+ if (!g_file_get_contents(path, &data, NULL, NULL))
|
|
|
|
|
+ return;
|
|
|
|
|
+ for (s = data; (s = strchr(s, '<')); s++) {
|
|
|
|
|
+ if (!strncmp(s, "<trkseg>", 8)) {
|
|
|
|
|
+ seg = g_array_new(FALSE, FALSE, sizeof(struct mpoint));
|
|
|
|
|
+ g_ptr_array_add(app.segments, seg);
|
|
|
|
|
+ } else if (!strncmp(s, "<trkpt ", 7) && seg) {
|
|
|
|
|
+ const char *la = strstr(s, "lat=\""), *lo = strstr(s, "lon=\""), *end = strchr(s, '>');
|
|
|
|
|
+ struct mpoint p;
|
|
|
|
|
+ if (!la || !lo || !end || la > end || lo > end)
|
|
|
|
|
+ continue;
|
|
|
|
|
+ latlon_to_merc(g_ascii_strtod(la + 5, NULL), g_ascii_strtod(lo + 5, NULL), &p);
|
|
|
|
|
+ g_array_append_val(seg, p);
|
|
|
|
|
+ app.gpx_points++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ g_free(data);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void load_summary(const char *path) {
|
|
|
|
|
+ char *data, *s;
|
|
|
|
|
+ if (!g_file_get_contents(path, &data, NULL, NULL)) {
|
|
|
|
|
+ gtk_label_set_text(GTK_LABEL(app.summary_label), "");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ s = strstr(data, "=== Summary ===\n");
|
|
|
|
|
+ gtk_label_set_text(GTK_LABEL(app.summary_label), s ? g_strstrip(s + 16) : "");
|
|
|
|
|
+ g_free(data);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ===== Processing (runs the command-line tool) ===== */
|
|
|
|
|
+
|
|
|
|
|
+static void set_status(const char *text) {
|
|
|
|
|
+ gtk_label_set_text(GTK_LABEL(app.status_label), text);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void start_processing(void);
|
|
|
|
|
+
|
|
|
|
|
+static void child_exited(GPid pid, gint status, gpointer data) {
|
|
|
|
|
+ GError *err = NULL;
|
|
|
|
|
+ (void)data;
|
|
|
|
|
+ g_spawn_close_pid(pid);
|
|
|
|
|
+ app.running = FALSE;
|
|
|
|
|
+ if (app.pending) {
|
|
|
|
|
+ start_processing();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!g_spawn_check_wait_status(status, &err)) {
|
|
|
|
|
+ char *msg = g_strdup_printf("Processing failed: %s", err->message);
|
|
|
|
|
+ set_status(msg);
|
|
|
|
|
+ g_free(msg);
|
|
|
|
|
+ g_error_free(err);
|
|
|
|
|
+ app.have_result = FALSE;
|
|
|
|
|
+ free_segments();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ char *msg;
|
|
|
|
|
+ load_gpx(app.out_gpx);
|
|
|
|
|
+ load_summary(app.out_log);
|
|
|
|
|
+ app.have_result = TRUE;
|
|
|
|
|
+ msg = g_strdup_printf("%u NMEA fixes → %" G_GSIZE_FORMAT " GPX points in %u segment(s)",
|
|
|
|
|
+ app.nmea ? app.nmea->len : 0, app.gpx_points, app.segments->len);
|
|
|
|
|
+ set_status(msg);
|
|
|
|
|
+ g_free(msg);
|
|
|
|
|
+ }
|
|
|
|
|
+ gtk_widget_set_sensitive(app.save_button, app.have_result);
|
|
|
|
|
+ gtk_widget_queue_draw(app.map);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void start_processing(void) {
|
|
|
|
|
+ GPtrArray *argv;
|
|
|
|
|
+ GError *err = NULL;
|
|
|
|
|
+ char *vals[SPIN_COUNT];
|
|
|
|
|
+ unsigned i;
|
|
|
|
|
+
|
|
|
|
|
+ if (!app.input_file)
|
|
|
|
|
+ return;
|
|
|
|
|
+ if (app.running) {
|
|
|
|
|
+ /* Restart with the new settings once this run is gone */
|
|
|
|
|
+ app.pending = TRUE;
|
|
|
|
|
+ kill(app.child, SIGTERM);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ app.pending = FALSE;
|
|
|
|
|
+
|
|
|
|
|
+ argv = g_ptr_array_new();
|
|
|
|
|
+ g_ptr_array_add(argv, app.cli_path);
|
|
|
|
|
+ g_ptr_array_add(argv, "-o");
|
|
|
|
|
+ g_ptr_array_add(argv, app.out_gpx);
|
|
|
|
|
+ g_ptr_array_add(argv, "-l");
|
|
|
|
|
+ g_ptr_array_add(argv, app.out_log);
|
|
|
|
|
+ if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_disable_filters)))
|
|
|
|
|
+ g_ptr_array_add(argv, "-n");
|
|
|
|
|
+ if (!gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_auto_pause)))
|
|
|
|
|
+ g_ptr_array_add(argv, "-A");
|
|
|
|
|
+ for (i = 0; i < SPIN_COUNT; i++) {
|
|
|
|
|
+ vals[i] = g_strdup_printf("%d", gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(spins[i].widget)));
|
|
|
|
|
+ g_ptr_array_add(argv, (gpointer)spins[i].cli_opt);
|
|
|
|
|
+ g_ptr_array_add(argv, vals[i]);
|
|
|
|
|
+ }
|
|
|
|
|
+ g_ptr_array_add(argv, "--");
|
|
|
|
|
+ g_ptr_array_add(argv, app.input_file);
|
|
|
|
|
+ g_ptr_array_add(argv, NULL);
|
|
|
|
|
+
|
|
|
|
|
+ if (g_spawn_async(NULL, (char **)argv->pdata, NULL,
|
|
|
|
|
+ G_SPAWN_DO_NOT_REAP_CHILD | G_SPAWN_STDOUT_TO_DEV_NULL,
|
|
|
|
|
+ NULL, NULL, &app.child, &err)) {
|
|
|
|
|
+ app.running = TRUE;
|
|
|
|
|
+ g_child_watch_add(app.child, child_exited, NULL);
|
|
|
|
|
+ set_status("Processing…");
|
|
|
|
|
+ } else {
|
|
|
|
|
+ char *msg = g_strdup_printf("Cannot run %s: %s", app.cli_path, err->message);
|
|
|
|
|
+ set_status(msg);
|
|
|
|
|
+ g_free(msg);
|
|
|
|
|
+ g_error_free(err);
|
|
|
|
|
+ }
|
|
|
|
|
+ for (i = 0; i < SPIN_COUNT; i++)
|
|
|
|
|
+ g_free(vals[i]);
|
|
|
|
|
+ g_ptr_array_free(argv, TRUE);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static gboolean reprocess_timeout(gpointer data) {
|
|
|
|
|
+ (void)data;
|
|
|
|
|
+ app.reprocess_id = 0;
|
|
|
|
|
+ start_processing();
|
|
|
|
|
+ return G_SOURCE_REMOVE;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void setting_changed(GtkWidget *w, gpointer data) {
|
|
|
|
|
+ unsigned i;
|
|
|
|
|
+ (void)w;
|
|
|
|
|
+ (void)data;
|
|
|
|
|
+ if (app.loading_settings)
|
|
|
|
|
+ return;
|
|
|
|
|
+ for (i = SPIN_AP_FIRST; i < SPIN_COUNT; i++)
|
|
|
|
|
+ gtk_widget_set_sensitive(spins[i].widget,
|
|
|
|
|
+ gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_auto_pause)));
|
|
|
|
|
+ settings_store();
|
|
|
|
|
+ /* Debounced: holding a spin button's arrow fires many changes */
|
|
|
|
|
+ if (app.reprocess_id)
|
|
|
|
|
+ g_source_remove(app.reprocess_id);
|
|
|
|
|
+ app.reprocess_id = g_timeout_add(REPROCESS_DELAY_MS, reprocess_timeout, NULL);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void view_changed(GtkWidget *w, gpointer data) {
|
|
|
|
|
+ (void)w;
|
|
|
|
|
+ (void)data;
|
|
|
|
|
+ settings_store();
|
|
|
|
|
+ gtk_widget_queue_draw(app.map);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ===== File handling ===== */
|
|
|
|
|
+
|
|
|
|
|
+static void open_file(const char *path) {
|
|
|
|
|
+ char *title, *base;
|
|
|
|
|
+ if (!load_nmea(path))
|
|
|
|
|
+ return;
|
|
|
|
|
+ g_free(app.input_file);
|
|
|
|
|
+ app.input_file = g_canonicalize_filename(path, NULL);
|
|
|
|
|
+ g_key_file_set_string(app.config, "view", "input_file", app.input_file);
|
|
|
|
|
+ config_save();
|
|
|
|
|
+ base = g_path_get_basename(path);
|
|
|
|
|
+ title = g_strdup_printf("%s – GPS Test Tool", base);
|
|
|
|
|
+ gtk_window_set_title(GTK_WINDOW(app.window), title);
|
|
|
|
|
+ g_free(title);
|
|
|
|
|
+ g_free(base);
|
|
|
|
|
+ free_segments();
|
|
|
|
|
+ app.have_result = FALSE;
|
|
|
|
|
+ gtk_widget_set_sensitive(app.save_button, FALSE);
|
|
|
|
|
+ gtk_label_set_text(GTK_LABEL(app.summary_label), "");
|
|
|
|
|
+ app.need_fit = TRUE;
|
|
|
|
|
+ gtk_widget_queue_draw(app.map);
|
|
|
|
|
+ start_processing();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void open_clicked(GtkWidget *w, gpointer data) {
|
|
|
|
|
+ GtkWidget *d;
|
|
|
|
|
+ GtkFileFilter *f;
|
|
|
|
|
+ (void)w;
|
|
|
|
|
+ (void)data;
|
|
|
|
|
+ d = gtk_file_chooser_dialog_new("Open NMEA log", GTK_WINDOW(app.window),
|
|
|
|
|
+ GTK_FILE_CHOOSER_ACTION_OPEN, "_Cancel", GTK_RESPONSE_CANCEL,
|
|
|
|
|
+ "_Open", GTK_RESPONSE_ACCEPT, NULL);
|
|
|
|
|
+ f = gtk_file_filter_new();
|
|
|
|
|
+ gtk_file_filter_set_name(f, "NMEA logs");
|
|
|
|
|
+ gtk_file_filter_add_pattern(f, "*.nmea");
|
|
|
|
|
+ gtk_file_filter_add_pattern(f, "*.NMEA");
|
|
|
|
|
+ gtk_file_filter_add_pattern(f, "*.log");
|
|
|
|
|
+ gtk_file_filter_add_pattern(f, "*.LOG");
|
|
|
|
|
+ gtk_file_filter_add_pattern(f, "*.txt");
|
|
|
|
|
+ gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(d), f);
|
|
|
|
|
+ f = gtk_file_filter_new();
|
|
|
|
|
+ gtk_file_filter_set_name(f, "All files");
|
|
|
|
|
+ gtk_file_filter_add_pattern(f, "*");
|
|
|
|
|
+ gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(d), f);
|
|
|
|
|
+ if (app.input_file) {
|
|
|
|
|
+ char *dir = g_path_get_dirname(app.input_file);
|
|
|
|
|
+ gtk_file_chooser_set_current_folder(GTK_FILE_CHOOSER(d), dir);
|
|
|
|
|
+ g_free(dir);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (gtk_dialog_run(GTK_DIALOG(d)) == GTK_RESPONSE_ACCEPT) {
|
|
|
|
|
+ char *path = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(d));
|
|
|
|
|
+ gtk_widget_destroy(d);
|
|
|
|
|
+ open_file(path);
|
|
|
|
|
+ g_free(path);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ gtk_widget_destroy(d);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void save_clicked(GtkWidget *w, gpointer data) {
|
|
|
|
|
+ GtkWidget *d;
|
|
|
|
|
+ (void)w;
|
|
|
|
|
+ (void)data;
|
|
|
|
|
+ if (!app.have_result)
|
|
|
|
|
+ return;
|
|
|
|
|
+ d = gtk_file_chooser_dialog_new("Save GPX", GTK_WINDOW(app.window),
|
|
|
|
|
+ GTK_FILE_CHOOSER_ACTION_SAVE, "_Cancel", GTK_RESPONSE_CANCEL,
|
|
|
|
|
+ "_Save", GTK_RESPONSE_ACCEPT, NULL);
|
|
|
|
|
+ gtk_file_chooser_set_do_overwrite_confirmation(GTK_FILE_CHOOSER(d), TRUE);
|
|
|
|
|
+ if (app.input_file) {
|
|
|
|
|
+ char *dir = g_path_get_dirname(app.input_file);
|
|
|
|
|
+ char *base = g_path_get_basename(app.input_file);
|
|
|
|
|
+ char *dot = strrchr(base, '.');
|
|
|
|
|
+ char *name;
|
|
|
|
|
+ if (dot)
|
|
|
|
|
+ *dot = '\0';
|
|
|
|
|
+ name = g_strdup_printf("%s.gpx", base);
|
|
|
|
|
+ gtk_file_chooser_set_current_folder(GTK_FILE_CHOOSER(d), dir);
|
|
|
|
|
+ gtk_file_chooser_set_current_name(GTK_FILE_CHOOSER(d), name);
|
|
|
|
|
+ g_free(name);
|
|
|
|
|
+ g_free(base);
|
|
|
|
|
+ g_free(dir);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (gtk_dialog_run(GTK_DIALOG(d)) == GTK_RESPONSE_ACCEPT) {
|
|
|
|
|
+ GFile *src = g_file_new_for_path(app.out_gpx);
|
|
|
|
|
+ GFile *dst = gtk_file_chooser_get_file(GTK_FILE_CHOOSER(d));
|
|
|
|
|
+ GError *err = NULL;
|
|
|
|
|
+ if (!g_file_copy(src, dst, G_FILE_COPY_OVERWRITE, NULL, NULL, NULL, &err)) {
|
|
|
|
|
+ GtkWidget *m = gtk_message_dialog_new(GTK_WINDOW(d), GTK_DIALOG_MODAL,
|
|
|
|
|
+ GTK_MESSAGE_ERROR, GTK_BUTTONS_CLOSE, "Cannot save: %s", err->message);
|
|
|
|
|
+ gtk_dialog_run(GTK_DIALOG(m));
|
|
|
|
|
+ gtk_widget_destroy(m);
|
|
|
|
|
+ g_error_free(err);
|
|
|
|
|
+ }
|
|
|
|
|
+ g_object_unref(src);
|
|
|
|
|
+ g_object_unref(dst);
|
|
|
|
|
+ }
|
|
|
|
|
+ gtk_widget_destroy(d);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void fit_clicked(GtkWidget *w, gpointer data) {
|
|
|
|
|
+ (void)w;
|
|
|
|
|
+ (void)data;
|
|
|
|
|
+ app.need_fit = TRUE;
|
|
|
|
|
+ gtk_widget_queue_draw(app.map);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ===== Setup ===== */
|
|
|
|
|
+
|
|
|
|
|
+static char *find_cli(void) {
|
|
|
|
|
+ char *self = g_file_read_link("/proc/self/exe", NULL);
|
|
|
|
|
+ if (self) {
|
|
|
|
|
+ char *dir = g_path_get_dirname(self);
|
|
|
|
|
+ char *path = g_build_filename(dir, "gps-test-tool", NULL);
|
|
|
|
|
+ g_free(dir);
|
|
|
|
|
+ g_free(self);
|
|
|
|
|
+ if (g_file_test(path, G_FILE_TEST_IS_EXECUTABLE))
|
|
|
|
|
+ return path;
|
|
|
|
|
+ g_free(path);
|
|
|
|
|
+ }
|
|
|
|
|
+ return g_find_program_in_path("gps-test-tool");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static GtkWidget *section(GtkWidget *box, const char *title) {
|
|
|
|
|
+ GtkWidget *frame = gtk_frame_new(title);
|
|
|
|
|
+ GtkWidget *inner = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
|
|
|
|
|
+ gtk_container_set_border_width(GTK_CONTAINER(inner), 6);
|
|
|
|
|
+ gtk_container_add(GTK_CONTAINER(frame), inner);
|
|
|
|
|
+ gtk_box_pack_start(GTK_BOX(box), frame, FALSE, FALSE, 0);
|
|
|
|
|
+ return inner;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static GtkWidget *check(GtkWidget *box, const char *label, const char *group, const char *key,
|
|
|
|
|
+ gboolean def, GCallback cb) {
|
|
|
|
|
+ GtkWidget *w = gtk_check_button_new_with_label(label);
|
|
|
|
|
+ GError *err = NULL;
|
|
|
|
|
+ gboolean v = g_key_file_get_boolean(app.config, group, key, &err);
|
|
|
|
|
+ if (err) {
|
|
|
|
|
+ g_error_free(err);
|
|
|
|
|
+ v = def;
|
|
|
|
|
+ }
|
|
|
|
|
+ gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(w), v);
|
|
|
|
|
+ g_signal_connect(w, "toggled", cb, NULL);
|
|
|
|
|
+ gtk_box_pack_start(GTK_BOX(box), w, FALSE, FALSE, 0);
|
|
|
|
|
+ return w;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void spin(GtkWidget *box, struct setting_spin *s) {
|
|
|
|
|
+ GtkWidget *row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
|
|
|
|
+ int v = CLAMP(config_get_int("settings", s->key, s->def), s->min, s->max);
|
|
|
|
|
+ s->widget = gtk_spin_button_new_with_range(s->min, s->max, 1);
|
|
|
|
|
+ gtk_spin_button_set_value(GTK_SPIN_BUTTON(s->widget), v);
|
|
|
|
|
+ g_signal_connect(s->widget, "value-changed", G_CALLBACK(setting_changed), NULL);
|
|
|
|
|
+ gtk_box_pack_start(GTK_BOX(row), gtk_label_new(s->label), FALSE, FALSE, 0);
|
|
|
|
|
+ gtk_box_pack_end(GTK_BOX(row), s->widget, FALSE, FALSE, 0);
|
|
|
|
|
+ gtk_box_pack_start(GTK_BOX(box), row, FALSE, FALSE, 0);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void build_ui(void) {
|
|
|
|
|
+ GtkWidget *header, *button, *hbox, *side, *sec, *vbox, *bar, *scroll;
|
|
|
|
|
+ PangoAttrList *attrs;
|
|
|
|
|
+ unsigned i;
|
|
|
|
|
+
|
|
|
|
|
+ app.window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
|
|
|
|
|
+ gtk_window_set_default_size(GTK_WINDOW(app.window),
|
|
|
|
|
+ config_get_int("view", "width", 1200), config_get_int("view", "height", 800));
|
|
|
|
|
+ g_signal_connect(app.window, "destroy", G_CALLBACK(gtk_main_quit), NULL);
|
|
|
|
|
+
|
|
|
|
|
+ header = gtk_header_bar_new();
|
|
|
|
|
+ gtk_header_bar_set_show_close_button(GTK_HEADER_BAR(header), TRUE);
|
|
|
|
|
+ gtk_header_bar_set_title(GTK_HEADER_BAR(header), "GPS Test Tool");
|
|
|
|
|
+ button = gtk_button_new_with_mnemonic("_Open NMEA…");
|
|
|
|
|
+ g_signal_connect(button, "clicked", G_CALLBACK(open_clicked), NULL);
|
|
|
|
|
+ gtk_header_bar_pack_start(GTK_HEADER_BAR(header), button);
|
|
|
|
|
+ app.save_button = gtk_button_new_with_mnemonic("_Save GPX…");
|
|
|
|
|
+ gtk_widget_set_sensitive(app.save_button, FALSE);
|
|
|
|
|
+ g_signal_connect(app.save_button, "clicked", G_CALLBACK(save_clicked), NULL);
|
|
|
|
|
+ gtk_header_bar_pack_start(GTK_HEADER_BAR(header), app.save_button);
|
|
|
|
|
+ gtk_window_set_titlebar(GTK_WINDOW(app.window), header);
|
|
|
|
|
+
|
|
|
|
|
+ vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
|
|
|
|
+ hbox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
|
|
|
|
|
+ gtk_box_pack_start(GTK_BOX(vbox), hbox, TRUE, TRUE, 0);
|
|
|
|
|
+
|
|
|
|
|
+ side = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
|
|
|
|
|
+ gtk_container_set_border_width(GTK_CONTAINER(side), 8);
|
|
|
|
|
+ scroll = gtk_scrolled_window_new(NULL, NULL);
|
|
|
|
|
+ gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
|
|
|
|
|
+ gtk_container_add(GTK_CONTAINER(scroll), side);
|
|
|
|
|
+ gtk_widget_set_size_request(scroll, 270, -1);
|
|
|
|
|
+ gtk_box_pack_start(GTK_BOX(hbox), scroll, FALSE, FALSE, 0);
|
|
|
|
|
+ gtk_box_pack_start(GTK_BOX(hbox), gtk_separator_new(GTK_ORIENTATION_VERTICAL), FALSE, FALSE, 0);
|
|
|
|
|
+
|
|
|
|
|
+ app.loading_settings = TRUE;
|
|
|
|
|
+ sec = section(side, "Filtering");
|
|
|
|
|
+ app.chk_disable_filters = check(sec, "Disable filters", "settings", "disable_filters", FALSE, G_CALLBACK(setting_changed));
|
|
|
|
|
+ spin(sec, &spins[SPIN_SKIP]);
|
|
|
|
|
+ spin(sec, &spins[SPIN_MIN_SATS]);
|
|
|
|
|
+
|
|
|
|
|
+ sec = section(side, "Auto-pause");
|
|
|
|
|
+ app.chk_auto_pause = check(sec, "Enabled", "settings", "auto_pause", TRUE, G_CALLBACK(setting_changed));
|
|
|
|
|
+ for (i = SPIN_AP_FIRST; i < SPIN_COUNT; i++) {
|
|
|
|
|
+ spin(sec, &spins[i]);
|
|
|
|
|
+ gtk_widget_set_sensitive(spins[i].widget,
|
|
|
|
|
+ gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_auto_pause)));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ sec = section(side, "Display");
|
|
|
|
|
+ app.chk_show_nmea = check(sec, "NMEA input (grey)", "view", "show_nmea", TRUE, G_CALLBACK(view_changed));
|
|
|
|
|
+ app.chk_show_gpx = check(sec, "GPX output (colour per segment)", "view", "show_gpx", TRUE, G_CALLBACK(view_changed));
|
|
|
|
|
+ button = gtk_button_new_with_label("Zoom to fit");
|
|
|
|
|
+ g_signal_connect(button, "clicked", G_CALLBACK(fit_clicked), NULL);
|
|
|
|
|
+ gtk_box_pack_start(GTK_BOX(sec), button, FALSE, FALSE, 0);
|
|
|
|
|
+ app.loading_settings = FALSE;
|
|
|
|
|
+
|
|
|
|
|
+ sec = section(side, "Summary");
|
|
|
|
|
+ app.summary_label = gtk_label_new("");
|
|
|
|
|
+ gtk_label_set_selectable(GTK_LABEL(app.summary_label), TRUE);
|
|
|
|
|
+ gtk_label_set_xalign(GTK_LABEL(app.summary_label), 0);
|
|
|
|
|
+ gtk_label_set_line_wrap(GTK_LABEL(app.summary_label), TRUE);
|
|
|
|
|
+ attrs = pango_attr_list_new();
|
|
|
|
|
+ pango_attr_list_insert(attrs, pango_attr_family_new("monospace"));
|
|
|
|
|
+ pango_attr_list_insert(attrs, pango_attr_scale_new(0.85));
|
|
|
|
|
+ gtk_label_set_attributes(GTK_LABEL(app.summary_label), attrs);
|
|
|
|
|
+ pango_attr_list_unref(attrs);
|
|
|
|
|
+ gtk_box_pack_start(GTK_BOX(sec), app.summary_label, FALSE, FALSE, 0);
|
|
|
|
|
+
|
|
|
|
|
+ app.map = gtk_drawing_area_new();
|
|
|
|
|
+ gtk_widget_add_events(app.map, GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK |
|
|
|
|
|
+ GDK_POINTER_MOTION_MASK | GDK_SCROLL_MASK | GDK_SMOOTH_SCROLL_MASK | GDK_LEAVE_NOTIFY_MASK);
|
|
|
|
|
+ g_signal_connect(app.map, "draw", G_CALLBACK(map_draw), NULL);
|
|
|
|
|
+ g_signal_connect(app.map, "scroll-event", G_CALLBACK(map_scroll), NULL);
|
|
|
|
|
+ g_signal_connect(app.map, "button-press-event", G_CALLBACK(map_button_press), NULL);
|
|
|
|
|
+ g_signal_connect(app.map, "button-release-event", G_CALLBACK(map_button_release), NULL);
|
|
|
|
|
+ g_signal_connect(app.map, "motion-notify-event", G_CALLBACK(map_motion), NULL);
|
|
|
|
|
+ g_signal_connect(app.map, "leave-notify-event", G_CALLBACK(map_leave), NULL);
|
|
|
|
|
+ gtk_box_pack_start(GTK_BOX(hbox), app.map, TRUE, TRUE, 0);
|
|
|
|
|
+
|
|
|
|
|
+ bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12);
|
|
|
|
|
+ gtk_container_set_border_width(GTK_CONTAINER(bar), 4);
|
|
|
|
|
+ app.coord_label = gtk_label_new("");
|
|
|
|
|
+ gtk_label_set_selectable(GTK_LABEL(app.coord_label), TRUE);
|
|
|
|
|
+ gtk_box_pack_start(GTK_BOX(bar), app.coord_label, FALSE, FALSE, 4);
|
|
|
|
|
+ app.status_label = gtk_label_new("Open an NMEA log to start");
|
|
|
|
|
+ gtk_box_pack_end(GTK_BOX(bar), app.status_label, FALSE, FALSE, 4);
|
|
|
|
|
+ gtk_box_pack_start(GTK_BOX(vbox), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL), FALSE, FALSE, 0);
|
|
|
|
|
+ gtk_box_pack_start(GTK_BOX(vbox), bar, FALSE, FALSE, 0);
|
|
|
|
|
+
|
|
|
|
|
+ gtk_container_add(GTK_CONTAINER(app.window), vbox);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static gboolean window_configure(GtkWidget *w, GdkEvent *ev, gpointer data) {
|
|
|
|
|
+ int width, height;
|
|
|
|
|
+ (void)ev;
|
|
|
|
|
+ (void)data;
|
|
|
|
|
+ gtk_window_get_size(GTK_WINDOW(w), &width, &height);
|
|
|
|
|
+ g_key_file_set_integer(app.config, "view", "width", width);
|
|
|
|
|
+ g_key_file_set_integer(app.config, "view", "height", height);
|
|
|
|
|
+ return FALSE;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static gboolean quit_signal(gpointer data) {
|
|
|
|
|
+ (void)data;
|
|
|
|
|
+ gtk_main_quit();
|
|
|
|
|
+ return G_SOURCE_REMOVE;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+int main(int argc, char *argv[]) {
|
|
|
|
|
+ char *input;
|
|
|
|
|
+
|
|
|
|
|
+ gtk_init(&argc, &argv);
|
|
|
|
|
+
|
|
|
|
|
+ app.config = g_key_file_new();
|
|
|
|
|
+ app.config_path = g_build_filename(g_get_user_config_dir(), "gps-test-tool", "gui.ini", NULL);
|
|
|
|
|
+ g_key_file_load_from_file(app.config, app.config_path, G_KEY_FILE_NONE, NULL);
|
|
|
|
|
+ app.cache_dir = g_build_filename(g_get_user_cache_dir(), "gps-test-tool", "tiles", NULL);
|
|
|
|
|
+
|
|
|
|
|
+ app.cli_path = find_cli();
|
|
|
|
|
+ app.tmpdir = g_dir_make_tmp("gps-test-tool-gui-XXXXXX", NULL);
|
|
|
|
|
+ if (!app.cli_path || !app.tmpdir) {
|
|
|
|
|
+ fprintf(stderr, "Error: %s\n", app.cli_path ? "cannot create temporary directory"
|
|
|
|
|
+ : "gps-test-tool binary not found next to this program or in PATH");
|
|
|
|
|
+ return 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ app.out_gpx = g_build_filename(app.tmpdir, "output.gpx", NULL);
|
|
|
|
|
+ app.out_log = g_build_filename(app.tmpdir, "debug.txt", NULL);
|
|
|
|
|
+
|
|
|
|
|
+ app.tiles = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, tile_free);
|
|
|
|
|
+ g_queue_init(&app.dl_queue);
|
|
|
|
|
+ app.soup = soup_session_new_with_options("user-agent", USER_AGENT,
|
|
|
|
|
+ "max-conns-per-host", MAX_DOWNLOADS, NULL);
|
|
|
|
|
+ app.zoom = MIN_ZOOM;
|
|
|
|
|
+ app.cx = app.cy = world_size(app.zoom) / 2;
|
|
|
|
|
+
|
|
|
|
|
+ build_ui();
|
|
|
|
|
+ g_signal_connect(app.window, "configure-event", G_CALLBACK(window_configure), NULL);
|
|
|
|
|
+ gtk_widget_show_all(app.window);
|
|
|
|
|
+ update_coord_label();
|
|
|
|
|
+
|
|
|
|
|
+ input = argc > 1 ? g_strdup(argv[1]) : g_key_file_get_string(app.config, "view", "input_file", NULL);
|
|
|
|
|
+ if (input && (argc > 1 || g_file_test(input, G_FILE_TEST_EXISTS)))
|
|
|
|
|
+ open_file(input);
|
|
|
|
|
+ g_free(input);
|
|
|
|
|
+
|
|
|
|
|
+ /* Clean up the temporary directory on Ctrl+C / kill too */
|
|
|
|
|
+ g_unix_signal_add(SIGINT, quit_signal, NULL);
|
|
|
|
|
+ g_unix_signal_add(SIGTERM, quit_signal, NULL);
|
|
|
|
|
+ gtk_main();
|
|
|
|
|
+
|
|
|
|
|
+ if (app.running)
|
|
|
|
|
+ kill(app.child, SIGTERM);
|
|
|
|
|
+ config_save();
|
|
|
|
|
+ g_unlink(app.out_gpx);
|
|
|
|
|
+ g_unlink(app.out_log);
|
|
|
|
|
+ g_rmdir(app.tmpdir);
|
|
|
|
|
+ return 0;
|
|
|
|
|
+}
|