"""
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))