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 (Steve Smith)
:copyright: 2020 semuadmin
:license: BSD 3-Clause
"""

# pylint: disable=unused-argument

import logging
import traceback
from statistics import mean, median
from tkinter import (
    ALL,
    NE,
    NW,
    SE,
    SW,
    Button,
    Canvas,
    E,
    Frame,
    IntVar,
    Label,
    N,
    S,
    Spinbox,
    StringVar,
    W,
)
from xml.dom import minidom
from xml.parsers import expat

from pynmeagps import haversine, planar

from pygpsclient.globals import (
    AXISCOL,
    BGCOL,
    CUSTOM,
    ERRCOL,
    FGCOL,
    GRIDCOL,
    HOME,
    INFOCOL,
    M2FT,
    M2KM,
    M2MIL,
    M2NMIL,
    MPS2KNT,
    MPS2KPH,
    MPS2MPH,
    READONLY,
    ROUTE,
    TRACK,
    UI,
    UIK,
    UMK,
    WAYPOINT,
    Area,
    AreaXY,
    Point,
    TrackPoint,
)
from pygpsclient.helpers import (
    data2xy,
    fontheight,
    get_grid,
    isot2dt,
    time2str,
)
from pygpsclient.map_canvas import HYB, MAP, SAT, MapCanvas
from pygpsclient.strings import (
    DLGGPXERROR,
    DLGGPXLOAD,
    DLGGPXLOADED,
    DLGGPXNOMINAL,
    DLGGPXNULL,
    DLGGPXOPEN,
    DLGGPXWAIT,
    DLGTGPX,
    NA,
)
from pygpsclient.toplevel_dialog import ToplevelDialog

# 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 = "coral"  # color of speed plot
TRK_COL = "magenta"  # color of track
MD_LINES = 3  # number of lines of metadata
MINDIM = (400, 500)

GPXTYPES = {TRACK: "trkpt", WAYPOINT: "wpt", ROUTE: "rtept"}
AXISTAG = "axt"
AXISLBLTAG = "axl"
ERRCOL = "coral"
LBLCOL = "white"
CONTRASTCOL = "black"
CHARTMINY = 0
CHARTMAXY = 100
CHARTSCALE = 1
MAXCHANS = 2
CHANELE = 0
CHANSPD = 1
RESFONT = 28  # font size relative to widget size
MINFONT = 8  # minimum font size
PLOTWID = 1
PLOTCOLS = ("yellow", "cyan", "magenta", "deepskyblue")
GRIDMINCOL = "grey30"
LBLGRID = 5
GRIDSTEPS = get_grid(5)
XLBLSTEPS = get_grid(LBLGRID)
YLBLSTEPS = get_grid(3)
YLBLRANGE = {
    0: (50, 100, 250, 500, 1000, 2500, 5000, 10000, 25000),
    1: (5, 10, 25, 50, 100, 200, 500, 1000),
}


[docs] class GPXViewerDialog(ToplevelDialog): """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 super().__init__(app, DLGTGPX, MINDIM) self._mapzoom = IntVar() self._maptype = StringVar() self._gpxtype = StringVar() zoom = int(kwargs.get("zoom", 12)) self._mapzoom.set(zoom) self._info = [] for _ in range(MD_LINES): self._info.append(StringVar()) self._mtt = None self._mtz = None self._mtg = None self._mapimg = None self._track = None self._gpxfile = None self._bounds = None self._center = None self._initdir = HOME self._no_time = False self._no_ele = False # elevation/speed profile parameters self._font = self.__app.font_vsm self._fonth = fontheight(self._font) self._num_chans = 2 self._xoff = 20 # chart X offset for labels self._yoff = 20 # chart Y offset for labels self._plotcols = PLOTCOLS self._mintim = 1e20 self._maxtim = 0 self._rng = 0 self._elapsed = 0 self._dist = 0 self._minele = self._minspd = 1e20 self._maxele = self._maxspd = -1e20 self._body() self._do_layout() self._reset() self._attach_events() self._init_profile() self._finalise()
def _body(self): """ Create widgets. """ self._frm_body = Frame(self.container, borderwidth=2, relief="groove") self._frm_map = Frame(self._frm_body, borderwidth=2, relief="groove", bg=BGCOL) self._frm_profile = Frame( self._frm_body, borderwidth=2, relief="groove", bg=BGCOL ) self._frm_info = Frame(self._frm_body, borderwidth=2, relief="groove", bg=BGCOL) self._frm_controls = Frame(self._frm_body, borderwidth=2, relief="groove") self._can_mapview = MapCanvas( self.__app, self._frm_map, height=self.height * 0.75, width=self.width, bg=BGCOL, ) self._can_profile = Canvas( self._frm_profile, height=self.height * 0.25, width=self.width, bg=BGCOL, ) self._lbl_info = [] for i in range(MD_LINES): fg = PLOTCOLS[i - 1] if i else FGCOL self._lbl_info.append( Label( self._frm_info, textvariable=self._info[i], anchor=W, bg=BGCOL, fg=fg, ) ) self._btn_load = Button( self._frm_controls, image=self.img_load, width=40, command=self._on_load, ) self._lbl_maptype = Label(self._frm_controls, text="Map Type") self._spn_maptype = Spinbox( self._frm_controls, values=(HYB, SAT, MAP, CUSTOM), width=7, wrap=True, textvariable=self._maptype, state=READONLY, ) self._spn_gpxtype = Spinbox( self._frm_controls, values=list(GPXTYPES.keys()), width=6, wrap=True, textvariable=self._gpxtype, state=READONLY, ) self._lbl_zoom = Label(self._frm_controls, text="Zoom") self._spn_zoom = Spinbox( self._frm_controls, from_=1, to=20, width=5, wrap=False, textvariable=self._mapzoom, state=READONLY, ) self._btn_redraw = Button( self._frm_controls, image=self.img_redraw, width=40, command=self._on_redraw, ) def _do_layout(self): """ Arrange widgets. """ self._frm_body.grid(column=0, row=0, sticky=(N, S, E, W)) 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.grid(column=0, row=0, sticky=(N, S, E, W)) self._can_profile.grid(column=0, row=0, sticky=(N, S, E, W)) for i in range(MD_LINES): self._lbl_info[i].grid(column=0, row=i, padx=1, pady=1, sticky=(W, E)) self._btn_load.grid(column=0, row=1, padx=3, pady=3) self._lbl_maptype.grid( column=1, row=1, padx=3, pady=3, ) self._spn_maptype.grid( column=2, row=1, padx=3, pady=3, ) self._spn_gpxtype.grid( column=3, row=1, padx=3, pady=3, ) self._lbl_zoom.grid( column=4, row=1, padx=3, pady=3, ) self._spn_zoom.grid( column=5, row=1, padx=3, pady=3, ) self._btn_redraw.grid( column=6, row=1, padx=3, pady=3, ) # set column and row weights # NB!!! these govern the 'pack' behaviour of the frames on resize self.container.grid_columnconfigure(0, weight=1) self.container.grid_rowconfigure(0, weight=1) self.grid_columnconfigure(0, weight=1) self.grid_rowconfigure(0, weight=1) self._frm_body.grid_columnconfigure(0, weight=1) self._frm_body.grid_rowconfigure(0, weight=20) # map self._frm_body.grid_rowconfigure(1, weight=1) # profile self._frm_body.grid_rowconfigure(2, weight=0) # info self._frm_map.grid_columnconfigure(0, weight=1) self._frm_map.grid_rowconfigure(0, weight=1) self._frm_profile.grid_columnconfigure(0, weight=1) self._frm_profile.grid_rowconfigure(0, weight=1) def _attach_events(self): """ Bind events to window. """ self._mtt = self._maptype.trace_add("write", self._on_maptype) self._mtz = self._mapzoom.trace_add("write", self._on_mapzoom) self._mtg = self._gpxtype.trace_add("write", self._on_gpxtype) def _detach_events(self): """ Unbind events to window. """ if self._mtt is not None: self._maptype.trace_remove("write", self._mtt) if self._mtz is not None: self._mapzoom.trace_remove("write", self._mtz) if self._mtg is not None: self._gpxtype.trace_remove("write", self._mtg) def _reset(self): """ Reset application. """ self._can_mapview.delete(ALL) self._can_profile.delete(ALL) self._maptype.set(self.__app.configuration.get("gpxmaptype_s")) self._mapzoom.set(self.__app.configuration.get("gpxmapzoom_n")) self._gpxtype.set(self.__app.configuration.get("gpxtype_s")) for i in range(MD_LINES): self._info[i].set("") self._can_mapview.draw_msg(DLGGPXOPEN, INFOCOL) def _on_maptype(self, var, index, mode): # pylint: disable=unused-argument """ Map type has changed. """ self.__app.configuration.set("gpxmaptype_s", self._maptype.get()) self._on_redraw() def _on_mapzoom(self, var, index, mode): # pylint: disable=unused-argument """ Map zoom has changed. """ self.__app.configuration.set("gpxmapzoom_n", self._mapzoom.get()) self._on_redraw() def _on_gpxtype(self, var, index, mode): # pylint: disable=unused-argument """ GPX type has changed. """ self.__app.configuration.set("gpxtype_s", self._gpxtype.get()) def _on_redraw(self, *args, **kwargs): """ Handle redraw button press. """ self.set_status(DLGGPXWAIT, INFOCOL) self._detach_events() self._reset() self._spn_zoom.config(highlightbackground="gray90", highlightthickness=3) self._parse_gpx() self._draw_map() self._draw_profile() self._draw_metadata() self._attach_events() 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.set_status(DLGGPXLOAD, INFOCOL) self._parse_gpx() def _parse_gpx(self): if self._gpxfile is None: return ptyp = GPXTYPES[self._gpxtype.get()] with open(self._gpxfile, "r", encoding="utf-8") as gpx: try: parser = minidom.parse(gpx) trkpts = parser.getElementsByTagName(f"{ptyp}") self._process_track(trkpts, ptyp) except (TypeError, AttributeError, expat.ExpatError) as err: self.set_status(f"{DLGGPXERROR}\n{repr(err)}", ERRCOL) self.logger.error(traceback.format_exc()) def _process_track(self, trkpts: list, ptyp: str): """ Process trackpoint data. :param list trk: list of trackpoints :param str ptyp: element type """ self._rng = len(trkpts) self._no_time = False self._no_ele = False if self._rng == 0: self.set_status(DLGGPXNULL.format(ptyp), ERRCOL) return minlat = minlon = 400 maxlat = maxlon = -400 track = [] start = end = 0 self._dist = 0 lat1 = lon1 = 0 tm = tim1 = spd = spd1 = 0 self._minele = self._minspd = 1e10 self._maxele = self._maxspd = -1e20 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) try: tim = isot2dt(trkpt.getElementsByTagName("time")[0].firstChild.data) except IndexError: # time element does not exist self._no_time = True tim = tm + i # use synthetic timestamp if gpx has no time element if i == 0: lat1, lon1, tim1 = lat, lon, tim start = tim else: leg = planar(lat1, lon1, lat, lon) # m if leg > 1000: leg = haversine(lat1, lon1, lat, lon) / 1000 # m self._dist += leg if tim > tim1: spd = leg / (tim - tim1) # m/s else: spd = spd1 spd1 = spd self._maxspd = max(spd, self._maxspd) self._minspd = min(spd, self._minspd) lat1, lon1, tim1 = lat, lon, tim end = tim try: ele = float(trkpt.getElementsByTagName("ele")[0].firstChild.data) self._maxele = max(ele, self._maxele) self._minele = min(ele, self._minele) except IndexError: # 'ele' element does not exist self._no_ele = True ele = 0.0 track.append(TrackPoint(lat, lon, tim, ele, spd)) self._bounds = Area(minlat, minlon, maxlat, maxlon) self._center = Point((maxlat + minlat) / 2, (maxlon + minlon) / 2) self._elapsed = end - start self._mintim = start self._maxtim = end self._track = track self._draw_map() self._draw_profile() self._draw_metadata() self.set_status(DLGGPXLOADED, INFOCOL) def _get_units(self) -> tuple: """ Get speed and elevation units and conversions. Default is metric - meters and meters per second. :return: tuple of (dst_u, dst_c, ele_u, ele_C, spd_u, spd_c) :rtype: tuple """ units = self.__app.configuration.get("units_s") if units == UI: dst_u = "miles" dst_c = M2MIL ele_u = "ft" ele_c = M2FT spd_u = "mph" spd_c = MPS2MPH elif units == UIK: dst_u = "naut miles" dst_c = M2NMIL ele_u = "ft" ele_c = M2FT spd_u = "knt" spd_c = MPS2KNT elif units == UMK: dst_u = "km" dst_c = M2KM ele_u = "m" ele_c = 1 spd_u = "kph" spd_c = MPS2KPH else: # UMM dst_u = "m" dst_c = 1 ele_u = "m" ele_c = 1 spd_u = "m/s" spd_c = 1 return dst_u, dst_c, ele_u, ele_c, spd_u, spd_c def _draw_map(self): """ Draw map on canvas. """ if self._track is None: return location = self._center maptype = self._maptype.get() zoom = self._mapzoom.get() bounds = self._can_mapview.zoom_bounds( self.height, self.width, location, zoom, maptype ) points = [Point(pnt.lat, pnt.lon) for pnt in self._track] self._can_mapview.draw_map( maptype, location=location, track=points, bounds=bounds, zoom=zoom, marker=self._can_mapview.marker, ) if self._can_mapview.zoommin: self._spn_zoom.config(highlightbackground=ERRCOL, highlightthickness=3) else: self._spn_zoom.config(highlightbackground="gray90", highlightthickness=3) def _init_profile(self): """ Initialise elevation/speed profile. """ self.update_idletasks() # Make sure we know about any resizing w, h = self._can_profile.winfo_width(), self._can_profile.winfo_height() self._xoff = self._fonth * self._num_chans / 2 + 3 # chart X offset for labels self._yoff = self._fonth + 3 # chart Y offset for labels self._can_profile.delete(ALL) # draw grid for i, p in enumerate(GRIDSTEPS): y = (h - self._yoff) * p col = AXISCOL if p in (0, 1.0) else GRIDMINCOL if i % LBLGRID else GRIDCOL self._can_profile.create_line( self._xoff, y, w - self._xoff, y, fill=col, tags=AXISTAG ) x = self._xoff + (w - self._xoff * 2) * p self._can_profile.create_line( x, 0, x, h - self._yoff, fill=col, tags=AXISTAG ) def _draw_profile(self): """ Update elevation/speed profile with data. """ if self._track is None: return self._init_profile() w, h = self._can_profile.winfo_width(), self._can_profile.winfo_height() _, _, ele_u, ele_c, spd_u, spd_c = self._get_units() # set default ranges for all channels minval = [CHARTMINY] * self._num_chans maxval = [CHARTMAXY] * self._num_chans scale = [CHARTSCALE] * self._num_chans label = [""] * self._num_chans scale[CHANELE] = ele_c label[CHANELE] = f"hmsl ({ele_u})" scale[CHANSPD] = spd_c label[CHANSPD] = f"speed ({spd_u})" # get X axis (time) range for all channels and draw labels mintim, maxtim = self._mintim, self._maxtim bounds = AreaXY(mintim, CHARTMINY, maxtim, CHARTMAXY) self._can_profile.delete(AXISLBLTAG) self._draw_xaxis_labels(w, h, bounds, mintim, maxtim) # plot each channel's data points for chn in range(self._num_chans): chncol = self._plotcols[chn] minval[chn] = 0 # autorange max y values if chn == CHANELE: for i in YLBLRANGE[chn]: if self._maxele * ele_c < i: maxval[chn] = i break elif chn == CHANSPD: for i in YLBLRANGE[chn]: if self._maxspd * spd_c < i: maxval[chn] = i break bounds = AreaXY(mintim, minval[chn], maxtim, maxval[chn]) # draw Y axis (data value) labels for this channel self._draw_yaxis_labels( w, h, bounds, minval[chn], maxval[chn], chn, label[chn] ) # plot each track element inr = False for pnt in self._track: tim = pnt.tim try: val = pnt.ele if chn == CHANELE else pnt.spd except KeyError: val = None if val is None: continue if scale[chn] != 1: val *= scale[chn] # scale data # convert datapoint to canvas x,y coordinates x, y = data2xy( w - self._xoff * 2, h - self._yoff, bounds, tim, val, self._xoff, ) if x <= self._xoff: inr = False # plot line if inr: x2, y2 = x, y self._can_profile.create_line( x1, y1, x2, y2, fill=chncol, width=PLOTWID, tags=f"plot_{chn:1d}", ) x1, y1 = x2, y2 else: x1, y1 = max(x, self._xoff), y inr = True if self._no_time: self._timelegend(w) def _draw_xaxis_labels( self, w: int, h: int, bounds: AreaXY, mintim: float, maxtim: float ): """ Draw X axis (time) labels. :param int w: canvas width :param int h: canvas height :param AreaXY bounds: data bounds :param float mintim: minimum time :param float maxtim: maximum time """ # pylint: disable=too-many-arguments, too-many-positional-arguments for g in XLBLSTEPS: xval = mintim + (maxtim - mintim) * g x, _ = data2xy(w - self._xoff * 2, h - self._yoff, bounds, xval, 0) if g == 0: anc = NW elif g == 1: anc = NE else: anc = N self._can_profile.create_text( x + self._xoff, h - self._yoff, text=time2str(xval), anchor=anc, fill=AXISCOL, font=self._font, tags=AXISLBLTAG, ) def _draw_yaxis_labels( self, w: int, h: int, bounds: AreaXY, minval: float, maxval: float, chn: int, lbl: str, ): """ Draw Y axis (data value) labels for this channel. :param int w: canvas width :param int h: canvas height :param AreaXY bounds: data bounds :param float minval: minimum val for chn :param float maxval: maximum val for chn :param int chn: channel """ # pylint: disable=too-many-arguments, too-many-positional-arguments col = self._plotcols[chn] yo = 2 # avoid edges # y axis labels alternate left and right if chn % 2: # odd channels x = w - yo - self._fonth * ((chn - 1) / 2) else: # even channels x = yo + self._fonth * (chn / 2) for g in YLBLSTEPS: yval = minval + (maxval - minval) * g _, y = data2xy( w - self._xoff * MAXCHANS / 2, h - self._yoff, bounds, 0, yval ) if g == 0: anc = SW if chn % 2 else NW elif g == 1: y += yo * 2 # avoid edges anc = SE if chn % 2 else NE else: anc = S if chn % 2 else N self._can_profile.create_text( x, y, text=int(yval), fill=col, font=self._font, angle=90, anchor=anc, tags=AXISLBLTAG, ) # legend self._can_profile.create_text( w - yo * 2 - self._fonth if chn % 2 else yo * 2 + self._fonth, 2, text=lbl, fill=self._plotcols[chn], font=self._font, anchor=NE if chn % 2 else NW, tags=AXISLBLTAG, ) def _timelegend(self, w: int): """ Draw nominal time legend. """ self._can_profile.create_text( w / 2, 2, text=DLGGPXNOMINAL, fill=AXISCOL, font=self._font, anchor=N, tags=AXISLBLTAG, ) def _draw_metadata(self) -> str: """ Draw metadata as lines of text. """ if self._rng == 0: return dst_u, dst_c, ele_u, ele_c, spd_u, spd_c = self._get_units() if self._elapsed > 3600: elapsed = self._elapsed / 3600 elp_u = "hours" else: elapsed = self._elapsed elp_u = "seconds" self._info[0].set( ( f"Track elements: {self._rng:,}; " f"Distance ({dst_u}): {self._dist*dst_c:,.2f}; " f"Elapsed ({elp_u}): {elapsed:,.2f}" ) ) if self._no_ele: ele = NA else: elelist = [i.ele * ele_c for i in self._track] ele_median = median(elelist) ele_mean = mean(elelist) ele_min = min(elelist) ele_max = max(elelist) ele = ( f"({ele_u}) min: {ele_min:,.2f} " f"max: {ele_max:,.2f} " f"avg: {ele_mean:,.2f} med: {ele_median:,.2f} " f"dif: {(ele_max-ele_min):,.2f}" ) self._info[1].set(f"Ele {ele}") if self._no_time: spd = NA else: spdlist = [i.spd * spd_c for i in self._track] spd_median = median(spdlist) spd_mean = mean(spdlist) spd_min = min(spdlist) spd_max = max(spdlist) spd = ( f"({spd_u}) min: {spd_min:,.2f} " f"max: {spd_max:,.2f} " f"avg: {spd_mean:,.2f} med: {spd_median:,.2f}" ) self._info[2].set(f"Spd {spd}")