Skip to content

Commit 500d840

Browse files
authored
Merge pull request #298 from funkelab/295-decouple-selection-and-zoom-to-this-node
295 decouple selection and zoom to this node
2 parents 3a6e17e + 6861ba6 commit 500d840

File tree

12 files changed

+224
-85
lines changed

12 files changed

+224
-85
lines changed

docs/source/key_bindings.rst

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ Napari viewer and layer key bindings and mouse functions
1111
* - Mouse / Key binding
1212
- Action
1313
* - Click on a point or label
14-
- Select this node (center view if necessary)
14+
- Select this node (centers view if only one node selected)
1515
* - SHIFT + click on point or label
16-
- Add this node to selection
16+
- Add this node to selection (does not center view)
17+
* - CTRL + click on point or label
18+
- Center view on this node (does not change selection)
1719
* - Mouse drag with point layer selection tool active
1820
- Select multiple nodes at once
1921
* - Q
@@ -30,9 +32,11 @@ Tree view key and mouse functions
3032
* - Mouse / Key binding
3133
- Action
3234
* - Click on a node
33-
- Select this node (center view if necessary)
35+
- Select this node (centers view if only one node selected)
3436
* - SHIFT + click on a node
35-
- Add this node to selection
37+
- Add this node to selection (does not center view)
38+
* - CTRL/CMD + click on a node
39+
- Center view on this node (does not change selection)
3640
* - Scroll
3741
- Zoom in or out
3842
* - Scroll + X / Right mouse click + drag horizontally

docs/source/tree_view.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ through the motile widget in the tree view, you can open the tree widget from th
1010
via ``Plugins`` > ``Motile`` > ``Lineage View``.
1111

1212
Clicking on individual nodes in the tree widget or in the napari Points or Labels layer will select that node,
13-
highlighting it both in the tree view and in the napari layers, and centering the view if necessary.
13+
highlighting it both in the tree view and in the napari layers. The view is centered on the selected node
14+
only when a single node is selected. Use ``SHIFT + click`` to add nodes to the selection without centering,
15+
or ``CTRL + click`` to center the view on a node without changing the selection.
16+
When multiple nodes are selected, you can cycle through them using the "Next selected node" and
17+
"Previous selected node" buttons in the Groups widget.
1418
You can navigate and select nodes in the tree view using the arrow keys (make sure to click on the tree widget first).
1519
Optionally, you can display only selected lineages in the tree view and/or napari layers (press ``Q`` in the tree widget and/or in the napari viewer).
1620
If you used a Labels layer as input for tracking, you will also have the option to plot the object sizes (area or volume) in calibrated units

src/motile_tracker/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ def main() -> None:
99
# Auto-load the motile tracker
1010
viewer = napari.Viewer()
1111
main_app = MainApp(viewer)
12-
viewer.window.add_dock_widget(main_app)
12+
viewer.window.add_dock_widget(main_app, name="Motile Tracker")
1313

1414
# Start napari event loop
1515
napari.run()

src/motile_tracker/application_menus/menu_widget.py

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import napari
2-
from qtpy.QtWidgets import QScrollArea, QTabWidget, QVBoxLayout
2+
from qtpy.QtWidgets import (
3+
QHBoxLayout,
4+
QLabel,
5+
QScrollArea,
6+
QTabWidget,
7+
QVBoxLayout,
8+
QWidget,
9+
)
310

411
from motile_tracker.application_menus.editing_menu import EditingMenu
512
from motile_tracker.application_menus.visualization_widget import (
@@ -8,6 +15,9 @@
815
from motile_tracker.data_views.views_coordinator.tracks_viewer import TracksViewer
916
from motile_tracker.motile.menus.motile_widget import MotileWidget
1017

18+
DOCS_URL = "https://funkelab.github.io/motile_tracker"
19+
KEYBINDINGS_URL = f"{DOCS_URL}/key_bindings.html"
20+
1121

1222
class MenuWidget(QScrollArea):
1323
"""Combines the different tracker menus into tabs for cleaner UI"""
@@ -25,19 +35,35 @@ def __init__(self, viewer: napari.Viewer):
2535

2636
self.tabwidget = QTabWidget()
2737

28-
self.tabwidget.addTab(motile_widget, "Track with Motile")
38+
self.tabwidget.addTab(motile_widget, "Tracking")
2939
self.tabwidget.addTab(self.tracks_viewer.tracks_list, "Tracks List")
3040
self.tabwidget.addTab(editing_widget, "Edit Tracks")
3141
self.tabwidget.addTab(self.tracks_viewer.collection_widget, "Groups")
3242

33-
layout = QVBoxLayout()
34-
layout.addWidget(self.tabwidget)
35-
36-
self.setWidget(self.tabwidget)
43+
# Header with title and help links
44+
header_layout = QHBoxLayout()
45+
header_layout.setContentsMargins(5, 5, 5, 0)
46+
header_label = QLabel(
47+
f"<b>Motile Tracker</b> · "
48+
f'<a href="{DOCS_URL}"><font color=yellow>Docs</font></a> · '
49+
f'<a href="{KEYBINDINGS_URL}"><font color=yellow>Keybindings</font></a>'
50+
)
51+
header_label.setOpenExternalLinks(True)
52+
header_layout.addWidget(header_label)
53+
header_layout.addStretch()
54+
55+
# Container widget with header + tabs
56+
container = QWidget()
57+
container_layout = QVBoxLayout()
58+
container_layout.setContentsMargins(0, 0, 0, 0)
59+
container_layout.setSpacing(2)
60+
container_layout.addLayout(header_layout)
61+
container_layout.addWidget(self.tabwidget)
62+
container.setLayout(container_layout)
63+
64+
self.setWidget(container)
3765
self.setWidgetResizable(True)
3866

39-
self.setLayout(layout)
40-
4167
def _has_visualization_tab(self):
4268
return self.tabwidget.indexOf(self.visualization_widget) != -1
4369

src/motile_tracker/data_views/views/layers/track_labels.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,11 @@ def process_click(self, event: Event, label: int):
147147
label is not None and label != 0 and self.colormap.map(label)[-1] != 0
148148
): # check opacity (=visibility) in the colormap
149149
append = "Shift" in event.modifiers
150-
self.tracks_viewer.selected_nodes.add(label, append)
150+
jump = "Control" in event.modifiers
151+
if jump:
152+
self.tracks_viewer.center_on_node(label)
153+
else:
154+
self.tracks_viewer.selected_nodes.add(label, append)
151155

152156
def _get_colormap(self) -> DirectLabelColormap:
153157
"""Get a DirectLabelColormap that maps node ids to their track ids, and then

src/motile_tracker/data_views/views/layers/track_points.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,11 @@ def process_click(self, event: Event, point_index: int | None):
130130
else:
131131
node_id = self.nodes[point_index]
132132
append = "Shift" in event.modifiers
133-
self.tracks_viewer.selected_nodes.add(node_id, append)
133+
jump = "Control" in event.modifiers
134+
if jump:
135+
self.tracks_viewer.center_on_node(node_id)
136+
else:
137+
self.tracks_viewer.selected_nodes.add(node_id, append)
134138

135139
def set_point_size(self, size: int) -> None:
136140
"""Sets a new default point size.

src/motile_tracker/data_views/views/tree_view/tree_widget.py

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ def mouseDragEvent(self, ev, axis=None):
9191

9292
class TreePlot(pg.PlotWidget):
9393
node_clicked = Signal(Any, bool) # node_id, append
94+
jump_to_node = Signal(int) # node to jump to
9495
nodes_selected = Signal(list, bool)
9596

9697
def __init__(self) -> pg.PlotWidget:
@@ -230,8 +231,12 @@ def _on_click(self, _, points: np.ndarray, ev: QMouseEvent) -> None:
230231
modifiers = ev.modifiers()
231232
node_id = points[0].data()
232233
append = Qt.ShiftModifier == modifiers
233-
self.node_clicked.emit(node_id, append)
234-
self.setFocus()
234+
jump = Qt.ControlModifier == modifiers
235+
if jump:
236+
self.jump_to_node.emit(node_id)
237+
else:
238+
self.node_clicked.emit(node_id, append)
239+
self.setFocus()
235240

236241
def set_data(self, track_df: pd.DataFrame, plot_type: str, feature: str) -> None:
237242
"""Updates the stored pyqtgraph content based on the given dataframe.
@@ -323,12 +328,14 @@ def _create_pyqtgraph_content(
323328

324329
def set_selection(self, selected_nodes: list[Any], plot_type: str) -> None:
325330
"""Set the provided list of nodes to be selected. Increases the size
326-
and highlights the outline with blue. Also centers the view
327-
if the first selected node is not visible in the current canvas.
331+
and highlights the outline with blue.
332+
333+
Note: Single-node centering is handled separately via the center_node signal
334+
from TracksViewer. Multi-node range centering is handled here.
328335
329336
Args:
330337
selected_nodes (list[Any]): A list of node ids to be selected.
331-
feature (str): the feature that is being plotted, either 'tree' or 'area'
338+
plot_type (str): the plot type being displayed, either 'tree' or 'feature'
332339
"""
333340

334341
# reset to default size and color to avoid problems with the array lengths
@@ -340,9 +347,7 @@ def set_selection(self, selected_nodes: list[Any], plot_type: str) -> None:
340347
) # just copy the size here to keep the original self.sizes intact
341348

342349
outlines = self.outline_pen.copy()
343-
axis_label = (
344-
self.feature if plot_type == "feature" else "x_axis_pos"
345-
) # check what is currently being shown, to know how to scale the view
350+
axis_label = self.feature if plot_type == "feature" else "x_axis_pos"
346351

347352
if len(selected_nodes) > 0:
348353
x_values = []
@@ -361,11 +366,9 @@ def set_selection(self, selected_nodes: list[Any], plot_type: str) -> None:
361366
size[index] += 5
362367
outlines[index] = pg.mkPen(color="c", width=2)
363368

364-
# Center point if a single node is selected, center range if multiple nodes
365-
# are selected
366-
if len(selected_nodes) == 1:
367-
self._center_view(x_axis_value, t)
368-
else:
369+
# Center range if multiple nodes are selected (single-node centering
370+
# is handled by the center_node signal)
371+
if len(x_values) > 1:
369372
min_x = np.min(x_values)
370373
max_x = np.max(x_values)
371374
min_t = np.min(t_values)
@@ -398,8 +401,27 @@ def _center_range(self, min_x: int, max_x: int, min_t: int, max_t: int):
398401
else:
399402
self.autoRange()
400403

404+
def center_on_node(self, node_id: int) -> None:
405+
"""Center the view on a specific node by ID.
406+
407+
Args:
408+
node_id: The node ID to center on.
409+
"""
410+
if not hasattr(self, "track_df") or self.track_df is None:
411+
return
412+
node_df = self.track_df.loc[self.track_df["node_id"] == node_id]
413+
if node_df.empty:
414+
return
415+
axis_label = self.feature if self.plot_type == "feature" else "x_axis_pos"
416+
x_axis_value = node_df[axis_label].values[0]
417+
t = node_df["t"].values[0]
418+
self._center_view(x_axis_value, t)
419+
401420
def _center_view(self, center_x: int, center_y: int):
402-
"""Center the Viewbox on given coordinates"""
421+
"""Center the Viewbox on given coordinates, preserving the current zoom level.
422+
423+
Only pans if the point is outside the current view.
424+
"""
403425

404426
if self.view_direction == "horizontal":
405427
center_x, center_y = (
@@ -420,7 +442,12 @@ def _center_view(self, center_x: int, center_y: int):
420442
):
421443
return
422444

423-
self.autoRange()
445+
# Pan to center the point while preserving current zoom level
446+
x_width = x_range[1] - x_range[0]
447+
y_width = y_range[1] - y_range[0]
448+
new_x_range = (center_x - x_width / 2, center_x + x_width / 2)
449+
new_y_range = (center_y - y_width / 2, center_y + y_width / 2)
450+
view_box.setRange(xRange=new_x_range, yRange=new_y_range, padding=0)
424451

425452

426453
class TreeWidget(QWidget):
@@ -445,7 +472,9 @@ def __init__(self, viewer: napari.Viewer):
445472

446473
self.tree_widget: TreePlot = TreePlot()
447474
self.tree_widget.node_clicked.connect(self.selected_nodes.add)
475+
self.tree_widget.jump_to_node.connect(self.tracks_viewer.center_on_node)
448476
self.tree_widget.nodes_selected.connect(self.selected_nodes.add_list)
477+
self.tracks_viewer.center_node.connect(self.tree_widget.center_on_node)
449478

450479
# Add radiobuttons for switching between different display modes
451480
self.mode_widget = TreeViewModeWidget()

src/motile_tracker/data_views/views_coordinator/groups.py

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -94,26 +94,34 @@ def __init__(self, tracks_viewer: TracksViewer):
9494

9595
# Select widget group
9696
select_widget = QGroupBox("Selection")
97-
selection_layout = QVBoxLayout()
97+
selection_layout = QHBoxLayout()
9898

99-
row1_layout = QHBoxLayout()
99+
col1_layout = QVBoxLayout()
100100
self.select_btn = QPushButton("Select nodes in group")
101101
self.select_btn.clicked.connect(self._select_nodes)
102102
self.invert_btn = QPushButton("Invert selection")
103103
self.invert_btn.clicked.connect(self._invert_selection)
104-
row1_layout.addWidget(self.select_btn)
105-
row1_layout.addWidget(self.invert_btn)
104+
self.jump_to_next_btn = QPushButton("Next selected node")
105+
self.jump_to_next_btn.clicked.connect(lambda: self._jump_to_node(forward=True))
106+
col1_layout.addWidget(self.select_btn)
107+
col1_layout.addWidget(self.invert_btn)
108+
col1_layout.addWidget(self.jump_to_next_btn)
106109

107-
row2_layout = QHBoxLayout()
110+
col2_layout = QVBoxLayout()
108111
self.deselect_btn = QPushButton("Deselect")
109112
self.deselect_btn.clicked.connect(self.tracks_viewer.selected_nodes.reset)
110113
self.reselect_btn = QPushButton("Restore selection")
111114
self.reselect_btn.clicked.connect(self.tracks_viewer.selected_nodes.restore)
112-
row2_layout.addWidget(self.deselect_btn)
113-
row2_layout.addWidget(self.reselect_btn)
115+
self.jump_to_previous_btn = QPushButton("Previous selected node")
116+
self.jump_to_previous_btn.clicked.connect(
117+
lambda: self._jump_to_node(forward=False)
118+
)
119+
col2_layout.addWidget(self.deselect_btn)
120+
col2_layout.addWidget(self.reselect_btn)
121+
col2_layout.addWidget(self.jump_to_previous_btn)
114122

115-
selection_layout.addLayout(row1_layout)
116-
selection_layout.addLayout(row2_layout)
123+
selection_layout.addLayout(col1_layout)
124+
selection_layout.addLayout(col2_layout)
117125
select_widget.setLayout(selection_layout)
118126

119127
# edit layout
@@ -201,14 +209,25 @@ def _update_buttons_and_node_count(self) -> None:
201209

202210
if len(self.tracks_viewer.selected_nodes) > 0:
203211
self.deselect_btn.setEnabled(True)
212+
self.jump_to_next_btn.setEnabled(True)
213+
self.jump_to_previous_btn.setEnabled(True)
204214
else:
205215
self.deselect_btn.setEnabled(False)
216+
self.jump_to_next_btn.setEnabled(False)
217+
self.jump_to_previous_btn.setEnabled(False)
206218

207219
if self.tracks_viewer.tracks is not None:
208220
self.new_group_button.setEnabled(True)
209221
else:
210222
self.new_group_button.setEnabled(False)
211223

224+
def _jump_to_node(self, forward: bool) -> None:
225+
"""Jump to the next/previous selected node in the list"""
226+
227+
node = self.tracks_viewer.selected_nodes.next_node(forward)
228+
if node:
229+
self.tracks_viewer.center_on_node(node)
230+
212231
def _invert_selection(self) -> None:
213232
"""Invert the current selection"""
214233

0 commit comments

Comments
 (0)