Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: clickable legend and annotation on the plot #2513

Draft
wants to merge 9 commits into
base: GSOC2024-RohitPrasad
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ in alphabetic order by first name
- May Bär <[email protected]>
- Nilupul Manodya <[email protected]>
- Reimar Bauer <[email protected]>
- Rohit Prasad <[email protected]>
- Rishabh Soni <[email protected]>
- Sakshi Chopkar <[email protected]>
- Shivashis Padhi <[email protected]>
Expand Down
132 changes: 125 additions & 7 deletions mslib/msui/mpl_qtwidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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()


Expand Down Expand Up @@ -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.
Expand Down
31 changes: 21 additions & 10 deletions mslib/msui/multiple_flightpath_dockwidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
23 changes: 20 additions & 3 deletions mslib/msui/qt5/ui_multiple_flightpath_dockwidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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: "))
Loading
Loading