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

1"""範囲選択モード: X の範囲をドラッグで選び、その範囲の点を今のデータセットのマスクに加える。 

2 

3SpanSelector は Axes に束縛されるので、Axes を作り直す redraw_all() のたびに作り直すことになる。ほかのモードと同じく 

4キャンバス全体の button_press / motion / release で event.inaxes を読む。 

5""" 

6import logging 

7 

8from matplotlib.patches import Rectangle 

9 

10from graphica.gui import notify 

11from graphica.gui.tools.pointer import clamped_data_point, legend_at 

12 

13logger = logging.getLogger(__name__) 

14 

15 

16class RangeSelectTool: 

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

18 

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 ) 

26 

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 

36 

37 def _toggle_range_select_mode(self, checked): 

38 self.range_select_mode_enabled = checked 

39 

40 if checked: 

41 self._app._deactivate_other_mouse_modes('range_select') 

42 

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 

69 

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 

81 

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) 

94 

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 

102 

103 x0, x1 = sorted((self._range_select_start_x, point[0])) 

104 ymin, ymax = axes.get_ylim() 

105 

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) 

118 

119 background = getattr(self, '_range_select_background', None) 

120 if background is None: 

121 # 背景が撮れていなければ全体を描き直す 

122 self._app.canvas.draw_idle() 

123 return 

124 

125 self._app.canvas.restore_region(background) 

126 axes.draw_artist(rect) 

127 self._app.canvas.blit(axes.bbox) 

128 

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 

133 

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 

138 

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 # クリックだけ(ドラッグなし)は選択とみなさない 

144 

145 x_min, x_max = sorted((start_x, end_x)) 

146 self._apply_range_mask(axes, x_min, x_max) 

147 

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 

154 

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 ) 

166 

167 if axes is not expected_axes: 

168 notify.information( 

169 self._app, "範囲選択", 

170 "ドラッグしたサブプロットに、選択中のデータセットが描画されていません。" 

171 ) 

172 return 

173 

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) 

177 

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 

184 

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 

189 

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 )