diff --git a/MANIFEST.in b/MANIFEST.in index 5a1cc9d1b..637454e03 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,5 @@ include chaco/*.h -include chaco/layers/data/*.svg +include chaco/overlays/layers/data/*.svg include chaco/tests/data/PngSuite/*.png include chaco/tools/toolbars/images*.png recursive-include chaco *.pyx diff --git a/chaco/api.py b/chaco/api.py index f5934a961..ffb4dce94 100644 --- a/chaco/api.py +++ b/chaco/api.py @@ -56,8 +56,8 @@ - :class:`~.BandedMapper` - :class:`~.PolarMapper` -Visual Components ------------------ +Visual Components / Overlays +---------------------------- - :class:`~.AbstractPlotRenderer` - :class:`~.AbstractOverlay` @@ -71,15 +71,30 @@ - :class:`~.VPlotContainer` - :class:`~.GridPlotContainer` - :class:`~.Label` -- :class:`~.PlotLabel` -- :class:`~.Legend` -- :class:`~.ToolTip` +- :class:`~.ColorBar` +- :class:`~.AlignedContainerOverlay` +- :class:`~.ColormappedSelectionOverlay` +- :class:`~.ContainerOverlay` +- :class:`~.CoordinateLineOverlay` +- :class:`~.DataBox` - :class:`~.DataLabel` - :class:`~.LassoOverlay` -- :class:`~.ColorBar` -- :class:`~.TextBoxOverlay` +- :class:`~.AbstractCompositeIconRenderer` +- :class:`~.CompositeIconRenderer` +- :class:`~.PlotLabel` - :class:`~.ScatterInspectorOverlay` -- :class:`~.ColormappedSelectionOverlay` +- :func:`~.basic_formatter` +- :func:`~.datetime_formatter` +- :func:`~.date_formatter` +- :class:`~.SimpleInspectorOverlay` +- :func:`~.time_formatter` +- :class:`~.TextBoxOverlay` +- :class:`~.TextGridOverlay` +- :class:`~.ToolTip` +- :class:`~.ImageInspectorOverlay` +- :class:`~.ErrorLayer` +- :class:`~.StatusLayer` +- :class:`~.WarningLayer` - :class:`~.ColormappedScatterPlotView` - :class:`~.ScatterPlotView` @@ -298,7 +313,7 @@ from .plots.horizon_plot import BandedMapper from .polar_mapper import PolarMapper -# Visual components +# Visual components / Overlays from .abstract_plot_renderer import AbstractPlotRenderer from .abstract_overlay import AbstractOverlay from .base_plot_container import BasePlotContainer @@ -323,15 +338,35 @@ pass from .label import Label -from .plot_label import PlotLabel -from .legend import Legend -from .tooltip import ToolTip -from .data_label import DataLabel -from .lasso_overlay import LassoOverlay + +from chaco.overlays.api import ( + AlignedContainerOverlay, + ColormappedSelectionOverlay, + ContainerOverlay, + CoordinateLineOverlay, + DataBox, + DataLabel, + LassoOverlay, + AbstractCompositeIconRenderer, + CompositeIconRenderer, + Legend, + PlotLabel, + ScatterInspectorOverlay, + basic_formatter, + datetime_formatter, + date_formatter, + SimpleInspectorOverlay, + time_formatter, + TextBoxOverlay, + TextGridOverlay, + ToolTip, + ImageInspectorOverlay, + ErrorLayer, + StatusLayer, + WarningLayer, +) + from .plots.color_bar import ColorBar -from .text_box_overlay import TextBoxOverlay -from .scatter_inspector_overlay import ScatterInspectorOverlay -from .colormapped_selection_overlay import ColormappedSelectionOverlay # Renderers from .base_1d_plot import Base1DPlot diff --git a/chaco/colormapped_selection_overlay.py b/chaco/colormapped_selection_overlay.py index 2af0c670e..6bd904dca 100644 --- a/chaco/colormapped_selection_overlay.py +++ b/chaco/colormapped_selection_overlay.py @@ -1,191 +1,22 @@ -""" Defines the ColormappedSelectionOverlay class. -""" -import functools - -from numpy import logical_and - -# Enthought library imports -from traits.api import Any, Bool, Float, Instance, Property, Enum -from traits.observation.events import TraitChangeEvent - -# Local imports -from .abstract_overlay import AbstractOverlay -from .plots.colormapped_scatterplot import ColormappedScatterPlot - - -class ColormappedSelectionOverlay(AbstractOverlay): - """ - Overlays and changes a ColormappedScatterPlot to fade its non-selected - points to a very low alpha. - """ - - #: The ColormappedScatterPlot that this overlay is listening to. - #: By default, it looks at self.component - plot = Property - - #: The amount to fade the unselected points. - fade_alpha = Float(0.15) - - #: The minimum difference, in float percent, between the starting and ending - #: selection values, if range selection mode is enabled - minimum_delta = Float(0.01) - - #: Outline width for selected points. - selected_outline_width = Float(1.0) - #: Outline width for unselected points. - unselected_outline_width = Float(0.0) - - #: The type of selection used by the data source. - selection_type = Enum("range", "mask") - - _plot = Instance(ColormappedScatterPlot) - - _visible = Bool(False) - - _old_alpha = Float - _old_outline_color = Any - _old_line_width = Float(0.0) - - def __init__(self, component=None, **kw): - super().__init__(**kw) - self.component = component - - def overlay(self, component, gc, view_bounds=None, mode="normal"): - """Draws this component overlaid on another component. - - Implements AbstractOverlay. - """ - if not self._visible: - return - - plot = self.plot - datasource = plot.color_data - - if self.selection_type == "range": - selections = datasource.metadata["selections"] - - if selections is not None and len(selections) == 0: - return - - low, high = selections - if abs(high - low) / abs(high + low) < self.minimum_delta: - return - - # Mask the data with just the points falling within the data - # range selected on the colorbar - data_pts = datasource.get_data() - mask = (data_pts >= low) & (data_pts <= high) - - elif self.selection_type == "mask": - mask = functools.reduce( - logical_and, datasource.metadata["selection_masks"] - ) - if sum(mask) < 2: - return - - datasource.set_mask(mask) - - # Store the current plot color settings before overwriting them - fade_outline_color = plot.outline_color_ - - # Overwrite marker outline color and fill alpha settings of - # the plot, then manually invoke the plot to draw onto the GC. - plot.outline_color = list(self._old_outline_color[:3]) + [1.0] - plot.fill_alpha = 1.0 - plot.line_width = self.selected_outline_width - plot._draw_plot(gc, view_bounds, mode) - - # Restore the plot's previous color settings and data mask. - plot.fill_alpha = self.fade_alpha - plot.outline_color = fade_outline_color - plot.line_width = self.unselected_outline_width - datasource.remove_mask() - - def _component_changed(self, old, new): - if old: - old.observe( - self.datasource_change_handler, "color_data", remove=True - ) - if new: - new.observe(self.datasource_change_handler, "color_data") - self._old_alpha = new.fill_alpha - self._old_outline_color = new.outline_color - self._old_line_width = new.line_width - - self.datasource_change_handler( - TraitChangeEvent( - object=new, name="color_data", old=None, new=new.color_data - ) - ) - - def datasource_change_handler(self, event): - obj, name, old, new = (event.object, event.name, event.old, event.new) - - if old: - old.observe( - self.selection_change_handler, "metadata_changed", remove=True - ) - if new: - new.observe(self.selection_change_handler, "metadata_changed") - self.selection_change_handler( - TraitChangeEvent( - object=new, - name="metadata_changed", - old=None, - new=new.metadata, - ) - ) - - def selection_change_handler(self, event): - obj, name, old, new = (event.object, event.name, event.old, event.new) - - if self.selection_type == "range": - selection_key = "selections" - elif self.selection_type == "mask": - selection_key = "selection_masks" - - if ( - type(new) == dict - and new.get(selection_key, None) is not None - and len(new[selection_key]) > 0 - ): - if not self._visible: - # We have a new selection, so replace the colors on the plot with the - # faded alpha and colors - plot = self.plot - - # Save the line width and set it to zero for the unselected points - self._old_line_width = plot.line_width - plot.line_width = self.unselected_outline_width - # Save the outline color and set it to the faded version - self._old_outline_color = plot.outline_color_ - outline_color = list(plot.outline_color_) - if len(outline_color) == 3: - outline_color += [self.fade_alpha] - else: - outline_color[3] = self.fade_alpha - plot.outline_color = outline_color - - # Save the alpha value and set it to a faded version - self._old_alpha = plot.fill_alpha - plot.fill_alpha = self.fade_alpha - - self.plot.invalidate_draw() - self._visible = True - else: - self.plot.fill_alpha = self._old_alpha - self.plot.outline_color = self._old_outline_color - self.plot.line_width = self._old_line_width - self.plot.invalidate_draw() - self._visible = False - - self.plot.request_redraw() - - def _get_plot(self): - if self._plot is not None: - return self._plot - else: - return self.component - - def _set_plot(self, val): - self._plot = val +# (C) Copyright 2006-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +import warnings + +from chaco.overlays.colormapped_selection_overlay import ( # noqa: F401 + ColormappedSelectionOverlay +) + +warnings.warn( + "Importing ColormappedSelectionOverlay from this module is deprecated. " + "Please use chaco.api or chaco.overlays.api instead. This module will be " + "removed in the next major release.", + DeprecationWarning, + stacklevel=2, +) diff --git a/chaco/data_label.py b/chaco/data_label.py index c92df4571..c81b763f8 100644 --- a/chaco/data_label.py +++ b/chaco/data_label.py @@ -1,556 +1,20 @@ -""" Defines the DataLabel class and related trait and function. -""" -# Major library imports -from math import sqrt -from numpy import array, asarray, inf -from numpy.linalg import norm - -# Enthought library imports -from traits.api import Any, ArrayOrNone, Bool, Enum, Float, Int, List, \ - Str, Tuple, Trait, observe, Property -from enable.api import ColorTrait, MarkerTrait - -# Local, relative imports -from .plots.scatterplot import render_markers -from .tooltip import ToolTip - - -# Specifies the position of a label relative to its target. This can -# be one of the text strings indicated, or a tuple or list of floats -# representing the (x_offset, y_offset) in screen space of the label's -# lower left corner. -LabelPositionTrait = Trait("top right", - Enum("bottom", "left", "right", "top", - "top right", "top left", - "bottom left", "bottom right"), - Tuple, List) - - -def draw_arrow(gc, pt1, pt2, color, arrowhead_size=10.0, offset1=0, - offset2=0, arrow=None, minlen=0, maxlen=inf): - """ Renders an arrow from *pt1* to *pt2*. If gc is None, then just returns - the arrow object. - - Parameters - ========== - gc : graphics context - where to render the arrow - pt1 : point - the origin of the arrow - pt2 : point - where the arrow is pointing - color : a 3- or 4-tuple of color value - the color to use for the arrow stem and head - arrowhead_size : number - screen units corresponding to the length of the arrowhead - offset1 : number - the amount of space from the start of the arrow to pt1 - offset2 : number - the amount of space from the tip of the arrow to pt2 - arrow : object - an opaque object returned by previous calls to draw_arrow. If this - argument is provided, all other arguments (except gc) are ignored - minlen: number or None - the minimum length of the arrow; if the arrow is shorter than this, - it will not be drawn - maxlen: number or None - the maximum length of the arrow; if the arrow is longer than this, it - will not be drawn - - Returns - ======= - An 'arrow' (opaque object) which can be passed in to subsequent - calls to this method to short-circuit some of the computation. - Even if an arrow is not drawn (due to minlen/maxlen restrictions), - an arrow will be returned. - """ - - if arrow is None: - pt1 = asarray(pt1) - pt2 = asarray(pt2) - - unit_vec = pt2 - pt1 - unit_vec /= norm(unit_vec) - - if unit_vec[0] == 0: - perp_vec = array((0.3 * arrowhead_size, 0)) - elif unit_vec[1] == 0: - perp_vec = array((0, 0.3 * arrowhead_size)) - else: - slope = unit_vec[1] / unit_vec[0] - perp_slope = -1 / slope - perp_vec = array((1.0, perp_slope)) - perp_vec *= 0.3 * arrowhead_size / norm(perp_vec) - - pt1 = pt1 + offset1 * unit_vec - pt2 = pt2 - offset2 * unit_vec - - arrowhead_l = pt2 - (arrowhead_size * unit_vec + perp_vec) - arrowhead_r = pt2 - (arrowhead_size * unit_vec - perp_vec) - arrow = (pt1, pt2, arrowhead_l, arrowhead_r) - else: - pt1, pt2, arrowhead_l, arrowhead_r = arrow - - arrowlen = norm(pt2 - pt1) - if arrowlen < minlen or arrowlen > maxlen: - # This is the easiest way to circumvent the actual drawing - gc = None - - if gc is not None: - gc.set_stroke_color(color) - gc.set_fill_color(color) - gc.begin_path() - gc.move_to(*pt1) - gc.line_to(*pt2) - gc.stroke_path() - gc.move_to(*pt2) - gc.line_to(*arrowhead_l) - gc.line_to(*arrowhead_r) - gc.fill_path() - return arrow - - -def find_region(px, py, x, y, x2, y2): - """Classify the location of the point (px, py) relative to a rectangle. - - (x, y) and (x2, y2) are the lower-left and upper-right corners of the - rectangle, respectively. (px, py) is classified as "left", "right", - "top", "bottom" or "inside", according to the following diagram: - - \ top / - \ / - +----------+ - left | inside | right - +----------+ - / \ - / bottom \ - - """ - if px < x: - dx = x - px - if py > y2 + dx: - region = 'top' - elif py < y - dx: - region = 'bottom' - else: - region = 'left' - elif px > x2: - dx = px - x2 - if py > y2 + dx: - region = 'top' - elif py < y - dx: - region = 'bottom' - else: - region = 'right' - else: # x <= px <= x2 - if py > y2: - region = 'top' - elif py < y: - region = 'bottom' - else: - region = 'inside' - return region - - -class DataLabel(ToolTip): - """ A label on a point in data space. - - Optionally, an arrow is drawn to the point. - """ - - #: The symbol to use if **marker** is set to "custom". This attribute must - #: be a compiled path for the given Kiva context. - custom_symbol = Any - - #: The point in data space where this label should anchor itself. - data_point = ArrayOrNone() - - #: The location of the data label relative to the data point. - label_position = LabelPositionTrait - - #: The format string that determines the label's text. This string is - #: formatted using a dict containing the keys 'x' and 'y', corresponding to - #: data space values. - label_format = Str("(%(x)f, %(y)f)") - - #: The text to show on the label, or above the coordinates for the label, if - #: show_label_coords is True - label_text = Str - - #: Flag whether to show coordinates with the label or not. - show_label_coords = Bool(True) - - #: Does the label clip itself against the main plot area? If not, then - #: the label draws into the padding area (where axes typically reside). - clip_to_plot = Bool(True) - - #: The center x position (average of x and x2) - xmid = Property(Float, observe=['x', 'x2']) - - #: The center y position (average of y and y2) - ymid = Property(Float, observe=['y', 'y2']) - - #: 'box' is a simple rectangular box, with an arrow that is a single line - #: with an arrowhead at the data point. - #: 'bubble' can be given rounded corners (by setting `corner_radius`), and - #: the 'arrow' is a thin triangular wedge with its point at the data point. - #: When label_style is 'bubble', the following traits are ignored: - #: arrow_size, arrow_color, arrow_root, and arrow_max_length. - label_style = Enum('box', 'bubble') - - #---------------------------------------------------------------------- - # Marker traits - #---------------------------------------------------------------------- - - #: Mark the point on the data that this label refers to? - marker_visible = Bool(True) - - #: The type of marker to use. This is a mapped trait using strings as the - #: keys. - marker = MarkerTrait - - #: The pixel size of the marker (doesn't include the thickness of the - #: outline). - marker_size = Int(4) - - #: The thickness, in pixels, of the outline to draw around the marker. - #: If this is 0, no outline will be drawn. - marker_line_width = Float(1.0) - - #: The color of the inside of the marker. - marker_color = ColorTrait("red") - - #: The color out of the border drawn around the marker. - marker_line_color = ColorTrait("black") - - #---------------------------------------------------------------------- - # Arrow traits - #---------------------------------------------------------------------- - - #: Draw an arrow from the label to the data point? Only - #: used if **data_point** is not None. - arrow_visible = Bool(True) # FIXME: replace with some sort of ArrowStyle - - #: The length of the arrowhead, in screen points (e.g., pixels). - arrow_size = Float(10) - - #: The color of the arrow. - arrow_color = ColorTrait("black") - - #: The position of the base of the arrow on the label. If this - #: is 'auto', then the label uses **label_position**. Otherwise, it - #: treats the label as if it were at the label position indicated by - #: this attribute. - arrow_root = Trait("auto", "auto", "top left", "top right", "bottom left", - "bottom right", "top center", "bottom center", - "left center", "right center") - - #: The minimum length of the arrow before it will be drawn. By default, - #: the arrow will be drawn regardless of how short it is. - arrow_min_length = Float(0) - - #: The maximum length of the arrow before it will be drawn. By default, - #: the arrow will be drawn regardless of how long it is. - arrow_max_length = Float(inf) - - #---------------------------------------------------------------------- - # Bubble traits - #---------------------------------------------------------------------- - - #: The radius (in screen coordinates) of the curved corners of the "bubble". - corner_radius = Float(10) - - #------------------------------------------------------------------------- - # Private traits - #------------------------------------------------------------------------- - - # Tuple (sx, sy) of the mapped screen coordinates of **data_point**. - _screen_coords = Any - - _cached_arrow = Any - - # When **arrow_root** is 'auto', this determines the location on the data - # label from which the arrow is drawn, based on the position of the label - # relative to its data point. - _position_root_map = { - "top left": "bottom right", - "top right": "bottom left", - "bottom left": "top right", - "bottom right": "top left", - "top center": "bottom center", - "bottom center": "top center", - "left center": "right center", - "right center": "left center" - } - - _root_positions = { - "bottom right": ("x2", "y"), - "bottom left": ("x", "y"), - "top right": ("x2", "y2"), - "top left": ("x", "y2"), - "top center": ("xmid", "y2"), - "bottom center": ("xmid", "y"), - "left center": ("x", "ymid"), - "right center": ("x2", "ymid"), - } - - def overlay(self, component, gc, view_bounds=None, mode="normal"): - """ Draws the tooltip overlaid on another component. - - Overrides and extends ToolTip.overlay() - """ - if self.clip_to_plot: - gc.save_state() - c = component - gc.clip_to_rect(c.x, c.y, c.width, c.height) - - self.do_layout() - - if self.label_style == 'box': - self._render_box(component, gc, view_bounds=view_bounds, - mode=mode) - else: - self._render_bubble(component, gc, view_bounds=view_bounds, - mode=mode) - - # draw the marker - if self.marker_visible: - render_markers(gc, [self._screen_coords], - self.marker, self.marker_size, - self.marker_color_, self.marker_line_width, - self.marker_line_color_, self.custom_symbol) - - if self.clip_to_plot: - gc.restore_state() - - def _render_box(self, component, gc, view_bounds=None, mode='normal'): - # draw the arrow if necessary - if self.arrow_visible: - if self._cached_arrow is None: - if self.arrow_root in self._root_positions: - ox, oy = self._root_positions[self.arrow_root] - else: - if self.arrow_root == "auto": - arrow_root = self.label_position - else: - arrow_root = self.arrow_root - pos = self._position_root_map.get(arrow_root, "DUMMY") - ox, oy = self._root_positions.get(pos, - (self.x + self.width / 2, - self.y + self.height / 2)) - - if type(ox) == str: - ox = getattr(self, ox) - oy = getattr(self, oy) - self._cached_arrow = draw_arrow(gc, (ox, oy), - self._screen_coords, - self.arrow_color_, - arrowhead_size=self.arrow_size, - offset1=3, - offset2=self.marker_size + 3, - minlen=self.arrow_min_length, - maxlen=self.arrow_max_length) - else: - draw_arrow(gc, None, None, self.arrow_color_, - arrow=self._cached_arrow, - minlen=self.arrow_min_length, - maxlen=self.arrow_max_length) - - # layout and render the label itself - ToolTip.overlay(self, component, gc, view_bounds, mode) - - def _render_bubble(self, component, gc, view_bounds=None, mode='normal'): - """ Render the bubble label in the graphics context. """ - # (px, py) is the data point in screen space. - px, py = self._screen_coords - - # (x, y) is the lower left corner of the label. - x = self.x - y = self.y - # (x2, y2) is the upper right corner of the label. - x2 = self.x2 - y2 = self.y2 - # r is the corner radius. - r = self.corner_radius - - if self.arrow_visible: - # FIXME: Make 'gap_width' a configurable trait (and give it a - # better name). - max_gap_width = 10 - gap_width = min(max_gap_width, - abs(x2 - x - 2 * r), - abs(y2 - y - 2 * r)) - region = find_region(px, py, x, y, x2, y2) - - # Figure out where the "arrow" connects to the "bubble". - if region == 'left' or region == 'right': - gap_start = py - gap_width / 2 - if gap_start < y + r: - gap_start = y + r - elif gap_start > y2 - r - gap_width: - gap_start = y2 - r - gap_width - by = gap_start + 0.5 * gap_width - if region == 'left': - bx = x - else: - bx = x2 - else: - gap_start = px - gap_width / 2 - if gap_start < x + r: - gap_start = x + r - elif gap_start > x2 - r - gap_width: - gap_start = x2 - r - gap_width - bx = gap_start + 0.5 * gap_width - if region == 'top': - by = y2 - else: - by = y - arrow_len = sqrt((px - bx)**2 + (py - by)**2) - - arrow_visible = (self.arrow_visible and - (arrow_len >= self.arrow_min_length)) - - with gc: - if self.border_visible: - gc.set_line_width(self.border_width) - gc.set_stroke_color(self.border_color_) - else: - gc.set_line_width(0) - gc.set_stroke_color((0, 0, 0, 0)) - gc.set_fill_color(self.bgcolor_) - - # Start at the lower left, on the left edge where the curved - # part of the box ends. - gc.move_to(x, y + r) - - # Draw the left side and the upper left curved corner. - if arrow_visible and region == 'left': - gc.line_to(x, gap_start) - gc.line_to(px, py) - gc.line_to(x, gap_start + gap_width) - gc.arc_to(x, y2, x + r, y2, r) - - # Draw the top and the upper right curved corner. - if arrow_visible and region == 'top': - gc.line_to(gap_start, y2) - gc.line_to(px, py) - gc.line_to(gap_start + gap_width, y2) - gc.arc_to(x2, y2, x2, y2 - r, r) - - # Draw the right side and the lower right curved corner. - if arrow_visible and region == 'right': - gc.line_to(x2, gap_start + gap_width) - gc.line_to(px, py) - gc.line_to(x2, gap_start) - gc.arc_to(x2, y, x2 - r, y, r) - - # Draw the bottom and the lower left curved corner. - if arrow_visible and region == 'bottom': - gc.line_to(gap_start + gap_width, y) - gc.line_to(px, py) - gc.line_to(gap_start, y) - gc.arc_to(x, y, x, y + r, r) - - # Finish the "bubble". - gc.draw_path() - - self._draw_overlay(gc) - - def _do_layout(self, size=None): - """Computes the size and position of the label and arrow. - - Overrides and extends ToolTip._do_layout() - """ - if not self.component or not hasattr(self.component, "map_screen"): - return - - # Call the parent class layout. This computes all the label - ToolTip._do_layout(self) - - self._screen_coords = self.component.map_screen([self.data_point])[0] - sx, sy = self._screen_coords - - if isinstance(self.label_position, str): - orientation = self.label_position - if ("left" in orientation) or ("right" in orientation): - if " " not in orientation: - self.y = sy - self.height / 2 - if "left" in orientation: - self.outer_x = sx - self.outer_width - 1 - elif "right" in orientation: - self.outer_x = sx - if ("top" in orientation) or ("bottom" in orientation): - if " " not in orientation: - self.x = sx - self.width / 2 - if "bottom" in orientation: - self.outer_y = sy - self.outer_height - 1 - elif "top" in orientation: - self.outer_y = sy - if "center" in orientation: - if " " not in orientation: - self.x = sx - (self.width / 2) - self.y = sy - (self.height / 2) - else: - self.x = sx - (self.outer_width / 2) - 1 - self.y = sy - (self.outer_height / 2) - 1 - else: - self.x = sx + self.label_position[0] - self.y = sy + self.label_position[1] - - self._cached_arrow = None - - def _data_point_changed(self, old, new): - if new is not None: - self._create_new_labels() - - def _label_format_changed(self, old, new): - self._create_new_labels() - - def _label_text_changed(self, old, new): - self._create_new_labels() - - def _show_label_coords_changed(self, old, new): - self._create_new_labels() - - def _create_new_labels(self): - pt = self.data_point - if pt is not None: - if self.show_label_coords: - self.lines = [self.label_text, - self.label_format % {"x": pt[0], "y": pt[1]}] - else: - self.lines = [self.label_text] - - def _component_changed(self, old, new): - for comp, attach in ((old, False), (new, True)): - if comp is not None: - if hasattr(comp, 'index_mapper'): - self._modify_mapper_listeners(comp.index_mapper, - attach=attach) - if hasattr(comp, 'value_mapper'): - self._modify_mapper_listeners(comp.value_mapper, - attach=attach) - - def _modify_mapper_listeners(self, mapper, attach=True): - if mapper is not None: - mapper.observe(self._handle_mapper, 'updated', remove=not attach) - - def _handle_mapper(self, event): - # This gets fired whenever a mapper on our plot fires its - # 'updated' event. - self._layout_needed = True - - @observe("arrow_size,arrow_root,arrow_min_length,arrow_max_length") - def _invalidate_arrow(self, event): - self._cached_arrow = None - self._layout_needed = True - - @observe("label_position,position.items,bounds.items") - def _invalidate_layout(self, event): - self._layout_needed = True - - def _get_xmid(self): - return 0.5 * (self.x + self.x2) - - def _get_ymid(self): - return 0.5 * (self.y + self.y2) +# (C) Copyright 2006-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +import warnings + +from chaco.overlays.data_label import DataLabel, draw_arrow, find_region + +warnings.warn( + "Importing DataLabel, draw_arrow, or find_region from this module is " + "deprecated. Please use chaco.api or chaco.overlays.api instead. This " + "module will be removed in the next major release.", + DeprecationWarning, + stacklevel=2, +) diff --git a/chaco/lasso_overlay.py b/chaco/lasso_overlay.py index 7d115ff64..2167cdb3a 100644 --- a/chaco/lasso_overlay.py +++ b/chaco/lasso_overlay.py @@ -1,82 +1,20 @@ -""" Defines the LassoOverlay class. -""" - - -from numpy import concatenate, newaxis - -# Enthought library imports -from enable.api import ColorTrait, LineStyle -from traits.api import Float, Instance, Bool - -# Local imports -from .abstract_overlay import AbstractOverlay - - -class LassoOverlay(AbstractOverlay): - """Draws a lasso selection region on top of a plot. - - LassoOverlay gets its data from a LassoSelection. - """ - - #: The LassoSelection that provides the data for this overlay. - lasso_selection = Instance("chaco.tools.lasso_selection.LassoSelection") - #: The fill color for the selection region. - selection_fill_color = ColorTrait("lightskyblue") - #: The border color for the selection region. - selection_border_color = ColorTrait("dodgerblue") - #: The transparency level for the selection fill color. - selection_alpha = Float(0.8) - #: The width of the selection border. - selection_border_width = Float(2.0) - #: The line style of the selection border. - selection_border_dash = LineStyle - - #: The background color (overrides AbstractOverlay). - bgcolor = "clear" - - # Whether to draw the lasso - # depends on the state of the lasso tool - _draw_selection = Bool(False) - - def overlay(self, other_component, gc, view_bounds=None, mode="normal"): - """Draws this component overlaid on another component. - - Implements AbstractOverlay. - """ - if not self._draw_selection: - return - with gc: - c = other_component - gc.clip_to_rect(c.x, c.y, c.width, c.height) - self._draw_component(gc, view_bounds, mode) - - def _updated_changed_for_lasso_selection(self): - self.component.invalidate_draw() - self.component.request_redraw() - - def _event_state_fired_for_lasso_selection(self, val): - self._draw_selection = val == "selecting" - self.component.invalidate_draw() - self.component.request_redraw() - - def _draw_component(self, gc, view_bounds=None, mode="normal"): - """Draws the component. - - This method is preserved for backwards compatibility with _old_draw(). - Overrides PlotComponent. - """ - with gc: - # We may need to make map_screen more flexible in the number of dimensions - # it accepts for ths to work well. - for selection in self.lasso_selection.disjoint_selections: - points = self.component.map_screen(selection) - if len(points) == 0: - return - points = concatenate((points, points[0, newaxis]), axis=0) - gc.set_line_width(self.selection_border_width) - gc.set_line_dash(self.selection_border_dash_) - gc.set_fill_color(self.selection_fill_color_) - gc.set_stroke_color(self.selection_border_color_) - gc.set_alpha(self.selection_alpha) - gc.lines(points) - gc.draw_path() +# (C) Copyright 2006-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +import warnings + +from chaco.overlays.lasso_overlay import LassoOverlay # noqa: F401 + +warnings.warn( + "Importing LassoOverlay from this module is deprecated. Please use " + "chaco.api or chaco.overlays.api instead. This module will be removed in " + "the next major release.", + DeprecationWarning, + stacklevel=2, +) diff --git a/chaco/layers/api.py b/chaco/layers/api.py index decda6ade..d315a05d0 100644 --- a/chaco/layers/api.py +++ b/chaco/layers/api.py @@ -1 +1,20 @@ -from .status_layer import StatusLayer, ErrorLayer, WarningLayer +# (C) Copyright 2006-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +import warnings + +from chaco.overlays.api import ErrorLayer, StatusLayer, WarningLayer + +warnings.warn( + "Importing from chaco.layers.api is deprecated, please import from " + "chaco.api or chaco.overlays.api going forward as chaco/layers will be " + "removed in a future release.", + DeprecationWarning, + stacklevel=2, +) diff --git a/chaco/layers/status_layer.py b/chaco/layers/status_layer.py index 2f9fa7a8c..4bd909c8c 100644 --- a/chaco/layers/status_layer.py +++ b/chaco/layers/status_layer.py @@ -1,146 +1,22 @@ -import os.path -import xml.etree.cElementTree as etree - -from chaco.abstract_overlay import AbstractOverlay -from pyface.timer.timer import Timer -from traits.api import Instance, Str, Enum, Float, Int -from enable.savage.svg.document import SVGDocument -from enable.savage.svg.backends.kiva.renderer import Renderer as KivaRenderer - - -class StatusLayer(AbstractOverlay): - - filename = Str() - document = Instance(SVGDocument) - - # Default size attributes if the svg does not specify them - doc_width = 48.0 - doc_height = 48.0 - - # The type determines if the layer is displayed as part of the component's - # overlay or underlays - type = Enum("overlay", "underlay") - - # The position of the legend with respect to its overlaid component. - # - # * c = Center - # * ur = Upper Right - # * ul = Upper Left - # * ll = Lower Left - # * lr = Lower Right - align = Enum("c", "ur", "ul", "ll", "lr") - - # How big should the graphic be in comparison to the rest of the plot - # area - scale_factor = Float(0.5) - - # Initial transparency - alpha = Float(1.0) - - # The minimum time it takes for the the layer to fade out, in - # milliseconds. Actual time may be longer, depending on the pyface toolkit - fade_out_time = Float(50) - - # The number of steps to take to fade from the initial transparency to - # invisible - fade_out_steps = Int(10) - - def __init__(self, component, *args, **kw): - super().__init__(component, *args, **kw) - - if self.document is None: - if self.filename == "": - self.filename = os.path.join( - os.path.dirname(__file__), "data", "Dialog-error.svg" - ) - tree = etree.parse(self.filename) - root = tree.getroot() - self.document = SVGDocument(root, renderer=KivaRenderer) - - if hasattr(self.document, "getSize"): - self.doc_width = self.document.getSize()[0] - self.doc_height = self.document.getSize()[1] - - def overlay(self, other_component, gc, view_bounds=None, mode="normal"): - """Draws this component overlaid on another component. - - Implements AbstractOverlay. - """ - with gc: - gc.set_alpha(self.alpha) - - plot_width = self.component.width - plot_height = self.component.height - - origin_x = self.component.padding_left - origin_y = self.component.padding_top - - # zoom percentage, use the scale_factor as a % of the plot size. - # base the size on the smaller aspect - if the plot is tall and narrow - # the overlay should be 50% of the width, if the plot is short and wide - # the overlay should be 50% of the height. - if gc.height() < gc.width(): - scale = (plot_height / self.doc_height) * self.scale_factor - else: - scale = (plot_width / self.doc_width) * self.scale_factor - - scale_width = scale * self.doc_width - scale_height = scale * self.doc_height - - # Set up the transforms to align the graphic to the desired position - if self.align == "ur": - gc.translate_ctm( - origin_x + (plot_width - scale_width), - origin_y + plot_height, - ) - elif self.align == "lr": - gc.translate_ctm( - origin_x + (plot_width - scale_width), - origin_y + scale_height, - ) - elif self.align == "ul": - gc.translate_ctm(origin_x, origin_y + plot_height) - elif self.align == "ll": - gc.translate_ctm(origin_x, origin_y + scale_height) - else: - gc.translate_ctm( - origin_x + (plot_width - scale_width) / 2, - origin_y + (plot_height + scale_height) / 2, - ) - - # SVG origin is the upper right with y positive down, so - # we need to flip everything - gc.scale_ctm(scale, -scale) - - self.document.render(gc) - - def fade_out(self): - interval = self.fade_out_time / self.fade_out_steps - self.timer = Timer(interval, self._fade_out_step) - - def _fade_out_step(self): - """Fades out the overlay over a half second. then removes it from - the other_component's overlays - """ - if self.alpha <= 0: - if self.type == "overlay": - self.component.overlays.remove(self) - else: - self.component.underlays.remove(self) - self.alpha = 1.0 - raise StopIteration - else: - self.alpha -= 0.1 - self.component.request_redraw() - - -class ErrorLayer(StatusLayer): - filename = os.path.join( - os.path.dirname(__file__), "data", "Dialog-error.svg" - ) - - -class WarningLayer(StatusLayer): - filename = os.path.join( - os.path.dirname(__file__), "data", "Dialog-warning.svg" - ) +# (C) Copyright 2006-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +import warnings + +from chaco.overlays.layers.status_layer import ( # noqa: F401 + ErrorLayer, StatusLayer, WarningLayer +) + +warnings.warn( + "Importing ErrorLayer, StatusLayer, or WarningLayer from this module is " + "deprecated. Please use chaco.api or chaco.overlays.api instead. This " + "module will be removed in the next major release.", + DeprecationWarning, + stacklevel=2, +) diff --git a/chaco/layers/svg_range_selection_overlay.py b/chaco/layers/svg_range_selection_overlay.py index 48ade99f3..fa5a9ec41 100644 --- a/chaco/layers/svg_range_selection_overlay.py +++ b/chaco/layers/svg_range_selection_overlay.py @@ -1,137 +1,22 @@ -import os -import numpy - -from chaco.grid_mapper import GridMapper -from traits.api import Property, Enum, Str, cached_property - -from .status_layer import StatusLayer - - -class SvgRangeSelectionOverlay(StatusLayer): - """This is a primitive range selection overlay which uses - a SVG to define the overlay. - - TODO: not inherit from StatusLayer, this was a convenience for a - quick prototype - - TODO: use 2 svgs, one which defines the border and does not scale, and - the other which defines the fill. - """ - - filename = os.path.join( - os.path.dirname(__file__), "data", "range_selection.svg" - ) - - alpha = 0.5 - - # The axis to which this tool is perpendicular. - axis = Enum("index", "value") - - axis_index = Property(observe="axis") - - # Mapping from screen space to data space. By default, it is just - # self.component. - plot = Property(observe="component") - - # The mapper (and associated range) that drive this RangeSelectionOverlay. - # By default, this is the mapper on self.plot that corresponds to self.axis. - mapper = Property(observe="plot") - - # The name of the metadata to look at for dataspace bounds. The metadata - # can be either a tuple (dataspace_start, dataspace_end) in "selections" or - # a boolean array mask of seleted dataspace points with any other name - metadata_name = Str("selections") - - def overlay(self, component, gc, view_bounds=None, mode="normal"): - """Draws this component overlaid on another component. - - Overrides AbstractOverlay. - """ - # Draw the selection - coords = self._get_selection_screencoords() - - if len(coords) == 0: - return - - with gc: - gc.set_alpha(self.alpha) - - plot_width = self.component.width - plot_height = self.component.height - - origin_x = self.component.padding_left - origin_y = self.component.padding_top - - if self.axis == "index": - if isinstance(self.mapper, GridMapper): - scale_width = ( - (coords[-1][0] - coords[0][0]) / self.doc_width - ) - else: - scale_width = ( - (coords[0][-1] - coords[0][0]) / self.doc_width - ) - scale_height = float(plot_height) / self.doc_height - gc.translate_ctm(coords[0][0], origin_y + plot_height) - else: - scale_height = (coords[0][-1] - coords[0][0]) / self.doc_height - scale_width = float(plot_width) / self.doc_width - gc.translate_ctm(origin_x, coords[0][0]) - - # SVG origin is the upper right with y positive down, so - # we need to flip everything - gc.scale_ctm(scale_width, -scale_height) - - self.document.render(gc) - - def _get_selection_screencoords(self): - """Returns a tuple of (x1, x2) screen space coordinates of the start - and end selection points. - - If there is no current selection, then returns None. - """ - ds = getattr(self.plot, self.axis) - selection = ds.metadata[self.metadata_name] - - # "selections" metadata must be a tuple - if self.metadata_name == "selections": - if selection is not None and len(selection) == 2: - return [self.mapper.map_screen(numpy.array(selection))] - else: - return [] - # All other metadata is interpreted as a mask on dataspace - else: - ar = numpy.arange(0, len(selection), 1) - runs = arg_find_runs(ar[selection]) - coords = [] - for inds in runs: - start = ds._data[ar[selection][inds[0]]] - end = ds._data[ar[selection][inds[1] - 1]] - coords.append(self.map_screen(numpy.array((start, end)))) - return coords - - @cached_property - def _get_plot(self): - return self.component - - @cached_property - def _get_axis_index(self): - if self.axis == "index": - return 0 - else: - return 1 - - @cached_property - def _get_mapper(self): - # If the plot's mapper is a GridMapper, return either its - # x mapper or y mapper - - mapper = getattr(self.plot, self.axis + "_mapper") - - if isinstance(mapper, GridMapper): - if self.axis == "index": - return mapper._xmapper - else: - return mapper._ymapper - else: - return mapper +# (C) Copyright 2006-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +import warnings + +from chaco.overlays.layers.svg_range_selection_overlay import ( # noqa: F401 + SvgRangeSelectionOverlay +) + +warnings.warn( + "Importing SvgRangeSelectionOverlay from this module is deprecated. " + "Please use chaco.api or chaco.overlays.api instead. This module will be " + "removed in the next major release.", + DeprecationWarning, + stacklevel=2, +) diff --git a/chaco/legend.py b/chaco/legend.py index 153301c7d..139c7f415 100644 --- a/chaco/legend.py +++ b/chaco/legend.py @@ -1,540 +1,23 @@ -""" Defines the Legend, AbstractCompositeIconRenderer, and -CompositeIconRenderer classes. -""" - - -from numpy import array, zeros_like - -from enable.api import black_color_trait, white_color_trait -from enable.font_metrics_provider import font_metrics_provider -from kiva.trait_defs.kiva_font_trait import KivaFont -from traits.api import ( - ArrayOrNone, - Bool, - CList, - Dict, - Enum, - Float, - HasTraits, - Instance, - Int, - List, - observe, - Str, +# (C) Copyright 2006-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +import warnings + +from chaco.overlays.legend import ( # noqa: F401 + AbstractCompositeIconRenderer, CompositeIconRenderer, Legend ) -# Local relative imports -from .abstract_overlay import AbstractOverlay -from .label import Label -from .plots.lineplot import LinePlot -from .plot_component import PlotComponent -from .plots.scatterplot import ScatterPlot - - -class AbstractCompositeIconRenderer(HasTraits): - """Abstract class for an icon renderer.""" - - def render_icon(self, plots, gc, x, y, width, height): - """Renders an icon representing the given list of plots onto the - graphics context, using the given dimensions and at the specified - position. - """ - raise NotImplementedError - - -class CompositeIconRenderer(AbstractCompositeIconRenderer): - """Renderer for composite icons.""" - - def render_icon(self, plots, *render_args): - """ Renders an icon for a list of plots. """ - types = set(map(type, plots)) - if types == set([ScatterPlot]): - self._render_scatterplots(plots, *render_args) - elif types == set([LinePlot]): - self._render_lineplots(plots, *render_args) - elif types == set([ScatterPlot, LinePlot]): - self._render_line_scatter(plots, *render_args) - else: - raise ValueError( - "Don't know how to render combination plot with " - + "renderers " - + str(types) - ) - - def _render_scatterplots(self, plots, gc, x, y, width, height): - # Don't support this for now - pass - - def _render_lineplots(self, plots, gc, x, y, width, height): - # Assume they are all the same color/appearance and use the first one - plots[0]._render_icon(gc, x, y, width, height) - - def _render_line_scatter(self, plots, gc, x, y, width, height): - # Separate plots into line and scatter renderers; render one of each - scatter = [p for p in plots if type(p) == ScatterPlot] - line = [p for p in plots if type(p) == LinePlot] - line[0]._render_icon(gc, x, y, width, height) - scatter[0]._render_icon(gc, x, y, width, height) - - -class Legend(AbstractOverlay): - """A legend for a plot.""" - - #: The font to use for the legend text. - font = KivaFont("modern 12") - - #: The amount of space between the content of the legend and the border. - border_padding = Int(10) - - #: The border is visible (overrides Enable Component). - border_visible = True - - #: The color of the text labels - color = black_color_trait - - #: The background color of the legend (overrides AbstractOverlay). - bgcolor = white_color_trait - - #: The position of the legend with respect to its overlaid component. (This - #: attribute applies only if the legend is used as an overlay.) - #: - #: * ur = Upper Right - #: * ul = Upper Left - #: * ll = Lower Left - #: * lr = Lower Right - align = Enum("ur", "ul", "ll", "lr") - - #: The amount of space between legend items. - line_spacing = Int(3) - - #: The size of the icon or marker area drawn next to the label. - icon_bounds = List([24, 24]) - - #: Amount of spacing between each label and its icon. - icon_spacing = Int(5) - - #: Map of labels (strings) to plot instances or lists of plot instances. The - #: Legend determines the appropriate rendering of each plot's marker/line. - plots = Dict - - #: The list of labels to show and the order to show them in. If this - #: list is blank, then the keys of self.plots is used and displayed in - #: alphabetical order. Otherwise, only the items in the **labels** - #: list are drawn in the legend. Labels are ordered from top to bottom. - labels = List - - #: Whether or not to hide plots that are not visible. (This is checked during - #: layout.) This option *will* filter out the items in **labels** above, so - #: if you absolutely, positively want to set the items that will always - #: display in the legend, regardless of anything else, then you should turn - #: this option off. Otherwise, it usually makes sense that a plot renderer - #: that is not visible will also not be in the legend. - hide_invisible_plots = Bool(True) - - #: If hide_invisible_plots is False, we can still choose to render the names - #: of invisible plots with an alpha. - invisible_plot_alpha = Float(0.33) - - #: The renderer that draws the icons for the legend. - composite_icon_renderer = Instance(AbstractCompositeIconRenderer) - - #: Action that the legend takes when it encounters a plot whose icon it - #: cannot render: - #: - #: * 'skip': skip it altogether and don't render its name - #: * 'blank': render the name but leave the icon blank (color=self.bgcolor) - #: * 'questionmark': render a "question mark" icon - error_icon = Enum("skip", "blank", "questionmark") - - #: Should the legend clip to the bounds it needs, or to its parent? - clip_to_component = Bool(False) - - #: The legend is not resizable (overrides PlotComponent). - resizable = "hv" - - #: An optional title string to show on the legend. - title = Str("") - - #: If True, title is at top, if False then at bottom. - title_at_top = Bool(True) - - #: The legend draws itself as in one pass when its parent is drawing - #: the **draw_layer** (overrides PlotComponent). - unified_draw = True - #: The legend is drawn on the overlay layer of its parent (overrides - #: PlotComponent). - draw_layer = "overlay" - - # ------------------------------------------------------------------------ - # Private Traits - # ------------------------------------------------------------------------ - - # A cached list of Label instances - _cached_labels = List - - # A cached array of label sizes. - _cached_label_sizes = ArrayOrNone() - - # A cached list of label names. - _cached_label_names = CList - - # A list of the visible plots. Each plot corresponds to the label at - # the same index in _cached_label_names. This list does not necessarily - # correspond to self.plots.value() because it is sorted according to - # the plot name and it potentially excludes invisible plots. - _cached_visible_plots = CList - - # A cached array of label positions relative to the legend's origin - _cached_label_positions = ArrayOrNone() - - def is_in(self, x, y): - """overloads from parent class because legend alignment - and padding does not cooperatate with the basic implementation - - This may just be caused byt a questionable implementation of the - legend tool, but it works by adjusting the padding. The Component - class implementation of is_in uses the outer positions which - includes the padding - """ - in_x = (x >= self.x) and (x <= self.x + self.width) - in_y = (y >= self.y) and (y <= self.y + self.height) - - return in_x and in_y - - def overlay(self, component, gc, view_bounds=None, mode="normal"): - """Draws this component overlaid on another component. - - Implements AbstractOverlay. - """ - self.do_layout() - valign, halign = self.align - if valign == "u": - y = component.y2 - self.outer_height - else: - y = component.y - if halign == "r": - x = component.x2 - self.outer_width - else: - x = component.x - self.outer_position = [x, y] - - if self.clip_to_component: - c = self.component - with gc: - gc.clip_to_rect(c.x, c.y, c.width, c.height) - PlotComponent._draw(self, gc, view_bounds, mode) - else: - PlotComponent._draw(self, gc, view_bounds, mode) - - # The following two methods implement the functionality of the Legend - # to act as a first-class component instead of merely as an overlay. - # The make the Legend use the normal PlotComponent render methods when - # it does not have a .component attribute, so that it can have its own - # overlays (e.g. a PlotLabel). - # - # The core legend rendering method is named _draw_as_overlay() so that - # it can be called from _draw_plot() when the Legend is not an overlay, - # and from _draw_overlay() when the Legend is an overlay. - - def _draw_plot(self, gc, view_bounds=None, mode="normal"): - if self.component is None: - self._draw_as_overlay(gc, view_bounds, mode) - - def _draw_overlay(self, gc, view_bounds=None, mode="normal"): - if self.component is not None: - self._draw_as_overlay(gc, view_bounds, mode) - else: - PlotComponent._draw_overlay(self, gc, view_bounds, mode) - - def _draw_as_overlay(self, gc, view_bounds=None, mode="normal"): - """Draws the overlay layer of a component. - - Overrides PlotComponent. - """ - # Determine the position we are going to draw at from our alignment - # corner and the corresponding outer_padding parameters. (Position - # refers to the lower-left corner of our border.) - - # First draw the border, if necesssary. This sort of duplicates - # the code in PlotComponent._draw_overlay, which is unfortunate; - # on the other hand, overlays of overlays seem like a rather obscure - # feature. - - with gc: - gc.clip_to_rect( - int(self.x), int(self.y), int(self.width), int(self.height) - ) - edge_space = self.border_width + self.border_padding - icon_width, icon_height = self.icon_bounds - - icon_x = self.x + edge_space - text_x = icon_x + icon_width + self.icon_spacing - y = self.y2 - edge_space - - if self._cached_label_positions is not None: - if len(self._cached_label_positions) > 0: - self._cached_label_positions[:, 0] = icon_x - - for i, label_name in enumerate(self._cached_label_names): - # Compute the current label's position - label_height = self._cached_label_sizes[i][1] - y -= label_height - self._cached_label_positions[i][1] = y - - # Try to render the icon - icon_y = y + (label_height - icon_height) / 2 - plots = self._cached_visible_plots[i] - render_args = (gc, icon_x, icon_y, icon_width, icon_height) - - try: - if isinstance(plots, list) or isinstance(plots, tuple): - # TODO: How do we determine if a *group* of plots is - # visible or not? For now, just look at the first one - # and assume that applies to all of them - if not plots[0].visible: - # TODO: the get_alpha() method isn't supported on the Mac kiva backend - # old_alpha = gc.get_alpha() - old_alpha = 1.0 - gc.set_alpha(self.invisible_plot_alpha) - else: - old_alpha = None - if len(plots) == 1: - plots[0]._render_icon(*render_args) - else: - self.composite_icon_renderer.render_icon( - plots, *render_args - ) - elif plots is not None: - # Single plot - if not plots.visible: - # old_alpha = gc.get_alpha() - old_alpha = 1.0 - gc.set_alpha(self.invisible_plot_alpha) - else: - old_alpha = None - plots._render_icon(*render_args) - else: - old_alpha = None # Or maybe 1.0? - - icon_drawn = True - except: - icon_drawn = self._render_error(*render_args) - - if icon_drawn: - # Render the text - gc.translate_ctm(text_x, y) - gc.set_antialias(0) - self._cached_labels[i].draw(gc) - gc.set_antialias(1) - gc.translate_ctm(-text_x, -y) - - # Advance y to the next label's baseline - y -= self.line_spacing - if old_alpha is not None: - gc.set_alpha(old_alpha) - - def _render_error(self, gc, icon_x, icon_y, icon_width, icon_height): - """Renders an error icon or performs some other action when a - plot is unable to render its icon. - - Returns True if something was actually drawn (and hence the legend - needs to advance the line) or False if nothing was drawn. - """ - if self.error_icon == "skip": - return False - elif self.error_icon == "blank" or self.error_icon == "questionmark": - with gc: - gc.set_fill_color(self.bgcolor_) - gc.rect(icon_x, icon_y, icon_width, icon_height) - gc.fill_path() - return True - else: - return False - - def get_preferred_size(self): - """ - Computes the size and position of the legend based on the maximum size of - the labels, the alignment, and position of the component to overlay. - """ - # Gather the names of all the labels we will create - if len(self.plots) == 0: - return [0, 0] - - plot_names, visible_plots = list( - map(list, zip(*sorted(self.plots.items()))) - ) - label_names = self.labels - if len(label_names) == 0: - if len(self.plots) > 0: - label_names = plot_names - else: - self._cached_labels = [] - self._cached_label_sizes = [] - self._cached_label_names = [] - self._cached_visible_plots = [] - self.outer_bounds = [0, 0] - return [0, 0] - - if self.hide_invisible_plots: - visible_labels = [] - visible_plots = [] - for name in label_names: - # If the user set self.labels, there might be a bad value, - # so ensure that each name is actually in the plots dict. - if name in self.plots: - val = self.plots[name] - # Rather than checking for a list/TraitListObject/etc., we just check - # for the attribute first - if hasattr(val, "visible"): - if val.visible: - visible_labels.append(name) - visible_plots.append(val) - else: - # If we have a list of renderers, add the name if any of them are - # visible - for renderer in val: - if renderer.visible: - visible_labels.append(name) - visible_plots.append(val) - break - label_names = visible_labels - - # Create the labels - labels = [self._create_label(text) for text in label_names] - - # For the legend title - if self.title_at_top: - labels.insert(0, self._create_label(self.title)) - label_names.insert(0, "Legend Label") - visible_plots.insert(0, None) - else: - labels.append(self._create_label(self.title)) - label_names.append(self.title) - visible_plots.append(None) - - # We need a dummy GC in order to get font metrics - dummy_gc = font_metrics_provider() - label_sizes = array( - [label.get_width_height(dummy_gc) for label in labels] - ) - - if len(label_sizes) > 0: - max_label_width = max(label_sizes[:, 0]) - total_label_height = ( - sum(label_sizes[:, 1]) - + (len(label_sizes) - 1) * self.line_spacing - ) - else: - max_label_width = 0 - total_label_height = 0 - - legend_width = ( - max_label_width - + self.icon_spacing - + self.icon_bounds[0] - + self.hpadding - + 2 * self.border_padding - ) - legend_height = ( - total_label_height + self.vpadding + 2 * self.border_padding - ) - - self._cached_labels = labels - self._cached_label_sizes = label_sizes - self._cached_label_positions = zeros_like(label_sizes) - self._cached_label_names = label_names - self._cached_visible_plots = visible_plots - - if "h" not in self.resizable: - legend_width = self.outer_width - if "v" not in self.resizable: - legend_height = self.outer_height - return [legend_width, legend_height] - - def get_label_at(self, x, y): - """ Returns the label object at (x,y) """ - for i, pos in enumerate(self._cached_label_positions): - size = self._cached_label_sizes[i] - corner = pos + size - if (pos[0] <= x <= corner[0]) and (pos[1] <= y <= corner[1]): - return self._cached_labels[i] - else: - return None - - def _do_layout(self): - if ( - self.component is not None - or len(self._cached_labels) == 0 - or self._cached_label_sizes is None - or len(self._cached_label_names) == 0 - ): - width, height = self.get_preferred_size() - self.outer_bounds = [width, height] - - def _create_label(self, text): - """Returns a new Label instance for the given text. Subclasses can - override this method to customize the creation of labels. - """ - return Label( - text=text, - font=self.font, - margin=0, - color=self.color_, - bgcolor="transparent", - border_width=0, - ) - - def _composite_icon_renderer_default(self): - return CompositeIconRenderer() - - # -- trait handlers -------------------------------------------------------- - @observe([ - "font", - "border_padding", - "padding", - "line_spacing", - "icon_bounds", - "icon_spacing", - "labels.items", - "plots.items", - "border_width", - "align", - "position.items", - "bounds.items", - "title_at_top", - ]) - def _invalidate_existing_layout(self, event): - self._layout_needed = True - - @observe("color") - def _update_caches(self, event): - self.get_preferred_size() - - def _plots_changed(self): - """Invalidate the caches.""" - self._cached_labels = [] - self._cached_label_sizes = None - self._cached_label_names = [] - self._cached_visible_plots = [] - self._cached_label_positions = None - - def _title_at_top_changed(self, old, new): - """ Trait handler for when self.title_at_top changes. """ - if old == True: - indx = 0 - else: - indx = -1 - if old != None: - self._cached_labels.pop(indx) - self._cached_label_names.pop(indx) - self._cached_visible_plots.pop(indx) - - # For the legend title - if self.title_at_top: - self._cached_labels.insert(0, self._create_label(self.title)) - self._cached_label_names.insert(0, "__legend_label__") - self._cached_visible_plots.insert(0, None) - else: - self._cached_labels.append(self._create_label(self.title)) - self._cached_label_names.append(self.title) - self._cached_visible_plots.append(None) +warnings.warn( + "Importing AbstractCompositeIconRenderer, CompositeIconRenderer, or Legend" + " from this module is deprecated. Please use chaco.api or " + "chaco.overlays.api instead. This module will be removed in the next major" + " release.", + DeprecationWarning, + stacklevel=2, +) diff --git a/chaco/overlays/api.py b/chaco/overlays/api.py index 0bf358e78..dcab60af0 100644 --- a/chaco/overlays/api.py +++ b/chaco/overlays/api.py @@ -1,13 +1,66 @@ -from .databox import DataBox -from .container_overlay import ContainerOverlay +# (C) Copyright 2006-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +""" +Defines the publicly accessible overlays in Chaco. + +- :class:`~.AlignedContainerOverlay` +- :class:`~.ColormappedSelectionOverlay` +- :class:`~.ContainerOverlay` +- :class:`~.CoordinateLineOverlay` +- :class:`~.DataBox` +- :classL`~.DataLabel` +- :class:`~.LassoOverlay` +- :class:`~.AbstractCompositeIconRenderer` +- :class:`~.CompositeIconRenderer` +- :class:`~.PlotLabel` +- :class:`~.ScatterInspectorOverlay` +- :func:`~.basic_formatter` +- :func:`~.datetime_formatter` +- :func:`~.date_formatter` +- :class:`~.SimpleInspectorOverlay` +- :func:`~.time_formatter` +- :class:`~.TextBoxOverlay` +- :class:`~.TextGridOverlay` +- :class:`~.ToolTip` +- :class:`~.ImageInspectorOverlay` +- :class:`~.ErrorLayer` +- :class:`~.StatusLayer` +- :class:`~.WarningLayer` + +""" from .aligned_container_overlay import AlignedContainerOverlay -from .text_grid_overlay import TextGridOverlay -from ..text_box_overlay import TextBoxOverlay -from ..tools.image_inspector_tool import ImageInspectorOverlay +from .colormapped_selection_overlay import ColormappedSelectionOverlay +from .container_overlay import ContainerOverlay +from .coordinate_line_overlay import CoordinateLineOverlay +from .databox import DataBox +from .data_label import DataLabel +from .lasso_overlay import LassoOverlay +from .legend import ( + AbstractCompositeIconRenderer, CompositeIconRenderer, Legend +) +from .plot_label import PlotLabel +from .scatter_inspector_overlay import ScatterInspectorOverlay from .simple_inspector_overlay import ( - SimpleInspectorOverlay, basic_formatter, datetime_formatter, date_formatter, + SimpleInspectorOverlay, time_formatter, ) +from .text_box_overlay import TextBoxOverlay +from .text_grid_overlay import TextGridOverlay +from .tooltip import ToolTip +from ..tools.image_inspector_tool import ImageInspectorOverlay + +from chaco.overlays.layers.api import ( + ErrorLayer, + StatusLayer, + WarningLayer, +) diff --git a/chaco/overlays/colormapped_selection_overlay.py b/chaco/overlays/colormapped_selection_overlay.py new file mode 100644 index 000000000..977d54af3 --- /dev/null +++ b/chaco/overlays/colormapped_selection_overlay.py @@ -0,0 +1,201 @@ +# (C) Copyright 2006-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +""" Defines the ColormappedSelectionOverlay class. +""" +import functools + +from numpy import logical_and + +# Enthought library imports +from traits.api import Any, Bool, Float, Instance, Property, Enum +from traits.observation.events import TraitChangeEvent + +# Local imports +from chaco.abstract_overlay import AbstractOverlay +from chaco.plots.colormapped_scatterplot import ColormappedScatterPlot + + +class ColormappedSelectionOverlay(AbstractOverlay): + """ + Overlays and changes a ColormappedScatterPlot to fade its non-selected + points to a very low alpha. + """ + + #: The ColormappedScatterPlot that this overlay is listening to. + #: By default, it looks at self.component + plot = Property + + #: The amount to fade the unselected points. + fade_alpha = Float(0.15) + + #: The minimum difference, in float percent, between the starting and + #: ending selection values, if range selection mode is enabled + minimum_delta = Float(0.01) + + #: Outline width for selected points. + selected_outline_width = Float(1.0) + #: Outline width for unselected points. + unselected_outline_width = Float(0.0) + + #: The type of selection used by the data source. + selection_type = Enum("range", "mask") + + _plot = Instance(ColormappedScatterPlot) + + _visible = Bool(False) + + _old_alpha = Float + _old_outline_color = Any + _old_line_width = Float(0.0) + + def __init__(self, component=None, **kw): + super().__init__(**kw) + self.component = component + + def overlay(self, component, gc, view_bounds=None, mode="normal"): + """Draws this component overlaid on another component. + + Implements AbstractOverlay. + """ + if not self._visible: + return + + plot = self.plot + datasource = plot.color_data + + if self.selection_type == "range": + selections = datasource.metadata["selections"] + + if selections is not None and len(selections) == 0: + return + + low, high = selections + if abs(high - low) / abs(high + low) < self.minimum_delta: + return + + # Mask the data with just the points falling within the data + # range selected on the colorbar + data_pts = datasource.get_data() + mask = (data_pts >= low) & (data_pts <= high) + + elif self.selection_type == "mask": + mask = functools.reduce( + logical_and, datasource.metadata["selection_masks"] + ) + if sum(mask) < 2: + return + + datasource.set_mask(mask) + + # Store the current plot color settings before overwriting them + fade_outline_color = plot.outline_color_ + + # Overwrite marker outline color and fill alpha settings of + # the plot, then manually invoke the plot to draw onto the GC. + plot.outline_color = list(self._old_outline_color[:3]) + [1.0] + plot.fill_alpha = 1.0 + plot.line_width = self.selected_outline_width + plot._draw_plot(gc, view_bounds, mode) + + # Restore the plot's previous color settings and data mask. + plot.fill_alpha = self.fade_alpha + plot.outline_color = fade_outline_color + plot.line_width = self.unselected_outline_width + datasource.remove_mask() + + def _component_changed(self, old, new): + if old: + old.observe( + self.datasource_change_handler, "color_data", remove=True + ) + if new: + new.observe(self.datasource_change_handler, "color_data") + self._old_alpha = new.fill_alpha + self._old_outline_color = new.outline_color + self._old_line_width = new.line_width + + self.datasource_change_handler( + TraitChangeEvent( + object=new, name="color_data", old=None, new=new.color_data + ) + ) + + def datasource_change_handler(self, event): + old, new = (event.old, event.new) + + if old: + old.observe( + self.selection_change_handler, "metadata_changed", remove=True + ) + if new: + new.observe(self.selection_change_handler, "metadata_changed") + self.selection_change_handler( + TraitChangeEvent( + object=new, + name="metadata_changed", + old=None, + new=new.metadata, + ) + ) + + def selection_change_handler(self, event): + new = event.new + + if self.selection_type == "range": + selection_key = "selections" + elif self.selection_type == "mask": + selection_key = "selection_masks" + + if ( + type(new) == dict + and new.get(selection_key, None) is not None + and len(new[selection_key]) > 0 + ): + if not self._visible: + # We have a new selection, so replace the colors on the plot + # with the faded alpha and colors + plot = self.plot + + # Save the line width and set it to zero for the unselected + # points + self._old_line_width = plot.line_width + plot.line_width = self.unselected_outline_width + # Save the outline color and set it to the faded version + self._old_outline_color = plot.outline_color_ + outline_color = list(plot.outline_color_) + if len(outline_color) == 3: + outline_color += [self.fade_alpha] + else: + outline_color[3] = self.fade_alpha + plot.outline_color = outline_color + + # Save the alpha value and set it to a faded version + self._old_alpha = plot.fill_alpha + plot.fill_alpha = self.fade_alpha + + self.plot.invalidate_draw() + self._visible = True + else: + self.plot.fill_alpha = self._old_alpha + self.plot.outline_color = self._old_outline_color + self.plot.line_width = self._old_line_width + self.plot.invalidate_draw() + self._visible = False + + self.plot.request_redraw() + + def _get_plot(self): + if self._plot is not None: + return self._plot + else: + return self.component + + def _set_plot(self, val): + self._plot = val diff --git a/chaco/overlays/data_label.py b/chaco/overlays/data_label.py new file mode 100644 index 000000000..eb0fe8e2a --- /dev/null +++ b/chaco/overlays/data_label.py @@ -0,0 +1,565 @@ +""" Defines the DataLabel class and related trait and function. +""" +# Major library imports +from math import sqrt +from numpy import array, asarray, inf +from numpy.linalg import norm + +# Enthought library imports +from traits.api import Any, ArrayOrNone, Bool, Enum, Float, Int, List, \ + Str, Tuple, Trait, observe, Property +from enable.api import ColorTrait, MarkerTrait + +# Local, relative imports +from chaco.overlays.tooltip import ToolTip +from chaco.plots.scatterplot import render_markers + + +# Specifies the position of a label relative to its target. This can +# be one of the text strings indicated, or a tuple or list of floats +# representing the (x_offset, y_offset) in screen space of the label's +# lower left corner. +LabelPositionTrait = Trait("top right", + Enum("bottom", "left", "right", "top", + "top right", "top left", + "bottom left", "bottom right"), + Tuple, List) + + +def draw_arrow(gc, pt1, pt2, color, arrowhead_size=10.0, offset1=0, + offset2=0, arrow=None, minlen=0, maxlen=inf): + """ Renders an arrow from *pt1* to *pt2*. If gc is None, then just returns + the arrow object. + + Parameters + ========== + gc : graphics context + where to render the arrow + pt1 : point + the origin of the arrow + pt2 : point + where the arrow is pointing + color : a 3- or 4-tuple of color value + the color to use for the arrow stem and head + arrowhead_size : number + screen units corresponding to the length of the arrowhead + offset1 : number + the amount of space from the start of the arrow to pt1 + offset2 : number + the amount of space from the tip of the arrow to pt2 + arrow : object + an opaque object returned by previous calls to draw_arrow. If this + argument is provided, all other arguments (except gc) are ignored + minlen: number or None + the minimum length of the arrow; if the arrow is shorter than this, + it will not be drawn + maxlen: number or None + the maximum length of the arrow; if the arrow is longer than this, it + will not be drawn + + Returns + ======= + An 'arrow' (opaque object) which can be passed in to subsequent + calls to this method to short-circuit some of the computation. + Even if an arrow is not drawn (due to minlen/maxlen restrictions), + an arrow will be returned. + """ + + if arrow is None: + pt1 = asarray(pt1) + pt2 = asarray(pt2) + + unit_vec = pt2 - pt1 + unit_vec /= norm(unit_vec) + + if unit_vec[0] == 0: + perp_vec = array((0.3 * arrowhead_size, 0)) + elif unit_vec[1] == 0: + perp_vec = array((0, 0.3 * arrowhead_size)) + else: + slope = unit_vec[1] / unit_vec[0] + perp_slope = -1 / slope + perp_vec = array((1.0, perp_slope)) + perp_vec *= 0.3 * arrowhead_size / norm(perp_vec) + + pt1 = pt1 + offset1 * unit_vec + pt2 = pt2 - offset2 * unit_vec + + arrowhead_l = pt2 - (arrowhead_size * unit_vec + perp_vec) + arrowhead_r = pt2 - (arrowhead_size * unit_vec - perp_vec) + arrow = (pt1, pt2, arrowhead_l, arrowhead_r) + else: + pt1, pt2, arrowhead_l, arrowhead_r = arrow + + arrowlen = norm(pt2 - pt1) + if arrowlen < minlen or arrowlen > maxlen: + # This is the easiest way to circumvent the actual drawing + gc = None + + if gc is not None: + gc.set_stroke_color(color) + gc.set_fill_color(color) + gc.begin_path() + gc.move_to(*pt1) + gc.line_to(*pt2) + gc.stroke_path() + gc.move_to(*pt2) + gc.line_to(*arrowhead_l) + gc.line_to(*arrowhead_r) + gc.fill_path() + return arrow + + +def find_region(px, py, x, y, x2, y2): + """Classify the location of the point (px, py) relative to a rectangle. + + (x, y) and (x2, y2) are the lower-left and upper-right corners of the + rectangle, respectively. (px, py) is classified as "left", "right", + "top", "bottom" or "inside", according to the following diagram: + + \ top / # noqa + \ / # noqa + +----------+ # noqa + left | inside | right # noqa + +----------+ # noqa + / \ # noqa + / bottom \ # noqa + + """ + if px < x: + dx = x - px + if py > y2 + dx: + region = 'top' + elif py < y - dx: + region = 'bottom' + else: + region = 'left' + elif px > x2: + dx = px - x2 + if py > y2 + dx: + region = 'top' + elif py < y - dx: + region = 'bottom' + else: + region = 'right' + else: # x <= px <= x2 + if py > y2: + region = 'top' + elif py < y: + region = 'bottom' + else: + region = 'inside' + return region + + +class DataLabel(ToolTip): + """ A label on a point in data space. + + Optionally, an arrow is drawn to the point. + """ + + #: The symbol to use if **marker** is set to "custom". This attribute must + #: be a compiled path for the given Kiva context. + custom_symbol = Any + + #: The point in data space where this label should anchor itself. + data_point = ArrayOrNone() + + #: The location of the data label relative to the data point. + label_position = LabelPositionTrait + + #: The format string that determines the label's text. This string is + #: formatted using a dict containing the keys 'x' and 'y', corresponding to + #: data space values. + label_format = Str("(%(x)f, %(y)f)") + + #: The text to show on the label, or above the coordinates for the label, + #: if show_label_coords is True + label_text = Str + + #: Flag whether to show coordinates with the label or not. + show_label_coords = Bool(True) + + #: Does the label clip itself against the main plot area? If not, then + #: the label draws into the padding area (where axes typically reside). + clip_to_plot = Bool(True) + + #: The center x position (average of x and x2) + xmid = Property(Float, observe=['x', 'x2']) + + #: The center y position (average of y and y2) + ymid = Property(Float, observe=['y', 'y2']) + + #: 'box' is a simple rectangular box, with an arrow that is a single line + #: with an arrowhead at the data point. + #: 'bubble' can be given rounded corners (by setting `corner_radius`), and + #: the 'arrow' is a thin triangular wedge with its point at the data point. + #: When label_style is 'bubble', the following traits are ignored: + #: arrow_size, arrow_color, arrow_root, and arrow_max_length. + label_style = Enum('box', 'bubble') + + # ---------------------------------------------------------------------- + # Marker traits + # ---------------------------------------------------------------------- + + #: Mark the point on the data that this label refers to? + marker_visible = Bool(True) + + #: The type of marker to use. This is a mapped trait using strings as the + #: keys. + marker = MarkerTrait + + #: The pixel size of the marker (doesn't include the thickness of the + #: outline). + marker_size = Int(4) + + #: The thickness, in pixels, of the outline to draw around the marker. + #: If this is 0, no outline will be drawn. + marker_line_width = Float(1.0) + + #: The color of the inside of the marker. + marker_color = ColorTrait("red") + + #: The color out of the border drawn around the marker. + marker_line_color = ColorTrait("black") + + # ---------------------------------------------------------------------- + # Arrow traits + # ---------------------------------------------------------------------- + + #: Draw an arrow from the label to the data point? Only + #: used if **data_point** is not None. + arrow_visible = Bool(True) # FIXME: replace with some sort of ArrowStyle + + #: The length of the arrowhead, in screen points (e.g., pixels). + arrow_size = Float(10) + + #: The color of the arrow. + arrow_color = ColorTrait("black") + + #: The position of the base of the arrow on the label. If this + #: is 'auto', then the label uses **label_position**. Otherwise, it + #: treats the label as if it were at the label position indicated by + #: this attribute. + arrow_root = Trait("auto", "auto", "top left", "top right", "bottom left", + "bottom right", "top center", "bottom center", + "left center", "right center") + + #: The minimum length of the arrow before it will be drawn. By default, + #: the arrow will be drawn regardless of how short it is. + arrow_min_length = Float(0) + + #: The maximum length of the arrow before it will be drawn. By default, + #: the arrow will be drawn regardless of how long it is. + arrow_max_length = Float(inf) + + # ---------------------------------------------------------------------- + # Bubble traits + # ---------------------------------------------------------------------- + + #: The radius (in screen coordinates) of the curved corners of the "bubble" + corner_radius = Float(10) + + # ------------------------------------------------------------------------- + # Private traits + # ------------------------------------------------------------------------- + + # Tuple (sx, sy) of the mapped screen coordinates of **data_point**. + _screen_coords = Any + + _cached_arrow = Any + + # When **arrow_root** is 'auto', this determines the location on the data + # label from which the arrow is drawn, based on the position of the label + # relative to its data point. + _position_root_map = { + "top left": "bottom right", + "top right": "bottom left", + "bottom left": "top right", + "bottom right": "top left", + "top center": "bottom center", + "bottom center": "top center", + "left center": "right center", + "right center": "left center" + } + + _root_positions = { + "bottom right": ("x2", "y"), + "bottom left": ("x", "y"), + "top right": ("x2", "y2"), + "top left": ("x", "y2"), + "top center": ("xmid", "y2"), + "bottom center": ("xmid", "y"), + "left center": ("x", "ymid"), + "right center": ("x2", "ymid"), + } + + def overlay(self, component, gc, view_bounds=None, mode="normal"): + """ Draws the tooltip overlaid on another component. + + Overrides and extends ToolTip.overlay() + """ + if self.clip_to_plot: + gc.save_state() + c = component + gc.clip_to_rect(c.x, c.y, c.width, c.height) + + self.do_layout() + + if self.label_style == 'box': + self._render_box(component, gc, view_bounds=view_bounds, + mode=mode) + else: + self._render_bubble(component, gc, view_bounds=view_bounds, + mode=mode) + + # draw the marker + if self.marker_visible: + render_markers(gc, [self._screen_coords], + self.marker, self.marker_size, + self.marker_color_, self.marker_line_width, + self.marker_line_color_, self.custom_symbol) + + if self.clip_to_plot: + gc.restore_state() + + def _render_box(self, component, gc, view_bounds=None, mode='normal'): + # draw the arrow if necessary + if self.arrow_visible: + if self._cached_arrow is None: + if self.arrow_root in self._root_positions: + ox, oy = self._root_positions[self.arrow_root] + else: + if self.arrow_root == "auto": + arrow_root = self.label_position + else: + arrow_root = self.arrow_root + pos = self._position_root_map.get(arrow_root, "DUMMY") + ox, oy = self._root_positions.get( + pos, + (self.x + self.width / 2, self.y + self.height / 2) + ) + + if type(ox) == str: + ox = getattr(self, ox) + oy = getattr(self, oy) + self._cached_arrow = draw_arrow( + gc, + (ox, oy), + self._screen_coords, + self.arrow_color_, + arrowhead_size=self.arrow_size, + offset1=3, + offset2=self.marker_size + 3, + minlen=self.arrow_min_length, + maxlen=self.arrow_max_length + ) + else: + draw_arrow( + gc, + None, + None, + self.arrow_color_, + arrow=self._cached_arrow, + minlen=self.arrow_min_length, + maxlen=self.arrow_max_length + ) + + # layout and render the label itself + ToolTip.overlay(self, component, gc, view_bounds, mode) + + def _render_bubble(self, component, gc, view_bounds=None, mode='normal'): + """ Render the bubble label in the graphics context. """ + # (px, py) is the data point in screen space. + px, py = self._screen_coords + + # (x, y) is the lower left corner of the label. + x = self.x + y = self.y + # (x2, y2) is the upper right corner of the label. + x2 = self.x2 + y2 = self.y2 + # r is the corner radius. + r = self.corner_radius + + if self.arrow_visible: + # FIXME: Make 'gap_width' a configurable trait (and give it a + # better name). + max_gap_width = 10 + gap_width = min(max_gap_width, + abs(x2 - x - 2 * r), + abs(y2 - y - 2 * r)) + region = find_region(px, py, x, y, x2, y2) + + # Figure out where the "arrow" connects to the "bubble". + if region == 'left' or region == 'right': + gap_start = py - gap_width / 2 + if gap_start < y + r: + gap_start = y + r + elif gap_start > y2 - r - gap_width: + gap_start = y2 - r - gap_width + by = gap_start + 0.5 * gap_width + if region == 'left': + bx = x + else: + bx = x2 + else: + gap_start = px - gap_width / 2 + if gap_start < x + r: + gap_start = x + r + elif gap_start > x2 - r - gap_width: + gap_start = x2 - r - gap_width + bx = gap_start + 0.5 * gap_width + if region == 'top': + by = y2 + else: + by = y + arrow_len = sqrt((px - bx)**2 + (py - by)**2) + + arrow_visible = (self.arrow_visible and + (arrow_len >= self.arrow_min_length)) + + with gc: + if self.border_visible: + gc.set_line_width(self.border_width) + gc.set_stroke_color(self.border_color_) + else: + gc.set_line_width(0) + gc.set_stroke_color((0, 0, 0, 0)) + gc.set_fill_color(self.bgcolor_) + + # Start at the lower left, on the left edge where the curved + # part of the box ends. + gc.move_to(x, y + r) + + # Draw the left side and the upper left curved corner. + if arrow_visible and region == 'left': + gc.line_to(x, gap_start) + gc.line_to(px, py) + gc.line_to(x, gap_start + gap_width) + gc.arc_to(x, y2, x + r, y2, r) + + # Draw the top and the upper right curved corner. + if arrow_visible and region == 'top': + gc.line_to(gap_start, y2) + gc.line_to(px, py) + gc.line_to(gap_start + gap_width, y2) + gc.arc_to(x2, y2, x2, y2 - r, r) + + # Draw the right side and the lower right curved corner. + if arrow_visible and region == 'right': + gc.line_to(x2, gap_start + gap_width) + gc.line_to(px, py) + gc.line_to(x2, gap_start) + gc.arc_to(x2, y, x2 - r, y, r) + + # Draw the bottom and the lower left curved corner. + if arrow_visible and region == 'bottom': + gc.line_to(gap_start + gap_width, y) + gc.line_to(px, py) + gc.line_to(gap_start, y) + gc.arc_to(x, y, x, y + r, r) + + # Finish the "bubble". + gc.draw_path() + + self._draw_overlay(gc) + + def _do_layout(self, size=None): + """Computes the size and position of the label and arrow. + + Overrides and extends ToolTip._do_layout() + """ + if not self.component or not hasattr(self.component, "map_screen"): + return + + # Call the parent class layout. This computes all the label + ToolTip._do_layout(self) + + self._screen_coords = self.component.map_screen([self.data_point])[0] + sx, sy = self._screen_coords + + if isinstance(self.label_position, str): + orientation = self.label_position + if ("left" in orientation) or ("right" in orientation): + if " " not in orientation: + self.y = sy - self.height / 2 + if "left" in orientation: + self.outer_x = sx - self.outer_width - 1 + elif "right" in orientation: + self.outer_x = sx + if ("top" in orientation) or ("bottom" in orientation): + if " " not in orientation: + self.x = sx - self.width / 2 + if "bottom" in orientation: + self.outer_y = sy - self.outer_height - 1 + elif "top" in orientation: + self.outer_y = sy + if "center" in orientation: + if " " not in orientation: + self.x = sx - (self.width / 2) + self.y = sy - (self.height / 2) + else: + self.x = sx - (self.outer_width / 2) - 1 + self.y = sy - (self.outer_height / 2) - 1 + else: + self.x = sx + self.label_position[0] + self.y = sy + self.label_position[1] + + self._cached_arrow = None + + def _data_point_changed(self, old, new): + if new is not None: + self._create_new_labels() + + def _label_format_changed(self, old, new): + self._create_new_labels() + + def _label_text_changed(self, old, new): + self._create_new_labels() + + def _show_label_coords_changed(self, old, new): + self._create_new_labels() + + def _create_new_labels(self): + pt = self.data_point + if pt is not None: + if self.show_label_coords: + self.lines = [self.label_text, + self.label_format % {"x": pt[0], "y": pt[1]}] + else: + self.lines = [self.label_text] + + def _component_changed(self, old, new): + for comp, attach in ((old, False), (new, True)): + if comp is not None: + if hasattr(comp, 'index_mapper'): + self._modify_mapper_listeners(comp.index_mapper, + attach=attach) + if hasattr(comp, 'value_mapper'): + self._modify_mapper_listeners(comp.value_mapper, + attach=attach) + + def _modify_mapper_listeners(self, mapper, attach=True): + if mapper is not None: + mapper.observe(self._handle_mapper, 'updated', remove=not attach) + + def _handle_mapper(self, event): + # This gets fired whenever a mapper on our plot fires its + # 'updated' event. + self._layout_needed = True + + @observe("arrow_size,arrow_root,arrow_min_length,arrow_max_length") + def _invalidate_arrow(self, event): + self._cached_arrow = None + self._layout_needed = True + + @observe("label_position,position.items,bounds.items") + def _invalidate_layout(self, event): + self._layout_needed = True + + def _get_xmid(self): + return 0.5 * (self.x + self.x2) + + def _get_ymid(self): + return 0.5 * (self.y + self.y2) diff --git a/chaco/overlays/lasso_overlay.py b/chaco/overlays/lasso_overlay.py new file mode 100644 index 000000000..6a6b2fb02 --- /dev/null +++ b/chaco/overlays/lasso_overlay.py @@ -0,0 +1,91 @@ +# (C) Copyright 2006-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +""" Defines the LassoOverlay class. +""" + + +from numpy import concatenate, newaxis + +# Enthought library imports +from enable.api import ColorTrait, LineStyle +from traits.api import Float, Instance, Bool + +# Local imports +from chaco.abstract_overlay import AbstractOverlay + + +class LassoOverlay(AbstractOverlay): + """Draws a lasso selection region on top of a plot. + + LassoOverlay gets its data from a LassoSelection. + """ + + #: The LassoSelection that provides the data for this overlay. + lasso_selection = Instance("chaco.tools.lasso_selection.LassoSelection") + #: The fill color for the selection region. + selection_fill_color = ColorTrait("lightskyblue") + #: The border color for the selection region. + selection_border_color = ColorTrait("dodgerblue") + #: The transparency level for the selection fill color. + selection_alpha = Float(0.8) + #: The width of the selection border. + selection_border_width = Float(2.0) + #: The line style of the selection border. + selection_border_dash = LineStyle + + #: The background color (overrides AbstractOverlay). + bgcolor = "clear" + + # Whether to draw the lasso + # depends on the state of the lasso tool + _draw_selection = Bool(False) + + def overlay(self, other_component, gc, view_bounds=None, mode="normal"): + """Draws this component overlaid on another component. + + Implements AbstractOverlay. + """ + if not self._draw_selection: + return + with gc: + c = other_component + gc.clip_to_rect(c.x, c.y, c.width, c.height) + self._draw_component(gc, view_bounds, mode) + + def _updated_changed_for_lasso_selection(self): + self.component.invalidate_draw() + self.component.request_redraw() + + def _event_state_fired_for_lasso_selection(self, val): + self._draw_selection = val == "selecting" + self.component.invalidate_draw() + self.component.request_redraw() + + def _draw_component(self, gc, view_bounds=None, mode="normal"): + """Draws the component. + + This method is preserved for backwards compatibility with _old_draw(). + Overrides PlotComponent. + """ + with gc: + # We may need to make map_screen more flexible in the number of + # dimensions it accepts for ths to work well. + for selection in self.lasso_selection.disjoint_selections: + points = self.component.map_screen(selection) + if len(points) == 0: + return + points = concatenate((points, points[0, newaxis]), axis=0) + gc.set_line_width(self.selection_border_width) + gc.set_line_dash(self.selection_border_dash_) + gc.set_fill_color(self.selection_fill_color_) + gc.set_stroke_color(self.selection_border_color_) + gc.set_alpha(self.selection_alpha) + gc.lines(points) + gc.draw_path() diff --git a/chaco/overlays/layers/__init__.py b/chaco/overlays/layers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/chaco/overlays/layers/api.py b/chaco/overlays/layers/api.py new file mode 100644 index 000000000..9e7569edd --- /dev/null +++ b/chaco/overlays/layers/api.py @@ -0,0 +1 @@ +from .status_layer import ErrorLayer, StatusLayer, WarningLayer diff --git a/chaco/layers/data/Dialog-error.svg b/chaco/overlays/layers/data/Dialog-error.svg similarity index 100% rename from chaco/layers/data/Dialog-error.svg rename to chaco/overlays/layers/data/Dialog-error.svg diff --git a/chaco/layers/data/Dialog-warning.svg b/chaco/overlays/layers/data/Dialog-warning.svg similarity index 100% rename from chaco/layers/data/Dialog-warning.svg rename to chaco/overlays/layers/data/Dialog-warning.svg diff --git a/chaco/layers/data/range_selection.svg b/chaco/overlays/layers/data/range_selection.svg similarity index 100% rename from chaco/layers/data/range_selection.svg rename to chaco/overlays/layers/data/range_selection.svg diff --git a/chaco/overlays/layers/status_layer.py b/chaco/overlays/layers/status_layer.py new file mode 100644 index 000000000..19559f0d8 --- /dev/null +++ b/chaco/overlays/layers/status_layer.py @@ -0,0 +1,146 @@ +import os.path +import xml.etree.cElementTree as etree + +from chaco.abstract_overlay import AbstractOverlay +from pyface.timer.timer import Timer +from traits.api import Instance, Str, Enum, Float, Int +from enable.savage.svg.document import SVGDocument +from enable.savage.svg.backends.kiva.renderer import Renderer as KivaRenderer + + +class StatusLayer(AbstractOverlay): + + filename = Str() + document = Instance(SVGDocument) + + # Default size attributes if the svg does not specify them + doc_width = 48.0 + doc_height = 48.0 + + # The type determines if the layer is displayed as part of the component's + # overlay or underlays + type = Enum("overlay", "underlay") + + # The position of the legend with respect to its overlaid component. + # + # * c = Center + # * ur = Upper Right + # * ul = Upper Left + # * ll = Lower Left + # * lr = Lower Right + align = Enum("c", "ur", "ul", "ll", "lr") + + # How big should the graphic be in comparison to the rest of the plot + # area + scale_factor = Float(0.5) + + # Initial transparency + alpha = Float(1.0) + + # The minimum time it takes for the the layer to fade out, in + # milliseconds. Actual time may be longer, depending on the pyface toolkit + fade_out_time = Float(50) + + # The number of steps to take to fade from the initial transparency to + # invisible + fade_out_steps = Int(10) + + def __init__(self, component, *args, **kw): + super().__init__(component, *args, **kw) + + if self.document is None: + if self.filename == "": + self.filename = os.path.join( + os.path.dirname(__file__), "data", "Dialog-error.svg" + ) + tree = etree.parse(self.filename) + root = tree.getroot() + self.document = SVGDocument(root, renderer=KivaRenderer) + + if hasattr(self.document, "getSize"): + self.doc_width = self.document.getSize()[0] + self.doc_height = self.document.getSize()[1] + + def overlay(self, other_component, gc, view_bounds=None, mode="normal"): + """Draws this component overlaid on another component. + Implements AbstractOverlay. + """ + with gc: + gc.set_alpha(self.alpha) + + plot_width = self.component.width + plot_height = self.component.height + + origin_x = self.component.padding_left + origin_y = self.component.padding_top + + # zoom percentage, use the scale_factor as a % of the plot size. + # base the size on the smaller aspect - if the plot is tall and + # narrow the overlay should be 50% of the width, if the plot is + # short and wide the overlay should be 50% of the height. + if gc.height() < gc.width(): + scale = (plot_height / self.doc_height) * self.scale_factor + else: + scale = (plot_width / self.doc_width) * self.scale_factor + + scale_width = scale * self.doc_width + scale_height = scale * self.doc_height + + # Set up the transforms to align the graphic to the desired + # position + if self.align == "ur": + gc.translate_ctm( + origin_x + (plot_width - scale_width), + origin_y + plot_height, + ) + elif self.align == "lr": + gc.translate_ctm( + origin_x + (plot_width - scale_width), + origin_y + scale_height, + ) + elif self.align == "ul": + gc.translate_ctm(origin_x, origin_y + plot_height) + elif self.align == "ll": + gc.translate_ctm(origin_x, origin_y + scale_height) + else: + gc.translate_ctm( + origin_x + (plot_width - scale_width) / 2, + origin_y + (plot_height + scale_height) / 2, + ) + + # SVG origin is the upper right with y positive down, so + # we need to flip everything + gc.scale_ctm(scale, -scale) + + self.document.render(gc) + + def fade_out(self): + interval = self.fade_out_time / self.fade_out_steps + self.timer = Timer(interval, self._fade_out_step) + + def _fade_out_step(self): + """Fades out the overlay over a half second. then removes it from + the other_component's overlays + """ + if self.alpha <= 0: + if self.type == "overlay": + self.component.overlays.remove(self) + else: + self.component.underlays.remove(self) + self.alpha = 1.0 + raise StopIteration + else: + self.alpha -= 0.1 + self.component.request_redraw() + + +class ErrorLayer(StatusLayer): + filename = os.path.join( + os.path.dirname(__file__), "data", "Dialog-error.svg" + ) + + +class WarningLayer(StatusLayer): + filename = os.path.join( + os.path.dirname(__file__), "data", "Dialog-warning.svg" + ) diff --git a/chaco/overlays/layers/svg_range_selection_overlay.py b/chaco/overlays/layers/svg_range_selection_overlay.py new file mode 100644 index 000000000..0929f6740 --- /dev/null +++ b/chaco/overlays/layers/svg_range_selection_overlay.py @@ -0,0 +1,137 @@ +import os +import numpy + +from chaco.grid_mapper import GridMapper +from traits.api import Property, Enum, Str, cached_property + +from .status_layer import StatusLayer + + +class SvgRangeSelectionOverlay(StatusLayer): + """This is a primitive range selection overlay which uses + a SVG to define the overlay. + + TODO: not inherit from StatusLayer, this was a convenience for a + quick prototype + + TODO: use 2 svgs, one which defines the border and does not scale, and + the other which defines the fill. + """ + + filename = os.path.join( + os.path.dirname(__file__), "data", "range_selection.svg" + ) + + alpha = 0.5 + + # The axis to which this tool is perpendicular. + axis = Enum("index", "value") + + axis_index = Property(observe="axis") + + # Mapping from screen space to data space. By default, it is just + # self.component. + plot = Property(observe="component") + + # The mapper (and associated range) that drive this RangeSelectionOverlay. + # By default, this is the mapper on self.plot that corresponds to self.axis + mapper = Property(observe="plot") + + # The name of the metadata to look at for dataspace bounds. The metadata + # can be either a tuple (dataspace_start, dataspace_end) in "selections" or + # a boolean array mask of seleted dataspace points with any other name + metadata_name = Str("selections") + + def overlay(self, component, gc, view_bounds=None, mode="normal"): + """Draws this component overlaid on another component. + + Overrides AbstractOverlay. + """ + # Draw the selection + coords = self._get_selection_screencoords() + + if len(coords) == 0: + return + + with gc: + gc.set_alpha(self.alpha) + + plot_width = self.component.width + plot_height = self.component.height + + origin_x = self.component.padding_left + origin_y = self.component.padding_top + + if self.axis == "index": + if isinstance(self.mapper, GridMapper): + scale_width = ( + (coords[-1][0] - coords[0][0]) / self.doc_width + ) + else: + scale_width = ( + (coords[0][-1] - coords[0][0]) / self.doc_width + ) + scale_height = float(plot_height) / self.doc_height + gc.translate_ctm(coords[0][0], origin_y + plot_height) + else: + scale_height = (coords[0][-1] - coords[0][0]) / self.doc_height + scale_width = float(plot_width) / self.doc_width + gc.translate_ctm(origin_x, coords[0][0]) + + # SVG origin is the upper right with y positive down, so + # we need to flip everything + gc.scale_ctm(scale_width, -scale_height) + + self.document.render(gc) + + def _get_selection_screencoords(self): + """Returns a tuple of (x1, x2) screen space coordinates of the start + and end selection points. + + If there is no current selection, then returns None. + """ + ds = getattr(self.plot, self.axis) + selection = ds.metadata[self.metadata_name] + + # "selections" metadata must be a tuple + if self.metadata_name == "selections": + if selection is not None and len(selection) == 2: + return [self.mapper.map_screen(numpy.array(selection))] + else: + return [] + # All other metadata is interpreted as a mask on dataspace + else: + ar = numpy.arange(0, len(selection), 1) + runs = arg_find_runs(ar[selection]) + coords = [] + for inds in runs: + start = ds._data[ar[selection][inds[0]]] + end = ds._data[ar[selection][inds[1] - 1]] + coords.append(self.map_screen(numpy.array((start, end)))) + return coords + + @cached_property + def _get_plot(self): + return self.component + + @cached_property + def _get_axis_index(self): + if self.axis == "index": + return 0 + else: + return 1 + + @cached_property + def _get_mapper(self): + # If the plot's mapper is a GridMapper, return either its + # x mapper or y mapper + + mapper = getattr(self.plot, self.axis + "_mapper") + + if isinstance(mapper, GridMapper): + if self.axis == "index": + return mapper._xmapper + else: + return mapper._ymapper + else: + return mapper diff --git a/chaco/overlays/legend.py b/chaco/overlays/legend.py new file mode 100644 index 000000000..40168f374 --- /dev/null +++ b/chaco/overlays/legend.py @@ -0,0 +1,542 @@ +""" Defines the Legend, AbstractCompositeIconRenderer, and +CompositeIconRenderer classes. +""" + + +from numpy import array, zeros_like + +from enable.api import black_color_trait, white_color_trait +from enable.font_metrics_provider import font_metrics_provider +from kiva.trait_defs.kiva_font_trait import KivaFont +from traits.api import ( + ArrayOrNone, + Bool, + CList, + Dict, + Enum, + Float, + HasTraits, + Instance, + Int, + List, + observe, + Str, +) + +# Local relative imports +from chaco.abstract_overlay import AbstractOverlay +from chaco.label import Label +from chaco.plot_component import PlotComponent +from chaco.plots.lineplot import LinePlot +from chaco.plots.scatterplot import ScatterPlot + + +class AbstractCompositeIconRenderer(HasTraits): + """Abstract class for an icon renderer.""" + + def render_icon(self, plots, gc, x, y, width, height): + """Renders an icon representing the given list of plots onto the + graphics context, using the given dimensions and at the specified + position. + """ + raise NotImplementedError + + +class CompositeIconRenderer(AbstractCompositeIconRenderer): + """Renderer for composite icons.""" + + def render_icon(self, plots, *render_args): + """ Renders an icon for a list of plots. """ + types = set(map(type, plots)) + if types == set([ScatterPlot]): + self._render_scatterplots(plots, *render_args) + elif types == set([LinePlot]): + self._render_lineplots(plots, *render_args) + elif types == set([ScatterPlot, LinePlot]): + self._render_line_scatter(plots, *render_args) + else: + raise ValueError( + "Don't know how to render combination plot with " + + "renderers " + + str(types) + ) + + def _render_scatterplots(self, plots, gc, x, y, width, height): + # Don't support this for now + pass + + def _render_lineplots(self, plots, gc, x, y, width, height): + # Assume they are all the same color/appearance and use the first one + plots[0]._render_icon(gc, x, y, width, height) + + def _render_line_scatter(self, plots, gc, x, y, width, height): + # Separate plots into line and scatter renderers; render one of each + scatter = [p for p in plots if type(p) == ScatterPlot] + line = [p for p in plots if type(p) == LinePlot] + line[0]._render_icon(gc, x, y, width, height) + scatter[0]._render_icon(gc, x, y, width, height) + + +class Legend(AbstractOverlay): + """A legend for a plot.""" + + #: The font to use for the legend text. + font = KivaFont("modern 12") + + #: The amount of space between the content of the legend and the border. + border_padding = Int(10) + + #: The border is visible (overrides Enable Component). + border_visible = True + + #: The color of the text labels + color = black_color_trait + + #: The background color of the legend (overrides AbstractOverlay). + bgcolor = white_color_trait + + #: The position of the legend with respect to its overlaid component. (This + #: attribute applies only if the legend is used as an overlay.) + #: + #: * ur = Upper Right + #: * ul = Upper Left + #: * ll = Lower Left + #: * lr = Lower Right + align = Enum("ur", "ul", "ll", "lr") + + #: The amount of space between legend items. + line_spacing = Int(3) + + #: The size of the icon or marker area drawn next to the label. + icon_bounds = List([24, 24]) + + #: Amount of spacing between each label and its icon. + icon_spacing = Int(5) + + #: Map of labels (strings) to plot instances or lists of plot instances. + #: The Legend determines the appropriate rendering of each plot's + #: marker/line. + plots = Dict + + #: The list of labels to show and the order to show them in. If this + #: list is blank, then the keys of self.plots is used and displayed in + #: alphabetical order. Otherwise, only the items in the **labels** + #: list are drawn in the legend. Labels are ordered from top to bottom. + labels = List + + #: Whether or not to hide plots that are not visible. (This is checked + #: during layout.) This option *will* filter out the items in **labels** + #: above, so if you absolutely, positively want to set the items that will + #: always display in the legend, regardless of anything else, then you + #: should turn this option off. Otherwise, it usually makes sense that a + #: plot renderer that is not visible will also not be in the legend. + hide_invisible_plots = Bool(True) + + #: If hide_invisible_plots is False, we can still choose to render the + #: names of invisible plots with an alpha. + invisible_plot_alpha = Float(0.33) + + #: The renderer that draws the icons for the legend. + composite_icon_renderer = Instance(AbstractCompositeIconRenderer) + + #: Action that the legend takes when it encounters a plot whose icon it + #: cannot render: + #: + #: * 'skip': skip it altogether and don't render its name + #: * 'blank': render the name but leave the icon blank (color=self.bgcolor) + #: * 'questionmark': render a "question mark" icon + error_icon = Enum("skip", "blank", "questionmark") + + #: Should the legend clip to the bounds it needs, or to its parent? + clip_to_component = Bool(False) + + #: The legend is not resizable (overrides PlotComponent). + resizable = "hv" + + #: An optional title string to show on the legend. + title = Str("") + + #: If True, title is at top, if False then at bottom. + title_at_top = Bool(True) + + #: The legend draws itself as in one pass when its parent is drawing + #: the **draw_layer** (overrides PlotComponent). + unified_draw = True + #: The legend is drawn on the overlay layer of its parent (overrides + #: PlotComponent). + draw_layer = "overlay" + + # ------------------------------------------------------------------------ + # Private Traits + # ------------------------------------------------------------------------ + + # A cached list of Label instances + _cached_labels = List + + # A cached array of label sizes. + _cached_label_sizes = ArrayOrNone() + + # A cached list of label names. + _cached_label_names = CList + + # A list of the visible plots. Each plot corresponds to the label at + # the same index in _cached_label_names. This list does not necessarily + # correspond to self.plots.value() because it is sorted according to + # the plot name and it potentially excludes invisible plots. + _cached_visible_plots = CList + + # A cached array of label positions relative to the legend's origin + _cached_label_positions = ArrayOrNone() + + def is_in(self, x, y): + """overloads from parent class because legend alignment + and padding does not cooperatate with the basic implementation + + This may just be caused byt a questionable implementation of the + legend tool, but it works by adjusting the padding. The Component + class implementation of is_in uses the outer positions which + includes the padding + """ + in_x = (x >= self.x) and (x <= self.x + self.width) + in_y = (y >= self.y) and (y <= self.y + self.height) + + return in_x and in_y + + def overlay(self, component, gc, view_bounds=None, mode="normal"): + """Draws this component overlaid on another component. + + Implements AbstractOverlay. + """ + self.do_layout() + valign, halign = self.align + if valign == "u": + y = component.y2 - self.outer_height + else: + y = component.y + if halign == "r": + x = component.x2 - self.outer_width + else: + x = component.x + self.outer_position = [x, y] + + if self.clip_to_component: + c = self.component + with gc: + gc.clip_to_rect(c.x, c.y, c.width, c.height) + PlotComponent._draw(self, gc, view_bounds, mode) + else: + PlotComponent._draw(self, gc, view_bounds, mode) + + # The following two methods implement the functionality of the Legend + # to act as a first-class component instead of merely as an overlay. + # The make the Legend use the normal PlotComponent render methods when + # it does not have a .component attribute, so that it can have its own + # overlays (e.g. a PlotLabel). + # + # The core legend rendering method is named _draw_as_overlay() so that + # it can be called from _draw_plot() when the Legend is not an overlay, + # and from _draw_overlay() when the Legend is an overlay. + + def _draw_plot(self, gc, view_bounds=None, mode="normal"): + if self.component is None: + self._draw_as_overlay(gc, view_bounds, mode) + + def _draw_overlay(self, gc, view_bounds=None, mode="normal"): + if self.component is not None: + self._draw_as_overlay(gc, view_bounds, mode) + else: + PlotComponent._draw_overlay(self, gc, view_bounds, mode) + + def _draw_as_overlay(self, gc, view_bounds=None, mode="normal"): + """Draws the overlay layer of a component. + + Overrides PlotComponent. + """ + # Determine the position we are going to draw at from our alignment + # corner and the corresponding outer_padding parameters. (Position + # refers to the lower-left corner of our border.) + + # First draw the border, if necesssary. This sort of duplicates + # the code in PlotComponent._draw_overlay, which is unfortunate; + # on the other hand, overlays of overlays seem like a rather obscure + # feature. + + with gc: + gc.clip_to_rect( + int(self.x), int(self.y), int(self.width), int(self.height) + ) + edge_space = self.border_width + self.border_padding + icon_width, icon_height = self.icon_bounds + + icon_x = self.x + edge_space + text_x = icon_x + icon_width + self.icon_spacing + y = self.y2 - edge_space + + if self._cached_label_positions is not None: + if len(self._cached_label_positions) > 0: + self._cached_label_positions[:, 0] = icon_x + + for i, label_name in enumerate(self._cached_label_names): + # Compute the current label's position + label_height = self._cached_label_sizes[i][1] + y -= label_height + self._cached_label_positions[i][1] = y + + # Try to render the icon + icon_y = y + (label_height - icon_height) / 2 + plots = self._cached_visible_plots[i] + render_args = (gc, icon_x, icon_y, icon_width, icon_height) + + try: + if isinstance(plots, list) or isinstance(plots, tuple): + # TODO: How do we determine if a *group* of plots is + # visible or not? For now, just look at the first one + # and assume that applies to all of them + if not plots[0].visible: + # TODO: the get_alpha() method isn't supported on + # the Mac kiva backend + # old_alpha = gc.get_alpha() + old_alpha = 1.0 + gc.set_alpha(self.invisible_plot_alpha) + else: + old_alpha = None + if len(plots) == 1: + plots[0]._render_icon(*render_args) + else: + self.composite_icon_renderer.render_icon( + plots, *render_args + ) + elif plots is not None: + # Single plot + if not plots.visible: + # old_alpha = gc.get_alpha() + old_alpha = 1.0 + gc.set_alpha(self.invisible_plot_alpha) + else: + old_alpha = None + plots._render_icon(*render_args) + else: + old_alpha = None # Or maybe 1.0? + + icon_drawn = True + except BaseException: + icon_drawn = self._render_error(*render_args) + + if icon_drawn: + # Render the text + gc.translate_ctm(text_x, y) + gc.set_antialias(0) + self._cached_labels[i].draw(gc) + gc.set_antialias(1) + gc.translate_ctm(-text_x, -y) + + # Advance y to the next label's baseline + y -= self.line_spacing + if old_alpha is not None: + gc.set_alpha(old_alpha) + + def _render_error(self, gc, icon_x, icon_y, icon_width, icon_height): + """Renders an error icon or performs some other action when a + plot is unable to render its icon. + + Returns True if something was actually drawn (and hence the legend + needs to advance the line) or False if nothing was drawn. + """ + if self.error_icon == "skip": + return False + elif self.error_icon == "blank" or self.error_icon == "questionmark": + with gc: + gc.set_fill_color(self.bgcolor_) + gc.rect(icon_x, icon_y, icon_width, icon_height) + gc.fill_path() + return True + else: + return False + + def get_preferred_size(self): + """ + Computes the size and position of the legend based on the maximum size + of the labels, the alignment, and position of the component to overlay. + """ + # Gather the names of all the labels we will create + if len(self.plots) == 0: + return [0, 0] + + plot_names, visible_plots = list( + map(list, zip(*sorted(self.plots.items()))) + ) + label_names = self.labels + if len(label_names) == 0: + if len(self.plots) > 0: + label_names = plot_names + else: + self._cached_labels = [] + self._cached_label_sizes = [] + self._cached_label_names = [] + self._cached_visible_plots = [] + self.outer_bounds = [0, 0] + return [0, 0] + + if self.hide_invisible_plots: + visible_labels = [] + visible_plots = [] + for name in label_names: + # If the user set self.labels, there might be a bad value, + # so ensure that each name is actually in the plots dict. + if name in self.plots: + val = self.plots[name] + # Rather than checking for a list/TraitListObject/etc., we + # just check for the attribute first + if hasattr(val, "visible"): + if val.visible: + visible_labels.append(name) + visible_plots.append(val) + else: + # If we have a list of renderers, add the name if any + # of them are visible + for renderer in val: + if renderer.visible: + visible_labels.append(name) + visible_plots.append(val) + break + label_names = visible_labels + + # Create the labels + labels = [self._create_label(text) for text in label_names] + + # For the legend title + if self.title_at_top: + labels.insert(0, self._create_label(self.title)) + label_names.insert(0, "Legend Label") + visible_plots.insert(0, None) + else: + labels.append(self._create_label(self.title)) + label_names.append(self.title) + visible_plots.append(None) + + # We need a dummy GC in order to get font metrics + dummy_gc = font_metrics_provider() + label_sizes = array( + [label.get_width_height(dummy_gc) for label in labels] + ) + + if len(label_sizes) > 0: + max_label_width = max(label_sizes[:, 0]) + total_label_height = ( + sum(label_sizes[:, 1]) + + (len(label_sizes) - 1) * self.line_spacing + ) + else: + max_label_width = 0 + total_label_height = 0 + + legend_width = ( + max_label_width + + self.icon_spacing + + self.icon_bounds[0] + + self.hpadding + + 2 * self.border_padding + ) + legend_height = ( + total_label_height + self.vpadding + 2 * self.border_padding + ) + + self._cached_labels = labels + self._cached_label_sizes = label_sizes + self._cached_label_positions = zeros_like(label_sizes) + self._cached_label_names = label_names + self._cached_visible_plots = visible_plots + + if "h" not in self.resizable: + legend_width = self.outer_width + if "v" not in self.resizable: + legend_height = self.outer_height + return [legend_width, legend_height] + + def get_label_at(self, x, y): + """ Returns the label object at (x,y) """ + for i, pos in enumerate(self._cached_label_positions): + size = self._cached_label_sizes[i] + corner = pos + size + if (pos[0] <= x <= corner[0]) and (pos[1] <= y <= corner[1]): + return self._cached_labels[i] + else: + return None + + def _do_layout(self): + if ( + self.component is not None + or len(self._cached_labels) == 0 + or self._cached_label_sizes is None + or len(self._cached_label_names) == 0 + ): + width, height = self.get_preferred_size() + self.outer_bounds = [width, height] + + def _create_label(self, text): + """Returns a new Label instance for the given text. Subclasses can + override this method to customize the creation of labels. + """ + return Label( + text=text, + font=self.font, + margin=0, + color=self.color_, + bgcolor="transparent", + border_width=0, + ) + + def _composite_icon_renderer_default(self): + return CompositeIconRenderer() + + # -- trait handlers ------------------------------------------------------- + @observe([ + "font", + "border_padding", + "padding", + "line_spacing", + "icon_bounds", + "icon_spacing", + "labels.items", + "plots.items", + "border_width", + "align", + "position.items", + "bounds.items", + "title_at_top", + ]) + def _invalidate_existing_layout(self, event): + self._layout_needed = True + + @observe("color") + def _update_caches(self, event): + self.get_preferred_size() + + def _plots_changed(self): + """Invalidate the caches.""" + self._cached_labels = [] + self._cached_label_sizes = None + self._cached_label_names = [] + self._cached_visible_plots = [] + self._cached_label_positions = None + + def _title_at_top_changed(self, old, new): + """ Trait handler for when self.title_at_top changes. """ + if old is True: + indx = 0 + else: + indx = -1 + if old is not None: + self._cached_labels.pop(indx) + self._cached_label_names.pop(indx) + self._cached_visible_plots.pop(indx) + + # For the legend title + if self.title_at_top: + self._cached_labels.insert(0, self._create_label(self.title)) + self._cached_label_names.insert(0, "__legend_label__") + self._cached_visible_plots.insert(0, None) + else: + self._cached_labels.append(self._create_label(self.title)) + self._cached_label_names.append(self.title) + self._cached_visible_plots.append(None) diff --git a/chaco/overlays/plot_label.py b/chaco/overlays/plot_label.py new file mode 100644 index 000000000..ae06f611b --- /dev/null +++ b/chaco/overlays/plot_label.py @@ -0,0 +1,231 @@ +# (C) Copyright 2006-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +""" Defines the PlotLabel class. +""" + + +from enable.font_metrics_provider import font_metrics_provider +from traits.api import DelegatesTo, Enum, Instance, Str, Trait + +from chaco.abstract_overlay import AbstractOverlay +from chaco.label import Label + + +LabelDelegate = DelegatesTo("_label") + + +class PlotLabel(AbstractOverlay): + """A label used by plots. + + This class wraps a simple Label instance, and delegates some traits to it. + """ + + #: The text of the label. + text = LabelDelegate + #: The color of the label text. + color = DelegatesTo("_label") + #: The font for the label text. + font = LabelDelegate + #: The angle of rotation of the label. + angle = DelegatesTo("_label", "rotate_angle") + + bgcolor = LabelDelegate + border_width = LabelDelegate + border_color = LabelDelegate + border_visible = LabelDelegate + margin = LabelDelegate + line_spacing = LabelDelegate + + # ------------------------------------------------------------------------ + # Layout-related traits + # ------------------------------------------------------------------------ + + #: Horizontal justification used if the label has more horizontal space + #: than it needs. + hjustify = Enum("center", "left", "right") + + #: Vertical justification used if the label has more vertical space than it + #: needs. + vjustify = Enum("center", "bottom", "top") + + #: The position of this label relative to the object it is overlaying. + #: Can be "top", "left", "right", "bottom", and optionally can be preceeded + #: by the words "inside" or "outside", separated by a space. If "inside" + #: and "outside" are not provided, then defaults to "outside". + #: Examples: + #: inside top + #: outside right + overlay_position = Trait("outside top", Str, None) + + # Should this PlotLabel modify the padding on its underlying component + # if there is not enough room to lay out the text? + # FIXME: This could cause cycles in layout, so not implemented for now + # modify_component = Bool(True) + + #: By default, this acts like a component and will render on the main + #: "plot" layer unless its **component** attribute gets set. + draw_layer = "plot" + + # ------------------------------------------------------------------------ + # Private traits + # ------------------------------------------------------------------------ + + #: The label has a fixed height and can be resized horizontally. (Overrides + #: PlotComponent.) + resizable = "h" + + # The Label instance this plot label is wrapping. + _label = Instance(Label, args=()) + + def __init__(self, text="", *args, **kw): + super().__init__(*args, **kw) + self.text = text + + def overlay(self, component, gc, view_bounds=None, mode="normal"): + """Draws this label overlaid on another component. + + Overrides AbstractOverlay. + """ + self._draw_overlay(gc, view_bounds, mode) + + def get_preferred_size(self): + """Returns the label's preferred size. + + Overrides PlotComponent. + """ + dummy_gc = font_metrics_provider() + size = self._label.get_bounding_box(dummy_gc) + return size + + def do_layout(self): + """Tells this component to do layout. + + Overrides PlotComponent. + """ + if self.component is not None: + self._layout_as_overlay() + else: + self._layout_as_component() + + def _draw_overlay(self, gc, view_bounds=None, mode="normal"): + """Draws the overlay layer of a component. + + Overrides PlotComponent. + """ + # Perform justification and compute the correct offsets for + # the label position + width, height = self._label.get_bounding_box(gc) + if self.hjustify == "left": + x_offset = 0 + elif self.hjustify == "right": + x_offset = self.width - width + elif self.hjustify == "center": + x_offset = int((self.width - width) / 2) + + if self.vjustify == "bottom": + y_offset = 0 + elif self.vjustify == "top": + y_offset = self.height - height + elif self.vjustify == "center": + y_offset = int((self.height - height) / 2) + + with gc: + # XXX: Uncomment this after we fix kiva GL backend's clip stack + # gc.clip_to_rect(self.x, self.y, self.width, self.height) + + # We have to translate to our position because the label + # tries to draw at (0,0). + gc.translate_ctm(self.x + x_offset, self.y + y_offset) + self._label.draw(gc) + + def _draw_plot(self, gc, view_bounds=None, mode="normal"): + if self.component is None: + # We are not overlaying anything else, so we should render + # on this layer + self._draw_overlay(gc, view_bounds, mode) + + def _layout_as_component(self, size=None, force=False): + pass + + def _layout_as_overlay(self, size=None, force=False): + """Lays out the label as an overlay on another component.""" + if self.component is not None: + orientation = self.overlay_position + outside = True + if "inside" in orientation: + tmp = orientation.split() + tmp.remove("inside") + orientation = tmp[0] + outside = False + elif "outside" in orientation: + tmp = orientation.split() + tmp.remove("outside") + orientation = tmp[0] + + if orientation in ("left", "right"): + self.y = self.component.y + self.height = self.component.height + if not outside: + gc = font_metrics_provider() + self.width = self._label.get_bounding_box(gc)[0] + if orientation == "left": + if outside: + self.x = self.component.outer_x + self.width = self.component.padding_left + else: + self.outer_x = self.component.x + elif orientation == "right": + if outside: + self.x = self.component.x2 + 1 + self.width = self.component.padding_right + else: + self.x = self.component.x2 - self.outer_width + elif orientation in ("bottom", "top"): + self.x = self.component.x + self.width = self.component.width + if not outside: + gc = font_metrics_provider() + self.height = self._label.get_bounding_box(gc)[1] + if orientation == "bottom": + if outside: + self.y = self.component.outer_y + self.height = self.component.padding_bottom + else: + self.outer_y = self.component.y + elif orientation == "top": + if outside: + self.y = self.component.y2 + 1 + self.height = self.component.padding_top + else: + self.y = self.component.y2 - self.outer_height + else: + # Leave the position alone + pass + + def _text_changed(self, old, new): + self._label.text = new + self.do_layout() + + def _font_changed(self, old, new): + self._label.font = new + self.do_layout() + + def _angle_changed(self, old, new): + self._label.rotate_angle = new + self.do_layout() + + def _overlay_position_changed(self): + self.do_layout() + + def _component_changed(self, old, new): + if new: + self.draw_layer = "overlay" + else: + self.draw_layer = "plot" diff --git a/chaco/overlays/scatter_inspector_overlay.py b/chaco/overlays/scatter_inspector_overlay.py new file mode 100644 index 000000000..f2e6312e0 --- /dev/null +++ b/chaco/overlays/scatter_inspector_overlay.py @@ -0,0 +1,165 @@ +# (C) Copyright 2006-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +# Major library imports +from numpy import array, asarray + +# Enthought library imports +from enable.api import ColorTrait, MarkerTrait +from traits.api import Float, Int, Str, Trait +from traits.observation.events import TraitChangeEvent + +# Local, relative imports +from chaco.abstract_overlay import AbstractOverlay +from chaco.plots.scatterplot import render_markers + + +class ScatterInspectorOverlay(AbstractOverlay): + """ + Highlights points on a scatterplot as the mouse moves over them. + Can render the points in a different style, as well as display a + DataLabel. + + Used in conjuction with ScatterInspector. + """ + + #: The style to use when a point is hovered over + hover_metadata_name = Str("hover") + hover_marker = Trait(None, None, MarkerTrait) + hover_marker_size = Trait(None, None, Int) + hover_line_width = Trait(None, None, Float) + hover_color = Trait(None, None, ColorTrait) + hover_outline_color = Trait(None, None, ColorTrait) + + #: The style to use when a point has been selected by a click + selection_metadata_name = Str("selections") + selection_marker = Trait(None, None, MarkerTrait) + selection_marker_size = Trait(None, None, Int) + selection_line_width = Trait(None, None, Float) + selection_color = Trait(None, None, ColorTrait) + selection_outline_color = Trait(None, None, ColorTrait) + + # For now, implement the equivalent of this Traits 3 feature manually + # using a series of trait change handlers (defined at the end of the + # class) + # @on_trait_change( + # 'component.index.metadata_changed,component.value.metadata_changed' + # ) + def metadata_updated(self, event): + if self.component is not None: + self.component.request_redraw() + + def overlay(self, component, gc, view_bounds=None, mode="normal"): + plot = self.component + if not plot or not plot.index or not getattr(plot, "value", True): + return + + for inspect_type in ( + self.hover_metadata_name, + self.selection_metadata_name, + ): + if inspect_type in plot.index.metadata: + # if hasattr(plot,"value") and \ + # not inspect_type in plot.value.metadata: + # continue + index = plot.index.metadata.get(inspect_type, None) + + if index is not None and len(index) > 0: + index = asarray(index) + index_data = plot.index.get_data() + + # Only grab the indices which fall within the data range. + index = index[index < len(index_data)] + + # FIXME: In order to work around some problems with the + # selection model, we will only use the selection on the + # index. The assumption that they are the same is + # implicit, though unchecked, already. + # value = plot.value.metadata.get(inspect_type, None) + value = index + + if hasattr(plot, "value"): + value_data = plot.value.get_data() + screen_pts = plot.map_screen( + array([index_data[index], value_data[value]]).T + ) + else: + screen_pts = plot.map_screen(index_data[index]) + + if inspect_type == self.selection_metadata_name: + prefix = "selection" + else: + prefix = "hover" + self._render_at_indices(gc, screen_pts, prefix) + + def _render_at_indices(self, gc, screen_pts, inspect_type): + """ screen_pt should always be a list """ + self._render_marker_at_indices(gc, screen_pts, inspect_type) + + def _render_marker_at_indices(self, gc, screen_pts, prefix, sep="_"): + """ screen_pt should always be a list """ + if len(screen_pts) == 0: + return + + plot = self.component + + mapped_attribs = ("color", "outline_color", "marker") + other_attribs = ("marker_size", "line_width") + kwargs = {} + for attr in mapped_attribs + other_attribs: + if attr in mapped_attribs: + # Resolve the mapped trait + valname = attr + "_" + else: + valname = attr + + tmp = getattr(self, prefix + sep + valname) + if tmp is not None: + kwargs[attr] = tmp + else: + kwargs[attr] = getattr(plot, valname) + + # If the marker type is 'custom', we have to pass in the custom_symbol + # kwarg to render_markers. + if kwargs.get("marker", None) == "custom": + kwargs["custom_symbol"] = plot.custom_symbol + + with gc: + gc.clip_to_rect(plot.x, plot.y, plot.width, plot.height) + render_markers(gc, screen_pts, **kwargs) + + def _draw_overlay(self, gc, view_bounds=None, mode="normal"): + self.overlay(self.component, gc, view_bounds, mode) + + def _component_changed(self, old, new): + if old: + old.observe(self._ds_changed, "index", remove=True) + if hasattr(old, "value"): + old.observe(self._ds_changed, "value", remove=True) + if new: + for dsname in ("index", "value"): + if not hasattr(new, dsname): + continue + new.observe(self._ds_changed, dsname) + if getattr(new, dsname): + self._ds_changed( + TraitChangeEvent( + object=new, + name=dsname, + old=None, + new=getattr(new, dsname), + ) + ) + + def _ds_changed(self, event): + old, new = event.old, event.new + if old: + old.observe(self.metadata_updated, "metadata_changed", remove=True) + if new: + new.observe(self.metadata_updated, "metadata_changed") diff --git a/chaco/tests/test_data_label.py b/chaco/overlays/tests/test_data_label.py similarity index 100% rename from chaco/tests/test_data_label.py rename to chaco/overlays/tests/test_data_label.py diff --git a/chaco/overlays/text_box_overlay.py b/chaco/overlays/text_box_overlay.py new file mode 100644 index 000000000..795cde9a0 --- /dev/null +++ b/chaco/overlays/text_box_overlay.py @@ -0,0 +1,171 @@ +# (C) Copyright 2006-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +""" Defines the TextBoxOverlay class. +""" + + +# Enthought library imports +from enable.api import ColorTrait +from kiva.trait_defs.kiva_font_trait import KivaFont +from traits.api import Any, Enum, Int, Str, Float, Trait, Bool + +# Local, relative imports +from chaco.abstract_overlay import AbstractOverlay +from chaco.label import Label + + +class TextBoxOverlay(AbstractOverlay): + """Draws a box with text in it.""" + + # Configuration traits #################################################### + + #: The text to display in the box. + text = Str + + #: The font to use for the text. + font = KivaFont("modern 12") + + #: The background color for the box (overrides AbstractOverlay). + bgcolor = ColorTrait("transparent") + + #: The alpha value to apply to **bgcolor** + alpha = Trait(1.0, None, Float) + + #: The color of the outside box. + border_color = ColorTrait("dodgerblue") + + #: The color of the text. + text_color = ColorTrait("black") + + #: The thickness of box border. + border_size = Int(1) + + #: The border visibility. Defaults to true to duplicate previous behavior. + border_visible = Bool(True) + + #: Number of pixels of padding around the text within the box. + padding = Int(5) + + #: The maximum width of the displayed text. This affects the width of the + #: text only, not the text box, which includes margins around the text and + #: `padding`. + #: A `max_text_width` of 0.0 means that the width will not be restricted. + max_text_width = Float(0.0) + + #: Alignment of the text in the box: + #: + #: * "ur": upper right + #: * "ul": upper left + #: * "ll": lower left + #: * "lr": lower right + align = Enum("ur", "ul", "ll", "lr") + + #: This allows subclasses to specify an alternate position for the root + #: of the text box. Must be a sequence of length 2. + alternate_position = Any + + # Public 'AbstractOverlay' interface ###################################### + + def overlay(self, component, gc, view_bounds=None, mode="normal"): + """Draws the box overlaid on another component. + + Overrides AbstractOverlay. + """ + + if not self.visible: + return + + # draw the label on a transparent box. This allows us to draw + # different shapes and put the text inside it without the label + # filling a rectangle on top of it + label = Label( + text=self.text, + font=self.font, + bgcolor="transparent", + color=self.text_color, + max_width=self.max_text_width, + margin=5, + ) + width, height = label.get_width_height(gc) + + valign, halign = self.align + + if self.alternate_position: + x, y = self.alternate_position + if valign == "u": + y += self.padding + else: + y -= self.padding + height + + if halign == "r": + x += self.padding + else: + x -= self.padding + width + else: + if valign == "u": + y = component.y2 - self.padding - height + else: + y = component.y + self.padding + + if halign == "r": + x = component.x2 - self.padding - width + else: + x = component.x + self.padding + + # attempt to get the box entirely within the component + x_min, y_min, x_max, y_max = ( + component.x, + component.y, + component.x + component.width, + component.y + component.height, + ) + if x + width > x_max: + x = max(x_min, x_max - width) + if y + height > y_max: + y = max(y_min, y_max - height) + elif y < y_min: + y = y_min + + # apply the alpha channel + color = self.bgcolor_ + if self.bgcolor != "transparent": + if self.alpha: + color = list(self.bgcolor_) + if len(color) == 4: + color[3] = self.alpha + else: + color += [self.alpha] + + with gc: + gc.translate_ctm(x, y) + + gc.set_line_width(self.border_size) + gc.set_stroke_color(self.border_color_) + gc.set_fill_color(color) + + if self.border_visible: + # draw a rounded rectangle. + x = y = 0 + end_radius = 8.0 + gc.begin_path() + gc.move_to(x + end_radius, y) + gc.arc_to(x + width, y, x + width, y + end_radius, end_radius) + gc.arc_to( + x + width, + y + height, + x + width - end_radius, + y + height, + end_radius, + ) + gc.arc_to(x, y + height, x, y, end_radius) + gc.arc_to(x, y, x + width + end_radius, y, end_radius) + gc.draw_path() + + label.draw(gc) diff --git a/chaco/overlays/tooltip.py b/chaco/overlays/tooltip.py new file mode 100644 index 000000000..685d8dd60 --- /dev/null +++ b/chaco/overlays/tooltip.py @@ -0,0 +1,185 @@ +# (C) Copyright 2006-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +""" Defines the ToolTip class. +""" + + +from numpy import array + +# Enthought library imports +from enable.api import black_color_trait, white_color_trait +from enable.font_metrics_provider import font_metrics_provider +from kiva.trait_defs.kiva_font_trait import KivaFont +from traits.api import Any, Bool, List, Int, Float, observe + + +# Local imports +from chaco.abstract_overlay import AbstractOverlay +from chaco.plot_component import PlotComponent +from chaco.label import Label + + +class ToolTip(AbstractOverlay): + """An overlay that is a toolip.""" + + #: The font to render the tooltip. + font = KivaFont("modern 10") + + #: The color of the text in the tooltip + text_color = black_color_trait + + #: The ammount of space between the border and the text. + border_padding = Int(4) + + #: The number of pixels between lines. + line_spacing = Int(4) + + #: List of text strings to put in the tooltip. + lines = List + + #: Angle to rotate (counterclockwise) in degrees. NB this will *only* + #: currently affect text, so probably only useful if borders and background + #: are disabled + rotate_angle = Float(0.0) + + #: Should the tooltip automatically reposition itself to remain visible + #: and unclipped on its overlaid component? + auto_adjust = Bool(True) + + #: The tooltip is a fixed size. (Overrides PlotComponent.) + resizable = "" + + #: Use a visible border. (Overrides Enable Component.) + border_visible = True + + #: Use a white background color (overrides AbstractOverlay). + bgcolor = white_color_trait + + # ---------------------------------------------------------------------- + # Private Traits + # ---------------------------------------------------------------------- + + _font_metrics_provider = Any() + + _text_props_valid = Bool(False) + + _max_line_width = Float(0.0) + + _total_line_height = Float(0.0) + + def draw(self, gc, view_bounds=None, mode="normal"): + """Draws the plot component. + + Overrides PlotComponent. + """ + self.overlay(self, gc, view_bounds=view_bounds, mode="normal") + + def overlay(self, component, gc, view_bounds=None, mode="normal"): + """Draws the tooltip overlaid on another component. + + Overrides AbstractOverlay. + """ + self.do_layout() + PlotComponent._draw(self, gc, view_bounds, mode) + + def _draw_overlay(self, gc, view_bounds=None, mode="normal"): + """Draws the overlay layer of a component. + + Overrides PlotComponent. + """ + with gc: + edge_space = self.border_width + self.border_padding + gc.translate_ctm(self.x + edge_space, self.y) + y = self.height - edge_space + for i, label in enumerate(self._cached_labels): + label_height = self._cached_line_sizes[i][1] + y -= label_height + gc.translate_ctm(0, y) + label.draw(gc) + gc.translate_ctm(0, -y) + y -= self.line_spacing + + def _do_layout(self): + """Computes the size of the tooltip, and creates the label objects + for each line. + + Overrides PlotComponent. + """ + if not self._text_props_valid: + self._recompute_text() + + outer_bounds = [ + self._max_line_width + 2 * self.border_padding + self.hpadding, + self._total_line_height + 2 * self.border_padding + self.vpadding, + ] + + self.outer_bounds = outer_bounds + + if self.auto_adjust and self.component is not None: + new_pos = list(self.outer_position) + for dimindex in (0, 1): + pos = self.position[dimindex] + extent = outer_bounds[dimindex] + c_min = self.component.position[dimindex] + c_max = c_min + self.component.bounds[dimindex] + # Is the tooltip just too wide/tall? + if extent > (c_max - c_min): + new_pos[dimindex] = c_min + # Does it extend over the c_max edge? (right/top) + elif pos + extent > c_max: + new_pos[dimindex] = c_max - extent + + # Does it extend over the c_min edge? This is not an elif so + # that we can fix the situation where the c_max edge adjustment + # above pushes the position negative. + if new_pos[dimindex] < c_min: + new_pos[dimindex] = c_min + + self.outer_position = new_pos + + self._layout_needed = False + + def _recompute_text(self): + labels = [ + Label( + text=line, + font=self.font, + margin=0, + bgcolor="transparent", + border_width=0, + color=self.text_color, + rotate_angle=self.rotate_angle, + ) + for line in self.lines + ] + dummy_gc = self._font_metrics_provider + line_sizes = array( + [label.get_width_height(dummy_gc) for label in labels] + ) + self._cached_labels = labels + self._cached_line_sizes = line_sizes + self._max_line_width = max(line_sizes[:, 0]) + self._total_line_height = ( + sum(line_sizes[:, 1]) + len(line_sizes - 1) * self.line_spacing + ) + self._layout_needed = True + + def __font_metrics_provider_default(self): + return font_metrics_provider() + + @observe("font,text_color,lines.items") + def _invalidate_text_props(self, event): + self._text_props_valid = False + self._layout_needed = True + + @observe("border_padding,line_spacing,lines.items,padding") + def _invalidate_layout(self, event): + self._layout_needed = True + self.request_redraw() diff --git a/chaco/plot_label.py b/chaco/plot_label.py index b7c3949a2..8edc49715 100644 --- a/chaco/plot_label.py +++ b/chaco/plot_label.py @@ -1,222 +1,20 @@ -""" Defines the PlotLabel class. -""" - - -from enable.font_metrics_provider import font_metrics_provider -from traits.api import DelegatesTo, Enum, Instance, Str, Trait - -from .abstract_overlay import AbstractOverlay -from .label import Label - - -LabelDelegate = DelegatesTo("_label") - - -class PlotLabel(AbstractOverlay): - """A label used by plots. - - This class wraps a simple Label instance, and delegates some traits to it. - """ - - #: The text of the label. - text = LabelDelegate - #: The color of the label text. - color = DelegatesTo("_label") - #: The font for the label text. - font = LabelDelegate - #: The angle of rotation of the label. - angle = DelegatesTo("_label", "rotate_angle") - - bgcolor = LabelDelegate - border_width = LabelDelegate - border_color = LabelDelegate - border_visible = LabelDelegate - margin = LabelDelegate - line_spacing = LabelDelegate - - # ------------------------------------------------------------------------ - # Layout-related traits - # ------------------------------------------------------------------------ - - #: Horizontal justification used if the label has more horizontal space - #: than it needs. - hjustify = Enum("center", "left", "right") - - #: Vertical justification used if the label has more vertical space than it - #: needs. - vjustify = Enum("center", "bottom", "top") - - #: The position of this label relative to the object it is overlaying. - #: Can be "top", "left", "right", "bottom", and optionally can be preceeded - #: by the words "inside" or "outside", separated by a space. If "inside" - #: and "outside" are not provided, then defaults to "outside". - #: Examples: - #: inside top - #: outside right - overlay_position = Trait("outside top", Str, None) - - # Should this PlotLabel modify the padding on its underlying component - # if there is not enough room to lay out the text? - # FIXME: This could cause cycles in layout, so not implemented for now - # modify_component = Bool(True) - - #: By default, this acts like a component and will render on the main - #: "plot" layer unless its **component** attribute gets set. - draw_layer = "plot" - - # ------------------------------------------------------------------------ - # Private traits - # ------------------------------------------------------------------------ - - #: The label has a fixed height and can be resized horizontally. (Overrides - #: PlotComponent.) - resizable = "h" - - # The Label instance this plot label is wrapping. - _label = Instance(Label, args=()) - - def __init__(self, text="", *args, **kw): - super().__init__(*args, **kw) - self.text = text - - def overlay(self, component, gc, view_bounds=None, mode="normal"): - """Draws this label overlaid on another component. - - Overrides AbstractOverlay. - """ - self._draw_overlay(gc, view_bounds, mode) - - def get_preferred_size(self): - """Returns the label's preferred size. - - Overrides PlotComponent. - """ - dummy_gc = font_metrics_provider() - size = self._label.get_bounding_box(dummy_gc) - return size - - def do_layout(self): - """Tells this component to do layout. - - Overrides PlotComponent. - """ - if self.component is not None: - self._layout_as_overlay() - else: - self._layout_as_component() - - def _draw_overlay(self, gc, view_bounds=None, mode="normal"): - """Draws the overlay layer of a component. - - Overrides PlotComponent. - """ - # Perform justification and compute the correct offsets for - # the label position - width, height = self._label.get_bounding_box(gc) - if self.hjustify == "left": - x_offset = 0 - elif self.hjustify == "right": - x_offset = self.width - width - elif self.hjustify == "center": - x_offset = int((self.width - width) / 2) - - if self.vjustify == "bottom": - y_offset = 0 - elif self.vjustify == "top": - y_offset = self.height - height - elif self.vjustify == "center": - y_offset = int((self.height - height) / 2) - - with gc: - # XXX: Uncomment this after we fix kiva GL backend's clip stack - # gc.clip_to_rect(self.x, self.y, self.width, self.height) - - # We have to translate to our position because the label - # tries to draw at (0,0). - gc.translate_ctm(self.x + x_offset, self.y + y_offset) - self._label.draw(gc) - - def _draw_plot(self, gc, view_bounds=None, mode="normal"): - if self.component is None: - # We are not overlaying anything else, so we should render - # on this layer - self._draw_overlay(gc, view_bounds, mode) - - def _layout_as_component(self, size=None, force=False): - pass - - def _layout_as_overlay(self, size=None, force=False): - """Lays out the label as an overlay on another component.""" - if self.component is not None: - orientation = self.overlay_position - outside = True - if "inside" in orientation: - tmp = orientation.split() - tmp.remove("inside") - orientation = tmp[0] - outside = False - elif "outside" in orientation: - tmp = orientation.split() - tmp.remove("outside") - orientation = tmp[0] - - if orientation in ("left", "right"): - self.y = self.component.y - self.height = self.component.height - if not outside: - gc = font_metrics_provider() - self.width = self._label.get_bounding_box(gc)[0] - if orientation == "left": - if outside: - self.x = self.component.outer_x - self.width = self.component.padding_left - else: - self.outer_x = self.component.x - elif orientation == "right": - if outside: - self.x = self.component.x2 + 1 - self.width = self.component.padding_right - else: - self.x = self.component.x2 - self.outer_width - elif orientation in ("bottom", "top"): - self.x = self.component.x - self.width = self.component.width - if not outside: - gc = font_metrics_provider() - self.height = self._label.get_bounding_box(gc)[1] - if orientation == "bottom": - if outside: - self.y = self.component.outer_y - self.height = self.component.padding_bottom - else: - self.outer_y = self.component.y - elif orientation == "top": - if outside: - self.y = self.component.y2 + 1 - self.height = self.component.padding_top - else: - self.y = self.component.y2 - self.outer_height - else: - # Leave the position alone - pass - - def _text_changed(self, old, new): - self._label.text = new - self.do_layout() - - def _font_changed(self, old, new): - self._label.font = new - self.do_layout() - - def _angle_changed(self, old, new): - self._label.rotate_angle = new - self.do_layout() - - def _overlay_position_changed(self): - self.do_layout() - - def _component_changed(self, old, new): - if new: - self.draw_layer = "overlay" - else: - self.draw_layer = "plot" +# (C) Copyright 2006-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +import warnings + +from chaco.overlays.plot_label import PlotLabel # noqa: F401 + +warnings.warn( + "Importing PlotLabel from this module is deprecated. Please use chaco.api " + "or chaco.overlays.api instead. This module will be removed in the next " + "major release.", + DeprecationWarning, + stacklevel=2, +) diff --git a/chaco/scatter_inspector_overlay.py b/chaco/scatter_inspector_overlay.py index 5968f439f..61d4a03a6 100644 --- a/chaco/scatter_inspector_overlay.py +++ b/chaco/scatter_inspector_overlay.py @@ -1,153 +1,22 @@ -# Major library imports -from numpy import array, asarray - -# Enthought library imports -from enable.api import ColorTrait, MarkerTrait -from traits.api import Float, Int, Str, Trait -from traits.observation.events import TraitChangeEvent - -# Local, relative imports -from .abstract_overlay import AbstractOverlay -from .plots.scatterplot import render_markers - - -class ScatterInspectorOverlay(AbstractOverlay): - """ - Highlights points on a scatterplot as the mouse moves over them. - Can render the points in a different style, as well as display a - DataLabel. - - Used in conjuction with ScatterInspector. - """ - - #: The style to use when a point is hovered over - hover_metadata_name = Str("hover") - hover_marker = Trait(None, None, MarkerTrait) - hover_marker_size = Trait(None, None, Int) - hover_line_width = Trait(None, None, Float) - hover_color = Trait(None, None, ColorTrait) - hover_outline_color = Trait(None, None, ColorTrait) - - #: The style to use when a point has been selected by a click - selection_metadata_name = Str("selections") - selection_marker = Trait(None, None, MarkerTrait) - selection_marker_size = Trait(None, None, Int) - selection_line_width = Trait(None, None, Float) - selection_color = Trait(None, None, ColorTrait) - selection_outline_color = Trait(None, None, ColorTrait) - - # For now, implement the equivalent of this Traits 3 feature manually - # using a series of trait change handlers (defined at the end of the - # class) - # @on_trait_change('component.index.metadata_changed,component.value.metadata_changed') - def metadata_updated(self, event): - if self.component is not None: - self.component.request_redraw() - - def overlay(self, component, gc, view_bounds=None, mode="normal"): - plot = self.component - if not plot or not plot.index or not getattr(plot, "value", True): - return - - for inspect_type in ( - self.hover_metadata_name, - self.selection_metadata_name, - ): - if inspect_type in plot.index.metadata: - # if hasattr(plot,"value") and not inspect_type in plot.value.metadata: - # continue - index = plot.index.metadata.get(inspect_type, None) - - if index is not None and len(index) > 0: - index = asarray(index) - index_data = plot.index.get_data() - - # Only grab the indices which fall within the data range. - index = index[index < len(index_data)] - - # FIXME: In order to work around some problems with the - # selection model, we will only use the selection on the - # index. The assumption that they are the same is - # implicit, though unchecked, already. - # value = plot.value.metadata.get(inspect_type, None) - value = index - - if hasattr(plot, "value"): - value_data = plot.value.get_data() - screen_pts = plot.map_screen( - array([index_data[index], value_data[value]]).T - ) - else: - screen_pts = plot.map_screen(index_data[index]) - - if inspect_type == self.selection_metadata_name: - prefix = "selection" - else: - prefix = "hover" - self._render_at_indices(gc, screen_pts, prefix) - - def _render_at_indices(self, gc, screen_pts, inspect_type): - """ screen_pt should always be a list """ - self._render_marker_at_indices(gc, screen_pts, inspect_type) - - def _render_marker_at_indices(self, gc, screen_pts, prefix, sep="_"): - """ screen_pt should always be a list """ - if len(screen_pts) == 0: - return - - plot = self.component - - mapped_attribs = ("color", "outline_color", "marker") - other_attribs = ("marker_size", "line_width") - kwargs = {} - for attr in mapped_attribs + other_attribs: - if attr in mapped_attribs: - # Resolve the mapped trait - valname = attr + "_" - else: - valname = attr - - tmp = getattr(self, prefix + sep + valname) - if tmp is not None: - kwargs[attr] = tmp - else: - kwargs[attr] = getattr(plot, valname) - - # If the marker type is 'custom', we have to pass in the custom_symbol - # kwarg to render_markers. - if kwargs.get("marker", None) == "custom": - kwargs["custom_symbol"] = plot.custom_symbol - - with gc: - gc.clip_to_rect(plot.x, plot.y, plot.width, plot.height) - render_markers(gc, screen_pts, **kwargs) - - def _draw_overlay(self, gc, view_bounds=None, mode="normal"): - self.overlay(self.component, gc, view_bounds, mode) - - def _component_changed(self, old, new): - if old: - old.observe(self._ds_changed, "index", remove=True) - if hasattr(old, "value"): - old.observe(self._ds_changed, "value", remove=True) - if new: - for dsname in ("index", "value"): - if not hasattr(new, dsname): - continue - new.observe(self._ds_changed, dsname) - if getattr(new, dsname): - self._ds_changed( - TraitChangeEvent( - object=new, - name=dsname, - old=None, - new=getattr(new, dsname), - ) - ) - - def _ds_changed(self, event): - old, new = event.old, event.new - if old: - old.observe(self.metadata_updated, "metadata_changed", remove=True) - if new: - new.observe(self.metadata_updated, "metadata_changed") +# (C) Copyright 2006-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +import warnings + +from chaco.overlays.scatter_inspector_overlay import ( # noqa: F401 + ScatterInspectorOverlay +) + +warnings.warn( + "Importing ScatterInspectorOverlay from this module is deprecated. " + "Please use chaco.api or chaco.overlays.api instead. This module will be " + "removed in the next major release.", + DeprecationWarning, + stacklevel=2, +) diff --git a/chaco/text_box_overlay.py b/chaco/text_box_overlay.py index 5d5bffe21..e1d3a6e6c 100644 --- a/chaco/text_box_overlay.py +++ b/chaco/text_box_overlay.py @@ -1,162 +1,20 @@ -""" Defines the TextBoxOverlay class. -""" - - -# Enthought library imports -from enable.api import ColorTrait -from kiva.trait_defs.kiva_font_trait import KivaFont -from traits.api import Any, Enum, Int, Str, Float, Trait, Bool - -# Local, relative imports -from .abstract_overlay import AbstractOverlay -from .label import Label - - -class TextBoxOverlay(AbstractOverlay): - """Draws a box with text in it.""" - - #### Configuration traits ################################################# - - #: The text to display in the box. - text = Str - - #: The font to use for the text. - font = KivaFont("modern 12") - - #: The background color for the box (overrides AbstractOverlay). - bgcolor = ColorTrait("transparent") - - #: The alpha value to apply to **bgcolor** - alpha = Trait(1.0, None, Float) - - #: The color of the outside box. - border_color = ColorTrait("dodgerblue") - - #: The color of the text. - text_color = ColorTrait("black") - - #: The thickness of box border. - border_size = Int(1) - - #: The border visibility. Defaults to true to duplicate previous behavior. - border_visible = Bool(True) - - #: Number of pixels of padding around the text within the box. - padding = Int(5) - - #: The maximum width of the displayed text. This affects the width of the - #: text only, not the text box, which includes margins around the text and - #: `padding`. - #: A `max_text_width` of 0.0 means that the width will not be restricted. - max_text_width = Float(0.0) - - #: Alignment of the text in the box: - #: - #: * "ur": upper right - #: * "ul": upper left - #: * "ll": lower left - #: * "lr": lower right - align = Enum("ur", "ul", "ll", "lr") - - #: This allows subclasses to specify an alternate position for the root - #: of the text box. Must be a sequence of length 2. - alternate_position = Any - - #### Public 'AbstractOverlay' interface ################################### - - def overlay(self, component, gc, view_bounds=None, mode="normal"): - """Draws the box overlaid on another component. - - Overrides AbstractOverlay. - """ - - if not self.visible: - return - - # draw the label on a transparent box. This allows us to draw - # different shapes and put the text inside it without the label - # filling a rectangle on top of it - label = Label( - text=self.text, - font=self.font, - bgcolor="transparent", - color=self.text_color, - max_width=self.max_text_width, - margin=5, - ) - width, height = label.get_width_height(gc) - - valign, halign = self.align - - if self.alternate_position: - x, y = self.alternate_position - if valign == "u": - y += self.padding - else: - y -= self.padding + height - - if halign == "r": - x += self.padding - else: - x -= self.padding + width - else: - if valign == "u": - y = component.y2 - self.padding - height - else: - y = component.y + self.padding - - if halign == "r": - x = component.x2 - self.padding - width - else: - x = component.x + self.padding - - # attempt to get the box entirely within the component - x_min, y_min, x_max, y_max = ( - component.x, - component.y, - component.x + component.width, - component.y + component.height, - ) - if x + width > x_max: - x = max(x_min, x_max - width) - if y + height > y_max: - y = max(y_min, y_max - height) - elif y < y_min: - y = y_min - - # apply the alpha channel - color = self.bgcolor_ - if self.bgcolor != "transparent": - if self.alpha: - color = list(self.bgcolor_) - if len(color) == 4: - color[3] = self.alpha - else: - color += [self.alpha] - - with gc: - gc.translate_ctm(x, y) - - gc.set_line_width(self.border_size) - gc.set_stroke_color(self.border_color_) - gc.set_fill_color(color) - - if self.border_visible: - # draw a rounded rectangle. - x = y = 0 - end_radius = 8.0 - gc.begin_path() - gc.move_to(x + end_radius, y) - gc.arc_to(x + width, y, x + width, y + end_radius, end_radius) - gc.arc_to( - x + width, - y + height, - x + width - end_radius, - y + height, - end_radius, - ) - gc.arc_to(x, y + height, x, y, end_radius) - gc.arc_to(x, y, x + width + end_radius, y, end_radius) - gc.draw_path() - - label.draw(gc) +# (C) Copyright 2006-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +import warnings + +from chaco.overlays.text_box_overlay import TextBoxOverlay # noqa: F401 + +warnings.warn( + "Importing TextBoxOverlay from this module is deprecated. Please use " + "chaco.api or chaco.overlays.api instead. This module will be removed in " + "the next major release.", + DeprecationWarning, + stacklevel=2, +) diff --git a/chaco/tooltip.py b/chaco/tooltip.py index a64f6ce4d..4351b032e 100644 --- a/chaco/tooltip.py +++ b/chaco/tooltip.py @@ -1,176 +1,20 @@ -""" Defines the ToolTip class. -""" - - -from numpy import array - -# Enthought library imports -from enable.api import black_color_trait, white_color_trait -from enable.font_metrics_provider import font_metrics_provider -from kiva.trait_defs.kiva_font_trait import KivaFont -from traits.api import Any, Bool, List, Int, Float, observe - - -# Local imports -from .abstract_overlay import AbstractOverlay -from .plot_component import PlotComponent -from .label import Label - - -class ToolTip(AbstractOverlay): - """An overlay that is a toolip.""" - - #: The font to render the tooltip. - font = KivaFont("modern 10") - - #: The color of the text in the tooltip - text_color = black_color_trait - - #: The ammount of space between the border and the text. - border_padding = Int(4) - - #: The number of pixels between lines. - line_spacing = Int(4) - - #: List of text strings to put in the tooltip. - lines = List - - #: Angle to rotate (counterclockwise) in degrees. NB this will *only* - #: currently affect text, so probably only useful if borders and background - #: are disabled - rotate_angle = Float(0.0) - - #: Should the tooltip automatically reposition itself to remain visible - #: and unclipped on its overlaid component? - auto_adjust = Bool(True) - - #: The tooltip is a fixed size. (Overrides PlotComponent.) - resizable = "" - - #: Use a visible border. (Overrides Enable Component.) - border_visible = True - - #: Use a white background color (overrides AbstractOverlay). - bgcolor = white_color_trait - - # ---------------------------------------------------------------------- - # Private Traits - # ---------------------------------------------------------------------- - - _font_metrics_provider = Any() - - _text_props_valid = Bool(False) - - _max_line_width = Float(0.0) - - _total_line_height = Float(0.0) - - def draw(self, gc, view_bounds=None, mode="normal"): - """Draws the plot component. - - Overrides PlotComponent. - """ - self.overlay(self, gc, view_bounds=view_bounds, mode="normal") - - def overlay(self, component, gc, view_bounds=None, mode="normal"): - """Draws the tooltip overlaid on another component. - - Overrides AbstractOverlay. - """ - self.do_layout() - PlotComponent._draw(self, gc, view_bounds, mode) - - def _draw_overlay(self, gc, view_bounds=None, mode="normal"): - """Draws the overlay layer of a component. - - Overrides PlotComponent. - """ - with gc: - edge_space = self.border_width + self.border_padding - gc.translate_ctm(self.x + edge_space, self.y) - y = self.height - edge_space - for i, label in enumerate(self._cached_labels): - label_height = self._cached_line_sizes[i][1] - y -= label_height - gc.translate_ctm(0, y) - label.draw(gc) - gc.translate_ctm(0, -y) - y -= self.line_spacing - - def _do_layout(self): - """Computes the size of the tooltip, and creates the label objects - for each line. - - Overrides PlotComponent. - """ - if not self._text_props_valid: - self._recompute_text() - - outer_bounds = [ - self._max_line_width + 2 * self.border_padding + self.hpadding, - self._total_line_height + 2 * self.border_padding + self.vpadding, - ] - - self.outer_bounds = outer_bounds - - if self.auto_adjust and self.component is not None: - new_pos = list(self.outer_position) - for dimindex in (0, 1): - pos = self.position[dimindex] - extent = outer_bounds[dimindex] - c_min = self.component.position[dimindex] - c_max = c_min + self.component.bounds[dimindex] - # Is the tooltip just too wide/tall? - if extent > (c_max - c_min): - new_pos[dimindex] = c_min - # Does it extend over the c_max edge? (right/top) - elif pos + extent > c_max: - new_pos[dimindex] = c_max - extent - - # Does it extend over the c_min edge? This is not an elif so - # that we can fix the situation where the c_max edge adjustment - # above pushes the position negative. - if new_pos[dimindex] < c_min: - new_pos[dimindex] = c_min - - self.outer_position = new_pos - - self._layout_needed = False - - def _recompute_text(self): - labels = [ - Label( - text=line, - font=self.font, - margin=0, - bgcolor="transparent", - border_width=0, - color=self.text_color, - rotate_angle=self.rotate_angle, - ) - for line in self.lines - ] - dummy_gc = self._font_metrics_provider - line_sizes = array( - [label.get_width_height(dummy_gc) for label in labels] - ) - self._cached_labels = labels - self._cached_line_sizes = line_sizes - self._max_line_width = max(line_sizes[:, 0]) - self._total_line_height = ( - sum(line_sizes[:, 1]) + len(line_sizes - 1) * self.line_spacing - ) - self._layout_needed = True - - def __font_metrics_provider_default(self): - return font_metrics_provider() - - @observe("font,text_color,lines.items") - def _invalidate_text_props(self, event): - self._text_props_valid = False - self._layout_needed = True - - @observe("border_padding,line_spacing,lines.items,padding") - def _invalidate_layout(self, event): - self._layout_needed = True - self.request_redraw() +# (C) Copyright 2006-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +import warnings + +from chaco.overlays.tooltip import ToolTip # noqa: F401 + +warnings.warn( + "Importing ToolTip from this module is deprecated. Please use chaco.api or" + " chaco.overlays.api instead. This module will be removed in the next " + "major release.", + DeprecationWarning, + stacklevel=2, +) diff --git a/ci/edmtool.py b/ci/edmtool.py index 4c6aa492c..f8504f341 100644 --- a/ci/edmtool.py +++ b/ci/edmtool.py @@ -123,6 +123,15 @@ "*/tests", # The following stub modules were kept for backwards compatibility but # will be removed in the next major relaese. Ref: enthought/chaco#748 + "chaco/colormapped_selection_overlay.py", + "chaco/data_label.py", + "chaco/lasso_overlay.py", + "chaco/layers/*", + "chaco/legend.py", + "chaco/plot_label.py", + "chaco/scatter_inspector_overlay.py", + "chaco/text_box_overlay.py", + "chaco/tooltip.py", "chaco/barplot.py", "chaco/candle_plot.py", "chaco/cmap_image_plot.py", diff --git a/examples/demo/status_overlay.py b/examples/demo/status_overlay.py index 2deb757cd..b5cb4b465 100644 --- a/examples/demo/status_overlay.py +++ b/examples/demo/status_overlay.py @@ -4,7 +4,7 @@ import numpy from chaco.api import Plot, ArrayPlotData -from chaco.layers.api import ErrorLayer, WarningLayer, StatusLayer +from chaco.overlays.api import ErrorLayer, WarningLayer, StatusLayer from enable.api import ComponentEditor from traits.api import HasTraits, Instance, Button from traitsui.api import UItem, View, HGroup diff --git a/image_LICENSE.txt b/image_LICENSE.txt index e0e65c15b..0ce904501 100644 --- a/image_LICENSE.txt +++ b/image_LICENSE.txt @@ -17,7 +17,7 @@ examples/basic: capitol.jpg | Peter Wang cat.jpg | Peter Wang -chaco/layers/data: +chaco/overlays/layers/data: Dialog-error.svg | Tango, CC 2.5, modified to remove gradients Dialog-warning.svg | Tango, CC 2.5, modified to remove gradients diff --git a/setup.py b/setup.py index 37ac0cbb6..0e54adbac 100644 --- a/setup.py +++ b/setup.py @@ -333,7 +333,7 @@ def resolve_version(): """.splitlines() if len(c.strip()) > 0], package_data={ 'chaco': [ - 'layers/data/*.svg', + 'overlays/layers/data/*.svg', 'tests/data/PngSuite/*.png', 'tools/toolbars/images/*.png', ] diff --git a/tox.ini b/tox.ini index 5cf39b2f2..6d99e8afb 100644 --- a/tox.ini +++ b/tox.ini @@ -6,7 +6,6 @@ exclude = docs/source/conf.py chaco/plots/quiverplot.py chaco/abstract_colormap.py - chaco/junk2.py chaco/plot_containers.py chaco/grid.py chaco/base_1d_plot.py @@ -23,22 +22,14 @@ exclude = chaco/transform_color_mapper.py chaco/default_colormaps.py chaco/plotscrollbar.py - chaco/simple_plot_frame.py chaco/abstract_plot_renderer.py chaco/ticks.py - chaco/chaco_plot_editor.py - chaco/base_plot_frame.py chaco/base_candle_plot.py - chaco/thingy.py chaco/plots/polygon_plot.py - chaco/text_box_overlay.py - chaco/legend.py chaco/linear_mapper.py - chaco/serializable.py chaco/abstract_overlay.py chaco/selectable_legend.py chaco/abstract_plot_data.py - chaco/colormapped_selection_overlay.py chaco/point_data_source.py chaco/plots/colormapped_scatterplot.py chaco/plots/jitterplot.py @@ -49,7 +40,6 @@ exclude = chaco/plot_factory.py chaco/plot_component.py chaco/data_label.py - chaco/lasso_overlay.py chaco/_speedups_fallback.py chaco/plots/multi_line_plot.py chaco/label.py @@ -65,7 +55,6 @@ exclude = chaco/color_mapper.py chaco/plots/line_scatterplot_1d.py chaco/polar_mapper.py - chaco/scatter_inspector_overlay.py chaco/axis_view.py chaco/base.py chaco/plots/color_bar.py @@ -80,7 +69,6 @@ exclude = chaco/tools/range_selection.py chaco/tools/zoom_tool.py chaco/tools/lasso_selection.py - chaco/tools/drag_tool.py chaco/tools/line_inspector.py chaco/tools/traits_tool.py chaco/tools/image_inspector_tool.py @@ -96,21 +84,18 @@ exclude = chaco/tools/line_segment_tool.py chaco/tools/range_selection_2d.py chaco/tools/regression_lasso.py - chaco/tools/base_zoom_tool.py chaco/tools/range_selection_overlay.py chaco/tools/legend_highlighter.py chaco/tools/pan_tool2.py chaco/tools/better_selecting_zoom.py - chaco/tools/simple_zoom.py chaco/tools/legend_tool.py - chaco/tools/toolbars/toolbar_buttons.py chaco/tools/toolbars/plot_toolbar.py - chaco/layers/api.py - chaco/layers/status_layer.py - chaco/layers/svg_range_selection_overlay.py + chaco/tools/toolbars/toolbar_buttons.py chaco/overlays/simple_inspector_overlay.py chaco/overlays/databox.py chaco/overlays/api.py + chaco/overlays/layers/api.py + chaco/overlays/layers/svg_range_selection_overlay.py chaco/tests/test_plotcontainer.py chaco/tests/test_image_plot.py chaco/tests/test_data_view.py @@ -119,7 +104,6 @@ exclude = chaco/tests/test_array_or_none.py chaco/tests/test_speedups.py chaco/tests/test_base_utils.py - chaco/tests/test_serializable.py chaco/scales/time_scale.py chaco/scales/formatters.py chaco/scales/scales.py @@ -133,6 +117,7 @@ exclude = examples/demo/quiver.py examples/demo/scales_test.py examples/demo/qt_example.py + examples/demo/chaco_trait_editor.py examples/demo/multiaxis_using_Plot.py examples/demo/vertical_plot.py examples/demo/stacked_axis.py @@ -154,7 +139,6 @@ exclude = examples/demo/advanced/scalar_image_function_inspector.py examples/demo/advanced/javascript_hover_tools.py examples/demo/advanced/cmap_variable_sized_scatter.py - examples/demo/advanced/scalar_image_function_inspector_old.py examples/demo/zoomed_plot/wav_to_numeric.py examples/demo/zoomed_plot/zoom_overlay.py examples/demo/zoomed_plot/zoom_plot.py