Coverage for graphica/gui/tools/region_highlight.py: 84.9%
161 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 方向に大きくドラッグすると縦の帯、Y 方向なら横の帯を足す。
3帯は注釈と同じ 'annotations' に type='vspan' / 'hspan' で入れるので、保存・Undo・描画は注釈と共有する。
4データ座標で持つので、データを読み直しても位置は変わらない。
5"""
6import logging
8from matplotlib.patches import Rectangle
9from PySide6.QtWidgets import QMessageBox
11from graphica.gui import notify
12from graphica.core.axis_settings import axis_setting
13from graphica.core.commands import SetAnnotationsCommand
14from graphica.gui.tools.pointer import clamped_data_point, legend_at
16logger = logging.getLogger(__name__)
18# これより動かなければクリック(誤クリックで極小の帯ができないように)
19REGION_HIGHLIGHT_DRAG_THRESHOLD_PX = 5
21REGION_HIGHLIGHT_DEFAULT_COLOR = '#F2A72B'
22REGION_HIGHLIGHT_DEFAULT_ALPHA = 0.18
25class RegionHighlightTool:
26 """状態はこのツールが持ち、PlotterApp の同じ名前は窓口(gui/tools/__init__.py)。"""
28 # PlotterApp から同じ名前で読み書きできるもの(テスト・メニュー・ほかの mixin が使う)
29 EXPOSED_NAMES = (
30 '_clear_region_highlight_preview', '_on_region_highlight_motion', '_on_region_highlight_press',
31 '_on_region_highlight_release', '_region_highlight_axes', '_region_highlight_motion_cid',
32 '_region_highlight_orientation', '_region_highlight_press_cid', '_region_highlight_preview_artist',
33 '_region_highlight_release_cid', '_region_highlight_start', '_toggle_region_highlight_mode',
34 '_try_delete_region_near', 'region_highlight_mode_enabled',
35 )
37 def __init__(self, app):
38 self._app = app
39 self.region_highlight_mode_enabled = False
40 self._region_highlight_press_cid = None
41 self._region_highlight_motion_cid = None
42 self._region_highlight_release_cid = None
43 self._region_highlight_axes = None
44 self._region_highlight_start = None # (x, y) データ座標
45 self._region_highlight_preview_artist = None
46 self._region_highlight_background = None # blit 用に押したときに撮った背景
48 def _toggle_region_highlight_mode(self, checked):
49 self.region_highlight_mode_enabled = checked
51 if checked:
52 self._app._deactivate_other_mouse_modes('region_highlight')
54 self._region_highlight_press_cid = self._app.canvas.mpl_connect(
55 'button_press_event', self._on_region_highlight_press
56 )
57 self._region_highlight_motion_cid = self._app.canvas.mpl_connect(
58 'motion_notify_event', self._on_region_highlight_motion
59 )
60 self._region_highlight_release_cid = self._app.canvas.mpl_connect(
61 'button_release_event', self._on_region_highlight_release
62 )
63 self._app.statusBar().showMessage(
64 "領域ハイライトモード: 横方向にドラッグで縦帯、縦方向にドラッグで横帯を追加"
65 "(右クリックで削除)", 5000
66 )
67 else:
68 if getattr(self, '_region_highlight_press_cid', None) is not None:
69 self._app.canvas.mpl_disconnect(self._region_highlight_press_cid)
70 self._region_highlight_press_cid = None
71 if getattr(self, '_region_highlight_motion_cid', None) is not None:
72 self._app.canvas.mpl_disconnect(self._region_highlight_motion_cid)
73 self._region_highlight_motion_cid = None
74 if getattr(self, '_region_highlight_release_cid', None) is not None:
75 self._app.canvas.mpl_disconnect(self._region_highlight_release_cid)
76 self._region_highlight_release_cid = None
77 self._clear_region_highlight_preview()
78 self._region_highlight_axes = None
79 self._region_highlight_start = None
81 def _clear_region_highlight_preview(self):
82 """ドラッグ中のプレビューと blit 用の背景を捨てる。描き直しで既に消えていても例外にしない。"""
83 artist = getattr(self, '_region_highlight_preview_artist', None)
84 if artist is not None: 84 ↛ 85line 84 didn't jump to line 85 because the condition on line 84 was never true
85 try:
86 artist.remove()
87 except (ValueError, NotImplementedError):
88 pass
89 self._region_highlight_preview_artist = None
90 self._app.canvas.draw_idle()
91 self._region_highlight_background = None
93 def _on_region_highlight_press(self, event):
94 if not getattr(self, 'region_highlight_mode_enabled', False):
95 return
96 if event.inaxes is None or event.xdata is None or event.ydata is None: 96 ↛ 97line 96 didn't jump to line 97 because the condition on line 96 was never true
97 return
98 if legend_at(self._app.canvas, event) is not None:
99 return
101 if event.button == 3: # 右クリックは削除
102 self._try_delete_region_near(event)
103 return
105 self._region_highlight_axes = event.inaxes
106 self._region_highlight_start = (event.xdata, event.ydata)
107 # 背景は始めに1回だけ撮り、以降は帯だけを blit で描き直す(毎回全体を描くと系列が多いほど重い)
108 self._app.canvas.draw()
109 self._region_highlight_background = self._app.canvas.copy_from_bbox(event.inaxes.bbox)
111 def _on_region_highlight_motion(self, event):
112 axes = getattr(self, '_region_highlight_axes', None)
113 if axes is None: 113 ↛ 114line 113 didn't jump to line 114 because the condition on line 113 was never true
114 return
115 point = clamped_data_point(axes, event)
116 if point is None: 116 ↛ 117line 116 didn't jump to line 117 because the condition on line 116 was never true
117 return
119 start_x, start_y = self._region_highlight_start
120 current_x, current_y = point
121 orientation = self._region_highlight_orientation(axes, start_x, start_y, current_x, current_y)
122 if orientation is None:
123 return
125 xmin, xmax = axes.get_xlim()
126 ymin, ymax = axes.get_ylim()
127 if orientation == 'vspan': 127 ↛ 131line 127 didn't jump to line 131 because the condition on line 127 was always true
128 x0, x1 = sorted((start_x, current_x))
129 bounds = (x0, ymin, x1 - x0, ymax - ymin)
130 else:
131 y0, y1 = sorted((start_y, current_y))
132 bounds = (xmin, y0, xmax - xmin, y1 - y0)
134 rect = self._region_highlight_preview_artist
135 if rect is None:
136 # animated=True の図形は draw() では描かれず blit だけで出る。add_artist なのでデータの範囲にも入らない
137 rect = Rectangle(
138 bounds[:2], bounds[2], bounds[3],
139 facecolor=REGION_HIGHLIGHT_DEFAULT_COLOR, alpha=0.25,
140 edgecolor=REGION_HIGHLIGHT_DEFAULT_COLOR, linewidth=1, zorder=100, animated=True,
141 )
142 axes.add_artist(rect)
143 self._region_highlight_preview_artist = rect
144 else:
145 rect.set_bounds(*bounds)
147 background = self._region_highlight_background
148 if background is None: 148 ↛ 149line 148 didn't jump to line 149 because the condition on line 148 was never true
149 self._app.canvas.draw_idle()
150 return
151 self._app.canvas.restore_region(background)
152 axes.draw_artist(rect)
153 self._app.canvas.blit(axes.bbox)
155 def _on_region_highlight_release(self, event):
156 axes = getattr(self, '_region_highlight_axes', None)
157 if axes is None: 157 ↛ 158line 157 didn't jump to line 158 because the condition on line 157 was never true
158 return
160 self._clear_region_highlight_preview()
161 start = self._region_highlight_start
162 self._region_highlight_axes = None
163 self._region_highlight_start = None
164 if start is None: 164 ↛ 165line 164 didn't jump to line 165 because the condition on line 164 was never true
165 return
166 start_x, start_y = start
168 # 軸の外で離したら、軸の縁で止めた位置までの帯にする
169 end_point = clamped_data_point(axes, event)
170 if end_point is None: 170 ↛ 171line 170 didn't jump to line 171 because the condition on line 170 was never true
171 return
172 end_x, end_y = end_point
173 orientation = self._region_highlight_orientation(axes, start_x, start_y, end_x, end_y)
174 if orientation is None:
175 return # クリックだけ(ドラッグなし)
177 axis_index = self._app._find_axis_index(axes)
178 if axis_index is None: 178 ↛ 179line 178 didn't jump to line 179 because the condition on line 178 was never true
179 return
181 if orientation == 'vspan':
182 value_range = tuple(sorted((float(start_x), float(end_x))))
183 else:
184 value_range = tuple(sorted((float(start_y), float(end_y))))
186 self._app._add_annotation(axis_index, {
187 'type': orientation, 'range': value_range,
188 'color': REGION_HIGHLIGHT_DEFAULT_COLOR, 'alpha': REGION_HIGHLIGHT_DEFAULT_ALPHA,
189 }, description="領域ハイライトの追加")
191 def _region_highlight_orientation(self, axes, start_x, start_y, current_x, current_y):
192 """'vspan'(横に大きく動いた)/ 'hspan'(縦に大きく動いた)/ None(まだドラッグとみなせない)。"""
193 start_px = axes.transData.transform((start_x, start_y))
194 current_px = axes.transData.transform((current_x, current_y))
195 dx = abs(current_px[0] - start_px[0])
196 dy = abs(current_px[1] - start_px[1])
197 if max(dx, dy) < REGION_HIGHLIGHT_DRAG_THRESHOLD_PX:
198 return None
199 return 'vspan' if dx >= dy else 'hspan'
201 def _try_delete_region_near(self, event):
202 """右クリックした位置の帯を、確認してから消す。重なっていれば最後に足したもの(いちばん手前)。"""
203 axis_index = self._app._find_axis_index(event.inaxes)
204 if axis_index is None: 204 ↛ 205line 204 didn't jump to line 205 because the condition on line 204 was never true
205 return
207 settings = self._app.project.all_plot_settings[axis_index]
208 annotations = axis_setting(settings, 'annotations')
210 target_index = None
211 for i, ann in enumerate(annotations):
212 ann_type = ann.get('type')
213 if ann_type not in ('vspan', 'hspan'): 213 ↛ 214line 213 didn't jump to line 214 because the condition on line 213 was never true
214 continue
215 value = event.xdata if ann_type == 'vspan' else event.ydata
216 lo, hi = ann.get('range', (None, None))
217 if lo is None or hi is None or value is None: 217 ↛ 218line 217 didn't jump to line 218 because the condition on line 217 was never true
218 continue
219 if lo <= value <= hi:
220 target_index = i # 後ろのものほど手前
222 if target_index is None:
223 return
225 target = annotations[target_index]
226 label = "縦帯" if target.get('type') == 'vspan' else "横帯"
227 lo, hi = target.get('range', (None, None))
228 reply = notify.question(
229 self._app, "領域ハイライトの削除",
230 f"この{label}を削除しますか?\n\n範囲: {lo:.4g} 〜 {hi:.4g}",
231 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
232 QMessageBox.StandardButton.Yes
233 )
234 if reply != QMessageBox.StandardButton.Yes:
235 return
237 new_list = list(annotations)
238 del new_list[target_index]
240 command = SetAnnotationsCommand(
241 self._app.project, axis_index, annotations, new_list,
242 self._app._update_plot_appearance, description="領域ハイライトの削除"
243 )
244 self._app.undo_stack.push(command)