from typing import Dict, Tuple, List, Optional
import matplotlib.pyplot as plt
from matplotlib.patches import Wedge
import numpy as np
from sunburst.calc import (
complete_pv,
complete_paths,
structure_paths,
calculate_angles,
Angles,
)
from sunburst.path import Path
[docs]class SunburstPlot(object):
"""The central class of the suburst package.
Usage:
- Initialize SunburstPlot object
- Redefine attributes or methods
- Run SunburstPlot.plot()
All of the following attributes can be set
- as keyword argument during initialisation
- after initialization, but before calling :py:meth:`.prepare_data`
or :py:meth:`.plot`.
Arguments starting with the prefix `base` can also be defined
dynamically (usually depending on the path to which a wedge correspond)
by redefining the method with the same name without the prefix `base`.
E.g. the attribute :py:attr:`.self.base_wedge_width` corresponds to the
initialization keyword argument `base_wedge_width` and to the method
:py:meth:`.wedge_width`: ::
def wedge_width(self, path: Path) -> float:
return self.base_ring_width
Almost all of the methods can (or are meant to) be redefined.
Those which should be handled more carefully are designated by a leading
underscore.
Attributes:
pathvalues: pathvalues of type
MutuableMapping[Path, float]
axes:
origin: Coordinates of the center of the pie chart as tuple
cmap: Colormap: Controls the coloring based on the angle.
base_ring_width: Default width of each ring/wedge.
base_edge_color: Default edge color of the wedges.
base_line_width: Default line width of the wedges.
plot_center: Plot a circle in the middle corresponding to the
total of all paths.
plot_minimal_angle: Plot only wedges with an angle bigger
than plot_minimal_angle
label_minimal_angle: Only label wedges with an angle bigger than
this value
order: string with syntax keep|value|key [reverse], e.g.
"key reverse" (default) or "value" or "reverse"
controlling in which order the wedges will be created
- keep: Keep the order of the supplied pathvalues
dictionary (for this to work, use a dictionary
subclass that supports ordering, i.e.
collections.OrderedDict). This is the default, but
explicitly stating it, will warn you if you supply a
normal dict for pathvalues.
- value: Sort values from small to big
- key: Sort paths alphabetically
- reversed: take the order specified by one of the above
options (or none) and reverse it.
base_textbox_props: Properties of the textbox (bbox) that annotating
the wedge corresponding to `path`. See
http://matplotlib.org/users/annotations_guide.html
"""
def __init__(
self,
input_pv: Dict[Path, float],
axes: Optional[plt.Axes] = None, # todo: make optional argument?
origin=(0.0, 0.0),
cmap=plt.get_cmap("autumn"),
base_ring_width=0.4,
base_edge_color=(0, 0, 0, 1),
base_line_width=0.75,
plot_center=False,
plot_minimal_angle=0,
label_minimal_angle=0,
order="value reverse",
base_textbox_props=None,
):
if axes is None:
axes = plt.gca()
# *** Input & Config *** (emph)
self.input_pv = input_pv
self.axes = axes
self.cmap = cmap
self.origin = origin
self.base_wedge_width = base_ring_width
self.base_edge_color = base_edge_color
self.base_line_width = base_line_width
self.plot_center = plot_center
self.plot_minimal_angle = plot_minimal_angle
self.label_minimal_angle = label_minimal_angle
self.order = order
self.base_textbox_props = base_textbox_props
if not base_textbox_props:
self.base_textbox_props = dict(
boxstyle="round, pad=0.2",
fc=(1, 1, 1, 0.8),
ec=(0.4, 0.4, 0.4, 1),
lw=0.0,
)
# *** Variables used for computation *** (emph)
self._completed_pv = {} # type: Dict[Path, float]
self._completed_paths = [] # type: List[Path]
self._max_level = 0 # type: int
self._structured_paths = [] # type: List[List[List[Path]]]
self._angles = {} # type: Dict[Path, Angles]
# *** "Output" *** (emph)
self.wedges = {} # type: Dict[Path, Wedge]
[docs] def prepare_data(self) -> None:
"""Sets up auxiliary variables.
Most of the actual computations are defined as functions for better
testing (in file :file:`calc.py`).
"""
# todo: maybe join together with self.plot?
# todo maybe split up more....
# even if self.input_pv is of type OrderedDict,
# self._completed_pv will be a normal (unsorted) dictionary
self._completed_pv = complete_pv(self.input_pv)
# Complete the list of paths with possible missing ancestors:
# Do not take the keys of self._completed_pv, because they will
# not be sorted anymore. The sorting of self._completed_paths
# induces the sorting of self._structured_paths which is
# responsible for the order of the wedges.
ordered_paths: List[Path] = []
if self.order:
order_options = set(self.order.split(" "))
else:
order_options = set()
# check supplied order options: (emph)
lonely_order_options = {"value", "key", "keep"}
allowed_order_options = lonely_order_options | {"reverse"}
if not order_options <= allowed_order_options:
raise ValueError(
"'order' option must consist of a subset of "
"strings from {}, joined by "
"spaces.".format(allowed_order_options)
)
if not len(order_options & lonely_order_options) <= 1:
raise ValueError(
"Only one of the options {} "
"allowed.".format(lonely_order_options)
)
if not order_options:
ordered_paths = list(self.input_pv.keys())
elif "keep" in self.order:
ordered_paths = list(self.input_pv.keys())
if type(self.input_pv) is dict:
# do not use isinstance (because this would yield true for
# a OrderedDict or any other (possibly ordered subclass of dict
# as well)
print(
"Warning: Looks like you want to keep the order of your"
"input pathvalues, but pathvalues are of type dict "
"which does keep record of the order of its items."
)
elif "value" in self.order:
ordered_paths = sorted(
self._completed_pv.keys(),
key=lambda key: self._completed_pv[key],
)
elif "key" in self.order:
ordered_paths = sorted(self._completed_pv.keys())
if "reverse" in self.order:
ordered_paths = list(reversed(ordered_paths))
self._completed_paths = complete_paths(ordered_paths)
self._max_level = max((len(path) for path in self._completed_paths))
# the order of self._structured paths determines the
# arrangement of the wedges afterwards.
self._structured_paths = structure_paths(self._completed_paths)
self._angles = calculate_angles(
self._structured_paths, self._completed_pv
)
for path in self._completed_paths:
if self.plot_center or len(path) >= 1:
angle = self._angles[path].theta2 - self._angles[path].theta1
if len(path) == 0 or angle > self.plot_minimal_angle:
self.wedges[path] = self.wedge(path)
[docs] def _is_outmost(self, path: Path) -> bool:
"""Returns True if the wedge corresponding to `path` is the
"outmost" wedge, i.e. there is no descendant of `path`.
"""
# is there a descendant of path?
# to speed up things we use self._structured_paths
level = len(path)
if level == self._max_level:
return True
for group in self._structured_paths[level + 1]:
for p in group:
if p.startswith(path):
return False
# all paths in this group start the same
continue
return True
# noinspection PyUnusedLocal
[docs] def wedge_width(self, path: Path) -> float:
"""The width of the wedge corresponding to `path`.
This method is meant to be redefined. Per default it only returns
:py:attr:`base_wedge_width`.
"""
return self.base_wedge_width
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
[docs] def wedge_spacing(self, path: Path) -> Tuple[float, float]:
"""The radial space before the wedge corresponding to `path` and
after.
E.g. if `wedge_space(some/path) = 0.1, 0.2`, then this shifts the wedge
corresponding to some/path by 0.1 radially away from the center and all
wedges corresponding to ancestors of some/path (e.g. some/path/child,
some/path/child/grandchild) by 0.2 radially away from the center.
"""
return 0, 0
[docs] def _wedge_outer_radius(self, path: Path) -> float:
"""The outer radius of the wedge corresponding to a path.
Instead of redefining this method, adapt :py:meth:`.wedge_width` resp.
:py:meth:`.wedge_width`.
"""
return self._wedge_inner_radius(path) + self.wedge_width(path)
[docs] def _wedge_inner_radius(self, path: Path) -> float:
"""The inner radius of the wedge corresponding to a path.
Instead of redefining this method, adapt :py:meth:`.wedge_width` resp.
:py:meth:`.wedge_width`.
"""
start = 0 if self.plot_center else 1
ancestors = [path[:i] for i in range(start, len(path))]
return (
sum(
self.wedge_width(ancestor) + sum(self.wedge_spacing(ancestor))
for ancestor in ancestors
)
+ self.wedge_spacing(path)[0]
)
[docs] def _wedge_mid_radius(self, path: Path) -> float:
"""The radius of the middle of the wedge corresponding to a path.
Instead of redefining this method, adapt :py:meth:`.wedge_width` resp.
:py:meth:`.wedge_width`.
"""
return (
self._wedge_outer_radius(path) + self._wedge_inner_radius(path)
) / 2
# noinspection PyUnusedLocal
[docs] def edge_color(self, path: Path) -> Tuple[float, float, float, float]:
"""The line color of the wedge corresponding to `path`.
This method is meant to be redefined. Per default it only returns
:py:attr:`base_edge_color`."""
return self.base_edge_color
# noinspection PyUnusedLocal
[docs] def line_width(self, path: Path) -> float:
"""The line width of the wedge corresponding to `path`.
This method is meant to be redefined. Per default it only returns
:py:attr:`base_line_width`."""
return self.base_line_width
[docs] def face_color(self, path: Path) -> Tuple[float, float, float, float]:
"""The color of the wedge corresponding to `path`.
Per default, the color is calculated by the value of
:py:attr:`.cmap` at the mid-angle of the wedge. The color of an
inner circle (corresponding to an empty `path`) is always set to be
white. Colors a slightly brightened with increasing level.
"""
# take the middle angle, else the first wedge will have the same color
# as its parent or at least make sure, that we don't get the value 0
# (white or black in a lot of color maps)
if len(path) == 0:
color: List[float] = [1, 1, 1, 1]
else:
angle = (self._angles[path].theta1 + self._angles[path].theta2) / 2
color = list(self.cmap(angle / 360)) # type: ignore
# make the color get lighter with increasing level
for i in range(3):
color[i] += (
(1 - color[i]) * 0.7 * (len(path) / (self._max_level + 1))
)
# somehow the following seems to be ignored yet
# color[3] = 1 - (len(path) - 1)**3 / (self._max_level**3 )
# print(color[3])
return tuple(color) # type: ignore
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
[docs] def alpha(self, path: Path) -> float:
"""The alpha value of the wedge corresponding to `path`.
This method is meant to be redefined. Per default it only returns 1.
"""
return 1
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
[docs] def textbox_props(self, path: Path, text_type: str) -> Dict:
"""Properties of the textbox (bbox) that annotating the wedge
corresponding to `path`.
This method is meant to be redefined. Per default it is independent
of the arguments.
Args:
path (Path): path
text_type (str): Position type of the text box:
"tangential", "radial", etc.
Returns:
Dictionary of keyword properties for the bbox option of the
:py:meth:`matplotlib.pyplot.text` function.
See http://matplotlib.org/users/annotations_guide.html
"""
return self.base_textbox_props
# noinspection PyMethodMayBeStatic
[docs] def format_path_text(self, path) -> str:
"""Returns a string representation for `path` which is used to
annotate the corresponding wedge.
"""
return path[-1] if path else ""
# noinspection PyMethodMayBeStatic
[docs] def format_value_text(self, value: float) -> str:
"""Returns a string representation of the value corresponding to
`path` which is used to annotate the wedge corresponding to
`path`.
"""
return "{0:.2f}".format(value)
# todo:
# hours = int(value/60)
# minutes = int(value - hours * 60)
# if hours:
# return "({}h{})".format(hours, minutes)
# else:
# return "({})".format(minutes)
[docs] def format_text(self, path: Path) -> str:
"""Returns a string used annotate the wedge corresponding to `path`.
Most modifications of the annotations can be made by redefining
:py:meth:`.format_path_text` or :py:meth:`.format_value_text`, this
method combines both of those methods.
"""
path_text = self.format_path_text(path)
value_text = self.format_value_text(self._completed_pv[path])
if path_text and value_text:
return "{} ({})".format(path_text, value_text)
return path_text
[docs] def _radial_text(self, path: Path) -> None:
"""Adds a radially rotated annotation for the wedge corresponding to
`path` to the axes.
"""
theta1, theta2 = self._angles[path].theta1, self._angles[path].theta2
angle = (theta1 + theta2) / 2
radius = self._wedge_mid_radius(path)
if self._is_outmost(path):
radius = self._wedge_inner_radius(path)
mid_x = self.origin[0] + radius * np.cos(np.deg2rad(angle))
mid_y = self.origin[1] + radius * np.sin(np.deg2rad(angle))
if 0 <= angle < 90:
rotation = angle
elif 90 <= angle < 270:
rotation = angle - 180
elif 270 < angle <= 360:
rotation = angle - 360
# note that a rotation around 360 flips the text, so
# the -360 does matter.
else:
raise ValueError
# If the wedge is on the outmos layer, we can move the text farther out
# to avoid clashes with text from levels below
if self._is_outmost(path):
if 0 <= angle < 90:
va = "bottom"
ha = "left"
elif 90 <= angle <= 180:
va = "bottom"
ha = "right"
elif 180 <= angle <= 270:
va = "top"
ha = "right"
elif 270 <= angle <= 360:
va = "top"
ha = "left"
else:
raise ValueError
else:
ha = "center"
va = "center"
text = self.format_text(path)
self.axes.text(
mid_x,
mid_y,
text,
ha=ha,
va=va,
rotation=rotation,
bbox=self.textbox_props(path, "radial"),
)
[docs] def _tangential_text(self, path: Path) -> None:
"""Adds a tangentially rotated annotation for the wedge corresponding to
`path` to the axes.
"""
theta1, theta2 = self._angles[path].theta1, self._angles[path].theta2
angle = (theta1 + theta2) / 2
radius = self._wedge_mid_radius(path)
mid_x = self.origin[0] + radius * np.cos(np.deg2rad(angle))
mid_y = self.origin[1] + radius * np.sin(np.deg2rad(angle))
if 0 <= angle < 90:
rotation = angle - 90
elif 90 <= angle < 180:
rotation = angle - 90
elif 180 <= angle < 270:
rotation = angle - 270
elif 270 <= angle < 360:
rotation = angle - 270
else:
raise ValueError
text = self.format_text(path)
self.axes.text(
mid_x,
mid_y,
text,
ha="center",
va="center",
rotation=rotation,
bbox=self.textbox_props(path, "tangential"),
)
[docs] def _add_annotation(self, path):
"""Adds annotation to the wedge corresponding to `path`."""
angle = self._angles[path].theta2 - self._angles[path].theta1
if not angle > self.label_minimal_angle:
# no text
return
# fixme: replace with less random criteria!
if len(path) * angle > 90:
self._tangential_text(path)
else:
self._radial_text(path)
[docs] def plot(self, setup_axes=False, interactive=False) -> None:
"""Method that combines several others, to do all necessary
preparations and add the plot to the axes :py:attr:`self.axes`.
Args:
setup_axes (bool): Does some basic setup for the axes
(autoscale, margins, etc.). It won't always be the perfect setup
but it saves writing a few lines.
interactive (bool): Display label for the wedge under the cursor
only.
"""
if not self.wedges:
# we didn't prepare the data yet
self.prepare_data()
for path, wedge in self.wedges.items():
self.axes.add_patch(wedge)
if not interactive:
self._add_annotation(path)
if setup_axes:
self.axes.autoscale()
self.axes.set_aspect("equal")
self.axes.autoscale_view(True, True, True)
self.axes.axis("off")
self.axes.margins(x=0.1, y=0.1)
if interactive:
def hover(event):
if event.inaxes == self.axes:
found = False
for path in self.wedges:
if not found:
cont, ind = self.wedges[path].contains(event)
else:
cont = False
if cont:
self.wedges[path].set_alpha(0.5)
self.axes.set_title(self.format_text(path))
else:
self.wedges[path].set_alpha(1.0)
self.axes.figure.canvas.draw_idle()
self.axes.figure.canvas.mpl_connect("motion_notify_event", hover)
[docs] def wedge(self, path: Path) -> Wedge:
"""Generates the patches wedge object corresponding to `path`."""
return Wedge(
(self.origin[0], self.origin[1]),
self._wedge_outer_radius(path),
self._angles[path].theta1,
self._angles[path].theta2,
width=self.wedge_width(path),
label=self.format_text(path),
facecolor=self.face_color(path),
edgecolor=self.edge_color(path),
linewidth=self.line_width(path),
fill=True,
alpha=self.alpha(path),
)
# todo: supply rest of the arguments