Coverage for graphica/gui/tools/annotation.py: 94.1%
184 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"""注釈モード: クリックでテキスト、ドラッグで矢印の注釈を置き、右クリックのメニューで色を変える・消す。
3注釈は all_plot_settings[軸]['annotations'] に {'id', 'type', 'text', 'xy', 'xytext', 'color'} で持ち、プロジェクトと一緒に
4保存される。矢印は 'arrow_style' と 'arrow_curvature' も持つ(無ければ既定値)。追加と削除は SetAnnotationsCommand で Undo できる。
5"""
6import uuid
7import logging
9from PySide6.QtGui import QColor, QCursor
10from PySide6.QtWidgets import QDialog, QMenu, QMessageBox
12from graphica.gui import app_settings, notify
13from graphica.core.axis_settings import axis_setting
14from graphica.core.commands import SetAnnotationsCommand
15from graphica.gui.app_settings import DEFAULT_SNAP_GRID_INTERVAL_PX
16from graphica.gui.color_history import get_color_with_history
17from graphica.gui.dialogs import ArrowAnnotationDialog, TextAnnotationDialog
18from graphica.gui.tools.pointer import clamped_data_point, legend_at
20logger = logging.getLogger(__name__)
22# これより動かなければクリック、動けばドラッグ
23ANNOTATION_CLICK_THRESHOLD_PX = 5
24ANNOTATION_DELETE_TOLERANCE_PX = 15
27class AnnotationTool:
28 """状態はこのツールが持ち、PlotterApp の同じ名前は窓口(gui/tools/__init__.py)。"""
30 # PlotterApp から同じ名前で読み書きできるもの(テスト・メニュー・ほかの mixin が使う)
31 EXPOSED_NAMES = (
32 '_add_annotation', '_annotation_drag_start', '_annotation_press_cid', '_annotation_release_cid',
33 '_change_annotation_color', '_choose_annotation_action', '_confirm_and_delete_annotation',
34 '_find_annotation_near', '_find_axis_index', '_on_annotation_context_menu', '_on_annotation_press',
35 '_on_annotation_release', '_snap_point_to_grid', '_toggle_annotation_mode', '_try_delete_annotation_near',
36 'annotation_mode_enabled',
37 'snap_grid_interval_px', 'snap_to_grid_enabled',
38 )
40 def __init__(self, app):
41 self._app = app
42 self.annotation_mode_enabled = False
43 self._annotation_press_cid = None
44 self._annotation_release_cid = None
45 self._annotation_drag_start = None # (ax, x, y)
46 self.snap_to_grid_enabled = app_settings.SNAP_TO_GRID_ENABLED.read(self._app.settings)
47 self.snap_grid_interval_px = app_settings.SNAP_GRID_INTERVAL_PX.read(self._app.settings)
49 def _toggle_annotation_mode(self, checked):
50 self.annotation_mode_enabled = checked
52 if checked:
53 self._app._deactivate_other_mouse_modes('annotation')
55 self._annotation_press_cid = self._app.canvas.mpl_connect(
56 'button_press_event', self._on_annotation_press
57 )
58 self._annotation_release_cid = self._app.canvas.mpl_connect(
59 'button_release_event', self._on_annotation_release
60 )
61 self._app.statusBar().showMessage(
62 "注釈モード: クリックでテキスト注釈、ドラッグで矢印注釈を追加します(右クリックで色の変更・削除)", 5000
63 )
64 else:
65 if self._annotation_press_cid is not None:
66 self._app.canvas.mpl_disconnect(self._annotation_press_cid)
67 self._annotation_press_cid = None
68 if self._annotation_release_cid is not None:
69 self._app.canvas.mpl_disconnect(self._annotation_release_cid)
70 self._annotation_release_cid = None
71 self._annotation_drag_start = None
73 def _snap_point_to_grid(self, ax, x, y):
74 """有効なら、データ座標を画面のピクセルに直してグリッドの倍数に丸め、データ座標に戻す(整列は画面上の話なので)。"""
75 if not getattr(self, 'snap_to_grid_enabled', False):
76 return x, y
78 interval = getattr(self, 'snap_grid_interval_px', DEFAULT_SNAP_GRID_INTERVAL_PX)
79 if not interval or interval <= 0:
80 return x, y
82 px, py = ax.transData.transform((x, y))
83 snapped_px = round(px / interval) * interval
84 snapped_py = round(py / interval) * interval
85 data_x, data_y = ax.transData.inverted().transform((snapped_px, snapped_py))
86 return data_x, data_y
88 def _find_axis_index(self, ax):
89 """all_axes / all_secondary_axes での番号。無ければ None。"""
90 if ax in self._app.all_axes:
91 return self._app.all_axes.index(ax)
92 if ax in self._app.all_secondary_axes:
93 return self._app.all_secondary_axes.index(ax)
94 return None
96 def _on_annotation_press(self, event):
97 if not self.annotation_mode_enabled or event.inaxes is None or event.xdata is None:
98 return
99 if legend_at(self._app.canvas, event) is not None:
100 return
102 if event.button == 3: # 右クリックは色の変更と削除のメニュー
103 self._on_annotation_context_menu(event)
104 return
106 self._annotation_drag_start = (event.inaxes, event.xdata, event.ydata)
108 def _on_annotation_release(self, event):
109 if not self.annotation_mode_enabled or self._annotation_drag_start is None:
110 return
112 start_ax, start_x, start_y = self._annotation_drag_start
113 self._annotation_drag_start = None
115 # 軸の外で離したら、軸の縁で止めた位置を矢印の先にする
116 end_point = clamped_data_point(start_ax, event)
117 if end_point is None: 117 ↛ 118line 117 didn't jump to line 118 because the condition on line 117 was never true
118 return
120 axis_index = self._find_axis_index(start_ax)
121 if axis_index is None:
122 return
124 end_x, end_y = end_point
125 start_px = start_ax.transData.transform((start_x, start_y))
126 end_px = start_ax.transData.transform((end_x, end_y))
127 drag_distance_px = ((end_px[0] - start_px[0]) ** 2 + (end_px[1] - start_px[1]) ** 2) ** 0.5
129 if drag_distance_px < ANNOTATION_CLICK_THRESHOLD_PX:
130 dialog = TextAnnotationDialog(self._app, settings=self._app.settings)
131 if dialog.exec() != QDialog.DialogCode.Accepted:
132 return
133 text = dialog.get_text()
134 if not text:
135 return
136 snapped_x, snapped_y = self._snap_point_to_grid(start_ax, start_x, start_y)
137 self._add_annotation(axis_index, {
138 'type': 'text', 'text': text,
139 'xy': (snapped_x, snapped_y), 'xytext': (snapped_x, snapped_y),
140 'color': dialog.color(),
141 })
142 else:
143 dialog = ArrowAnnotationDialog(self._app, settings=self._app.settings)
144 if dialog.exec() != QDialog.DialogCode.Accepted:
145 return
146 text, arrow_style, arrow_curvature = dialog.get_settings()
147 snapped_end_x, snapped_end_y = self._snap_point_to_grid(start_ax, end_x, end_y)
148 snapped_start_x, snapped_start_y = self._snap_point_to_grid(start_ax, start_x, start_y)
149 self._add_annotation(axis_index, {
150 'type': 'arrow', 'text': text,
151 'xy': (snapped_end_x, snapped_end_y), 'xytext': (snapped_start_x, snapped_start_y),
152 'color': dialog.color(),
153 'arrow_style': arrow_style,
154 'arrow_curvature': arrow_curvature,
155 })
157 def _add_annotation(self, axis_index, annotation, description="注釈の追加"):
158 """注釈を1つ足す。リストは差し替える(浅いコピーで共有されているリストを書き換えないように)。"""
159 annotation = dict(annotation)
160 annotation['id'] = uuid.uuid4().hex
162 settings = self._app.project.all_plot_settings[axis_index]
163 old_annotations = list(axis_setting(settings, 'annotations'))
164 new_annotations = old_annotations + [annotation]
166 command = SetAnnotationsCommand(
167 self._app.project, axis_index, old_annotations, new_annotations,
168 self._app._update_plot_appearance, description=description
169 )
170 self._app.undo_stack.push(command)
172 def _on_annotation_context_menu(self, event):
173 found = self._find_annotation_near(event)
174 if found is None:
175 return
176 axis_index, annotations, index = found
177 choice = self._choose_annotation_action(annotations[index])
178 if choice == 'color':
179 self._change_annotation_color(axis_index, annotations, index)
180 elif choice == 'delete':
181 self._confirm_and_delete_annotation(axis_index, annotations, index)
183 def _choose_annotation_action(self, annotation):
184 """右クリックのメニューを出し、'color'・'delete'・None(閉じた)を返す。色を持てるのは文字と矢印だけ。"""
185 menu = QMenu(self._app)
186 color_action = menu.addAction("色を変更...") if annotation.get('type') in ('text', 'arrow') else None
187 delete_action = menu.addAction("削除...")
188 chosen = menu.exec(QCursor.pos())
189 if chosen is None: 189 ↛ 191line 189 didn't jump to line 191 because the condition on line 189 was always true
190 return None
191 if color_action is not None and chosen is color_action:
192 return 'color'
193 if chosen is delete_action:
194 return 'delete'
195 return None
197 def _change_annotation_color(self, axis_index, annotations, index):
198 current = annotations[index].get('color', '#000000')
199 color = get_color_with_history(self._app.settings, self._app, initial=QColor(current))
200 if not color.isValid() or color.name() == QColor(current).name():
201 return
202 new_list = list(annotations)
203 new_list[index] = dict(annotations[index], color=color.name())
204 command = SetAnnotationsCommand(
205 self._app.project, axis_index, annotations, new_list,
206 self._app._update_plot_appearance, description="注釈の色の変更"
207 )
208 self._app.undo_stack.push(command)
210 def _try_delete_annotation_near(self, event):
211 found = self._find_annotation_near(event)
212 if found is not None:
213 self._confirm_and_delete_annotation(*found)
215 def _find_annotation_near(self, event):
216 """クリックした位置のいちばん近い注釈の (軸の番号, その軸の注釈, 番号)。近くに無ければ None。"""
217 axis_index = self._find_axis_index(event.inaxes)
218 if axis_index is None:
219 return None
221 settings = self._app.project.all_plot_settings[axis_index]
222 annotations = axis_setting(settings, 'annotations')
223 if not annotations:
224 return None
226 ax = event.inaxes
227 click_px = ax.transData.transform((event.xdata, event.ydata))
229 best_index, best_distance = None, None
230 for i, ann in enumerate(annotations):
231 ann_type = ann.get('type')
232 if ann_type in ('vspan', 'hspan'):
233 # 領域の強調は領域強調モードの側で消す
234 continue
235 if ann_type == 'inset':
236 # 拡大図は xy を持たないので、角と大きさ(軸に対する座標)から中心を求めて当たり判定に使う
237 from graphica.gui.canvas import _INSET_CORNER_ORIGINS
238 x0, y0 = _INSET_CORNER_ORIGINS.get(ann.get('corner', '右上'), (0.55, 0.55))
239 size = ann.get('size', 0.4)
240 pos = (x0 + size / 2, y0 + size / 2)
241 transform = ax.transAxes
242 else:
243 pos = ann.get('xytext') or ann.get('xy')
244 if pos is None: 244 ↛ 245line 244 didn't jump to line 245 because the condition on line 244 was never true
245 continue
246 # 統計値のラベルは軸に対する座標(0〜1)で位置を持つ
247 transform = ax.transAxes if ann_type == 'stat' else ax.transData
248 pos_px = transform.transform(pos)
249 distance = ((pos_px[0] - click_px[0]) ** 2 + (pos_px[1] - click_px[1]) ** 2) ** 0.5
250 if best_distance is None or distance < best_distance: 250 ↛ 230line 250 didn't jump to line 230 because the condition on line 250 was always true
251 best_distance, best_index = distance, i
253 if best_index is None or best_distance > ANNOTATION_DELETE_TOLERANCE_PX:
254 return None
255 return axis_index, annotations, best_index
257 def _confirm_and_delete_annotation(self, axis_index, annotations, best_index):
258 target = annotations[best_index]
259 if target.get('type') == 'stat':
260 label = "統計値アンカーラベル"
261 elif target.get('type') == 'inset':
262 label = "インセット(拡大図)"
263 else:
264 label = target.get('text') or ("矢印注釈" if target.get('type') == 'arrow' else "テキスト注釈")
265 reply = notify.question(
266 self._app, "注釈の削除", f"この注釈を削除しますか?\n\n{label}",
267 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
268 QMessageBox.StandardButton.Yes
269 )
270 if reply != QMessageBox.StandardButton.Yes:
271 return
273 new_list = list(annotations)
274 del new_list[best_index]
276 command = SetAnnotationsCommand(
277 self._app.project, axis_index, annotations, new_list,
278 self._app._update_plot_appearance, description="注釈の削除"
279 )
280 self._app.undo_stack.push(command)