From d83ff2e273f7602386ed448d38bd392e5773a30d Mon Sep 17 00:00:00 2001 From: Alexander Hess Date: Mon, 13 Sep 2021 10:33:35 +0200 Subject: [PATCH] Add `Order.draw()` and `Path.draw()` - `Order.draw()` plots a `Courier`'s path from the `Order.pickup_address` to the `Order.delivery_address` - `Path.draw()` plots a `Courier`'s path between any two `Address` objects --- src/urban_meal_delivery/configuration.py | 1 + src/urban_meal_delivery/db/addresses.py | 3 +- .../db/addresses_addresses.py | 73 +++++++++++++++++++ src/urban_meal_delivery/db/orders.py | 36 +++++++++ 4 files changed, 112 insertions(+), 1 deletion(-) diff --git a/src/urban_meal_delivery/configuration.py b/src/urban_meal_delivery/configuration.py index 6392d36..2e4247e 100644 --- a/src/urban_meal_delivery/configuration.py +++ b/src/urban_meal_delivery/configuration.py @@ -59,6 +59,7 @@ class Config: # Colors for the visualizations ins `folium`. RESTAURANT_COLOR = 'red' CUSTOMER_COLOR = 'blue' + NEUTRAL_COLOR = 'black' # Implementation-specific settings # -------------------------------- diff --git a/src/urban_meal_delivery/db/addresses.py b/src/urban_meal_delivery/db/addresses.py index cea38d2..d75f59b 100644 --- a/src/urban_meal_delivery/db/addresses.py +++ b/src/urban_meal_delivery/db/addresses.py @@ -11,6 +11,7 @@ from sqlalchemy import orm from sqlalchemy.dialects import postgresql from sqlalchemy.ext import hybrid +from urban_meal_delivery import config from urban_meal_delivery.db import meta from urban_meal_delivery.db import utils @@ -151,7 +152,7 @@ class Address(meta.Base): `.city.map` for convenience in interactive usage """ defaults = { - 'color': 'black', + 'color': f'{config.NEUTRAL_COLOR}', 'popup': f'{self.street}, {self.zip_code} {self.city_name}', } defaults.update(kwargs) diff --git a/src/urban_meal_delivery/db/addresses_addresses.py b/src/urban_meal_delivery/db/addresses_addresses.py index 9164f1b..02fd978 100644 --- a/src/urban_meal_delivery/db/addresses_addresses.py +++ b/src/urban_meal_delivery/db/addresses_addresses.py @@ -7,6 +7,7 @@ import itertools import json from typing import List +import folium import googlemaps as gm import ordered_set import sqlalchemy as sa @@ -227,6 +228,11 @@ class Path(meta.Base): db.session.add(self) db.session.commit() + @property # pragma: no cover + def map(self) -> folium.Map: # noqa:WPS125 + """Convenience property to obtain the underlying `City.map`.""" + return self.first_address.city.map + @functools.cached_property def waypoints(self) -> List[utils.Location]: """The couriers' route from `.first_address` to `.second_address`. @@ -241,3 +247,70 @@ class Path(meta.Base): point.relate_to(self.first_address.city.southwest) return points + + def draw( # noqa:WPS211 + self, + *, + reverse: bool = False, + start_tooltip: str = 'Start', + end_tooltip: str = 'End', + start_color: str = 'green', + end_color: str = 'red', + path_color: str = 'black', + ) -> folium.Map: # pragma: no cover + """Draw the `.waypoints` from `.first_address` to `.second_address`. + + Args: + reverse: by default, `.first_address` is used as the start; + set to `False` to make `.second_address` the start + start_tooltip: text shown on marker at the path's start + end_tooltip: text shown on marker at the path's end + start_color: `folium` color for the path's start + end_color: `folium` color for the path's end + path_color: `folium` color along the path, which + is the line between the `.waypoints` + + Returns: + `.map` for convenience in interactive usage + """ + # Without `self._directions` synced from Google Maps, + # the `.waypoints` are not available. + self.sync_with_google_maps() + + # First, plot the couriers' path between the start and + # end locations, so that it is below the `folium.Circle`s. + line = folium.PolyLine( + locations=( + self.first_address.location.lat_lng, + *(point.lat_lng for point in self.waypoints), + self.second_address.location.lat_lng, + ), + color=path_color, + weight=2, + ) + line.add_to(self.map) + + # Draw the path's start and end locations, possibly reversed, + # on top of the couriers' path. + + if reverse: + start, end = self.second_address, self.first_address + else: + start, end = self.first_address, self.second_address + + start.draw( + radius=5, + color=start_color, + fill_color=start_color, + fill_opacity=1, + tooltip=start_tooltip, + ) + end.draw( + radius=5, + color=end_color, + fill_color=end_color, + fill_opacity=1, + tooltip=end_tooltip, + ) + + return self.map diff --git a/src/urban_meal_delivery/db/orders.py b/src/urban_meal_delivery/db/orders.py index 0b4550b..1b1341b 100644 --- a/src/urban_meal_delivery/db/orders.py +++ b/src/urban_meal_delivery/db/orders.py @@ -2,10 +2,13 @@ import datetime +import folium import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy.dialects import postgresql +from urban_meal_delivery import config +from urban_meal_delivery import db from urban_meal_delivery.db import meta @@ -524,3 +527,36 @@ class Order(meta.Base): # noqa:WPS214 return '<{cls}(#{order_id})>'.format( cls=self.__class__.__name__, order_id=self.id, ) + + def draw(self) -> folium.Map: # pragma: no cover + """Draw the `.waypoints` from `.pickup_address` to `.delivery_address`. + + Important: Do not put this in an automated script as a method call + triggers an API call to the Google Maps API and may result in costs. + + Returns: + `...city.map` for convenience in interactive usage + """ + path = db.Path.from_order(self) + + restaurant_tooltip = f'{self.restaurant.name} (#{self.restaurant.id})' + customer_tooltip = f'Customer #{self.customer.id}' + + # Because the underlying distance matrix is symmetric (i.e., a DB constraint), + # we must check if the `.pickup_address` is the couriers' `Path`'s start. + if path.first_address is self.pickup_address: + reverse = False + start_tooltip, end_tooltip = restaurant_tooltip, customer_tooltip + else: + reverse = True + start_tooltip, end_tooltip = customer_tooltip, restaurant_tooltip + + # This triggers `Path.sync_with_google_maps()` behind the scenes. + return path.draw( + reverse=reverse, + start_tooltip=start_tooltip, + end_tooltip=end_tooltip, + start_color=config.RESTAURANT_COLOR, + end_color=config.CUSTOMER_COLOR, + path_color=config.NEUTRAL_COLOR, + )