Coverage for graphica/gui/tools/slice_extraction.py: 88.6%

131 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-12 16:38 +0000

1"""スライス抽出モード: 2D マップの上でドラッグした線分に沿った断面を、新しい1次元のデータセットにする。 

2 

3対象は今選んでいるデータセット(その軸に描かれていること)。線分がほぼ水平・垂直ならその軸の値、 

4斜めなら始点からの距離を X にする。 

5""" 

6import logging 

7 

8from matplotlib.lines import Line2D 

9 

10from graphica.gui import notify 

11from graphica.core.provenance import build_provenance 

12from graphica.core.dataset import Dataset 

13from graphica.core.grid_data import extract_slice, GridDataError 

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

15 

16logger = logging.getLogger(__name__) 

17 

18SLICE_EXTRACTION_N_POINTS = 200 

19 

20# extract_slice() の 'axis_kind' ごとの、新しいデータセットの X の列名とラベル 

21_AXIS_KIND_LABELS = { 

22 'x': 'X', 

23 'y': 'Y', 

24 'distance': '始点からの距離', 

25} 

26 

27 

28class SliceExtractionTool: 

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

30 

31 # PlotterApp から同じ名前で読み書きできるもの(テスト・メニュー・ほかの mixin が使う) 

32 EXPOSED_NAMES = ( 

33 '_apply_slice_extraction', '_clear_slice_extraction_preview', '_create_slice_dataset', 

34 '_on_slice_extraction_motion', '_on_slice_extraction_press', '_on_slice_extraction_release', 

35 '_slice_extraction_axes', '_slice_extraction_motion_cid', '_slice_extraction_press_cid', 

36 '_slice_extraction_preview_artist', '_slice_extraction_release_cid', '_slice_extraction_start', 

37 '_toggle_slice_extraction_mode', 'slice_extraction_mode_enabled', 

38 ) 

39 

40 def __init__(self, app): 

41 self._app = app 

42 self.slice_extraction_mode_enabled = False 

43 self._slice_extraction_press_cid = None 

44 self._slice_extraction_motion_cid = None 

45 self._slice_extraction_release_cid = None 

46 self._slice_extraction_axes = None 

47 self._slice_extraction_start = None # (x, y) データ座標 

48 self._slice_extraction_preview_artist = None 

49 self._slice_extraction_background = None # blit 用に押したときに撮った背景 

50 

51 def _toggle_slice_extraction_mode(self, checked): 

52 self.slice_extraction_mode_enabled = checked 

53 

54 if checked: 

55 self._app._deactivate_other_mouse_modes('slice_extraction') 

56 

57 self._slice_extraction_press_cid = self._app.canvas.mpl_connect( 

58 'button_press_event', self._on_slice_extraction_press 

59 ) 

60 self._slice_extraction_motion_cid = self._app.canvas.mpl_connect( 

61 'motion_notify_event', self._on_slice_extraction_motion 

62 ) 

63 self._slice_extraction_release_cid = self._app.canvas.mpl_connect( 

64 'button_release_event', self._on_slice_extraction_release 

65 ) 

66 self._app.statusBar().showMessage( 

67 "スライス抽出モード: カレントの2Dマップ上でドラッグした線分に沿って" 

68 "1Dデータセットを抽出します", 5000 

69 ) 

70 else: 

71 for attr in ('_slice_extraction_press_cid', '_slice_extraction_motion_cid', 

72 '_slice_extraction_release_cid'): 

73 cid = getattr(self, attr, None) 

74 if cid is not None: 

75 self._app.canvas.mpl_disconnect(cid) 

76 setattr(self, attr, None) 

77 self._clear_slice_extraction_preview() 

78 self._slice_extraction_axes = None 

79 self._slice_extraction_start = None 

80 

81 def _clear_slice_extraction_preview(self): 

82 """ドラッグ中のプレビューと blit 用の背景を捨てる。描き直しで既に消えていても例外にしない。""" 

83 artist = getattr(self, '_slice_extraction_preview_artist', None) 

84 if artist is not None: 

85 try: 

86 artist.remove() 

87 except (ValueError, NotImplementedError): 

88 pass 

89 self._slice_extraction_preview_artist = None 

90 self._app.canvas.draw_idle() 

91 self._slice_extraction_background = None 

92 

93 def _on_slice_extraction_press(self, event): 

94 if not getattr(self, 'slice_extraction_mode_enabled', False): 

95 return 

96 if event.button != 1 or event.inaxes is None or event.xdata is None or event.ydata is None: 

97 return 

98 if legend_at(self._app.canvas, event) is not None: 

99 return 

100 self._slice_extraction_axes = event.inaxes 

101 self._slice_extraction_start = (event.xdata, event.ydata) 

102 # 背景は始めに1回だけ撮り、以降は線分だけを blit で描き直す(毎回全体を描くと系列が多いほど重い) 

103 self._app.canvas.draw() 

104 self._slice_extraction_background = self._app.canvas.copy_from_bbox(event.inaxes.bbox) 

105 

106 def _on_slice_extraction_motion(self, event): 

107 axes = getattr(self, '_slice_extraction_axes', None) 

108 if axes is None: 

109 return 

110 point = clamped_data_point(axes, event) 

111 if point is None: 111 ↛ 112line 111 didn't jump to line 112 because the condition on line 111 was never true

112 return 

113 

114 start = self._slice_extraction_start 

115 xs, ys = [start[0], point[0]], [start[1], point[1]] 

116 line = self._slice_extraction_preview_artist 

117 if line is None: 

118 # animated=True の線は draw() では描かれず blit だけで出る。add_artist なのでデータの範囲にも入らない 

119 line = Line2D(xs, ys, color='#E4572E', linestyle='--', linewidth=1.5, zorder=100, animated=True) 

120 axes.add_artist(line) 

121 self._slice_extraction_preview_artist = line 

122 else: 

123 line.set_data(xs, ys) 

124 

125 background = self._slice_extraction_background 

126 if background is None: 126 ↛ 127line 126 didn't jump to line 127 because the condition on line 126 was never true

127 self._app.canvas.draw_idle() 

128 return 

129 self._app.canvas.restore_region(background) 

130 axes.draw_artist(line) 

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

132 

133 def _on_slice_extraction_release(self, event): 

134 axes = getattr(self, '_slice_extraction_axes', None) 

135 if axes is None: 135 ↛ 136line 135 didn't jump to line 136 because the condition on line 135 was never true

136 return 

137 

138 self._clear_slice_extraction_preview() 

139 start = self._slice_extraction_start 

140 self._slice_extraction_axes = None 

141 self._slice_extraction_start = None 

142 

143 # 軸の外で離したら、軸の縁で止めた位置を終点にする 

144 end = clamped_data_point(axes, event) 

145 if end is None: 145 ↛ 146line 145 didn't jump to line 146 because the condition on line 145 was never true

146 return 

147 if start == end: 

148 return # クリックだけ(ドラッグなし) 

149 

150 self._apply_slice_extraction(axes, start, end) 

151 

152 def _apply_slice_extraction(self, axes, start, end): 

153 """今のデータセットが 2D の格子でないか、この軸に描かれていなければ、何もせず案内を出す。""" 

154 dataset = self._app._get_current_dataset() 

155 if dataset is None or dataset.data_kind != '2d_grid': 

156 notify.information( 

157 self._app, "スライス抽出", "スライス抽出の対象となる2Dマップのデータセットを選択してください。" 

158 ) 

159 return 

160 

161 target_axis = dataset.subplot_target 

162 if dataset.use_secondary_y: 162 ↛ 163line 162 didn't jump to line 163 because the condition on line 162 was never true

163 expected_axes = ( 

164 self._app.all_secondary_axes[target_axis] 

165 if 0 <= target_axis < len(self._app.all_secondary_axes) else None 

166 ) 

167 else: 

168 expected_axes = ( 

169 self._app.all_axes[target_axis] 

170 if 0 <= target_axis < len(self._app.all_axes) else None 

171 ) 

172 if axes is not expected_axes: 

173 notify.information( 

174 self._app, "スライス抽出", 

175 "ドラッグしたサブプロットに、選択中の2Dマップが描画されていません。" 

176 ) 

177 return 

178 

179 grid = dataset.z_grid 

180 if grid is None: 180 ↛ 181line 180 didn't jump to line 181 because the condition on line 180 was never true

181 notify.warning(self._app, "スライス抽出", "有効な2Dグリッドデータがありません。") 

182 return 

183 

184 try: 

185 result = extract_slice( 

186 grid['x_grid'], grid['y_grid'], grid['z_grid'], 

187 start=start, end=end, n_points=SLICE_EXTRACTION_N_POINTS, 

188 ) 

189 except GridDataError as e: 

190 notify.warning(self._app, "スライス抽出", f"スライスの抽出に失敗しました:\n{e}") 

191 return 

192 

193 self._create_slice_dataset(dataset, start, end, result) 

194 

195 def _create_slice_dataset(self, source_dataset, start, end, result): 

196 import pandas as pd 

197 

198 axis_kind = result['axis_kind'] 

199 x_label = _AXIS_KIND_LABELS.get(axis_kind, axis_kind) 

200 df = pd.DataFrame({'x': result['axis_values'], 'y': result['z_values']}) 

201 params = { 

202 'start': [float(start[0]), float(start[1])], 

203 'end': [float(end[0]), float(end[1])], 

204 'axis_kind': axis_kind, 

205 'n_points': SLICE_EXTRACTION_N_POINTS, 

206 } 

207 slice_dataset = Dataset( 

208 name=f"Slice ({source_dataset.name})", 

209 df=df, x_col_name='x', y_col_name='y', 

210 provenance=build_provenance('2d_slice', params, [source_dataset]), 

211 ) 

212 

213 original_item = self._app._get_dataset_tree_item(source_dataset) 

214 folder = original_item.parent() if original_item else None 

215 

216 def add(): 

217 self._app.dataset_order.append(slice_dataset, folder) 

218 self._app._update_plot() 

219 

220 self._app._push_dataset_additions(add, [slice_dataset], "スライスの抽出") 

221 self._app.statusBar().showMessage( 

222 f"「{source_dataset.name}」からスライスを抽出しました(X軸: {x_label})", 4000 

223 )