from enum import Enum
[docs]
class Side(Enum):
"""Specify which side of the plot to place an annotation.
Use ``Side.LEFT`` to place the label to the left of the grid or
``Side.RIGHT`` to place it to the right.
Examples
--------
>>> from lifegraph import Lifegraph, Side
>>> from datetime import date
>>> g = Lifegraph(date(1990, 1, 1))
>>> g.add_life_event("Event", date(2000, 6, 1), side=Side.LEFT)
"""
LEFT = 1
RIGHT = 2
[docs]
class Point:
"""A 2-D point in data coordinates.
Parameters
----------
x : float
The x coordinate.
y : float
The y coordinate.
Examples
--------
>>> from lifegraph.core import Point
>>> p = Point(10, 20)
>>> p.x
10
"""
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"({self.x}, {self.y})"
def __str__(self):
return f"({self.x}, {self.y})"
[docs]
class DatePosition(Point):
"""A point on the grid associated with a calendar date.
Extends :class:`Point` to additionally store the date that maps to the
``(week, year_of_life)`` grid coordinate.
Parameters
----------
x : int
Week number (1--52).
y : int
Year of life (0-indexed from birthdate).
date : datetime.date
The calendar date this position represents.
Examples
--------
>>> from lifegraph.core import DatePosition
>>> from datetime import date
>>> dp = DatePosition(10, 5, date(1995, 3, 1))
>>> dp.date
datetime.date(1995, 3, 1)
"""
def __init__(self, x, y, date):
super().__init__(x, y)
self.date = date
def __repr__(self):
return f"DatePosition: year({self.y}), week({self.x}), date({self.date}) at point {super().__repr__()}"
def __str__(self):
return f"DatePosition: year({self.y}), week({self.x}), date({self.date}) at point {super().__repr__()}"
[docs]
class Marker(Point):
"""Configuration for a colored marker drawn on the grid.
Extends :class:`Point` with matplotlib marker styling attributes.
Parameters
----------
x : float
The x position of the marker.
y : float
The y position of the marker.
marker : str, optional
A matplotlib marker style. Default is ``'s'`` (square).
fillstyle : str, optional
A matplotlib fill style. Default is ``'none'``.
color : str or tuple, optional
A matplotlib color. Default is ``'black'``.
Examples
--------
>>> from lifegraph.core import Marker
>>> m = Marker(5, 10, color='red')
"""
def __init__(self, x, y, marker='s', fillstyle='none', color='black'):
super().__init__(x, y)
self.marker = marker
self.fillstyle = fillstyle
self.color = color
def __repr__(self):
return f"Marker at {super().__repr__()}"
def __str__(self):
return f"Marker at {super().__repr__()}"
[docs]
class Annotation(Point):
"""A text annotation with layout-conflict resolution support.
Holds the label text and its position, along with metadata used by
:class:`~lifegraph.lifegraph.Lifegraph` to prevent overlapping labels.
Parameters
----------
date : datetime.date
When the annotated event occurred.
text : str
The label text.
label_point : Point
Initial location for the label text.
color : str or tuple, optional
A matplotlib color. Default is ``'black'``.
bbox : matplotlib.transforms.Bbox or None, optional
The bounding box of the rendered text. Set after layout.
event_point : Point or None, optional
Where on the grid the event is located.
put_circle_around_point : bool, optional
Whether to draw a circle around the event square. Default is ``True``.
marker : Marker or None, optional
A :class:`Marker` to draw at the event position.
relpos : tuple of float, optional
The relative position on the label from which the annotation arrow
originates. See `matplotlib annotation guide
<https://matplotlib.org/tutorials/text/annotations.html>`_.
Default is ``(0.5, 0.5)``.
source_y_range : tuple of (int, int) or None, optional
The ``(y_lo, y_hi)`` row range of the source object (event, era,
or era span). Used by
:class:`~lifegraph.lifegraph.Lifegraph` to filter annotations
outside the visible ``[min_age, max_age)`` window.
Default is ``None`` (always visible).
"""
def __init__(self, date, text, label_point, color='black', bbox=None, event_point=None, put_circle_around_point=True, marker=None, relpos=(.5, .5), source_y_range=None):
super().__init__(label_point.x, label_point.y)
self.date = date
self.text = text
self.color = color
self.bbox = bbox
self.event_point = event_point
self.put_circle_around_point = put_circle_around_point
self.marker = marker
self.relpos = relpos
self.source_y_range = source_y_range
[docs]
def set_bbox(self, bbox):
"""Set the bounding box of the rendered annotation text.
Parameters
----------
bbox : matplotlib.transforms.Bbox
The bounding box in data coordinates.
"""
self.bbox = bbox
[docs]
def set_relpos(self, relpos):
"""Set the arrow origin relative to the label bounding box.
Parameters
----------
relpos : tuple of float
``(rx, ry)`` where each value is in ``[0, 1]``. See the
`matplotlib annotation guide
<https://matplotlib.org/tutorials/text/annotations.html>`_.
"""
self.relpos = relpos
[docs]
def overlaps(self, that):
"""Check whether this annotation's bounding box overlaps another's.
Two boxes do *not* overlap when one is entirely to the right of the
other, or entirely below the other.
Parameters
----------
that : Annotation
The other annotation to test against.
Returns
-------
bool
``True`` if the bounding boxes overlap.
Raises
------
ValueError
If *that* is not an :class:`Annotation`.
"""
if (not isinstance(that, Annotation)):
raise ValueError("Argument for intersects should be an annotation")
if (self.bbox.xmin >= that.bbox.xmax or that.bbox.xmin >= self.bbox.xmax):
return False
if (self.bbox.ymin >= that.bbox.ymax or that.bbox.ymin >= self.bbox.ymax):
return False
return True
[docs]
def is_within_epsilon_of(self, that, epsilon):
"""Check whether two annotations are closer than a tolerance.
Parameters
----------
that : Annotation
The other annotation.
epsilon : float
Minimum allowed distance between bounding boxes.
Returns
-------
bool
``True`` if the annotations are within *epsilon* of each other.
Raises
------
ValueError
If *that* is not an :class:`Annotation`.
"""
if (not isinstance(that, Annotation)):
raise ValueError("Argument for intersects should be an annotation")
if (self.bbox.xmin - epsilon > that.bbox.xmax or that.bbox.xmin - epsilon > self.bbox.xmax):
return False
if (self.bbox.ymin - epsilon > that.bbox.ymax or that.bbox.ymin - epsilon > self.bbox.ymax):
return False
return True
[docs]
def get_bbox_overlap(self, that, epsilon):
"""Compute the overlap dimensions between two annotation bounding boxes.
Parameters
----------
that : Annotation
The other annotation.
epsilon : float
Buffer added to the height calculation.
Returns
-------
tuple of float
``(width, height)`` of the overlap region.
Raises
------
ValueError
If *that* is not an :class:`Annotation`.
"""
if (not isinstance(that, Annotation)):
raise ValueError("Argument for intersects should be an annotation")
width = min(self.bbox.xmax, that.bbox.xmax) - max(self.bbox.xmin, that.bbox.xmin)
height = min(self.bbox.ymax, that.bbox.ymax) - max(self.bbox.ymin, that.bbox.ymin)
height = abs(that.bbox.ymax - self.bbox.ymin) + epsilon
return (width, height)
[docs]
def get_xy_correction(self, that, epsilon):
"""Compute the correction needed to resolve an overlap.
Parameters
----------
that : Annotation
The other annotation.
epsilon : float
Buffer added to the correction.
Returns
-------
tuple of float
``(dx, dy)`` correction to apply.
Raises
------
ValueError
If *that* is not an :class:`Annotation`.
"""
if (not isinstance(that, Annotation)):
raise ValueError("Argument for intersects should be an annotation")
width = abs(that.bbox.xmax - self.bbox.xmin) + epsilon
height = abs(that.bbox.ymax - self.bbox.ymin) + epsilon
return (width, height)
[docs]
def update_X_with_correction(self, correction):
"""Shift the label in the x direction.
Parameters
----------
correction : tuple of float
``correction[0]`` is added to the x position and bounding box.
"""
self.x += correction[0]
self.bbox.x0 += correction[0]
self.bbox.x1 += correction[0]
[docs]
def update_Y_with_correction(self, correction):
"""Shift the label in the y direction.
Parameters
----------
correction : tuple of float
``correction[1]`` is added to the y position and bounding box.
"""
self.y += correction[1]
self.bbox.y0 += correction[1]
self.bbox.y1 += correction[1]
def __repr__(self):
return f"Annotation '{self.text}' at {super().__repr__()}"
def __str__(self):
return f"Annotation '{self.text}' at {super().__repr__()}"
[docs]
class Era():
"""A highlighted region on the grid representing a period of time.
Eras are drawn as colored rectangles spanning from ``start`` to ``end``
behind the grid squares.
Parameters
----------
text : str
Label for the era.
start : datetime.date
Start date of the era.
end : datetime.date
End date of the era.
color : str or tuple
A matplotlib color.
alpha : float, optional
Opacity of the era rectangle. Default is ``1``.
Examples
--------
>>> from lifegraph import Lifegraph
>>> from datetime import date
>>> g = Lifegraph(date(1990, 1, 1))
>>> g.add_era("College", date(2008, 9, 1), date(2012, 5, 15), color="blue")
"""
def __init__(self, text, start, end, color, alpha=1):
self.text = text
self.start = start
self.end = end
self.color = color
self.alpha = alpha
def __repr__(self):
return f"Era '{self.text}' starting at {self.start}, ending at {self.end}"
def __str__(self):
return f"Era '{self.text}' starting at {self.start}, ending at {self.end}"
[docs]
class EraSpan(Era):
"""A dumbbell-shaped annotation marking a span of time.
Draws circles at the start and end positions connected by a line,
with an optional label.
Parameters
----------
text : str
Label for the era span.
start : datetime.date
Start date.
end : datetime.date
End date.
color : str or tuple
A matplotlib color.
start_marker : Marker or None, optional
Custom marker for the start position.
end_marker : Marker or None, optional
Custom marker for the end position.
Examples
--------
>>> from lifegraph import Lifegraph
>>> from datetime import date
>>> g = Lifegraph(date(1990, 1, 1))
>>> g.add_era_span("Grad school", date(2012, 9, 1), date(2016, 5, 15),
... color="#4423fe")
"""
def __init__(self, text, start, end, color, start_marker=None, end_marker=None):
super().__init__(text, start, end, color)
self.start_marker = start_marker
self.end_marker = end_marker