Source code for pygpsclient.gpx_dialog

"""
gpx_dialog.py

This is the pop-up dialog for the GPX Viewer function.

Created on 10 Jan 2023

:author: semuadmin
:copyright: 2020 SEMU Consulting
:license: BSD 3-Clause
"""

# pylint: disable=unused-argument

import logging
from datetime import datetime
from http.client import responses
from io import BytesIO
from tkinter import (
    ALL,
    BOTH,
    CENTER,
    DISABLED,
    NW,
    YES,
    Button,
    Canvas,
    E,
    Frame,
    IntVar,
    Label,
    N,
    S,
    Spinbox,
    StringVar,
    Toplevel,
    W,
    font,
)
from xml.dom import minidom
from xml.parsers import expat

from PIL import Image, ImageTk, UnidentifiedImageError
from requests import ConnectionError as ConnError
from requests import ConnectTimeout, HTTPError, RequestException, get

from pygpsclient.globals import (
    BGCOL,
    CUSTOM,
    HOME,
    ICON_END,
    ICON_EXIT,
    ICON_LOAD,
    ICON_REDRAW,
    ICON_START,
    IMG_WORLD_CALIB,
    KM2M,
    KM2MIL,
    KM2NMIL,
    KPH2KNT,
    KPH2MPH,
    KPH2MPS,
    M2FT,
    MAP,
    POPUP_TRANSIENT,
    READONLY,
    RPTDELAY,
    SAT,
    UI,
    UIK,
    UMK,
    Area,
    Point,
)
from pygpsclient.helpers import haversine, isot2dt, ll2xy, points2area, xy2ll
from pygpsclient.mapquest import MAPQTIMEOUT, format_mapquest_request
from pygpsclient.strings import (
    DLGGPXERROR,
    DLGGPXLOAD,
    DLGGPXNULL,
    DLGGPXPROMPT,
    DLGGPXVIEWER,
    DLGTGPX,
    MAPCONFIGERR,
    MAPOPENERR,
    OUTOFBOUNDS,
)

# profile chart parameters:
AXIS_XL = 35  # x axis left offset
AXIS_XR = 35  # x axis right offset
AXIS_Y = 15  # y axis bottom offset
ELEAX_COL = "green4"  # color of elevation plot axis
ELE_COL = "palegreen3"  # color of elevation plot
SPD_COL = "blue"  # color of speed plot
TRK_COL = "magenta"  # color of track
MD_LINES = 2  # number of lines of metadata


[docs] class GPXViewerDialog(Toplevel): """GPXViewerDialog class."""
[docs] def __init__(self, app, *args, **kwargs): """Constructor.""" self.__app = app self.logger = logging.getLogger(__name__) # self.__master = self.__app.appmaster # link to root Tk window Toplevel.__init__(self, app) if POPUP_TRANSIENT: self.transient(self.__app) self.resizable(True, True) self.title(DLGGPXVIEWER) # pylint: disable=E1102 self.protocol("WM_DELETE_WINDOW", self.on_exit) self._img_load = ImageTk.PhotoImage(Image.open(ICON_LOAD)) self._img_redraw = ImageTk.PhotoImage(Image.open(ICON_REDRAW)) self._img_exit = ImageTk.PhotoImage(Image.open(ICON_EXIT)) self._img_start = ImageTk.PhotoImage(Image.open(ICON_START)) self._img_end = ImageTk.PhotoImage(Image.open(ICON_END)) self.width = int(kwargs.get("width", 600)) self.height = int(kwargs.get("height", 600)) self.mheight = int(self.height * 0.75) self.mwidth = self.width self.pheight = int(self.height * 0.25) self._zoom = IntVar() self._maptype = StringVar() zoom = int(kwargs.get("zoom", 12)) self._zoom.set(zoom) self._info = [] for _ in range(MD_LINES): self._info.append(StringVar()) self._mapimg = None self._track = None self._gpxfile = None self._metadata = {} self._bounds = None self._initdir = HOME self._body() self._do_layout() self._attach_events() self._reset() self._do_mapalert(DLGGPXPROMPT)
def _body(self): """ Create widgets. """ self._frm_map = Frame(self, borderwidth=2, relief="groove", bg=BGCOL) self._frm_profile = Frame(self, borderwidth=2, relief="groove", bg=BGCOL) self._frm_info = Frame(self, borderwidth=2, relief="groove") self._frm_controls = Frame(self, borderwidth=2, relief="groove") self._can_mapview = Canvas( self._frm_map, width=self.width, height=self.mheight, bg=BGCOL ) self._can_profile = Canvas( self._frm_profile, width=self.width, height=self.pheight, bg="#f0f0e8" ) self._lbl_info = [] for i in range(MD_LINES): self._lbl_info.append( Label(self._frm_info, textvariable=self._info[i], anchor=W) ) self._btn_load = Button( self._frm_controls, image=self._img_load, width=40, command=self._on_load, ) self._lbl_zoom = Label(self._frm_controls, text="Zoom") self._spn_zoom = Spinbox( self._frm_controls, from_=1, to=20, width=5, wrap=True, repeatdelay=RPTDELAY, repeatinterval=RPTDELAY, textvariable=self._zoom, state=READONLY, ) self._lbl_maptype = Label(self._frm_controls, text="Map Type") self._spn_maptype = Spinbox( self._frm_controls, values=(MAP, SAT, CUSTOM), width=5, wrap=True, repeatdelay=RPTDELAY, repeatinterval=RPTDELAY, textvariable=self._maptype, state=READONLY, ) self._btn_redraw = Button( self._frm_controls, image=self._img_redraw, width=40, command=self._on_redraw, ) self._btn_exit = Button( self._frm_controls, image=self._img_exit, width=40, command=self.on_exit, ) def _do_layout(self): """ Arrange widgets. """ self._frm_map.grid(column=0, row=0, sticky=(N, S, E, W)) self._frm_profile.grid(column=0, row=1, sticky=(W, E)) self._frm_info.grid(column=0, row=2, sticky=(W, E)) self._frm_controls.grid(column=0, row=3, columnspan=7, sticky=(W, E)) self._can_mapview.pack(fill=BOTH, expand=YES) self._can_profile.pack(fill=BOTH, expand=YES) for i in range(MD_LINES): self._lbl_info[i].grid(column=0, row=i, padx=3, pady=1, sticky=(W, E)) self._btn_load.grid(column=0, row=1, padx=3, pady=3) self._lbl_zoom.grid( column=1, row=1, padx=3, pady=3, ) self._spn_zoom.grid( column=2, row=1, padx=3, pady=3, ) self._lbl_maptype.grid( column=3, row=1, padx=3, pady=3, ) self._spn_maptype.grid( column=4, row=1, padx=3, pady=3, ) self._btn_redraw.grid( column=5, row=1, padx=3, pady=3, ) self._btn_exit.grid(column=6, row=1, padx=3, pady=3, sticky=E) self.grid_columnconfigure(0, weight=1) self.grid_rowconfigure(0, weight=3) self.grid_rowconfigure(1, weight=1) def _attach_events(self): """ Bind events to window. """ self.bind("<Configure>", self._on_resize) self._maptype.trace_add("write", self._on_maptype) def _reset(self): """ Reset application. """ self._can_mapview.delete(ALL) self._can_profile.delete(ALL) for i in range(MD_LINES): self._info[i].set("")
[docs] def on_exit(self, *args, **kwargs): """ Handle Exit button press. """ self.__app.stop_dialog(DLGTGPX) self.destroy()
def _on_redraw(self, *args, **kwargs): """ Handle redraw button press. """ self._reset() if self._maptype.get() == CUSTOM: self._draw_offline_map(self._track) else: self._draw_online_map(self._track) self._draw_profile(self._track) self._format_metadata(self._metadata) def _on_mark(self, event): """ Mark point on map. :param event: right click event """ w, h = self.mwidth, self.mheight self._can_mapview.delete("pos") x, y = event.x, event.y pos = xy2ll(w, h, self._bounds, (x, y)) self._can_mapview.create_circle( x, y, 2, outline=TRK_COL, fill=TRK_COL, tags="pos" ) self._can_mapview.create_text( x, y - 3, text=f"{pos.lat:.08f},{pos.lon:.08f}", anchor=S, fill=TRK_COL, tags="pos", ) def _on_maptype(self, var, index, mode): """ Disable zoom when using custom offline map. :param var: _description_ :param index: _description_ :param mode: _description_ """ if self._maptype.get() == CUSTOM: self._spn_zoom.configure(stat=DISABLED) self._can_mapview.bind("<Button-2>", self._on_mark) self._can_mapview.bind("<Button-3>", self._on_mark) else: self._spn_zoom.configure(stat=READONLY) self._can_mapview.unbind("<Button-2>") self._can_mapview.unbind("<Button-3>")
[docs] def get_size(self): """ Get current frame size. :return: window size (width, height) :rtype: tuple """ self.update_idletasks() # Make sure we know about any resizing return self.winfo_width(), self.winfo_height()
def _on_resize(self, event): """ Resize frame :param event event: resize event """ self.width, self.height = self.get_size() self.mheight = self._can_mapview.winfo_height() self.mwidth = self._can_mapview.winfo_width() self.pheight = self._can_profile.winfo_height() - 5 def _open_gpxfile(self) -> str: """ Open gpx file. """ return self.__app.file_handler.open_file( "gpx", (("gpx files", "*.gpx"), ("all files", "*.*")), ) def _on_load(self): """ Load gpx track from file. """ self._gpxfile = self._open_gpxfile() if self._gpxfile is None: # user cancelled return self._do_mapalert(DLGGPXLOAD) with open(self._gpxfile, "r", encoding="utf-8") as gpx: try: parser = minidom.parse(gpx) trkpts = parser.getElementsByTagName("trkpt") self._process_track(trkpts) except (TypeError, AttributeError, expat.ExpatError) as err: self._do_mapalert(f"{DLGGPXERROR}\n{repr(err)}") def _process_track(self, trkpts: list): """ Process trackpoint data. :param list trk: list of trackpoints """ rng = len(trkpts) if rng == 0: self._do_mapalert(DLGGPXNULL) return minlat = minlon = 400 maxlat = maxlon = -400 track = [] start = end = dist = lat1 = lon1 = tim1 = maxele = spd = spd1 = maxspd = 0 minele = minspd = 1e10 for i, trkpt in enumerate(trkpts): lat = float(trkpt.attributes["lat"].value) lon = float(trkpt.attributes["lon"].value) # establish bounding box minlat = min(minlat, lat) minlon = min(minlon, lon) maxlat = max(maxlat, lat) maxlon = max(maxlon, lon) tim = isot2dt(trkpt.getElementsByTagName("time")[0].firstChild.data) if i == 0: lat1, lon1, tim1 = lat, lon, tim start = tim else: leg = haversine(lat1, lon1, lat, lon) # km dist += leg if tim > tim1: spd = leg * 3600 / (tim - tim1) # km/h else: spd = spd1 spd1 = spd maxspd = max(spd, maxspd) minspd = min(spd, minspd) lat1, lon1, tim1 = lat, lon, tim end = tim try: ele = float(trkpt.getElementsByTagName("ele")[0].firstChild.data) maxele = max(ele, maxele) minele = min(ele, minele) except IndexError: # 'ele' element does not exist ele = 0.0 track.append((lat, lon, tim, ele, spd)) # store bounding box self._bounds = Area(minlat, minlon, maxlat, maxlon) elapsed = end - start self._track = track self._metadata["points"] = rng self._metadata["elapsed"] = elapsed self._metadata["distance"] = dist self._metadata["min_elevation"] = minele self._metadata["max_elevation"] = maxele self._metadata["min_speed"] = minspd self._metadata["max_speed"] = maxspd if self._maptype.get() == CUSTOM: self._draw_offline_map(self._track) else: self._draw_online_map(self._track) self._draw_profile(self._track) self._format_metadata(self._metadata) def _draw_offline_map(self, track: list): """ Draw fixed offline map using optional user-provided georeferenced image path(s) and calibration bounding box(es). :param list track: list of lat/lon points """ # pylint: disable=no-member w, h = self.mwidth, self.mheight bounds = IMG_WORLD_CALIB err = "" err = OUTOFBOUNDS usermaps = self.__app.saved_config.get("usermaps_l", []) for mp in usermaps: try: mpath, bounds = mp bounds = points2area((bounds[0], bounds[1], bounds[2], bounds[3])) if ( bounds.lat1 <= self._bounds.lat1 and bounds.lat2 >= self._bounds.lat2 and bounds.lon1 <= self._bounds.lon1 and bounds.lon2 >= self._bounds.lon2 ): self._mapimg = Image.open(mpath) # Area is minlat, minlon, maxlat, maxlon self._bounds = bounds err = "" break except (ValueError, IndexError): err = MAPCONFIGERR break except (FileNotFoundError, UnidentifiedImageError): err = MAPOPENERR.format(mpath.split("/")[-1]) break self._can_mapview.delete(ALL) if err == "": self._mapimg = ImageTk.PhotoImage(self._mapimg.resize((w, h))) self._can_mapview.create_image( 0, 0, image=self._mapimg, anchor=NW, tags="image" ) else: self._do_mapalert(err) # draw track with start and end icons i = 0 for i, (lat, lon, _, _, _) in enumerate(track): x, y = ll2xy(w, h, self._bounds, Point(lat, lon)) if i: x2, y2 = x, y self._can_mapview.create_line( x1, y1, x2, y2, fill=TRK_COL, width=3, tags="track" ) x1, y1 = x2, y2 else: x1, y1 = x, y self._can_mapview.create_image( x1, y1, image=self._img_start, anchor=S, tags="track" ) if i: self._can_mapview.create_image( x2, y2, image=self._img_end, anchor=S, tags="track" ) def _draw_online_map(self, track: list): """ Draw static map image via MapQuest API. :param list track: list of lat/lon points """ # pylint: disable=unused-variable mqapikey = self.__app.frm_settings.config.get( "mqapikey_s", self.__app.frm_settings.config.get("mqapikey", "") ) locations = [Point(lat, lon) for lat, lon, _, _, _ in track] try: url = format_mapquest_request( mqapikey, self._maptype.get(), self.width, self.mheight, self._zoom.get(), locations, ) response = get(url, timeout=MAPQTIMEOUT) response.raise_for_status() # raise Exception on HTTP error self._mapimg = ImageTk.PhotoImage(Image.open(BytesIO(response.content))) except (ConnError, ConnectTimeout, RequestException, HTTPError): self._do_mapalert( f"MAPQUEST API ERROR: HTTP code {response.status_code} " + f"{responses[response.status_code]}\n\n{response.text}" ) self._can_mapview.create_image( 0, 0, image=self._mapimg, anchor=NW, tags="image" ) def _get_point(self, maxy: int, maxx: int, ele: float, pnt: int) -> tuple: """ Convert elevation values to canvas pixel coordinates (x,y). :param int maxy: max extent of y axis :param int maxx: max extent of x axis :param int ele: elevation value :param int pnt: point value :return: (x,y) :rtype: tuple """ x = AXIS_XL + ((self.width - AXIS_XL - AXIS_XR) * pnt / maxx) y = self.pheight - AXIS_Y - ((self.pheight - AXIS_Y) * ele / maxy) return int(x), int(y) def _draw_profile( self, track: list ): # pylint: disable = too-many-branches, too-many-statements """ Plot elevation profile with auto-ranged axes. :param list track: list of lat/lon points """ _, _, ele_u, ele_c, spd_u, spd_c = self._get_units() mid = int(len(track) / 2) self._can_profile.delete(ALL) # find maximum extents for x and y axes _, _, tim1, _, _ = self._track[0] # start point, labelled 1 _, _, timm, _, _ = self._track[mid] # mid point _, _, tim2, _, _ = self._track[-1] # end point, labelled 2 maxx = len(track) maxe = self._metadata["max_elevation"] * ele_c maxs = self._metadata["max_speed"] * spd_c for i in (50, 100, 250, 500, 1000, 2500, 5000, 10000, 25000): if maxe < i: maxe = i break for i in (5, 10, 25, 50, 100, 200, 500, 1000): if maxs < i: maxs = i break # plot elevation x2 = AXIS_XL y2 = self.pheight - AXIS_Y for i, (_, _, _, ele, _) in enumerate(track): if ele is None: continue x1, y1 = self._get_point(maxe, maxx, 0, i) x2, y2 = self._get_point(maxe, maxx, ele * ele_c, i) if i: self._can_profile.create_line(x1, y1, x2, y2, fill=ELE_COL, width=2) # plot speed x2 = AXIS_XL y2 = self.pheight - AXIS_Y for i, (_, _, _, _, spd) in enumerate(track): if spd is None: continue x1, y1 = x2, y2 x2, y2 = self._get_point(maxs, maxx, spd * spd_c, i) if i: self._can_profile.create_line(x1, y1, x2, y2, fill=SPD_COL, width=1) # plot elevation (yL) axis grid fnt = font.Font(size=10) for ele in range(0, maxe, int(maxe / 5)): if ele is None: continue x1, y1 = self._get_point(maxe, maxx, ele, 0) x2, y2 = self._get_point(maxe, maxx, ele, maxx) self._can_profile.create_line(x1, y1, x2 + 1, y1, fill="grey") self._can_profile.create_text( x1 - 2, y1, text=f"{ele}", fill=ELEAX_COL, font=fnt, anchor=E ) self._can_profile.create_text( AXIS_XL - 2, self.pheight / 2 - 8, text=ele_u, fill=ELEAX_COL, font=fnt, anchor=E, ) # plot speed (yR) axis grid for spd in range(0, int(maxs), int(maxs / 5)): if spd is None: continue x1, y1 = self._get_point(maxs, maxx, spd, maxx) x2, y2 = self._get_point(maxs, maxx, spd, maxx - 5) self._can_profile.create_text( self.width - AXIS_XR, y1, text=f"{spd}", fill=SPD_COL, font=fnt, anchor=W, ) self._can_profile.create_text( self.width - AXIS_XR + 1, self.pheight / 2 - 8, text=spd_u, fill=SPD_COL, font=fnt, anchor=W, ) # plot trackpoint (X) axis grid step = int(maxx / 10) if maxx > 10 else 1 for n in range(0, maxx, step): x1, y1 = self._get_point(maxe, maxx, 0, n) x2, y2 = self._get_point(maxe, maxx, maxe, n) self._can_profile.create_line(x1, y1 - 1, x1, y2, fill="grey") for xtick in ( (tim1, 0, W), (timm, maxx / 2, CENTER), (tim2, maxx, E), ): x, y = self._get_point(maxe, maxx, 0, xtick[1]) self._can_profile.create_text( x, y + 8, text=f"{datetime.fromtimestamp(xtick[0])}", fill="grey", font=fnt, anchor=xtick[2], ) def _format_metadata(self, metadata: dict) -> str: """ Format metadata as string. """ dst_u, dst_c, ele_u, ele_c, spd_u, spd_c = self._get_units() elapsed = self._metadata["elapsed"] / 3600 # h dist = self._metadata["distance"] # km minele = self._metadata["min_elevation"] # m maxele = self._metadata["max_elevation"] # m maxspd = self._metadata["max_speed"] # km/h if maxele == 0 and minele == 1e10: ele = "N/A" else: ele = ( f"({ele_u}) min: {minele*ele_c:,.2f} max: {maxele*ele_c:,.2f} " + f"diff: {(maxele-minele)*ele_c:,.2f}; " ) metadata = [] metadata.append( f"Points: {self._metadata['points']:,}; Dist ({dst_u}): {dist*dst_c:,.2f}; " + f"Elapsed (hours): {elapsed:,.2f}; " + f"Speed ({spd_u}) avg: {(dist/elapsed)*spd_c:,.2f} " + f"max: {maxspd*spd_c:,.2f};" ) metadata.append(f"Elevation {ele}") for i in range(MD_LINES): self._info[i].set(metadata[i]) def _get_units(self) -> tuple: """ Get speed and elevation units and conversions. Default is metric. :return: tuple of (dst_u, dst_c, ele_u, ele_C, spd_u, spd_c) :rtype: tuple """ settings = self.__app.frm_settings.config units = settings["units_s"] if units == UI: dst_u = "miles" dst_c = KM2MIL ele_u = "ft" ele_c = M2FT spd_u = "mph" spd_c = KPH2MPH elif units == UIK: dst_u = "naut miles" dst_c = KM2NMIL ele_u = "ft" ele_c = M2FT spd_u = "knt" spd_c = KPH2KNT elif units == UMK: dst_u = "km" dst_c = 1 ele_u = "m" ele_c = 1 spd_u = "kph" spd_c = 1 else: # UMM dst_u = "m" dst_c = KM2M ele_u = "m" ele_c = 1 spd_u = "m/s" spd_c = KPH2MPS return (dst_u, dst_c, ele_u, ele_c, spd_u, spd_c) def _do_mapalert(self, msg: str): """ Display alert on map canvas. """ self._reset() self._can_mapview.create_text( self.width / 2, self.mheight / 2, text=msg, fill="orange", tags="alert", ) self.update_idletasks() @property def metadata(self) -> dict: """ Getter for metadata. """ return self._metadata @property def track(self) -> str: """ Getter for track. """ return self._track @property def mapimage(self) -> ImageTk.PhotoImage: """ Getter for image of map. """ return self._mapimg