Source code for pygpsclient.map_canvas

"""
map_canvas.py

Multi-purpose canvas for offline and online maps.

This handles a canvas containing a location map which can be either:

 - one or more fixed offline maps based on user-provided georeferenced
   images e.g. geoTIFF (defaults to Mercator world image).
 - dynamic online map or satellite image accessed via a MapQuest API.

NOTE: The free MapQuest API key is subject to a limit of 15,000
transactions / month, or roughly 500 / day, so the map updates are only
run periodically (once a minute). This utility is NOT intended to be used for
real time navigation.

Created on 13 Sep 2020

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

# pylint: disable=too-many-positional-arguments, too-many-arguments

from http.client import responses
from io import BytesIO
from math import sqrt
from random import randrange
from tkinter import (
    ALL,
    CENTER,
    Canvas,
    S,
)

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

from pygpsclient.globals import (
    BGCOL,
    CUSTOM,
    ERRCOL,
    ICON_END,
    ICON_START,
    IMG_WORLD,
    IMG_WORLD_BOUNDS,
    IMPORT,
    PNTCOL,
    WORLD,
    Area,
    AreaXY,
    Point,
)
from pygpsclient.helpers import (
    area_in_bounds,
    fontheight,
    get_track_bounds,
    ll2xy,
    normalise_area,
    point_in_bounds,
    scale_font,
)
from pygpsclient.mapquest import (
    HYB,
    MAP,
    MAPQTIMEOUT,
    POINTLIMIT,
    SAT,
    format_mapquest_request,
)
from pygpsclient.strings import (
    DLGGPXOOB,
    MAPCONFIGERR,
    MAPOPENERR,
    NOCONN,
    NOWEBMAPCONN,
    NOWEBMAPFIX,
    NOWEBMAPHTTP,
    NOWEBMAPKEY,
    OUTOFBOUNDS,
)

ZOOM = 10
POSCOL = ERRCOL
TRK_COL = "magenta"  # color of track
HACCCOL = "skyblue"
MARKERCOL = "red"
TAG_TRACK = "trak"
TAG_MARKER = "mark"
TAG_HACC = "hacc"
TAG_CLOCK = "clok"
TAG_LOCATION = "loc"
MARKERSIZE = 6
MAX_SIZE = 100000000  # 154,746,100 pixels for PIL/Image
"""Maximum image size allowed by PIL Image library"""
MAPTYPES = (WORLD, HYB, SAT, MAP, CUSTOM)
""" Map Types """


[docs] class MapCanvas(Canvas): # pylint: disable=too-many-ancestors """ Map Canvas class. """
[docs] def __init__(self, app, container, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application :param Frame container: reference to container frame :param args: optional args to pass to Frame parent class :param kwargs: optional kwargs to pass to Frame parent class """ self.__app = app # Reference to main application class self.__master = self.__app.appmaster # Reference to root class (Tk) self.__container = container # container frame self.width = kwargs.get("width", 400) self.height = kwargs.get("height", 400) self._img_start = ImageTk.PhotoImage(Image.open(ICON_START)) self._img_end = ImageTk.PhotoImage(Image.open(ICON_END)) self._img = None self._mapimage = None self._bounds = None self._native_bounds = None self._track = None self._marker = None self._zoom = None self._zoommin = False self._last_map_update = 0 self._last_image = None self._last_bounds = None self._lastmaptype = "" self._lastmappath = "" self._font = self.__app.font_sm self._fonth = fontheight(self._font) super().__init__(self.__container, *args, **kwargs) self.config(background=BGCOL) self.bind("<Configure>", self._on_resize) self.bind("<Double-Button-2>", self.on_clear) self.bind("<Double-Button-3>", self.on_clear)
[docs] def draw_map( self, maptype: str = WORLD, location: Point = None, marker: Point = None, track: list = None, hacc: float = None, mappath: str = None, bounds: Area = None, zoom: int = ZOOM, ): """ Draw selected map type on canvas. :param str maptype: map type (CUSTOM/IMPORT/MAP/SAT/WORLD) :param Point location: location to draw on map :param Point marker: marker to draw on map :param list track: track to draw on map :param float hacc: horizontal accuracy in meters :param str mappath: path to map image (if known) :param Area bounds: bounds (extent) of map image (if known) :param int zoom: zoom level """ self._zoom = zoom if maptype in (WORLD, CUSTOM, IMPORT): self._draw_offline_map( maptype, location, marker, track, hacc, mappath, bounds, zoom ) elif maptype in (MAP, SAT, HYB): self._draw_online_map(maptype, location, marker, track, hacc, bounds, zoom)
def _draw_offline_map( self, maptype: str, location: Point, marker: Point, track: list, hacc: float, mappath: str = None, bounds: Area = None, zoom: int = ZOOM, ): # pylint: disable=unused-argument """ Draw fixed offline map using optional user-provided georeferenced image path(s) and calibration bounding box(es). Defaults to Mercator world image with bounding box [90, -180, -90, 180] if location is not within bounds of any custom map. :param str maptype: offline map type (CUSTOM/IMPORT/WORLD) :param Point location: location to draw on map :param Point marker: marker to draw on map :param list track: track to draw on map :param float hacc: horizontal accuracy in meters :param str mappath: path to map image (if known) :param Area bounds: bounds (extent) of map image (if known) :param int zoom: zoom level """ err = self.open_offline_map(maptype, location, mappath, bounds) if err != "": self.draw_msg(err, ERRCOL) return self._lastmaptype = maptype self.delete(ALL) if ( zoom is None or location is None or maptype == WORLD or self.__app.configuration.get("mapzoom_disabled_b") ): image = self._mapimage else: image, self._bounds = self._zoom_offline_map( self._mapimage, self._native_bounds, location, zoom ) self._last_image = image self._last_bounds = self._bounds if image is None: self.draw_msg(DLGGPXOOB, ERRCOL) return self._img = ImageTk.PhotoImage(image.resize((self.width, self.height))) self.create_image( self.width / 2, self.height / 2, image=self._img, anchor=CENTER ) if location is not None: self.draw_marker(location, TAG_LOCATION) if marker is not None: self.draw_marker(marker, TAG_MARKER) if track is not None: self.draw_track(track) if location is not None and hacc is not None: self.draw_hacc(location, hacc)
[docs] def open_offline_map( self, maptype: str, location: Point, mappath: str = None, bounds: Area = None, ) -> str: """ Open map image at path, or find first available map image from usermaps_l list which includes location. :param str maptype: map type (CUSTOM/IMPORT/WORLD) :param Point location: location :param str mappath: path to map image, if known :param Area bounds: bounds (extent) of map image, if known :return: error code :rtype: str """ err = OUTOFBOUNDS.format("unknown") try: mpath = None if maptype == IMPORT: mpath = mappath self._bounds = bounds elif maptype == WORLD: mpath = IMG_WORLD self._bounds = IMG_WORLD_BOUNDS else: mpath = self._find_offline_map(location, bounds) if mpath is None: err = OUTOFBOUNDS.format("bounds" if bounds is not None else "location") elif self._lastmappath == mpath: # don't bother opening again err = "" else: self._mapimage = Image.open(mpath) self._lastmappath = mpath err = "" except (ValueError, IndexError): err = MAPCONFIGERR except (FileNotFoundError, UnidentifiedImageError): err = MAPOPENERR.format(mpath.split("/")[-1]) return err
def _find_offline_map(self, location: Point, bounds: Area) -> str: """ Find first map image with bounds containing location. :param Point location: location :param Area bounds: native extents of map image, if known :return: map path :rtype: str """ # pylint: disable=arguments-out-of-order mpath = None usermaps = self.__app.configuration.get("usermaps_l") for fpath, extent in usermaps: extents = normalise_area((extent[0], extent[1], extent[2], extent[3])) # check if bounds or location are within map extents if (bounds is not None and area_in_bounds(extents, bounds)) or ( location is not None and point_in_bounds(extents, location) ): mpath = fpath self._bounds = extents self._native_bounds = extents break return mpath def _zoom_offline_map( self, image: Image, extents: Area, location: Point, zoom: int ) -> tuple: """ Zoom (crop) offline image centered at location and calculate new bounding box. Automatically increments zoom until image is entirely within zoom bounds. ;param Image image: native map image :param Area extents: native map extents :param Point location: location (center point) :param int zoom: zoom level :return: tuple of (zoomed image, zoomed bounds) :rtype: tuple """ zoombounds = self.zoom_bounds(self.height, self.width, location, zoom, CUSTOM) self._zoom = zoom (x1, y1), (x2, y2) = [ ll2xy(image.width, image.height, extents, pnt) for pnt in ( Point(zoombounds.lat2, zoombounds.lon1), Point(zoombounds.lat1, zoombounds.lon2), ) ] # check if cropped image exceeds PIL's max permissible size size = (x2 - x1) * (y2 - y1) if size >= MAX_SIZE: self._zoommin = True return self._last_image, self._last_bounds self._zoommin = False return image.crop(AreaXY(x1, y1, x2, y2)), zoombounds def _draw_online_map( self, maptype: str, location: Point, marker: Point, track: list, hacc: float, bounds: Area = None, zoom: int = ZOOM, ): # pylint: disable=unused-argument """ Draw scalable web map or satellite image via online MapQuest API. :param str maptype: online map type (MAP/SAT) :param Point location: location to draw on map :param Point marker: marker to draw on map :param list track: track to draw on map :param float hacc: horizontal accuracy in meters :param Area bounds: bounds (extent) of map image, if known :param int zoom: zoom level """ sc = NOCONN err = "" hacc = hacc if isinstance(hacc, (float, int)) else 0 if maptype != self._lastmaptype: self._lastmaptype = maptype mqapikey = self.__app.configuration.get("mqapikey_s") if mqapikey == "": self.draw_msg(NOWEBMAPKEY, ERRCOL) return if track is not None: points = track elif location is not None: points = [ location, ] else: self.draw_msg(NOWEBMAPFIX, ERRCOL) return if bounds is None: # set bounds relative to location and zoom if zoom > 1: bounds = self.zoom_bounds( self.height, self.width, location, zoom, maptype ) url = format_mapquest_request( mqapikey, maptype, self.width, self.height, zoom, points, # list bounds, # bbox hacc, ) try: response = get(url, timeout=MAPQTIMEOUT) sc = responses[response.status_code] # get descriptive HTTP status response.raise_for_status() # raise Exception on HTTP error if sc == "OK": self._bounds = bounds img_data = response.content self._img = ImageTk.PhotoImage(Image.open(BytesIO(img_data))) self.delete(ALL) self.create_image( self.width / 2, self.height / 2, image=self._img, anchor=CENTER ) self.update_idletasks() return except (ConnError, ConnectTimeout): err = NOWEBMAPCONN except RequestException: err = NOWEBMAPHTTP.format(sc) self.draw_msg(err, ERRCOL)
[docs] def draw_track(self, track: list): """ Draw track on canvas. :param list track: list of track points """ self.delete(TAG_TRACK) i = 0 for i, pnt in enumerate(track): x, y = ll2xy(self.width, self.height, self._bounds, Point(pnt.lat, pnt.lon)) if i: x2, y2 = x, y self.create_line(x1, y1, x2, y2, fill=TRK_COL, width=3, tags=TAG_TRACK) x1, y1 = x2, y2 else: x1, y1 = x, y xstart, ystart = x, y if i: self.create_image( xstart, ystart, image=self._img_start, anchor=S, tags=TAG_TRACK ) self.create_image(x2, y2, image=self._img_end, anchor=S, tags=TAG_TRACK)
[docs] def draw_marker(self, marker: Point, markertype: str = TAG_LOCATION): """ Draw marker point on canvas :param Point marker: marker point :param str markertype: marker type """ x, y = ll2xy(self.width, self.height, self._bounds, marker) if markertype == TAG_MARKER: self.delete(TAG_MARKER) self.create_circle( x, y, 2, outline=MARKERCOL, fill=MARKERCOL, tags=TAG_MARKER ) self.create_text( x, y, text=f"{marker.lat:.08f}\n{marker.lon:.08f}", anchor=CENTER, fill=MARKERCOL, tags=TAG_MARKER, ) else: self.delete(TAG_LOCATION) self.create_line( x, y - MARKERSIZE, x, y + MARKERSIZE, fill=MARKERCOL, width=2, tags=TAG_LOCATION, ) self.create_line( x - MARKERSIZE, y, x + MARKERSIZE, y, fill=MARKERCOL, width=2, tags=TAG_LOCATION, )
[docs] def draw_hacc(self, location: Point, hacc: float): """ Draw horizontal accuracy perimeter on canvas. :param float hacc: horizontal accurancy in meters """ # FYI not possible to draw translucent circles in tkinter # other than by messing around with stippled polygons self.delete(TAG_HACC) radius = int( sqrt(self.height**2 + self.width**2) * hacc / planar( self._bounds.lat1, self._bounds.lon1, self._bounds.lat2, self._bounds.lon2, ) ) x, y = ll2xy(self.width, self.height, self._bounds, location) self.create_circle(x, y, radius, outline=HACCCOL, fill="", tags=TAG_HACC)
[docs] def draw_countdown(self, wait: int): """ Draw clock icon indicating time until next scheduled map refresh. :param int wait: wait time in seconds """ self.delete(TAG_CLOCK) self.create_oval((5, 5, 20, 20), fill="", outline="black", tags=TAG_CLOCK) self.create_arc( (5, 5, 20, 20), start=90, extent=wait, fill="black", outline="black", tags=TAG_CLOCK, )
[docs] def draw_msg(self, msg: str, color: str = PNTCOL): """ Draw message on canvas. :param str msg: message :param str color: color """ w, h = self.get_size() self.delete(ALL) self.create_text( w / 2, h / 2, text=msg, fill=color, font=self._font, anchor=CENTER, )
[docs] def on_clear(self, event): # pylint: disable=unused-argument """ Clear map image. """ self.delete(ALL) self._marker = None
def _on_resize(self, event): # pylint: disable=unused-argument """ Resize frame :param event event: resize event """ self.width, self.height = self.get_size() self._font, self._fonth = scale_font(self.width, 10, 25, 20)
[docs] def get_size(self) -> tuple: """ Get current canvas 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()
[docs] @staticmethod def zoom_bounds( height: int, width: int, location: Point, zoom: int, maptype: str = CUSTOM ) -> Area: """ Get map bounds for given zoom level. :param int height: height :param int width: width :param Point location: location :param int zoom: zoom level :param str maptype: map type :return: zoom bounds :rtype: Area """ xoff = 90 / 2**zoom yoff = xoff * height / width return Area( location.lat - yoff, location.lon - xoff, location.lat + yoff, location.lon + xoff, )
@property def bounds(self) -> Area: """ Getter for custom map bounds. :return: bounds of displayed map :rtype: Area or None """ return self._bounds @property def marker(self) -> Point: """ Getter for marker. :return: marked location :rtype: Point """ return self._marker @property def track(self) -> list: """ Getter for track. :return: recorded track :rtype: list or None """ return self._track @property def trackbounds(self) -> tuple: """ Getter for track bounds and center. :return: tuple of (bounds, center point) :rtype: (Area, Point) """ if isinstance(self._track, list): if len(self._track) > 0: return get_track_bounds(self._track) return None, None @property def zoom(self) -> int: """ Getter for zoom. :return: applied zoom :rtype: int or None """ return self._zoom @property def zoommin(self) -> bool: """ Getter for zoommin flag. :return: if max zoom reached :rtype: bool """ return self._zoommin @track.setter def track(self, location: Point): """ Update or reset track list. Set location to None to reset. :param Point location: location """ if location is None: self._track = None return if self._track is None: self._track = [] if len(self._track) > 0: # only record if different from previous if round(self._track[-1][0], 8) != round(location.lat, 8) or round( self._track[-1][1], 8 ) != round(location.lon, 8): self._track.append(Point(location.lat, location.lon)) else: self._track.append(Point(location.lat, location.lon)) # limit size of track list while len(self._track) > POINTLIMIT: self._track.pop(randrange(1, len(self._track) - 1))