from abc import ABCMeta
from dataclasses import dataclass
from typing import Any, Dict, IO, Iterable, Optional, Union
import cairocffi as cairo
import geopandas as gpd
import numpy as np
from shapely.affinity import affine_transform, translate
from shapely.geometry import LineString, Polygon, MultiPolygon, Point
from shapely.geometry.base import BaseGeometry, BaseMultipartGeometry
import tvx
from gewel.color import BaseColor, BLACK, TRANSPARENT
from gewel.draw import (
float_at_time, XYDrawable, BoundedMixin, _time_device_drawing, _xform_context,
walk_text, TextJustification
)
[docs]def read_shapefile(path: Union[str, IO], crs: Optional[Any] = None) -> gpd.GeoDataFrame:
gdf = gpd.read_file(path)
if crs is not None:
gdf.to_crs(crs, inplace=True)
return gdf
[docs]@dataclass(init=False)
class ShapelyDrawable(XYDrawable, BoundedMixin, metaclass=ABCMeta):
color: BaseColor = TRANSPARENT
fill_color: BaseColor = TRANSPARENT
marker_width: float = 8.0
def __init__(
self,
geometry: Union[Polygon, MultiPolygon],
color: BaseColor,
fill_color: BaseColor = TRANSPARENT,
line_width: tvx.FloatOrTVF = 1.0,
text_color: Optional[BaseColor] = None,
theta: tvx.FloatOrTVF = 0.0,
centered: bool = True,
z: tvx.FloatOrTVF = 0.0,
):
x, y, max_x, max_y = geometry.bounds
width, height = max_x - x, max_y - y
if centered:
x, y = x + width / 2, y + height / 2
super().__init__(x=x, y=y, theta=theta, z=z)
self._width = width
self._height = height
self.color = color
self.fill_color = fill_color
self.line_width = line_width
if text_color is None:
text_color = color
self.text_color = text_color
self.centered = centered
if centered:
self._geometry = translate(
geometry,
-self.x + self.width / 2,
-self.y + self.height / 2
)
else:
self._geometry = translate(geometry, -self.x, -self.y)
@property
def width(self) -> float:
return self._width
@property
def height(self) -> float:
return self._height
[docs] def draw(self, ctx: cairo.Context, t: float) -> None:
with _time_device_drawing():
xform = self.local_xform(t)
with _xform_context(ctx, xform):
if not self.color.is_transparent() or not self.fill_color.is_transparent():
_draw_geometry(
ctx, t, self._geometry,
self.color, self.fill_color,
self.line_width, self.marker_width, self.alpha
)
def _draw_point(
ctx: cairo.Context,
t: float,
point: Point,
fill_color: BaseColor,
marker_width: float,
color: BaseColor,
line_width: float,
alpha: float
):
if marker_width != 0.0:
if not fill_color.is_transparent():
ctx.set_source_rgba(*fill_color.tuple(t, alpha_multiplier=float_at_time(alpha, t)))
ctx.arc(point.x, point.y, marker_width / 2, 0.0, 2 * np.pi)
ctx.close_path()
ctx.clip()
ctx.paint()
ctx.reset_clip()
if not color.is_transparent():
ctx.set_source_rgba(*color.tuple(t, alpha_multiplier=float_at_time(alpha, t)))
ctx.arc(point.x, point.y, marker_width / 2, 0.0, 2 * np.pi)
ctx.set_line_width(line_width)
ctx.stroke()
def _draw_polygon(
ctx: cairo.Context,
t: float,
polygon: Polygon,
color: BaseColor,
fill_color: BaseColor,
line_width: float,
alpha: float
):
ctx.set_fill_rule(cairo.FILL_RULE_EVEN_ODD)
ctx.set_line_width(line_width)
ctx.set_line_cap(cairo.LINE_CAP_ROUND)
exterior_points = polygon.exterior.coords
for x, y in exterior_points:
ctx.line_to(x, y)
ctx.close_path()
ctx.clip_preserve()
for interior in polygon.interiors:
interior_points = interior.coords
for x, y in interior_points:
ctx.line_to(x, y)
ctx.close_path()
ctx.clip()
ctx.set_source_rgba(*fill_color.tuple(t, alpha_multiplier=float_at_time(alpha, t)))
ctx.paint()
ctx.reset_clip()
if not color.is_transparent():
ctx.set_source_rgba(*color.tuple(t, alpha_multiplier=float_at_time(alpha, t)))
exterior_points = polygon.exterior.coords
for x, y in exterior_points:
ctx.line_to(x, y)
ctx.stroke()
for interior in polygon.interiors:
interior_points = interior.coords
for x, y in interior_points:
ctx.line_to(x, y)
ctx.stroke()
def _draw_line_string(
ctx: cairo.Context,
t: float,
line_string: LineString,
color: BaseColor,
line_width: float,
alpha: float
):
if not color.is_transparent():
ctx.set_source_rgba(*color.tuple(t, alpha_multiplier=float_at_time(alpha, t)))
ctx.set_line_width(line_width)
ctx.set_line_cap(cairo.LINE_CAP_ROUND)
points = line_string.coords
if len(points):
ctx.move_to(*points[0])
for x, y in points[1:]:
ctx.line_to(x, y)
ctx.stroke()
def _draw_geometry(
ctx: cairo.Context,
t: float,
geometry: BaseGeometry,
color: BaseColor,
fill_color: BaseColor,
line_width: float,
marker_width: float,
alpha: float,
):
if isinstance(geometry, Point):
_draw_point(ctx, t, geometry, fill_color, marker_width, color, line_width, alpha)
elif isinstance(geometry, Polygon):
_draw_polygon(ctx, t, geometry, color, fill_color, line_width, alpha)
elif isinstance(geometry, LineString):
_draw_line_string(ctx, t, geometry, color, line_width, alpha)
elif isinstance(geometry, BaseMultipartGeometry):
for g in geometry.geoms:
_draw_geometry(ctx, t, g, color, fill_color, line_width, marker_width, alpha)
else:
raise ValueError("Unrecognized geometry type {:}".format(type(geometry)))
def _draw_geometry_label(
ctx: cairo.Context,
t: float,
geometry: BaseGeometry,
alpha: float,
label: str,
text_color: BaseColor,
font_size: float,
glow_color: Optional[BaseColor] = None,
glow_width: float = 1.0,
):
if label is not None:
p = geometry.centroid
lines = walk_text(
ctx, label, p.x - 100, p.y, 200, font_size, 1.2,
TextJustification.CENTER,
)
ctx.set_font_size(font_size)
dy = -0.6 * font_size * (len(lines) + 1 - 1)
for line_x, line_y, line in lines:
if line is not None:
if glow_color is not None and not glow_color.is_transparent() and glow_width != 1:
ctx.set_line_width(glow_width * 2)
ctx.set_line_cap(cairo.LINE_CAP_ROUND)
ctx.set_source_rgba(*glow_color.tuple(t, alpha_multiplier=float_at_time(alpha, t)))
ctx.move_to(line_x, line_y + dy)
ctx.text_path(line)
ctx.stroke()
ctx.set_source_rgba(*text_color.tuple(t, alpha_multiplier=float_at_time(alpha, t)))
ctx.move_to(line_x, line_y + dy)
ctx.show_text(line)
[docs]@dataclass(init=False)
class MapDrawable(XYDrawable, BoundedMixin):
width: tvx.FloatOrTVF = 0.0
height: tvx.FloatOrTVF = 0.0
color: BaseColor = BLACK
fill_color: BaseColor = TRANSPARENT
line_width: tvx.FloatOrTVF = 1.0
marker_width: float = 16.0
text_color: BaseColor = BLACK
font_size: float = 9
highlight_line_width: tvx.FloatOrTVF = 5.0,
centered: bool = True
def _region_drawable(self, geometry: Union[Polygon, MultiPolygon], **kwargs) -> ShapelyDrawable:
default_kwargs = {
'line_width': self.line_width,
'color': self.color,
'fill_color': self.fill_color,
'text_color': self.text_color,
}
all_kwargs = dict(**default_kwargs, **kwargs)
if isinstance(geometry, (Polygon, MultiPolygon)):
return ShapelyDrawable(geometry, **all_kwargs)
else:
raise ValueError(
"Each geometry must either be a Polygon or BasePolygon; got a {:}".format(type(geometry))
)
def __init__(
self,
regions: Iterable[Dict[str, Any]],
x: tvx.FloatOrTVF,
y: tvx.FloatOrTVF,
width: tvx.FloatOrTVF,
height: tvx.FloatOrTVF,
color: BaseColor = BLACK,
fill_color: BaseColor = TRANSPARENT,
line_width: tvx.FloatOrTVF = 1.0,
marker_width: float = 16.0,
text_color: Optional[BaseColor] = None,
font_size: Optional[float] = None,
highlight_line_width: tvx.FloatOrTVF = 5.0,
map_base: Optional['MapDrawable'] = None,
theta: tvx.FloatOrTVF = 0.0,
centered: bool = True,
z: tvx.FloatOrTVF = 0.0,
alpha: tvx.FloatOrTVF = 1.0
):
if centered:
x = x + width / 2
y = y + height / 2
super().__init__(x, y, theta, z, alpha)
self.width = width
self.height = height
self.color = color
self.fill_color = fill_color
self.line_width = line_width
self.marker_width = marker_width
if text_color is None:
text_color = color
self.text_color = text_color
if font_size is None:
font_size = 9
self.font_size = font_size
self.highlight_line_width = highlight_line_width
if map_base is None:
map_base = self
self._map_base = map_base
self.centered = centered
self._regions = list(regions)
self._regions.sort(key=lambda r: r.get('z', 0.0))
bounds = [r['geometry'].bounds for r in self._regions]
min_x = min([b[0] for b in bounds])
min_y = min([b[1] for b in bounds])
max_x = max([b[2] for b in bounds])
max_y = max([b[3] for b in bounds])
self._raw_width = max_x - min_x
self._raw_height = max_y - min_y
self._raw_x = min_x
self._raw_y = min_y
self._drawing_kwargs = {
'line_width': self.line_width,
'color': self.color,
'fill_color': self.fill_color,
'marker_width': self.marker_width,
'alpha': self.alpha,
}
self._label_kwargs = {
'alpha': self.alpha,
'text_color': self.text_color,
'font_size': self.font_size,
}
@property
def raw_x(self):
return self._raw_x
@property
def raw_y(self):
return self._raw_y
@property
def raw_width(self):
return self._raw_width
@property
def raw_height(self):
return self._raw_height
[docs] def draw(self, ctx: cairo.Context, t: float) -> None:
xform = self.local_xform(t)
region_xform_tuple = self._region_xform_tuple(t)
with _xform_context(ctx, xform):
for region in self._regions:
draw_kwargs = dict(self._drawing_kwargs)
transformed_region = dict(region)
transformed_region['geometry'] = affine_transform(
region['geometry'], region_xform_tuple
)
draw_kwargs.update({
k: v for k, v in transformed_region.items()
if k not in [
'label', 'font_size', 'text_color',
'highlight_color', 'highlight_alpha', 'highlight_line_width',
'glow_color', 'glow_width',
'z',
]
})
_draw_geometry(ctx, t, **draw_kwargs)
# An optional highlight in a separate loop so it is
# on top of all of the geometry and none of the
# geometry of other regions can get drawn over it.
for region in self._regions:
if 'highlight_color' in region.keys():
draw_highlight_kwargs = dict()
draw_highlight_kwargs['geometry'] = affine_transform(
region['geometry'], region_xform_tuple
)
draw_highlight_kwargs['color'] = region['highlight_color']
draw_highlight_kwargs['fill_color'] = TRANSPARENT
draw_highlight_kwargs['line_width'] = region.get(
'highlight_line_width', self.highlight_line_width
)
draw_highlight_kwargs['marker_width'] = 0.0
draw_highlight_kwargs['alpha'] = region.get(
'highlight_alpha', self.alpha
)
_draw_geometry(ctx, t, **draw_highlight_kwargs)
# We draw the label in a separate loop so it is
# on top of all of the geometry and none of the
# geometry of other regions can get drawn over it.
for region in self._regions:
if 'label' in region.keys():
draw_label_kwargs = dict(self._label_kwargs)
transformed_region = dict(region)
transformed_region['geometry'] = affine_transform(
region['geometry'], region_xform_tuple
)
draw_label_kwargs.update({
k: v for k, v in transformed_region.items()
if k not in [
'color', 'fill_color', 'line_width', 'marker_width',
'highlight_color', 'highlight_alpha', 'highlight_line_width',
'z',
]
})
_draw_geometry_label(ctx, t, **draw_label_kwargs)
def _region_xform_tuple(self, t: float):
# So we have two libraries loaded, cairo and
# shapely, both of which can do affine transforms.
# shapely has the advantage that we can, in one
# call, apply a transform to all the points in
# a BaseGeometry. But cairo has the advantage that
# we can multiply transform matrices together to
# get a matrix that represents a series of
# translations and rotations in a single matrix.
# That's a key point of the power of affine
# transformation. But shapely has no such notion.
# It just uses tuples as the matrices and has
# some helper methods to translate and rotate a
# geometry.
#
# So, in an attempt to get the best of both worlds,
# we construct a transform matrix using cairo, then
# convert that into a tuple that shapely can use.
# This lets us do the matrix construction once and
# then apply it to each point of each geometry as
# apposed to applying each individual sub-transform
# (translation or rotation) to every point, which
# results in a lot more multiplication and adding
# overall.
#
# We construct the matrix here and then return it to
# ``draw``, which applies it once to each geometry.
width = float_at_time(self._map_base.width, t)
height = float_at_time(self._map_base.height, t)
# Move the raw center to the origin.
translate_to_origin = cairo.Matrix(
x0=-(self._map_base._raw_x + self._map_base._raw_width / 2),
y0=-(self._map_base._raw_y + self._map_base._raw_height / 2)
)
# Flip vertically.
flip = cairo.Matrix(xx=1.0, yy=-1.0)
# Scale to the max size that fits in our boundary.
scale_x = width / self._map_base._raw_width
scale_y = height / self._map_base._raw_height
scale_both = min(scale_x, scale_y)
scale_to_max = cairo.Matrix(xx=scale_both, yy=scale_both)
# Translate back in drawing coordinates.
translate_back = cairo.Matrix(x0=width / 2, y0=height / 2)
xform = translate_to_origin * flip * scale_to_max * translate_back
xform_tuple = (xform.xx, xform.xy, xform.yx, xform.yy, xform.x0, xform.y0)
return xform_tuple