import random from PyQt5.QtCore import QEvent, Qt from PyQt5.QtGui import QCursor from PyQt5.QtWidgets import QErrorMessage, QInputDialog from kmeans.algorithms import k_means from clusterview2.colors import Color from clusterview2.exceptions import ExceededWindowBoundsError from clusterview2.mode import Mode from clusterview2.ui.opengl_widget import (set_drawing_event, set_move_bb_top_left, set_move_bb_bottom_right, reset_move_bbs, viewport_height, viewport_width, draw_mean_circles) from clusterview2.point_manager import PointManager class _ClickFlag: # This is the first stage. On mouse release it goes to # SELECTION_MOVE. NONE = 0 # We are now in selection box mode. SELECTION_BOX = 1 # Second stage - we have selected a number of points # and now we are going to track the left mouse button # to translate those points. After a left click # this moves to SELECTED_MOVED. SELECTION_MOVE = 2 # Any subsequent click in this mode will send it back # to NONE - we are done. SELECTED_MOVED = 3 # GLOBALS # Canvas pixel border - empirical, not sure where this is stored officially _CANVAS_BORDER = 1 # Module level flag for left click events (used to detect a left # click hold drag) _left_click_flag = _ClickFlag.NONE # Variable to track the mouse state during selection movement _last_mouse_pos = None # Used to implement mouse dragging when clicked _left_click_down = False # TODO: WHEN THE GROUPING ENDS AND THE USER CANCELS THE CENTROID COUNT # SHOULD BE ZEROED AND REMAINING COLORS SHOULD BE REPOPULATED. # Count of centroids for comparison with the spin widget _centroid_count = 0 _remaining_colors = [c for c in Color if c not in [Color.BLUE, Color.GREY]] def refresh_point_list(ctx): """ Refreshes the point list display. @param ctx A handle to the window context. """ # In order to make some guarantees and avoid duplicate # data we will clear the point list widget and re-populate # it using the current _point_set. ctx.point_list_widget.clear() for p in PointManager.point_set.points: ctx.point_list_widget.addItem(f"({p.x}, {p.y}) | Weight: {p.weight}") ctx.point_list_widget.update() num_of_points = len(list(PointManager.point_set.points)) ctx.number_of_points_label.setText(str(num_of_points)) def _handle_add_point(ctx, event): """ Event handler for the add point mode. Sets the drawing mode for the OpenGL Widget using `set_drawing_mode`, converts a point to our point representation, and adds it to the list. @param ctx A context handle to the main window. @param event The click event. """ # Update information as needed _handle_info_updates(ctx, event) if (event.button() == Qt.LeftButton and event.type() == QEvent.MouseButtonPress): # At this point we can be sure resize_gl has been called # at least once, so set the viewport properties of the # point set so it knows the canvas bounds. PointManager.point_set.viewport_width = viewport_width() PointManager.point_set.viewport_height = viewport_height() # Clear any existing selections PointManager.point_set.clear_selection() try: # No attribute at the moment, default point color is Color.GREY. PointManager.point_set.add_point(event.x(), event.y(), Color.GREY) except ExceededWindowBoundsError: # The user tried to place a point whos edges would be # on the outside of the window. We will just ignore it. return refresh_point_list(ctx) set_drawing_event(event) ctx.opengl_widget.update() ctx.point_list_widget.update() def _handle_edit_point(ctx, event): _handle_info_updates(ctx, event) PointManager.point_set.clear_selection() if (event.button() == Qt.LeftButton and event.type() == QEvent.MouseButtonPress): # See if a point was hit point = None for p in PointManager.point_set.points: if p.hit(event.x(), event.y()): point = p break # Get point weight from user and assign it to the point. if point is not None: value, ok = QInputDialog.getDouble(None, 'Weight', 'Weight(Float): ', 1, 1, 3000, 1) if ok: if not isinstance(value, float): error_dialog = QErrorMessage() error_dialog.showMessage('Point weight must be a floating point value.') error_dialog.exec_() else: point.weight = value # Store old x, y from event set_drawing_event(event) ctx.update() refresh_point_list(ctx) def ogl_keypress_handler(ctx, event): """ A keypress handler attached to the OpenGL widget. It primarily exists to allow the user to cancel selection. Also allows users to escape from modes. @param ctx A handle to the window context. @param event The event associated with this handler. """ global _left_click_flag global _last_mouse_pos if event.key() == Qt.Key_Escape: if ctx.mode is Mode.MOVE: if _left_click_flag is not _ClickFlag.NONE: _last_mouse_pos = None _left_click_flag = _ClickFlag.NONE PointManager.point_set.clear_selection() reset_move_bbs() refresh_point_list(ctx) elif ctx.mode is not Mode.OFF: ctx.mode = Mode.OFF # Also change the mouse back to normal ctx.opengl_widget.setCursor(QCursor(Qt.CursorShape.ArrowCursor)) ctx.status_bar.showMessage("") ctx.opengl_widget.update() def _handle_move_points(ctx, event): """ A relatively complicated state machine that handles the process of selection, clicking, and dragging. @param ctx The context to the window. @param event The event. """ global _left_click_flag global _left_mouse_down global _last_mouse_pos set_drawing_event(event) _handle_info_updates(ctx, event) # If we release the mouse, we want to quickly alert drag mode. if (event.button() == Qt.LeftButton and event.type() == QEvent.MouseButtonRelease): _left_mouse_down = False # This if statement block is used to set the bounding box for # drawing and call the selection procedure. if (event.button() == Qt.LeftButton and event.type() == QEvent.MouseButtonPress): _left_mouse_down = True if _left_click_flag is _ClickFlag.NONE: _left_click_flag = _ClickFlag.SELECTION_BOX set_move_bb_top_left(event.x(), event.y()) elif (_left_click_flag is _ClickFlag.SELECTION_BOX and _left_mouse_down): # We are now in the click-and-hold to signal move # tracking and translation _left_click_flag = _ClickFlag.SELECTION_MOVE _last_mouse_pos = (event.x(), event.y()) # Post-selection handlers if (_left_click_flag is _ClickFlag.SELECTION_BOX and event.type() == QEvent.MouseMove): set_move_bb_bottom_right(event.x(), event.y()) elif (_left_click_flag is _ClickFlag.SELECTION_MOVE and _last_mouse_pos is not None and _left_mouse_down and event.type() == QEvent.MouseMove): dx = abs(_last_mouse_pos[0] - event.x()) dy = abs(_last_mouse_pos[1] - event.y()) for p in PointManager.point_set.points: if p.selected: # Use the deltas to decide what direction to move. # We only want to move in small unit increments. # If we used the deltas directly the points would # fly off screen quickly as we got farther from our # start. try: if event.x() < _last_mouse_pos[0]: p.move(-dx, 0) if event.y() < _last_mouse_pos[1]: p.move(0, -dy) if event.x() > _last_mouse_pos[0]: p.move(dx, 0) if event.y() > _last_mouse_pos[1]: p.move(0, dy) except ExceededWindowBoundsError: # This point has indicated a move would exceed # it's bounds, so we'll just go to the next # point. continue _last_mouse_pos = (event.x(), event.y()) elif (_left_click_flag is not _ClickFlag.NONE and event.type() == QEvent.MouseButtonRelease): if _left_click_flag is _ClickFlag.SELECTION_BOX: set_move_bb_bottom_right(event.x(), event.y()) # Satisfy the post condition by resetting the bounding box reset_move_bbs() ctx.opengl_widget.update() def _handle_delete_point(ctx, event): _handle_info_updates(ctx, event) if (event.button() == Qt.LeftButton and event.type() == QEvent.MouseButtonPress): set_drawing_event(event) PointManager.point_set.remove_point(event.x(), event.y()) refresh_point_list(ctx) ctx.opengl_widget.update() ctx.point_list_widget.update() def _handle_info_updates(ctx, event): """ Updates data under the "information" header. @param ctx The context to the main window. @param event The event. """ if event.type() == QEvent.MouseMove: ctx.mouse_position_label.setText(f"{event.x(), event.y()}") def reset_colors(): global _remaining_colors _remaining_colors = [c for c in Color if c not in [Color.BLUE, Color.GREY]] for point in PointManager.point_set.points: point.color = Color.GREY def generate_random_points(point_count, x_bound, y_bound): """ Using the random module of python generate a unique set of xs and ys to use as points, bounded by the canvas edges. @param point_count The count of points to generate. @param x_bound The width bound. @param y_bound The height bound. """ # TODO: The window size should be increased slightly to # accomodate 3000 points (the maximum) given the point size. # Work out an algorithm and limit the number of points # selectable based on the maximum amount of points on the screen # given the point size. # First clear the point set PointManager.point_set.clear() point_size = PointManager.point_set.point_size # Sample without replacement so points are not duplicated. xs = random.sample(range(point_size, x_bound), point_count) ys = random.sample(range(point_size, y_bound), point_count) points = list(zip(xs, ys)) for point in points: PointManager.point_set.add_point(point[0], point[1], Color.GREY) def _handle_clustering(ctx, _): points = list(PointManager.point_set.points) if not ctx.clustering_solved: clusters = k_means(points, ctx.number_of_clusters.value(), 0.001) # We can leverage the paint function by first assigning every cluster # a color (for completeness) and then assigning every point in that # cluster the cluster's color. for i, cluster in enumerate(clusters): cluster.color = _remaining_colors[i] for point in cluster.points: point.color = cluster.color PointManager.clusters = clusters ctx.opengl_widget.update() ctx.clustering_solved = True # Simple dispatcher to make it easy to dispatch the right mode # function when the OpenGL window is acted on. MODE_HANDLER_MAP = { Mode.OFF: _handle_info_updates, Mode.LOADED: _handle_info_updates, Mode.ADD: _handle_add_point, Mode.EDIT: _handle_edit_point, Mode.MOVE: _handle_move_points, Mode.DELETE: _handle_delete_point, Mode.CLUSTERING: _handle_clustering }