Coverage for graphica/gui/tools/range_select.py: 93.6%
119 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
1"""範囲選択モード: X の範囲をドラッグで選び、その範囲の点を今のデータセットのマスクに加える。
3SpanSelector は Axes に束縛されるので、Axes を作り直す redraw_all() のたびに作り直すことになる。ほかのモードと同じく
4キャンバス全体の button_press / motion / release で event.inaxes を読む。
5"""
6import logging
8from matplotlib.patches import Rectangle
10from graphica.gui import notify
11from graphica.gui.tools.pointer import clamped_data_point, legend_at
13logger = logging.getLogger(__name__)
16class RangeSelectTool:
17 """状態はこのツールが持ち、PlotterApp の同じ名前は窓口(gui/tools/__init__.py)。"""
19 # PlotterApp から同じ名前で読み書きできるもの(テスト・メニュー・ほかの mixin が使う)
20 EXPOSED_NAMES = (
21 '_apply_range_mask', '_clear_range_select_preview', '_on_range_select_motion', '_on_range_select_press',
22 '_on_range_select_release', '_range_select_axes', '_range_select_background', '_range_select_motion_cid',
23 '_range_select_press_cid', '_range_select_preview_artist', '_range_select_release_cid',
24 '_range_select_start_x', '_toggle_range_select_mode', 'range_select_mode_enabled',
25 )
27 def __init__(self, app):
28 self._app = app
29 self.range_select_mode_enabled = False
30 self._range_select_press_cid = None
31 self._range_select_motion_cid = None
32 self._range_select_release_cid = None
33 self._range_select_axes = None
34 self._range_select_start_x = None
35 self._range_select_preview_artist = None
37 def _toggle_range_select_mode(self, checked):
38 self.range_select_mode_enabled = checked
40 if checked:
41 self._app._deactivate_other_mouse_modes('range_select')
43 self._range_select_press_cid = self._app.canvas.mpl_connect(
44 'button_press_event', self._on_range_select_press
45 )
46 self._range_select_motion_cid = self._app.canvas.mpl_connect(
47 'motion_notify_event', self._on_range_select_motion
48 )
49 self._range_select_release_cid = self._app.canvas.mpl_connect(
50 'button_release_event', self._on_range_select_release
51 )
52 self._app.statusBar().showMessage(
53 "範囲選択モード: カレントデータセット上でドラッグした範囲を"
54 "マスク(除外)します", 5000
55 )
56 else:
57 if getattr(self, '_range_select_press_cid', None) is not None:
58 self._app.canvas.mpl_disconnect(self._range_select_press_cid)
59 self._range_select_press_cid = None
60 if getattr(self, '_range_select_motion_cid', None) is not None:
61 self._app.canvas.mpl_disconnect(self._range_select_motion_cid)
62 self._range_select_motion_cid = None
63 if getattr(self, '_range_select_release_cid', None) is not None:
64 self._app.canvas.mpl_disconnect(self._range_select_release_cid)
65 self._range_select_release_cid = None
66 self._clear_range_select_preview()
67 self._range_select_axes = None
68 self._range_select_start_x = None
70 def _clear_range_select_preview(self):
71 """ドラッグ中のプレビューの矩形と、blit 用の背景を捨てる。描き直しで既に消えていても例外にしない。"""
72 artist = getattr(self, '_range_select_preview_artist', None)
73 if artist is not None:
74 try:
75 artist.remove()
76 except (ValueError, NotImplementedError):
77 pass
78 self._range_select_preview_artist = None
79 self._app.canvas.draw_idle()
80 self._range_select_background = None
82 def _on_range_select_press(self, event):
83 if not getattr(self, 'range_select_mode_enabled', False):
84 return
85 if event.button != 1 or event.inaxes is None or event.xdata is None:
86 return
87 if legend_at(self._app.canvas, event) is not None:
88 return
89 self._range_select_axes = event.inaxes
90 self._range_select_start_x = event.xdata
91 # 始めに1回だけ背景を撮り、以降は選択の矩形だけを blit で描き直す(毎回全体を描くと系列が多いほど重い)
92 self._app.canvas.draw()
93 self._range_select_background = self._app.canvas.copy_from_bbox(event.inaxes.bbox)
95 def _on_range_select_motion(self, event):
96 axes = getattr(self, '_range_select_axes', None)
97 if axes is None:
98 return
99 point = clamped_data_point(axes, event)
100 if point is None: 100 ↛ 101line 100 didn't jump to line 101 because the condition on line 100 was never true
101 return
103 x0, x1 = sorted((self._range_select_start_x, point[0]))
104 ymin, ymax = axes.get_ylim()
106 rect = getattr(self, '_range_select_preview_artist', None)
107 if rect is None:
108 # animated=True の Artist は draw() では描かれず、blit だけで出る。矩形は最初に1回作り、座標だけ変える
109 rect = Rectangle(
110 (x0, ymin), x1 - x0, ymax - ymin,
111 facecolor='#3948B3', alpha=0.15, edgecolor='#3948B3',
112 linewidth=1, zorder=100, animated=True,
113 )
114 axes.add_patch(rect)
115 self._range_select_preview_artist = rect
116 else:
117 rect.set_bounds(x0, ymin, x1 - x0, ymax - ymin)
119 background = getattr(self, '_range_select_background', None)
120 if background is None:
121 # 背景が撮れていなければ全体を描き直す
122 self._app.canvas.draw_idle()
123 return
125 self._app.canvas.restore_region(background)
126 axes.draw_artist(rect)
127 self._app.canvas.blit(axes.bbox)
129 def _on_range_select_release(self, event):
130 axes = getattr(self, '_range_select_axes', None)
131 if axes is None: 131 ↛ 132line 131 didn't jump to line 132 because the condition on line 131 was never true
132 return
134 self._clear_range_select_preview()
135 start_x = self._range_select_start_x
136 self._range_select_axes = None
137 self._range_select_start_x = None
139 # 軸の外で離したら、軸の縁で止めた位置までを選ぶ
140 point = clamped_data_point(axes, event)
141 end_x = point[0] if point is not None else None
142 if end_x is None or start_x is None or start_x == end_x:
143 return # クリックだけ(ドラッグなし)は選択とみなさない
145 x_min, x_max = sorted((start_x, end_x))
146 self._apply_range_mask(axes, x_min, x_max)
148 def _apply_range_mask(self, axes, x_min, x_max):
149 """[x_min, x_max] の点をマスクに加える。今のデータセットがこの軸に描かれていなければ、何もせず案内を出す。"""
150 dataset = self._app._get_current_dataset()
151 if dataset is None:
152 notify.information(self._app, "範囲選択", "マスク対象のデータセットを選択してください。")
153 return
155 target_axis = dataset.subplot_target
156 if dataset.use_secondary_y: 156 ↛ 157line 156 didn't jump to line 157 because the condition on line 156 was never true
157 expected_axes = (
158 self._app.all_secondary_axes[target_axis]
159 if 0 <= target_axis < len(self._app.all_secondary_axes) else None
160 )
161 else:
162 expected_axes = (
163 self._app.all_axes[target_axis]
164 if 0 <= target_axis < len(self._app.all_axes) else None
165 )
167 if axes is not expected_axes:
168 notify.information(
169 self._app, "範囲選択",
170 "ドラッグしたサブプロットに、選択中のデータセットが描画されていません。"
171 )
172 return
174 # ドラッグの x はウォーターフォールのずらしが掛かった表示座標なので、データ座標に戻して比べる
175 data_x_min, _ = self._app.canvas.display_to_data(dataset, x_min, 0.0)
176 data_x_max, _ = self._app.canvas.display_to_data(dataset, x_max, 0.0)
178 x_data = dataset.x_data
179 visible_index = dataset.visible_df.index
180 in_range_mask = (x_data >= data_x_min) & (x_data <= data_x_max)
181 newly_masked = [int(idx) for idx, flag in zip(visible_index, in_range_mask) if flag]
182 if not newly_masked:
183 return
185 old_masked = list(dataset.masked_row_indices)
186 new_masked = sorted(set(old_masked) | set(newly_masked))
187 if new_masked == old_masked: 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true
188 return
190 self._app.dataset_host.push_masked_rows(
191 dataset, old_masked, new_masked,
192 description=f"範囲選択でのマスク({len(newly_masked)}件)",
193 )
194 self._app.statusBar().showMessage(
195 f"「{dataset.name}」の{len(newly_masked)}点をマスクしました", 3000
196 )