diff --git a/AUTHORS b/AUTHORS index 6215a054f..2990531be 100644 --- a/AUTHORS +++ b/AUTHORS @@ -20,6 +20,7 @@ in alphabetic order by first name - May Bär - Nilupul Manodya - Reimar Bauer +- Rohit Prasad - Rishabh Soni - Sakshi Chopkar - Shivashis Padhi diff --git a/mslib/msui/mpl_qtwidget.py b/mslib/msui/mpl_qtwidget.py index cd09d5a6f..12891b7f5 100644 --- a/mslib/msui/mpl_qtwidget.py +++ b/mslib/msui/mpl_qtwidget.py @@ -182,6 +182,8 @@ def __init__(self, fig=None, ax=None, settings=None): self.map = None self.legimg = None self.legax = None + self.flightpath_dict = {} # Store flightpath_dict as instance variable + self.annotations = {} # Store annotations by flighttrack name # stores the topview plot title size(tov_pts) and topview axes label size(tov_als),initially as None. self.tov_pts = None self.tov_als = None @@ -353,6 +355,17 @@ def draw_flightpath_legend(self, flightpath_dict): """ Draw the flight path legend on the plot, attached to the upper-left corner. """ + # Update the internal flightpath_dict to make sure it's always in sync + # but keep the existing labels if modified by the user. + for key, (label, color, linestyle, waypoints) in flightpath_dict.items(): + # Check if the label was modified if not, use the flight track name as default. + if key in self.flightpath_dict: + # Preserve the user-updated label + flightpath_dict[key] = (self.flightpath_dict[key][0], color, linestyle, waypoints) + else: + # New entry or unmodified, just add it + self.flightpath_dict[key] = (label, color, linestyle, waypoints) + # Clear any existing legend if self.ax.get_legend() is not None: self.ax.get_legend().remove() @@ -363,20 +376,119 @@ def draw_flightpath_legend(self, flightpath_dict): # Create legend handles legend_handles = [] - for name, (color, linestyle) in flightpath_dict.items(): + for name, (label, color, linestyle, waypoints) in flightpath_dict.items(): line = Line2D([0], [0], color=color, linestyle=linestyle, linewidth=2) - legend_handles.append((line, name)) + legend_handles.append((line, label)) - # Add legend directly to the main axis, attached to the upper-left corner - self.ax.legend( + legend = self.ax.legend( [handle for handle, _ in legend_handles], - [name for _, name in legend_handles], + [label for _, label in legend_handles], loc='upper left', - bbox_to_anchor=(0, 1), # (x, y) coordinates relative to the figure - bbox_transform=self.fig.transFigure, # Use figure coordinates + bbox_to_anchor=(0, 1), + bbox_transform=self.fig.transFigure, frameon=False ) + # Connect the click event to the legend + for legend_item, (line, label) in zip(legend.get_texts(), legend_handles): + legend_item.set_picker(True) # Make the legend items clickable + legend_item.label = label # Attach the label to the item + + # Attach the pick event handler + self.fig.canvas.mpl_connect('pick_event', self.on_legend_click) + self.ax.figure.canvas.draw_idle() + + def on_legend_click(self, event): + """ + Handle the legend click event, prompting the user to update the label. + """ + legend_item = event.artist + old_label = legend_item.label # Retrieve the old label + + # Open a dialog to input a new label + new_label, ok = QtWidgets.QInputDialog.getText( + None, "Update Legend Label", f"Enter new label for [{old_label}]:" + ) + + if ok and new_label: + # Find the entry in self.flightpath_dict and update the label + for key, (label, color, linestyle, waypoints) in self.flightpath_dict.items(): + if label == old_label: + self.flightpath_dict[key] = (new_label, color, linestyle, waypoints) + break + + # Redraw the legend with the updated label + self.draw_flightpath_legend(self.flightpath_dict) + + # Update annotations without making them visible + self.update_annotation_labels_only(self.flightpath_dict) + + def update_annotation_labels_only(self, flightpath_dict): + """ + Update the label of the annotation without making the annotation visible. + """ + for flighttrack, (label, color, linestyle, waypoints) in flightpath_dict.items(): + if flighttrack in self.annotations: + # Update only the label of the existing annotation + annotation = self.annotations[flighttrack] + annotation.set_text(label) + + # Redraw the canvas to reflect the updated labels + self.ax.figure.canvas.draw_idle() + + def annotate_flight_tracks(self, flightpath_dict): + """ + Annotate each flight track with its corresponding label next to the track, avoiding overlap. + """ + annotated_positions = [] # Store positions to avoid overlap + + for flighttrack, (label, color, linestyle, waypoints) in flightpath_dict.items(): + if len(waypoints) >= 2: + # Remove old annotation if it exists + if flighttrack in self.annotations: + self.annotations[flighttrack].remove() + + # Convert lat/lon of the waypoints to the map's projected coordinates + waypoint_coords = [self.map(waypoint[1], waypoint[0]) for waypoint in waypoints] + + # Compute the midpoint between the first two waypoints in map coordinates + midpoint_x = (waypoint_coords[0][0] + waypoint_coords[1][0]) / 2 + midpoint_y = (waypoint_coords[0][1] + waypoint_coords[1][1]) / 2 + + # Offset to avoid overlap + offset_x, offset_y = 10, 10 + + for pos in annotated_positions: + dist = np.linalg.norm(np.array([midpoint_x, midpoint_y]) - np.array(pos)) + if dist < 30: # Adjust based on your layout + offset_x += 20 + offset_y += 20 + + # Plot the new annotation + annotation = self.ax.annotate( + label, + xy=(midpoint_x, midpoint_y), # Annotation position + xytext=(midpoint_x + offset_x, midpoint_y + offset_y), # Offset position + textcoords='offset points', + arrowprops=dict(facecolor=color, shrink=0.05), + fontsize=15, + color=color + ) + + # Save the annotation and position + self.annotations[flighttrack] = annotation + annotated_positions.append((midpoint_x + offset_x, midpoint_y + offset_y)) + + # Redraw the canvas to reflect the annotations + self.ax.figure.canvas.draw_idle() + + def remove_annotations(self): + """ + Remove all annotations from the plot. + """ + for annotation in self.annotations.values(): + annotation.remove() + self.annotations.clear() self.ax.figure.canvas.draw_idle() @@ -1670,6 +1782,12 @@ def draw_legend(self, img): # required so that it is actually drawn... QtWidgets.QApplication.processEvents() + def annotation(self, state, flightpath_dict): + if state == QtCore.Qt.Checked: + self.plotter.annotate_flight_tracks(flightpath_dict) + else: + self.plotter.remove_annotations() + def update_flightpath_legend(self, flightpath_dict): """ Update the flight path legend. diff --git a/mslib/msui/multiple_flightpath_dockwidget.py b/mslib/msui/multiple_flightpath_dockwidget.py index 13741f5f9..3071f693a 100644 --- a/mslib/msui/multiple_flightpath_dockwidget.py +++ b/mslib/msui/multiple_flightpath_dockwidget.py @@ -208,6 +208,7 @@ def __init__(self, parent=None, view=None, listFlightTracks=None, self.ui.signal_ft_vertices_color_change.connect(self.ft_vertices_color) self.dsbx_linewidth.valueChanged.connect(self.set_linewidth) self.hsTransparencyControl.valueChanged.connect(self.set_transparency) + self.annotationCB.stateChanged.connect(lambda state: self.view.annotation(state, self.flightpath_dict)) self.cbLineStyle.currentTextChanged.connect(self.set_linestyle) self.cbSlectAll1.stateChanged.connect(self.selectAll) self.ui.signal_login_mscolab.connect(self.login) @@ -555,22 +556,27 @@ def update_flighttrack_patch(self, wp_model): def update_flightpath_legend(self): """ - Collects flight path data and updates the legend in the TopView. - Only checked flight tracks will be included in the legend. - Unchecked flight tracks will be removed from the flightpath_dict. + Collects flight path data, including waypoints, and updates the legend in the TopView. + Only checked and non-active flight tracks will be included in the legend. """ # Iterate over all items in the list_flighttrack for i in range(self.list_flighttrack.count()): listItem = self.list_flighttrack.item(i) wp_model = listItem.flighttrack_model - # If the flight track is checked, add/update it in the dictionary - if listItem.checkState() == QtCore.Qt.Checked: + # Check if the flight track is non-active and checked + if listItem.checkState() == QtCore.Qt.Checked and wp_model != self.active_flight_track: + # Extract relevant data name = wp_model.name if hasattr(wp_model, 'name') else 'Unnamed flighttrack' color = self.dict_flighttrack[wp_model].get('color', '#000000') # Default to black linestyle = self.dict_flighttrack[wp_model].get('line_style', '-') # Default to solid line - self.flightpath_dict[name] = (color, linestyle) - # If the flight track is unchecked, ensure it is removed from the dictionary + label = self.flightpath_dict.get(name, (name, color, linestyle))[0] # Existing label or use name + + # Extract waypoints as a list of (lat, lon) tuples + waypoints = [(wp.lat, wp.lon) for wp in wp_model.all_waypoint_data()] + + # Update the flightpath_dict with the label, color, linestyle, and waypoints + self.flightpath_dict[name] = (label, color, linestyle, waypoints) else: name = wp_model.name if hasattr(wp_model, 'name') else 'Unnamed flighttrack' if name in self.flightpath_dict: @@ -1177,15 +1183,20 @@ def update_operation_legend(self): # Iterate over all items in the list_operation_track for i in range(self.list_operation_track.count()): listItem = self.list_operation_track.item(i) + op_id = listItem.op_id # If the operation is checked, add/update it in the dictionary - if listItem.checkState() == QtCore.Qt.Checked: + if listItem.checkState() == QtCore.Qt.Checked and op_id != self.active_op_id: wp_model = listItem.flighttrack_model name = wp_model.name if hasattr(wp_model, 'name') else 'Unnamed operation' - op_id = listItem.op_id color = self.dict_operations[op_id].get('color', '#000000') # Default to black linestyle = self.dict_operations[op_id].get('line_style', '-') # Default to solid line - self.parent.flightpath_dict[name] = (color, linestyle) + label = self.dict_operations.get(name, (name, color, linestyle))[0] + + # Extract waypoints as a list of (lat, lon) tuples + waypoints = [(wp.lat, wp.lon) for wp in wp_model.all_waypoint_data()] + + self.parent.flightpath_dict[name] = (label, color, linestyle, waypoints) # If the flight track is unchecked, ensure it is removed from the dictionary else: wp_model = listItem.flighttrack_model diff --git a/mslib/msui/qt5/ui_multiple_flightpath_dockwidget.py b/mslib/msui/qt5/ui_multiple_flightpath_dockwidget.py index 11257f53d..209215f2e 100644 --- a/mslib/msui/qt5/ui_multiple_flightpath_dockwidget.py +++ b/mslib/msui/qt5/ui_multiple_flightpath_dockwidget.py @@ -13,7 +13,7 @@ class Ui_MultipleViewWidget(object): def setupUi(self, MultipleViewWidget): MultipleViewWidget.setObjectName("MultipleViewWidget") - MultipleViewWidget.resize(798, 282) + MultipleViewWidget.resize(828, 313) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Minimum) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) @@ -87,6 +87,8 @@ def setupUi(self, MultipleViewWidget): self.list_operation_track.setObjectName("list_operation_track") self.verticalLayout_3.addWidget(self.list_operation_track) self.horizontalLayout_2.addLayout(self.verticalLayout_3) + self.gridLayout_2 = QtWidgets.QGridLayout() + self.gridLayout_2.setObjectName("gridLayout_2") self.groupBox = QtWidgets.QGroupBox(MultipleViewWidget) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) @@ -96,7 +98,7 @@ def setupUi(self, MultipleViewWidget): self.groupBox.setMinimumSize(QtCore.QSize(220, 160)) self.groupBox.setObjectName("groupBox") self.pushButton_color = QtWidgets.QPushButton(self.groupBox) - self.pushButton_color.setGeometry(QtCore.QRect(10, 30, 174, 23)) + self.pushButton_color.setGeometry(QtCore.QRect(10, 30, 201, 23)) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) @@ -129,7 +131,20 @@ def setupUi(self, MultipleViewWidget): self.hsTransparencyControl.setProperty("value", 20) self.hsTransparencyControl.setOrientation(QtCore.Qt.Horizontal) self.hsTransparencyControl.setObjectName("hsTransparencyControl") - self.horizontalLayout_2.addWidget(self.groupBox) + self.gridLayout_2.addWidget(self.groupBox, 0, 0, 1, 1) + self.groupBox_2 = QtWidgets.QGroupBox(MultipleViewWidget) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.groupBox_2.sizePolicy().hasHeightForWidth()) + self.groupBox_2.setSizePolicy(sizePolicy) + self.groupBox_2.setMinimumSize(QtCore.QSize(220, 60)) + self.groupBox_2.setObjectName("groupBox_2") + self.annotationCB = QtWidgets.QCheckBox(self.groupBox_2) + self.annotationCB.setGeometry(QtCore.QRect(10, 30, 191, 21)) + self.annotationCB.setObjectName("annotationCB") + self.gridLayout_2.addWidget(self.groupBox_2, 1, 0, 1, 1) + self.horizontalLayout_2.addLayout(self.gridLayout_2) self.horizontalLayout.addLayout(self.horizontalLayout_2) self.verticalLayout_2.addLayout(self.horizontalLayout) self.labelStatus = QtWidgets.QLabel(MultipleViewWidget) @@ -154,4 +169,6 @@ def retranslateUi(self, MultipleViewWidget): self.label.setText(_translate("MultipleViewWidget", "Thickness")) self.label_3.setText(_translate("MultipleViewWidget", "Style")) self.label_2.setText(_translate("MultipleViewWidget", "Transparency")) + self.groupBox_2.setTitle(_translate("MultipleViewWidget", "Plot Annotation")) + self.annotationCB.setText(_translate("MultipleViewWidget", "Enable Annotation")) self.labelStatus.setText(_translate("MultipleViewWidget", "Status: ")) diff --git a/mslib/msui/ui/ui_multiple_flightpath_dockwidget.ui b/mslib/msui/ui/ui_multiple_flightpath_dockwidget.ui index 01154e3a1..e5efa5eb8 100644 --- a/mslib/msui/ui/ui_multiple_flightpath_dockwidget.ui +++ b/mslib/msui/ui/ui_multiple_flightpath_dockwidget.ui @@ -6,8 +6,8 @@ 0 0 - 798 - 282 + 828 + 313 @@ -137,126 +137,162 @@ Check box to activate and display track on topview. - - - - 0 - 0 - - - - - 220 - 160 - - - - Flight Track style options - - - - - 10 - 30 - 174 - 23 - - - - - 0 - 0 - - - - Change Color - - - - - - 120 - 130 - 101 - 20 - - - - Thickness - - - - - - 10 - 120 - 101 - 31 - - - - - 0 - 0 - - - - Qt::AlignCenter - - - - - - 120 - 100 - 61 - 16 - - - - Style - - - - - - 10 - 90 - 101 - 22 - - - - - - - 120 - 60 - 81 - 20 - - - - Transparency - - - - - - 10 - 60 - 101 - 21 - - - - 20 - - - Qt::Horizontal - - - + + + + + + 0 + 0 + + + + + 220 + 160 + + + + Flight Track style options + + + + + 10 + 30 + 201 + 23 + + + + + 0 + 0 + + + + Change Color + + + + + + 120 + 130 + 101 + 20 + + + + Thickness + + + + + + 10 + 120 + 101 + 31 + + + + + 0 + 0 + + + + Qt::AlignCenter + + + + + + 120 + 100 + 61 + 16 + + + + Style + + + + + + 10 + 90 + 101 + 22 + + + + + + + 120 + 60 + 81 + 20 + + + + Transparency + + + + + + 10 + 60 + 101 + 21 + + + + 20 + + + Qt::Horizontal + + + + + + + + + 0 + 0 + + + + + 220 + 60 + + + + Plot Annotation + + + + + 10 + 30 + 191 + 21 + + + + Enable Annotation + + + + + diff --git a/tests/_test_msui/test_multiple_flightpath_dockwidget.py b/tests/_test_msui/test_multiple_flightpath_dockwidget.py index 5a28cffdb..03341a531 100644 --- a/tests/_test_msui/test_multiple_flightpath_dockwidget.py +++ b/tests/_test_msui/test_multiple_flightpath_dockwidget.py @@ -237,13 +237,13 @@ def test_random_custom_color_selection(main_window): def test_update_flightpath_legend(main_window): """ - Test update_flightpath_legend to ensure only checked flight tracks - are included in the legend with correct name, color, and style. + Test update_flightpath_legend to ensure only checked, non-active flight tracks + are included in the legend with correct name, color, style, and waypoints. """ main_window, multiple_flightpath_widget = main_window # Activate the first flight track - activate_flight_track_at_index(main_window, 0) + activate_flight_track_at_index(main_window, 1) # Set the first flight track as checked and the second as unchecked first_item = multiple_flightpath_widget.list_flighttrack.item(0) @@ -251,23 +251,31 @@ def test_update_flightpath_legend(main_window): first_item.setCheckState(QtCore.Qt.Checked) second_item.setCheckState(QtCore.Qt.Unchecked) - # Define color and style for the first flight track - multiple_flightpath_widget.dict_flighttrack[first_item.flighttrack_model] = { + # Define color, style, and mock waypoints for the first flight track + wp_model = first_item.flighttrack_model + multiple_flightpath_widget.dict_flighttrack[wp_model] = { "color": "#FF0000", "line_style": "--" } - # Calling the method + # Mocking waypoint data for the first flight track + mock_waypoints = [(21.15, 79.083), (28.566, 77.103)] + wp_model.all_waypoint_data = lambda: [ + type('Waypoint', (object,), {'lat': lat, 'lon': lon}) for lat, lon in mock_waypoints + ] + + # Call the method multiple_flightpath_widget.update_flightpath_legend() - # Verify that only the checked flight track is included in the legend - assert first_item.flighttrack_model.name in multiple_flightpath_widget.flightpath_dict + # Verify that only the checked, non-active flight track is included in the legend + assert wp_model.name in multiple_flightpath_widget.flightpath_dict assert second_item.flighttrack_model.name not in multiple_flightpath_widget.flightpath_dict - # Verify that the color and style in the legend match the first flight track - legend_color, legend_style = multiple_flightpath_widget.flightpath_dict[first_item.flighttrack_model.name] + # Verify that the color, style, and waypoints in the legend match the first flight track + label, legend_color, legend_style, legend_waypoints = multiple_flightpath_widget.flightpath_dict[wp_model.name] assert legend_color == "#FF0000" assert legend_style == "--" + assert legend_waypoints == mock_waypoints def activate_flight_track_at_index(main_window, index): diff --git a/tests/fixtures.py b/tests/fixtures.py index 9077612ac..7a79dc323 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -124,7 +124,7 @@ def mscolab_session_server(mscolab_session_app, mscolab_session_managers): with _running_eventlet_server(mscolab_session_app) as url: # Wait until the Flask-SocketIO server is ready for connections sio = socketio.Client() - sio.connect(url)#, retry=True) + sio.connect(url) # retry=True) sio.disconnect() del sio yield url