class MplPathROI(MplPolygonalROI): """ Matplotlib ROI for path selections Parameters ---------- axes : :class:`matplotlib.axes.Axes` The Matplotlib axes to draw to. """ _roi_cls = Path def __init__(self, axes, roi=None): super(MplPolygonalROI, self).__init__(axes) self.plot_opts = { 'edgecolor': PATCH_COLOR, 'facecolor': PATCH_COLOR, 'alpha': 0.3 } self._patch = None def start_selection(self, event): if self._patch is not None: self._patch.remove() self._patch = None self._background_cache = None self._axes.figure.canvas.draw() super(MplPathROI, self).start_selection(event) def _sync_patch(self): if self._patch is not None: self._patch.remove() self._patch = None if self._roi.defined(): x, y = self._roi.to_polygon() p = MplPath(np.column_stack((x, y))) self._patch = PathPatch(p, transform=self._axes.transData) self._patch.set_visible(True) self._patch.set(**self.plot_opts) self._axes.add_artist(self._patch) def finalize_selection(self, event): self._mid_selection = False if self._patch is not None: self._patch.remove() self._patch = None self._draw()
class VerticalMarker(QObject): moved = Signal(float) def __init__(self, canvas, x, color): super(VerticalMarker, self).__init__() self.x = x self.ax = canvas.figure.get_axes()[0] y0, y1 = self.ax.get_ylim() path = Path([(x, y0), (x, y1)], [Path.MOVETO, Path.LINETO]) self.patch = PathPatch(path, facecolor='None', edgecolor=color, picker=5, linewidth=2.0, animated=True) self.ax.add_patch(self.patch) self.is_moving = False def remove(self): self.patch.remove() def redraw(self): y0, y1 = self.ax.get_ylim() vertices = self.patch.get_path().vertices vertices[0] = self.x, y0 vertices[1] = self.x, y1 self.ax.draw_artist(self.patch) def get_x_in_pixels(self): x_pixels, _ = self.patch.get_transform().transform((self.x, 0)) return x_pixels def is_above(self, x): return np.abs(self.get_x_in_pixels() - x) < 3 def on_click(self, x): if self.is_above(x): self.is_moving = True def stop(self): self.is_moving = False def mouse_move(self, xd): if self.is_moving and xd is not None: self.x = xd self.moved.emit(xd) def should_override_cursor(self, x): return self.is_moving or self.is_above(x)
class VerticalMarker(QObject): """ An interactive marker displayed as a vertical line. """ x_moved = Signal(float) def __init__(self, canvas, color, x, y0=None, y1=None, line_width=1.0, picker_width=5, line_style='-'): """ Init the marker. :param canvas: A MPL canvas. :param color: An MPL colour value :param x: The x coordinate (data) of the marker. :param y0: The y coordinate (data) of the bottom end of the marker. Default is None which means dynamically set it to the current lowest y value displayed. :param y1: The y coordinate (data) of the top end of the marker. Default is None which means dynamically set it to the current highest y value displayed. :param line_width: The line width (pixels). :param picker_width: The picker sensitivity (pixels). :param line_style: An MPL line style value. """ super(VerticalMarker, self).__init__() self.ax = canvas.figure.get_axes()[0] self.x = x self.y0 = y0 self.y1 = y1 y0, y1 = self._get_y0_y1() path = Path([(x, y0), (x, y1)], [Path.MOVETO, Path.LINETO]) self.patch = PathPatch(path, facecolor='None', edgecolor=color, picker=picker_width, linewidth=line_width, linestyle=line_style, animated=True) self.ax.add_patch(self.patch) self.is_moving = False def _get_y0_y1(self): """ Calculate the current y coordinates of the line ends. :return: Tuple y0, y1. """ if self.y0 is None or self.y1 is None: y0, y1 = self.ax.get_ylim() if self.y0 is not None: y0 = self.y0 if self.y1 is not None: y1 = self.y1 return y0, y1 def remove(self): """ Remove this marker from the canvas. """ self.patch.remove() def redraw(self): """ Redraw this marker. """ y0, y1 = self._get_y0_y1() vertices = self.patch.get_path().vertices vertices[0] = self.x, y0 vertices[1] = self.x, y1 self.ax.draw_artist(self.patch) def get_x_in_pixels(self): """ Get the x coordinate in screen pixels. """ x_pixels, _ = self.patch.get_transform().transform((self.x, 0)) return x_pixels def is_above(self, x, y): """ Check if a mouse positioned at (x, y) is over this marker. :param x: An x mouse coordinate. :param y: An y mouse coordinate. :return: True or False. """ x_pixels, y_pixels = self.patch.get_transform().transform((x, y)) if self.y0 is not None and y < self.y0: return False if self.y1 is not None and y > self.y1: return False return abs(self.get_x_in_pixels() - x_pixels) < 3 def mouse_move_start(self, x, y): """ Start moving this marker if (x, y) is above it. Ignore otherwise. :param x: An x mouse coordinate. :param y: An y mouse coordinate. """ self.is_moving = self.is_above(x, y) def mouse_move_stop(self): """ Stop moving. """ self.is_moving = False def mouse_move(self, x, y=None): """ Move this marker to a new position if movement had been started earlier by a call to mouse_move_start(x, y) :param x: An x mouse coordinate. :param y: An y mouse coordinate. :return: True if moved or False if stayed at the old position. """ if self.is_moving and x is not None: self.x = x self.x_moved.emit(x) return True return False def get_cursor_at_y(self, y): """ Get an override cursor for an y coordinate given that the x == self.x :param y: A y coordinate. :return: QCursor or None. """ return QCursor(Qt.SizeHorCursor) def override_cursor(self, x, y): """ Get the override cursor for mouse position (x, y) :param x: An x mouse coordinate. :param y: An y mouse coordinate. :return: QCursor or None. """ if self.y0 is not None and y < self.y0: return None if self.y1 is not None and y > self.y1: return None if self.is_moving or self.is_above(x, y): return self.get_cursor_at_y(y) return None
class HorizontalMarker(QObject): """ An interactive marker displayed as a horizontal line. """ y_moved = Signal(float) def __init__(self, canvas, color, y, x0=None, x1=None, line_width=1.0, picker_width=5, line_style='-', move_cursor=None, axis=None): """ Init the marker. :param canvas: A MPL canvas. :param color: An MPL colour value :param y: The y coordinate (data) of the marker. :param x0: The x coordinate (data) of the left end of the marker. Default is None which means dynamically set it to the current maximum x value displayed. :param x1: The x coordinate (data) of the right end of the marker. Default is None which means dynamically set it to the current minimum x value displayed. :param line_width: The line width (pixels). :param picker_width: The picker sensitivity (pixels). :param line_style: An MPL line style value. """ super(HorizontalMarker, self).__init__() self.canvas = canvas if axis is None: self.axis = canvas.figure.get_axes()[0] else: self.axis = axis self.y = y self.x0 = x0 self.x1 = x1 x0, x1 = self._get_x0_x1() path = Path([(x0, y), (x1, y)], [Path.MOVETO, Path.LINETO]) self.patch = PathPatch(path, facecolor='None', edgecolor=color, picker=picker_width, linewidth=line_width, linestyle=line_style, animated=True) self.axis.add_patch(self.patch) self.axis.interactive_markers.append(self.patch) self.is_moving = False self.move_cursor = move_cursor def _get_x0_x1(self): """ Calculate the current x coordinates of the line ends. :return: Tuple x0, x1. """ if self.x0 is None or self.x1 is None: x0, x1 = self.axis.get_xlim() if self.x0 is not None: x0 = self.x0 if self.x1 is not None: x1 = self.x1 return x0, x1 def remove(self): """ Remove this marker from the canvas. """ self.patch.remove() def redraw(self): """ Redraw this marker. """ x0, x1 = self._get_x0_x1() vertices = self.patch.get_path().vertices vertices[0] = x0, self.y vertices[1] = x1, self.y self.axis.draw_artist(self.patch) def set_visible(self, visible): self.patch.set_visible(visible) def set_color(self, color): """ Set the colour of the marker :param color: The color to set the marker to. """ self.patch.set_edgecolor(color) def get_position(self): """ Get the y coordinate in axes coords. :return: y in axes coords """ return self.y def set_position(self, y): """ Set the y position of the marker. :param y: An y axis coordinate. """ self.y = y self.y_moved.emit(y) def get_y_in_pixels(self): """ Returns the y coordinate in screen pixels. :return: y in pixels """ _, y_pixels = self.patch.get_transform().transform((0, self.y)) return y_pixels def is_above(self, x, y): """ Check if a mouse positioned at (x, y) is over this marker. :param x: An x mouse coordinate. :param y: An y mouse coordinate. :return: True or False. """ _, y_pixels = self.patch.get_transform().transform((x, y)) if self.x0 is not None and x < self.x0: return False if self.x1 is not None and x > self.x1: return False return abs(self.get_y_in_pixels() - y_pixels) < MARKER_SENSITIVITY def mouse_move_start(self, x, y): """ Start moving this marker if (x, y) is above it. Ignore otherwise. :param x: An x mouse coordinate. :param y: An y mouse coordinate. :param pixels: True if the coordinates are already in pixels. """ self.is_moving = self.is_above(x, y) def mouse_move_stop(self): """ Stop moving. """ self.is_moving = False def mouse_move(self, x, y): """ Move this marker to a new position if movement had been started earlier by a call to mouse_move_start(x, y) :param x: An x mouse coordinate. :param y: An y mouse coordinate. :return: True if moved or False if stayed at the old position. """ if self.is_moving and y is not None and x is not None: self.set_position(y) return True return False def is_marker_moving(self): """ Returns true if the marker is being moved :return: True if the marker is being moved. """ return self.is_moving def override_cursor(self, x, y): """ Get the override cursor for mouse position (x, y) :param x: An x mouse coordinate. :param y: An y mouse coordinate. :return: QCursor or None. """ if self.x0 is not None and x < self.x0: return None if self.x1 is not None and x > self.x1: return None if self.is_moving or self.is_above(x, y): return self.move_cursor if self.move_cursor is not None else QCursor( Qt.SizeVerCursor) return None def set_move_cursor(self, cursor, x_pos, y_pos): """Set the style of the cursor to use when the marker is moving""" if cursor is not None: cursor = QCursor(cursor) self.move_cursor = cursor self.override_cursor(x_pos, y_pos)
class TopoModule(ModuleTemplate): """ Module for simple Topography visualization without computing a geological model """ def __init__(self, extent: list = None, **kwargs): pn.extension() self.max_height = 800 self.center = 0 self.min_height = -200 self.sea = False self.sea_contour = False self.sea_level_patch = None self.col = None # Check if image is loaded self.type_fluid = ["water", "lava", "slime"] self.name_fluid = self.type_fluid[0] self.fluid = None # Image to create the texture self.texture = None # resultant texture after masking with path if extent is not None: self.extent = extent self.load_fluid() self.animate = True self._anim = 0 # animation # Settings for sea level polygon self.path = None self.sea_level_polygon_alpha = 0.7 self.sea_level_polygon_line_thickness = 2. self.sea_level_polygon_line_color = mcolors.to_hex("blue") self.sea_zorder = 1000 self.sea_fill = True self._marker_contour_val = None self.side_flooding = False logger.info("TopoModule loaded successfully") def update(self, sb_params: dict): """ Acquire the information from th sb_params dict and call the functions to modify the frame and/or plot in the axes Args: sb_params: Returns: """ frame = sb_params.get('frame') extent = sb_params.get('extent') ax = sb_params.get('ax') frame, extent = self.normalize_topography(frame, extent, min_height=self.min_height, max_height=self.max_height) marker = sb_params.get('marker') if len(marker) > 0: self._marker_contour_val = marker.loc[marker.is_inside_box, ('box_x', 'box_y')].values[0] else: self._marker_contour_val = None self.extent = extent self.plot(frame, ax, extent) sb_params['frame'] = frame sb_params['ax'] = ax sb_params['extent'] = extent return sb_params def plot(self, frame, ax, extent): """ Deals with everything related to plotting in the axes Args: frame: Sandbox frame ax: axes of matplotlib figure to paint on Returns: """ if self.sea or self.sea_contour: self._delete_path() #if self._center != self.center: # TODO: Avoid unnecessary calculation of new paths try: if self.side_flooding and self._marker_contour_val is not None: self.path, self.center = self.find_single_path( frame, self._marker_contour_val) else: self.path = self.create_paths(frame, self.center) except Exception as e: logger.error(e, exc_info=True) if self.sea and self.path is not None: self.set_texture(self.path) if self.col is None: self.col = ax.imshow(self.texture, origin='lower', aspect='auto', zorder=self.sea_zorder + 1, alpha=self.sea_level_polygon_alpha) else: self.col.set_data(self.texture) self.col.set_alpha(self.sea_level_polygon_alpha) else: self._delete_image() if self.sea_contour and self.path is not None: # Add contour polygon of sea level self.sea_level_patch = PathPatch( self.path, alpha=self.sea_level_polygon_alpha, linewidth=self.sea_level_polygon_line_thickness, # ec=self.sea_level_polygon_line_color, color=self.sea_level_polygon_line_color, zorder=self.sea_zorder, fill=self.sea_fill) # ContourLinesModule ax.add_patch(self.sea_level_patch) else: self._delete_image() self._delete_path() def _delete_image(self): """Remove sea-texture""" if self.col: self.col.remove() self.col = None def _delete_path(self): """remove sea-level patch, if previously defined""" if self.sea_level_patch: self.sea_level_patch.remove() self.sea_level_patch = None def set_texture(self, path): x = np.arange(0, self.extent[1] - 1, 1) y = np.arange(0, self.extent[3] - 1, 1) xx, yy = np.meshgrid(x, y) xy = np.vstack((xx.ravel(), yy.ravel())).T mask = path.contains_points(xy) present = xy[mask] self.texture = self.fluid.copy() if self.animate: self.animate_texture() for i in range(len(present)): self.texture[present[i][1], present[i][0], -1] = 1 def animate_texture(self): """ Move the texture image to give the appearance of moving like waves Returns: """ A = self.texture.shape[0] / 5 w = 2.0 / self.texture.shape[1] shift = lambda x: A * np.sin(2.0 * np.pi * (x + self._anim) * w) for i in range(self.texture.shape[1]): vector = np.roll(np.arange(0, self.texture.shape[0]), int(round(shift(i)))) self.texture[:, i][:] = self.texture[:, i][:][vector] self._anim += 1 @staticmethod def normalize_topography(frame, extent, min_height, max_height): """ Change the max an min value of the frame and normalize accordingly Args: frame: sensor frame extent: sensor extent max_height: Target max height min_height: Target min height Returns: normalized frame and new extent """ # first position the frame in 0 if the original extent is not in 0 if extent[-2] != 0: displ = 0 - extent[-2] frame = frame - displ # calculate how much we need to move the frame so the 0 value correspond to the approximate 0 in the frame # min_height assuming is under 0. if min_height < 0: displace = min_height * (-1) * (extent[-1] - extent[-2]) / ( max_height - min_height) frame = frame - displace extent[-1] = extent[-1] - displace extent[-2] = extent[-2] - displace # now we set 2 regions. One above sea level and one below sea level. So now we can normalize these two # regions above 0 frame[frame > 0] = frame[frame > 0] * (max_height / extent[-1]) # below 0 frame[frame < 0] = frame[frame < 0] * (min_height / extent[-2]) elif min_height > 0: frame = frame * (max_height - min_height) / (extent[-1] - extent[-2]) frame = frame + min_height # just displace all up to start in min_height elif min_height == 0: frame = frame * max_height / (extent[-1]) else: raise AttributeError extent[-1] = max_height # self.plot.vmax = max_height extent[-2] = min_height # self.plot.vmin = min_height return frame, extent @staticmethod def reshape(image, shape: tuple): """ Reshape any image to the desired shape. Change shape of numpy array to desired shape Args: image: shape: sandbox shape Returns: reshaped frame """ return skimage.transform.resize(image, shape, order=3, mode='edge', anti_aliasing=True, preserve_range=False) def load_fluid(self): image = plt.imread(_package_dir + '/modules/img/' + self.name_fluid + '.jpg') fluid = self.reshape(image, (self.extent[3], self.extent[1])) # height, width # convert RGB image to RGBA self.fluid = np.dstack( (fluid, np.zeros((self.extent[3], self.extent[1])))) @staticmethod def create_paths(frame, contour_val): """Create compound path for given contour value Args: frame: sensor frame contour_val (float): value of contour Returns: path: matplotlib.Path object for contour polygon """ # create padding frame_padded = np.pad(frame, pad_width=1, mode='constant', constant_values=np.max(frame) + 1) contours = measure.find_contours(frame_padded.T, contour_val) # combine values contour_comb = np.concatenate(contours, axis=0) # generate codes to close polygons # First: link all codes = [Path.LINETO for _ in range(contour_comb.shape[0])] # Next: find ends of each contour and close index = 0 for contour in contours: codes[index] = Path.MOVETO index += len(contour) codes[index - 1] = Path.CLOSEPOLY path = Path(contour_comb, codes) return path @staticmethod def find_single_path(frame: tuple, point: tuple): """ From a frame, find the point that contains the point and start changing the contour val excluding isolated contours Args: frame: frame of sandbox point: Aruco point coordinate (x, y) Returns: path: contour_val: """ # create padding contour_val = frame[int(point[1]), int( point[0])] # To be sure that there will be a contour in that point frame_padded = np.pad(frame, pad_width=1, mode='constant', constant_values=np.max(frame) + 1) contours = measure.find_contours(frame_padded.T, contour_val) # Look 5 pixels in the surrounding (2 each side) if a path contains any of those points surrounding_points = [(point[0] - 2 + i, point[1] - 2 + j) for i in range(5) for j in range(5)] for con in contours: codes = [Path.LINETO for _ in range(len(con))] codes[0] = Path.MOVETO codes[-1] = Path.CLOSEPOLY path = Path(con, codes) if True in path.contains_points(surrounding_points): return path, contour_val logger.warning("No path includes the point %s" % point) return None, None def show_widgets(self): self._create_widgets() panel = pn.Column( "### Widgets for Topography normalization", pn.Row( pn.Column(self._widget_min_height, self._widget_max_height, self._widget_sea, pn.Row(self._widget_sea_contour, self._widget_fill), self._widget_sea_level), pn.Column(self._widget_animation, self._widget_type_fluid, self._widget_transparency, self._widget_color)), " #### Use aruco marker for local filling", self._widget_aruco) return panel def _create_widgets(self): self._widget_min_height = pn.widgets.Spinner( name="Minimum height of topography", value=self.min_height, step=20) self._widget_min_height.param.watch(self._callback_min_height, 'value', onlychanged=False) self._widget_max_height = pn.widgets.Spinner( name="Maximum height of topography", value=self.max_height, step=20) self._widget_max_height.param.watch(self._callback_max_height, 'value', onlychanged=False) self._widget_sea_level = pn.widgets.IntSlider( name="Set sea level height", start=self.min_height, end=self.max_height, step=5, value=self.center, ) self._widget_sea_level.param.watch(self._callback_sea_level, 'value', onlychanged=False) self._widget_sea = pn.widgets.Checkbox(name='Show sea level', value=self.sea) self._widget_sea.param.watch(self._callback_see, 'value', onlychanged=False) self._widget_sea_contour = pn.widgets.Checkbox( name='Show sea level contour', value=self.sea_contour, width_policy='min') self._widget_sea_contour.param.watch(self._callback_see_contour, 'value', onlychanged=False) self._widget_fill = pn.widgets.Checkbox(name='Fill contour', value=self.sea_fill, width_policy='min') self._widget_fill.param.watch(self._callback_fill, 'value', onlychanged=False) self._widget_type_fluid = pn.widgets.Select( name='Select type of texture for the fluid', options=self.type_fluid, size=3, value=self.name_fluid) self._widget_type_fluid.param.watch(self._callback_select_fluid, 'value', onlychanged=False) self._widget_transparency = pn.widgets.Spinner( name="Select transparency", value=self.sea_level_polygon_alpha, step=0.05) self._widget_transparency.param.watch(self._callback_transparency, 'value', onlychanged=False) self._widget_color = pn.widgets.ColorPicker( name='Color level contour', value=self.sea_level_polygon_line_color) self._widget_color.param.watch(self._callback_color, 'value', onlychanged=False) self._widget_animation = pn.widgets.Checkbox( name='Animate wave movement', value=self.animate) self._widget_animation.param.watch(self._callback_animation, 'value', onlychanged=False) self._widget_aruco = pn.widgets.Checkbox(name='Local level', value=self.side_flooding) self._widget_aruco.param.watch(self._callback_aruco, 'value', onlychanged=False) def _callback_aruco(self, event): self.side_flooding = event.new self._widget_sea_level.disabled = event.new def _callback_fill(self, event): self.sea_fill = event.new def _callback_transparency(self, event): self.sea_level_polygon_alpha = event.new def _callback_color(self, event): self.sea_level_polygon_line_color = event.new def _callback_animation(self, event): self.animate = event.new def _callback_select_fluid(self, event): self.name_fluid = event.new self.load_fluid() def _callback_min_height(self, event): self.min_height = event.new if self.center < self.min_height: self.center = self.min_height + 1 self._widget_sea_level.value = self.center + 1 self._widget_sea_level.start = event.new + 1 def _callback_max_height(self, event): self.max_height = event.new if self.center > self.max_height: self.center = self.max_height - 1 self._widget_sea_level.value = self.center - 1 self._widget_sea_level.end = event.new - 1 def _callback_sea_level(self, event): self.center = event.new def _callback_see(self, event): self.sea = event.new def _callback_see_contour(self, event): self.sea_contour = event.new
class VerticalMarker(QObject): """ An interactive marker displayed as a vertical line. """ x_moved = Signal(float) def __init__(self, canvas, color, x, y0=None, y1=None, line_width=1.0, picker_width=5, line_style='-'): """ Init the marker. :param canvas: A MPL canvas. :param color: An MPL colour value :param x: The x coordinate (data) of the marker. :param y0: The y coordinate (data) of the bottom end of the marker. Default is None which means dynamically set it to the current lowest y value displayed. :param y1: The y coordinate (data) of the top end of the marker. Default is None which means dynamically set it to the current highest y value displayed. :param line_width: The line width (pixels). :param picker_width: The picker sensitivity (pixels). :param line_style: An MPL line style value. """ super(VerticalMarker, self).__init__() self.ax = canvas.figure.get_axes()[0] self.x = x self.y0 = y0 self.y1 = y1 y0, y1 = self._get_y0_y1() path = Path([(x, y0), (x, y1)], [Path.MOVETO, Path.LINETO]) self.patch = PathPatch(path, facecolor='None', edgecolor=color, picker=picker_width, linewidth=line_width, linestyle=line_style, animated=True) self.ax.add_patch(self.patch) self.is_moving = False def _get_y0_y1(self): """ Calculate the current y coordinates of the line ends. :return: Tuple y0, y1. """ if self.y0 is None or self.y1 is None: y0, y1 = self.ax.get_ylim() if self.y0 is not None: y0 = self.y0 if self.y1 is not None: y1 = self.y1 return y0, y1 def remove(self): """ Remove this marker from the canvas. """ self.patch.remove() def redraw(self): """ Redraw this marker. """ y0, y1 = self._get_y0_y1() vertices = self.patch.get_path().vertices vertices[0] = self.x, y0 vertices[1] = self.x, y1 self.ax.draw_artist(self.patch) def get_x_in_pixels(self): """ Get the x coordinate in screen pixels. """ x_pixels, _ = self.patch.get_transform().transform((self.x, 0)) return x_pixels def is_above(self, x, y): """ Check if a mouse positioned at (x, y) is over this marker. :param x: An x mouse coordinate. :param y: An y mouse coordinate. :return: True or False. """ x_pixels, y_pixels = self.patch.get_transform().transform((x, y)) if self.y0 is not None and y < self.y0: return False if self.y1 is not None and y > self.y1: return False return abs(self.get_x_in_pixels() - x_pixels) < 3 def mouse_move_start(self, x, y): """ Start moving this marker if (x, y) is above it. Ignore otherwise. :param x: An x mouse coordinate. :param y: An y mouse coordinate. """ self.is_moving = self.is_above(x, y) def mouse_move_stop(self): """ Stop moving. """ self.is_moving = False def mouse_move(self, x, y=None): """ Move this marker to a new position if movement had been started earlier by a call to mouse_move_start(x, y) :param x: An x mouse coordinate. :param y: An y mouse coordinate. :return: True if moved or False if stayed at the old position. """ if self.is_moving and x is not None: self.x = x self.x_moved.emit(x) return True return False def get_cursor_at_y(self, y): """ Get an override cursor for an y coordinate given that the x == self.x :param y: A y coordinate. :return: QCursor or None. """ return QCursor(Qt.SizeHorCursor) def override_cursor(self, x, y): """ Get the override cursor for mouse position (x, y) :param x: An x mouse coordinate. :param y: An y mouse coordinate. :return: QCursor or None. """ if self.y0 is not None and y < self.y0: return None if self.y1 is not None and y > self.y1: return None if self.is_moving or self.is_above(x, y): return self.get_cursor_at_y(y) return None