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

1"""注釈モード: クリックでテキスト、ドラッグで矢印の注釈を置き、右クリックのメニューで色を変える・消す。 

2 

3注釈は all_plot_settings[軸]['annotations'] に {'id', 'type', 'text', 'xy', 'xytext', 'color'} で持ち、プロジェクトと一緒に 

4保存される。矢印は 'arrow_style' と 'arrow_curvature' も持つ(無ければ既定値)。追加と削除は SetAnnotationsCommand で Undo できる。 

5""" 

6import uuid 

7import logging 

8 

9from PySide6.QtGui import QColor, QCursor 

10from PySide6.QtWidgets import QDialog, QMenu, QMessageBox 

11 

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 

19 

20logger = logging.getLogger(__name__) 

21 

22# これより動かなければクリック、動けばドラッグ 

23ANNOTATION_CLICK_THRESHOLD_PX = 5 

24ANNOTATION_DELETE_TOLERANCE_PX = 15 

25 

26 

27class AnnotationTool: 

28 """状態はこのツールが持ち、PlotterApp の同じ名前は窓口(gui/tools/__init__.py)。""" 

29 

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 ) 

39 

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) 

48 

49 def _toggle_annotation_mode(self, checked): 

50 self.annotation_mode_enabled = checked 

51 

52 if checked: 

53 self._app._deactivate_other_mouse_modes('annotation') 

54 

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 

72 

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 

77 

78 interval = getattr(self, 'snap_grid_interval_px', DEFAULT_SNAP_GRID_INTERVAL_PX) 

79 if not interval or interval <= 0: 

80 return x, y 

81 

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 

87 

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 

95 

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 

101 

102 if event.button == 3: # 右クリックは色の変更と削除のメニュー 

103 self._on_annotation_context_menu(event) 

104 return 

105 

106 self._annotation_drag_start = (event.inaxes, event.xdata, event.ydata) 

107 

108 def _on_annotation_release(self, event): 

109 if not self.annotation_mode_enabled or self._annotation_drag_start is None: 

110 return 

111 

112 start_ax, start_x, start_y = self._annotation_drag_start 

113 self._annotation_drag_start = None 

114 

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 

119 

120 axis_index = self._find_axis_index(start_ax) 

121 if axis_index is None: 

122 return 

123 

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 

128 

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 }) 

156 

157 def _add_annotation(self, axis_index, annotation, description="注釈の追加"): 

158 """注釈を1つ足す。リストは差し替える(浅いコピーで共有されているリストを書き換えないように)。""" 

159 annotation = dict(annotation) 

160 annotation['id'] = uuid.uuid4().hex 

161 

162 settings = self._app.project.all_plot_settings[axis_index] 

163 old_annotations = list(axis_setting(settings, 'annotations')) 

164 new_annotations = old_annotations + [annotation] 

165 

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) 

171 

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) 

182 

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 

196 

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) 

209 

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) 

214 

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 

220 

221 settings = self._app.project.all_plot_settings[axis_index] 

222 annotations = axis_setting(settings, 'annotations') 

223 if not annotations: 

224 return None 

225 

226 ax = event.inaxes 

227 click_px = ax.transData.transform((event.xdata, event.ydata)) 

228 

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 

252 

253 if best_index is None or best_distance > ANNOTATION_DELETE_TOLERANCE_PX: 

254 return None 

255 return axis_index, annotations, best_index 

256 

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 

272 

273 new_list = list(annotations) 

274 del new_list[best_index] 

275 

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)