Skip to content

Commit afb380e

Browse files
committed
SelectPointsTool and test
1 parent d7d9d47 commit afb380e

5 files changed

Lines changed: 313 additions & 11 deletions

File tree

plotpy/plot/plotwidget.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,7 +553,7 @@ def __init__(
553553
self.button_layout = None
554554
self.plot_layout = QW.QGridLayout()
555555
self.plot_widget: PlotWidget = None
556-
self.manager: PlotManager = None
556+
self. manager: PlotManager = None
557557
self.setup_widget(toolbar, options, panels, auto_tools)
558558
self.setWindowFlags(QC.Qt.Window)
559559

plotpy/tests/tools/test_get_point.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
# guitest: show
1313

14-
from guidata.qthelpers import qt_app_context
14+
from guidata.qthelpers import exec_dialog, qt_app_context
1515
from numpy import linspace, sin
1616

1717
from plotpy.builder import make
@@ -41,24 +41,24 @@ def get_point(*args):
4141
)
4242
default.activate()
4343
plot = win.manager.get_plot()
44-
for cx, cy in args:
44+
for cx, cy in args[:-1]:
4545
item = make.mcurve(cx, cy)
4646
plot.add_item(item)
47+
item = make.mcurve(*args[-1], "r-+")
48+
plot.add_item(item)
4749
plot.set_active_item(item)
4850
plot.unselect_item(item)
49-
win.show()
50-
return win, default.get_coordinates()
51+
exec_dialog(win)
5152

5253

5354
def test_get_point():
5455
"""Test"""
55-
with qt_app_context(exec_loop=True):
56-
x = linspace(-10, 10, 500)
56+
with qt_app_context(exec_loop=False):
57+
x = linspace(-10, 10, 200)
5758
y = 0.25 * sin(sin(sin(x * 0.5)))
5859
x2 = linspace(-10, 10, 200)
5960
y2 = sin(sin(sin(x2)))
60-
_persist_dialog, coordinates = get_point((x, y), (x2, y2), (x, sin(2 * y)))
61-
print(coordinates)
61+
get_point((x, y), (x2, y2), (x, sin(2 * y)))
6262

6363

6464
if __name__ == "__main__":
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Licensed under the terms of the BSD 3-Clause
4+
# (see plotpy/LICENSE for details)
5+
6+
"""
7+
SelectPointTool test
8+
9+
This plotpy tool provide a MATLAB-like "ginput" feature.
10+
"""
11+
12+
# guitest: show
13+
14+
from guidata.qthelpers import exec_dialog, qt_app_context
15+
from numpy import linspace, sin
16+
17+
from plotpy.builder import make
18+
from plotpy.config import _
19+
from plotpy.tools import SelectPointsTool
20+
21+
22+
def callback_function(tool: SelectPointsTool):
23+
points = tool.get_coordinates()
24+
print("Points : ", points)
25+
26+
27+
def get_points(*args):
28+
"""
29+
Plot curves and return selected point(s) coordinates
30+
"""
31+
win = make.dialog(
32+
wintitle=_("Select one point then press OK to accept"),
33+
edit=True,
34+
type="curve",
35+
curve_antialiasing=True,
36+
)
37+
default = win.manager.add_tool(
38+
SelectPointsTool,
39+
title="Test",
40+
on_active_item=True,
41+
mode="reuse",
42+
end_callback=callback_function,
43+
max_select=5,
44+
)
45+
default.activate()
46+
plot = win.manager.get_plot()
47+
for cx, cy in args[:-1]:
48+
item = make.mcurve(cx, cy)
49+
plot.add_item(item)
50+
# the last curve will be actibe and needs to have a different style for explictness
51+
item = make.mcurve(*args[-1], "r-+")
52+
plot.add_item(item)
53+
plot.set_active_item(item)
54+
plot.unselect_item(item)
55+
exec_dialog(win)
56+
57+
58+
def test_get_point():
59+
"""Test"""
60+
with qt_app_context(exec_loop=False):
61+
x = linspace(-10, 10, num=200)
62+
y = 0.25 * sin(sin(sin(x * 0.5)))
63+
x2 = linspace(-10, 10, 200)
64+
y2 = sin(sin(sin(x2)))
65+
get_points((x, y), (x2, y2), (x, sin(2 * y)))
66+
67+
68+
if __name__ == "__main__":
69+
test_get_point()

plotpy/tools/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
YCSPanelTool,
3232
)
3333
from .cursor import HCursorTool, HRangeTool, VCursorTool, XCursorTool
34-
from .curve import AntiAliasingTool, CurveStatsTool, SelectPointTool
34+
from .curve import AntiAliasingTool, CurveStatsTool, SelectPointsTool, SelectPointTool
3535
from .image import (
3636
AspectRatioTool,
3737
ColormapTool,

plotpy/tools/curve.py

Lines changed: 234 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# -*- coding: utf-8 -*-
22
import weakref
3+
from email.mime import base
4+
from sre_parse import State
5+
from turtle import update
6+
from typing import Callable
37

48
import numpy as np
59
from guidata.widgets.arrayeditor import ArrayEditor
@@ -8,9 +12,10 @@
812

913
from plotpy.config import _
1014
from plotpy.constants import SHAPE_Z_OFFSET
11-
from plotpy.events import QtDragHandler, setup_standard_tool_filter
15+
from plotpy.events import QtDragHandler, StatefulEventFilter, setup_standard_tool_filter
1216
from plotpy.interfaces import ICurveItemType
1317
from plotpy.items import Marker, XRangeSelection
18+
from plotpy.plot.base import BasePlot
1419
from plotpy.tools.base import DefaultToolbarID, InteractiveTool, ToggleTool
1520
from plotpy.tools.cursor import BaseCursorTool
1621

@@ -266,6 +271,234 @@ def get_coordinates(self):
266271
return self.last_pos
267272

268273

274+
class SelectPointsTool(InteractiveTool):
275+
""" """
276+
277+
TITLE = _("Multi-point selection")
278+
ICON = "point_selection.png"
279+
MARKER_STYLE_SECT = "plot"
280+
MARKER_STYLE_KEY = "marker/curve"
281+
CURSOR = QC.Qt.CursorShape.PointingHandCursor
282+
283+
def __init__(
284+
self,
285+
manager,
286+
mode="reuse",
287+
on_active_item=False,
288+
title=None,
289+
icon=None,
290+
tip=None,
291+
end_callback=None,
292+
toolbar_id=DefaultToolbarID,
293+
marker_style=None,
294+
switch_to_default_tool=None,
295+
max_select: int | None = None,
296+
):
297+
super().__init__(
298+
manager,
299+
toolbar_id,
300+
title=title,
301+
icon=icon,
302+
tip=tip,
303+
switch_to_default_tool=switch_to_default_tool,
304+
)
305+
assert mode in ("reuse", "create")
306+
self.mode = mode
307+
self.end_callback = end_callback
308+
self.current_location_marker: Marker | None = None
309+
self.markers: dict[tuple[float, float], Marker] = {}
310+
self.on_active_item = on_active_item
311+
self.max_select = max_select
312+
if marker_style is not None:
313+
self.marker_style_sect = marker_style[0]
314+
self.marker_style_key = marker_style[1]
315+
else:
316+
self.marker_style_sect = self.MARKER_STYLE_SECT
317+
self.marker_style_key = self.MARKER_STYLE_KEY
318+
319+
def set_marker_style(self, marker):
320+
"""
321+
322+
:param marker:
323+
"""
324+
marker.set_style(self.marker_style_sect, self.marker_style_key)
325+
326+
def setup_filter(self, baseplot: BasePlot):
327+
"""
328+
329+
:param baseplot:
330+
:return:
331+
"""
332+
filter = baseplot.filter
333+
# Initialisation du filtre
334+
start_state = filter.new_state()
335+
# Bouton gauche :
336+
single_selection_handler = QtDragHandler(
337+
filter,
338+
QC.Qt.MouseButton.LeftButton,
339+
start_state=start_state,
340+
)
341+
single_selection_handler.SIG_START_TRACKING.connect(self.start_single_selection)
342+
single_selection_handler.SIG_MOVE.connect(self.move)
343+
single_selection_handler.SIG_STOP_NOT_MOVING.connect(self.stop_single_selection)
344+
single_selection_handler.SIG_STOP_MOVING.connect(self.stop_single_selection)
345+
346+
multi_selection_handler = QtDragHandler(
347+
filter,
348+
QC.Qt.MouseButton.LeftButton,
349+
start_state=start_state,
350+
mods=QC.Qt.KeyboardModifier.ControlModifier,
351+
)
352+
multi_selection_handler.SIG_START_TRACKING.connect(self.start_multi_selection)
353+
multi_selection_handler.SIG_MOVE.connect(self.move)
354+
multi_selection_handler.SIG_STOP_NOT_MOVING.connect(self.stop_multi_selection)
355+
multi_selection_handler.SIG_STOP_MOVING.connect(self.stop_multi_selection)
356+
357+
return setup_standard_tool_filter(filter, start_state)
358+
359+
def _init_current_marker(
360+
self,
361+
filter: StatefulEventFilter,
362+
event: QC.QEvent,
363+
force_new_marker=False,
364+
title: str = "",
365+
):
366+
if force_new_marker or self.current_location_marker is None:
367+
title = title or f"<b>{self.TITLE} {len(self.markers)}</b><br>"
368+
if self.on_active_item:
369+
constraint_cb = filter.plot.on_active_curve
370+
371+
else:
372+
constraint_cb = None
373+
374+
label_cb = self._new_label_cb(filter, title)
375+
self.current_location_marker = Marker(
376+
label_cb=label_cb, constraint_cb=constraint_cb
377+
)
378+
assert self.current_location_marker
379+
self.set_marker_style(self.current_location_marker)
380+
self.current_location_marker.attach(filter.plot)
381+
self.current_location_marker.setZ(filter.plot.get_max_z() + 1)
382+
self.current_location_marker.setVisible(True)
383+
384+
def start_single_selection(self, filter: StatefulEventFilter, event: QC.QEvent):
385+
"""
386+
387+
:param filter:
388+
:param event:
389+
"""
390+
self._init_current_marker(filter, event, force_new_marker=True)
391+
392+
def start_multi_selection(self, filter: StatefulEventFilter, event: QC.QEvent):
393+
"""
394+
395+
:param filter:
396+
:param event:
397+
"""
398+
self._init_current_marker(filter, event, force_new_marker=True)
399+
assert self.current_location_marker
400+
self.current_location_marker.label_cb = self._new_label_cb(
401+
filter, index=len(self.markers) + 1
402+
)
403+
404+
def move(self, filter: StatefulEventFilter, event: QC.QEvent):
405+
"""
406+
407+
:param filter:
408+
:param event:
409+
:return:
410+
"""
411+
if self.current_location_marker is None:
412+
return # something is wrong ...
413+
self.current_location_marker.move_local_point_to(0, event.pos())
414+
filter.plot.replot()
415+
416+
def common_stop(self, filter: StatefulEventFilter, event: QC.QEvent):
417+
self.move(filter, event)
418+
if self.mode != "reuse":
419+
self.current_location_marker.detach()
420+
self.current_location_marker = None
421+
422+
def stop_single_selection(self, filter: StatefulEventFilter, event: QC.QEvent):
423+
"""
424+
425+
:param filter:
426+
:param event:
427+
"""
428+
assert self.current_location_marker
429+
self.common_stop(filter, event)
430+
# self.clear_markers((self.current_location_marker,))
431+
self.clear_markers()
432+
self.toggle_marker(self.current_location_marker)
433+
self.update_labels(filter)
434+
if self.end_callback:
435+
self.end_callback(self)
436+
437+
def stop_multi_selection(self, filter: StatefulEventFilter, event: QC.QEvent):
438+
"""
439+
440+
:param filter:
441+
:param event:
442+
"""
443+
assert self.current_location_marker
444+
self.common_stop(filter, event)
445+
self.toggle_marker(self.current_location_marker)
446+
if self.max_select is not None and len(self.markers) > self.max_select:
447+
xy = next(iter(self.markers))
448+
self.markers.pop(xy).detach()
449+
self.update_labels(filter)
450+
if self.end_callback:
451+
self.end_callback(self)
452+
453+
def toggle_marker(self, marker: Marker) -> bool:
454+
assert marker
455+
xy = marker.xValue(), marker.yValue()
456+
entry_marker = self.markers.setdefault(xy, marker)
457+
if entry_marker is not marker:
458+
self.markers.pop(xy).detach()
459+
entry_marker.detach()
460+
marker.detach()
461+
self.current_location_marker = None
462+
return False
463+
return True
464+
465+
def clear_markers(self, exclude_detach: tuple[Marker, ...] | None = None):
466+
exclude_detach = exclude_detach or tuple()
467+
_ = list(
468+
map(
469+
lambda m: m.detach(),
470+
filter(
471+
lambda m: m not in exclude_detach,
472+
self.markers.values(),
473+
),
474+
)
475+
)
476+
self.markers.clear()
477+
478+
def update_labels(self, filter: StatefulEventFilter):
479+
for i, marker in enumerate(self.markers.values()):
480+
marker.label_cb = self._new_label_cb(filter, index=i + 1)
481+
482+
def _new_label_cb(
483+
self, filter: StatefulEventFilter, title: str = "", index: int = 1
484+
) -> Callable[[float, float], str]:
485+
title = title or f"<b>{self.TITLE} - {index}</b><br>"
486+
if self.on_active_item:
487+
488+
def label_cb(x, y):
489+
return title + filter.plot.get_coordinates_str(x, y)
490+
491+
else:
492+
493+
def label_cb(x, y):
494+
return f"{title}{' - ' if title else ''}{index} x = {x:g}<br>y = {y:g}"
495+
496+
return label_cb
497+
498+
def get_coordinates(self):
499+
return tuple(self.markers.keys())
500+
501+
269502
def export_curve_data(item):
270503
"""Export curve item data to text file"""
271504
item_data = item.get_data()

0 commit comments

Comments
 (0)