import uuid
import weakref
from traitlets import HasTraits
from astropy import units as u
from aas_timeseries.traits import (Unicode, CFloat, PositiveCFloat, Opacity, Color,
UnicodeChoice, DataTrait, ColumnTrait, AstropyTime,
AstropyQuantity, Tooltip)
__all__ = ['BaseLayer', 'Markers', 'Line', 'Range', 'VerticalLine',
'VerticalRange', 'HorizontalLine', 'HorizontalRange', 'Text',
'time_to_vega', 'TimeDependentLayer']
DEFAULT_COLOR = '#000000'
[docs]def time_to_vega(time):
"""
Convert an `~astropy.time.Time` object into a string compatible with Vega.
"""
year, month, day, hour, minute, second = time.datetime.timetuple()[:6]
# Note that Vega assumes months are zero-based.
month -= 1
return f'datetime({year}, {month}, {day}, {hour}, {minute}, {second})'
def generate_tooltip(tooltip_option, default_tooltip):
if isinstance(tooltip_option, bool):
if tooltip_option:
return default_tooltip
else:
return None
elif isinstance(tooltip_option, (tuple, list)):
return {'signal': "{" + ', '.join(["'{0}': datum.{0}".format(col) for col in tooltip_option]) + "}"}
elif isinstance(tooltip_option, dict):
return {'signal': "{" + ', '.join(["'{0}': datum.{1}".format(val, key) for key, val in tooltip_option.items()]) + "}"}
[docs]class BaseLayer(HasTraits):
"""
Base class for any layer object
"""
n_uuids = 1
label = Unicode(help='The label to use to designate the layers in the legend.')
# Potential properties that could be implemented: toolTip
def __init__(self, parent=None, *args, **kwargs):
super().__init__(*args, **kwargs)
# NOTE: we use weakref to avoid circular references
self.parent = weakref.ref(parent)
self.uuids = [str(uuid.uuid4()) for i in range(self.n_uuids)]
[docs] def remove(self):
"""
Remove the layer from the visualization.
"""
if self.parent is None or self.parent() is None:
raise Exception(f"Layer '{self.label}' is no longer in a figure/view")
else:
self.parent().remove(self)
self.parent = None
[docs] def to_vega(self):
"""
Convert the layer to its Vega representation.
"""
[docs] def to_mpl(self, ax, yunit=None):
"""
Add the layer to a Matplotlib `~matplotlib.axes.Axes` instance.
"""
@property
def _required_xdata(self):
"""
Return a list of (data, column) tuples giving the data/columns required
for the x-axis of the layer.
"""
return []
@property
def _required_ydata(self):
"""
Return a list of (data, column) tuples giving the data/columns required
for the y-axis of the layer.
"""
return []
@property
def _required_tooltipdata(self):
"""
Return a list of (data, column) tuples giving the data/columns required
for the tool tip of the layer.
"""
return []
[docs]class TimeDependentLayer(BaseLayer):
"""
A common class for all layers that depend on time
"""
time_column = ColumnTrait(None, help='The column to use.')
MARKER_SHAPES = ['circle', 'square', 'cross', 'diamond', 'triangle-up',
'triangle-down', 'triangle-right', 'triangle-left']
[docs]class Markers(TimeDependentLayer):
"""
A set of time series data points represented by markers.
"""
n_uuids = 2
data = DataTrait(help='The time series object containing the data.')
column = ColumnTrait(None, help='The field in the time series containing the data.')
error = ColumnTrait(None, help='The field in the time series '
'containing the data uncertainties.')
shape = UnicodeChoice('circle', help='The symbol shape.', choices=MARKER_SHAPES)
size = PositiveCFloat(20, help='The area in pixels of the bounding box of the symbols.\n\n'
'Note that this value sets the area of the symbol; the '
'side lengths will increase with the square root of this '
'value.')
color = Color(None, help='The fill color of the symbols.')
opacity = Opacity(1, help='The opacity of the fill color from 0 (transparent) to 1 (opaque).')
edge_color = Color(None, help='The edge color of the symbol.')
edge_opacity = Opacity(0.2, help='The opacity of the edge color from 0 (transparent) to 1 (opaque).')
edge_width = PositiveCFloat(0, help='The thickness of the edge, in pixels.')
error_width = PositiveCFloat(1, help='The width of the error bar, in pixels.')
tooltip = Tooltip(True, help='Whether to show a tooltip (`False` or '
'`True`). Can also be set to a list of data '
'columns to show, or a dictionary mapping the '
'display name to the column name.')
[docs] def to_vega(self, yunit=None):
default_tooltip = {'signal': "{{'{0}': datum.{0}, '{1}': datum.{1}}}".format(self.time_column, self.column)}
# The main markers
vega = [{'type': 'symbol',
'name': self.uuids[0],
'description': self.label,
'clip': True,
'from': {'data': self.data.uuid},
'encode': {'enter': {'x': {'scale': 'xscale', 'field': self.time_column},
'y': {'scale': 'yscale', 'field': self.column},
'shape': {'value': self.shape}},
'update': {'shape': {'value': self.shape},
'size': {'value': self.size},
'fill': {'value': self.color or DEFAULT_COLOR},
'fillOpacity': {'value': self.opacity},
'stroke': {'value': self.edge_color or DEFAULT_COLOR},
'strokeOpacity': {'value': self.edge_opacity},
'strokeWidth': {'value': self.edge_width}}}}]
if self.tooltip:
vega[0]['encode']['hover'] = {'size': {'value': self.size * 4},
'tooltip': generate_tooltip(self.tooltip, default_tooltip)}
# The error bars (if requested)
if self.error:
vega.append({'type': 'rect',
'name': self.uuids[1],
'description': self.label,
'clip': True,
'from': {'data': self.data.uuid},
'encode': {'enter': {'x': {'scale': 'xscale', 'field': self.time_column},
'y': {'scale': 'yscale', 'signal': f"datum['{self.column}'] - datum['{self.error}']"},
'y2': {'scale': 'yscale', 'signal': f"datum['{self.column}'] + datum['{self.error}']"}},
'update': {'shape': {'value': self.shape},
'width': {'value': self.error_width},
'fill': {'value': self.color or DEFAULT_COLOR},
'fillOpacity': {'value': self.opacity},
'stroke': {'value': self.edge_color or DEFAULT_COLOR},
'strokeOpacity': {'value': self.edge_opacity},
'strokeWidth': {'value': self.edge_width}}}})
return vega
[docs] def to_mpl(self, ax, yunit=None):
x = self.data.time_series[self.time_column]
y = self.data.column_to_values(self.column, yunit)
ax.scatter(x, y, s=self.size / 2,
color=self.color or DEFAULT_COLOR,
alpha=self.opacity)
if self.error:
yerr = self.data.column_to_values(self.error, yunit)
ax.errorbar(x, y, yerr=yerr, fmt='none',
color=self.color or DEFAULT_COLOR,
alpha=self.opacity, linewidth=self.error_width)
@property
def _required_xdata(self):
return [(self.data, self.time_column)]
@property
def _required_ydata(self):
return [(self.data, self.column), (self.data, self.error)]
@property
def _required_tooltipdata(self):
if isinstance(self.tooltip, bool):
if self.tooltip:
return self._required_xdata + self._required_ydata
else:
return []
elif isinstance(self.tooltip, (tuple, list, dict)):
return [(self.data, col) for col in self.tooltip]
[docs]class Line(TimeDependentLayer):
"""
A set of time series data points connected by a line.
"""
data = DataTrait(help='The time series object containing the data.')
column = ColumnTrait(None, help='The field in the time series containing the data.')
width = PositiveCFloat(1, help='The width of the line, in pixels.')
color = Color(None, help='The color of the line.')
opacity = Opacity(1, help='The opacity of the line from 0 (transparent) to 1 (opaque).')
[docs] def to_vega(self, yunit=None):
vega = {'type': 'line',
'name': self.uuids[0],
'description': self.label,
'clip': True,
'from': {'data': self.data.uuid},
'encode': {'enter': {'x': {'scale': 'xscale', 'field': self.time_column},
'y': {'scale': 'yscale', 'field': self.column},
'stroke': {'value': self.color or DEFAULT_COLOR},
'strokeOpacity': {'value': self.opacity},
'strokeWidth': {'value': self.width}}}}
return [vega]
[docs] def to_mpl(self, ax, yunit=None):
x = self.data.time_series[self.time_column]
y = self.data.column_to_values(self.column, yunit)
ax.plot(x, y, '-',
linewidth=self.width,
color=self.color or DEFAULT_COLOR,
alpha=self.opacity)
@property
def _required_xdata(self):
return [(self.data, self.time_column)]
@property
def _required_ydata(self):
return [(self.data, self.column)]
[docs]class Range(TimeDependentLayer):
"""
An interval defined by lower and upper values as a function of time.
"""
data = DataTrait(help='The time series object containing the data.')
column_lower = ColumnTrait(None, help='The field in the time series containing the lower value of the data range.')
column_upper = ColumnTrait(None, help='The field in the time series containing the upper value of the data range.')
color = Color(None, help='The fill color of the range.')
opacity = Opacity(0.2, help='The opacity of the fill color from 0 (transparent) to 1 (opaque).')
edge_color = Color(None, help='The edge color of the range.')
edge_opacity = Opacity(0.2, help='The opacity of the edge color from 0 (transparent) to 1 (opaque).')
edge_width = PositiveCFloat(0, help='The thickness of the edge, in pixels.')
# Potential properties that could be implemented: strokeCap, strokeDash
[docs] def to_vega(self, yunit=None):
vega = {'type': 'area',
'name': self.uuids[0],
'description': self.label,
'clip': True,
'from': {'data': self.data.uuid},
'encode': {'enter': {'x': {'scale': 'xscale', 'field': self.time_column},
'y': {'scale': 'yscale', 'field': self.column_lower},
'y2': {'scale': 'yscale', 'field': self.column_upper},
'fill': {'value': self.color or DEFAULT_COLOR},
'fillOpacity': {'value': self.opacity},
'stroke': {'value': self.edge_color or DEFAULT_COLOR},
'strokeOpacity': {'value': self.edge_opacity},
'strokeWidth': {'value': self.edge_width}}}}
return [vega]
[docs] def to_mpl(self, ax, yunit=None):
x = self.data.time_series[self.time_column]
y1 = self.data.column_to_values(self.column_lower, yunit)
y2 = self.data.column_to_values(self.column_upper, yunit)
ax.fill_between(x, y1, y2,
color=self.color or DEFAULT_COLOR,
alpha=self.opacity)
@property
def _required_xdata(self):
return [(self.data, self.time_column)]
@property
def _required_ydata(self):
return [(self.data, self.column_lower), (self.data, self.column_upper)]
[docs]class VerticalLine(BaseLayer):
"""
A vertical line at a specific time.
"""
time = AstropyTime(help='The date/time at which the vertical line is shown.')
width = PositiveCFloat(1, help='The width of the line, in pixels.')
color = Color(None, help='The color of the line.')
opacity = Opacity(1, help='The opacity of the line from 0 (transparent) to 1 (opaque).')
# Potential properties that could be implemented: strokeCap, strokeDash
[docs] def to_vega(self, yunit=None):
vega = {'type': 'rule',
'name': self.uuids[0],
'description': self.label,
'clip': True,
'encode': {'enter': {'x': {'scale': 'xscale', 'signal': time_to_vega(self.time)},
'y': {'value': 0},
'y2': {'field': {'group': 'height'}},
'strokeWidth': {'value': self.width},
'stroke': {'value': self.color or DEFAULT_COLOR},
'strokeOpacity': {'value': self.opacity}}}}
return [vega]
[docs] def to_mpl(self, ax, yunit=None):
ax.axvline(self.time,
linewidth=self.width,
color=self.color or DEFAULT_COLOR,
alpha=self.opacity)
[docs]class VerticalRange(BaseLayer):
"""
A continuous range specified by a lower and upper time.
"""
time_lower = AstropyTime(help='The date/time at which the range starts.')
time_upper = AstropyTime(help='The date/time at which the range ends.')
color = Color(None, help='The fill color of the range.')
opacity = Opacity(0.2, help='The opacity of the fill color from 0 (transparent) to 1 (opaque).')
edge_color = Color(None, help='The edge color of the range.')
edge_opacity = Opacity(0.2, help='The opacity of the edge color from 0 (transparent) to 1 (opaque).')
edge_width = PositiveCFloat(0, help='The thickness of the edge, in pixels.')
# Potential properties that could be implemented: strokeCap, strokeDash
[docs] def to_vega(self, yunit=None):
vega = {'type': 'rect',
'name': self.uuids[0],
'description': self.label,
'clip': True,
'encode': {'enter': {'x': {'scale': 'xscale', 'signal': time_to_vega(self.time_lower)},
'x2': {'scale': 'xscale', 'signal': time_to_vega(self.time_upper)},
'y': {'value': 0},
'y2': {'field': {'group': 'height'}},
'fill': {'value': self.color or DEFAULT_COLOR},
'fillOpacity': {'value': self.opacity},
'stroke': {'value': self.edge_color or DEFAULT_COLOR},
'strokeOpacity': {'value': self.edge_opacity},
'strokeWidth': {'value': self.edge_width}}}}
return [vega]
[docs] def to_mpl(self, ax, yunit=None):
ax.fill_betweenx([-1e30, 1e30], self.time_lower, self.time_upper,
color=self.color or DEFAULT_COLOR,
alpha=self.opacity)
[docs]class HorizontalLine(BaseLayer):
"""
A horizontal line at a specific y value.
"""
# TODO: validate value and allow it to be a quantity
value = AstropyQuantity(help='The y value at which the horizontal line is shown.')
width = PositiveCFloat(1, help='The width of the line, in pixels.')
color = Color(None, help='The color of the line.')
opacity = Opacity(1, help='The opacity of the line from 0 (transparent) to 1 (opaque).')
# Potential properties that could be implemented: strokeCap, strokeDash
[docs] def to_vega(self, yunit=None):
if yunit is None:
yunit = u.one
value = self.value.to_value(yunit)
vega = {'type': 'rule',
'name': self.uuids[0],
'description': self.label,
'clip': True,
'encode': {'enter': {'x': {'value': 0},
'x2': {'field': {'group': 'width'}},
'y': {'scale': 'yscale', 'value': float(value)},
'strokeWidth': {'value': self.width},
'stroke': {'value': self.color or DEFAULT_COLOR},
'strokeOpacity': {'value': self.opacity}}}}
return [vega]
[docs] def to_mpl(self, ax, yunit=None):
if yunit is None:
yunit = u.one
ax.axhline(self.value.to_value(yunit),
linewidth=self.width,
color=self.color or DEFAULT_COLOR,
alpha=self.opacity)
[docs]class HorizontalRange(BaseLayer):
"""
A continuous range specified by a lower and upper value.
"""
value_lower = AstropyQuantity(help='The value at which the range starts.')
value_upper = AstropyQuantity(help='The value at which the range ends.')
color = Color(None, help='The fill color of the range.')
opacity = Opacity(0.2, help='The opacity of the fill color from 0 (transparent) to 1 (opaque).')
edge_color = Color(None, help='The edge color of the range.')
edge_opacity = Opacity(0.2, help='The opacity of the edge color from 0 (transparent) to 1 (opaque).')
edge_width = PositiveCFloat(0, help='The thickness of the edge, in pixels.')
# Potential properties that could be implemented: strokeCap, strokeDash
[docs] def to_vega(self, yunit=None):
if yunit is None:
yunit = u.one
value_lower = self.value_lower.to_value(yunit)
value_upper = self.value_upper.to_value(yunit)
vega = {'type': 'rect',
'name': self.uuids[0],
'description': self.label,
'clip': True,
'encode': {'enter': {'x': {'value': 0},
'x2': {'field': {'group': 'width'}},
'y': {'scale': 'yscale', 'value': float(value_lower)},
'y2': {'scale': 'yscale', 'value': float(value_upper)},
'fill': {'value': self.color or DEFAULT_COLOR},
'fillOpacity': {'value': self.opacity},
'stroke': {'value': self.edge_color or DEFAULT_COLOR},
'strokeOpacity': {'value': self.edge_opacity},
'strokeWidth': {'value': self.edge_width}}}}
return [vega]
[docs] def to_mpl(self, ax, yunit=None):
value_lower = self.value_lower.to_value(yunit)
value_upper = self.value_upper.to_value(yunit)
ax.fill_between([-1e30, 1e30], value_lower, value_upper,
color=self.color or DEFAULT_COLOR,
alpha=self.opacity)
[docs]class Text(BaseLayer):
"""
A text label.
"""
text = Unicode(help='The text label to show.')
time = AstropyTime(help='The date/time at which the text is shown.')
value = AstropyQuantity(help='The y value at which the text is shown.')
weight = UnicodeChoice('normal', help='The weight of the text.',
choices=['normal', 'bold'])
baseline = UnicodeChoice('alphabetic', help='The vertical text baseline.',
choices=['alphabetic', 'top', 'middle', 'bottom'])
align = UnicodeChoice('left', help='The horizontal text alignment.',
choices=['left', 'center', 'right'])
angle = CFloat(0, help='The rotation angle of the text in degrees (default 0).')
color = Color(None, help='The color of the text.')
opacity = Opacity(1, help='The opacity of the text from 0 (transparent) to 1 (opaque).')
[docs] def to_vega(self, yunit=None):
if yunit is None:
yunit = u.one
value = self.value.to_value(yunit)
vega = {'type': 'text',
'name': self.uuids[0],
'description': self.label,
'clip': True,
'encode': {'enter': {'x': {'scale': 'xscale', 'signal': time_to_vega(self.time)},
'y': {'scale': 'yscale', 'value': float(value)},
'text': {'value': self.text},
'fill': {'value': self.color or DEFAULT_COLOR},
'fillOpacity': {'value': self.opacity},
'fontWeigth': {'value': self.weight},
'baseline': {'value': self.baseline},
'align': {'value': self.align},
'angle': {'value': self.angle}}}}
return [vega]
[docs] def to_mpl(self, ax, yunit=None):
if yunit is None:
yunit = u.one
x = self.time
value = self.value.to_value(yunit)
ax.text(x, value, self.text,
color=self.color or DEFAULT_COLOR,
alpha=self.opacity)