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
This commit is contained in:
Alexander Hess 2021-09-13 10:33:35 +02:00
parent 2449492aba
commit d83ff2e273
Signed by: alexander
GPG key ID: 344EA5AB10D868E0
4 changed files with 112 additions and 1 deletions

View file

@ -59,6 +59,7 @@ class Config:
# Colors for the visualizations ins `folium`. # Colors for the visualizations ins `folium`.
RESTAURANT_COLOR = 'red' RESTAURANT_COLOR = 'red'
CUSTOMER_COLOR = 'blue' CUSTOMER_COLOR = 'blue'
NEUTRAL_COLOR = 'black'
# Implementation-specific settings # Implementation-specific settings
# -------------------------------- # --------------------------------

View file

@ -11,6 +11,7 @@ from sqlalchemy import orm
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
from sqlalchemy.ext import hybrid from sqlalchemy.ext import hybrid
from urban_meal_delivery import config
from urban_meal_delivery.db import meta from urban_meal_delivery.db import meta
from urban_meal_delivery.db import utils from urban_meal_delivery.db import utils
@ -151,7 +152,7 @@ class Address(meta.Base):
`.city.map` for convenience in interactive usage `.city.map` for convenience in interactive usage
""" """
defaults = { defaults = {
'color': 'black', 'color': f'{config.NEUTRAL_COLOR}',
'popup': f'{self.street}, {self.zip_code} {self.city_name}', 'popup': f'{self.street}, {self.zip_code} {self.city_name}',
} }
defaults.update(kwargs) defaults.update(kwargs)

View file

@ -7,6 +7,7 @@ import itertools
import json import json
from typing import List from typing import List
import folium
import googlemaps as gm import googlemaps as gm
import ordered_set import ordered_set
import sqlalchemy as sa import sqlalchemy as sa
@ -227,6 +228,11 @@ class Path(meta.Base):
db.session.add(self) db.session.add(self)
db.session.commit() 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 @functools.cached_property
def waypoints(self) -> List[utils.Location]: def waypoints(self) -> List[utils.Location]:
"""The couriers' route from `.first_address` to `.second_address`. """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) point.relate_to(self.first_address.city.southwest)
return points 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

View file

@ -2,10 +2,13 @@
import datetime import datetime
import folium
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import orm from sqlalchemy import orm
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
from urban_meal_delivery import config
from urban_meal_delivery import db
from urban_meal_delivery.db import meta from urban_meal_delivery.db import meta
@ -524,3 +527,36 @@ class Order(meta.Base): # noqa:WPS214
return '<{cls}(#{order_id})>'.format( return '<{cls}(#{order_id})>'.format(
cls=self.__class__.__name__, order_id=self.id, 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,
)