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

1"""領域強調モード: X 方向に大きくドラッグすると縦の帯、Y 方向なら横の帯を足す。 

2 

3帯は注釈と同じ 'annotations' に type='vspan' / 'hspan' で入れるので、保存・Undo・描画は注釈と共有する。 

4データ座標で持つので、データを読み直しても位置は変わらない。 

5""" 

6import logging 

7 

8from matplotlib.patches import Rectangle 

9from PySide6.QtWidgets import QMessageBox 

10 

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 

15 

16logger = logging.getLogger(__name__) 

17 

18# これより動かなければクリック(誤クリックで極小の帯ができないように) 

19REGION_HIGHLIGHT_DRAG_THRESHOLD_PX = 5 

20 

21REGION_HIGHLIGHT_DEFAULT_COLOR = '#F2A72B' 

22REGION_HIGHLIGHT_DEFAULT_ALPHA = 0.18 

23 

24 

25class RegionHighlightTool: 

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

27 

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 ) 

36 

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 用に押したときに撮った背景 

47 

48 def _toggle_region_highlight_mode(self, checked): 

49 self.region_highlight_mode_enabled = checked 

50 

51 if checked: 

52 self._app._deactivate_other_mouse_modes('region_highlight') 

53 

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 

80 

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 

92 

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 

100 

101 if event.button == 3: # 右クリックは削除 

102 self._try_delete_region_near(event) 

103 return 

104 

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) 

110 

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 

118 

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 

124 

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) 

133 

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) 

146 

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) 

154 

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 

159 

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 

167 

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 # クリックだけ(ドラッグなし) 

176 

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 

180 

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

185 

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="領域ハイライトの追加") 

190 

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' 

200 

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 

206 

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

208 annotations = axis_setting(settings, 'annotations') 

209 

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 # 後ろのものほど手前 

221 

222 if target_index is None: 

223 return 

224 

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 

236 

237 new_list = list(annotations) 

238 del new_list[target_index] 

239 

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)