Coverage for graphica/gui/tools/cursor.py: 91.4%

189 statements  

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

1"""データカーソル(点をクリックして値を見る)と、グラフの要素のクリックでの選択。表示範囲の操作は view_navigation.py。""" 

2import logging 

3import numpy as np 

4from matplotlib.patches import Rectangle 

5 

6from graphica.gui import theme 

7from graphica.gui.shell import bottom_panel 

8from graphica.gui.ui_mode import ADVANCED 

9 

10logger = logging.getLogger(__name__) 

11 

12 

13# ステータスバーの座標(見本: 「X = 7.42 Y = 40.18」。アドバンスドだけ出る) 

14COORDINATE_EMPTY_TEXT = "X = --- Y = ---" 

15 

16 

17def coordinate_text(x, y, prefix=""): 

18 """prefix はどのサブプロットか(「P1 · 」など)。""" 

19 return f"{prefix}X = {x:.4g} Y = {y:.4g}" 

20 

21 

22def cursor_label_text(dataset, x, y, z=None, row_number=None, mode=ADVANCED): 

23 """データカーソルの吹き出しの文字: 「時間_s 7.5 · 温度_degC 41.12」。 

24 

25 アドバンスドは先頭に行番号(データエディタの行と同じ、1始まり)を付ける。データセットが分からなければ X / Y。 

26 """ 

27 x_name = getattr(dataset, 'x_col_name', None) or "X" 

28 y_name = getattr(dataset, 'y_col_name', None) or "Y" 

29 parts = [f"{x_name} {x:.4g}", f"{y_name} {y:.4g}"] 

30 if z is not None: 

31 parts.append(f"Z {z:.4g}") 

32 if mode == ADVANCED and row_number is not None: 

33 parts.insert(0, f"#{row_number}") 

34 return " · ".join(parts) 

35 

36 

37class CursorTool: 

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

39 

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

41 EXPOSED_NAMES = ( 

42 '_on_element_pick', '_on_mouse_move', '_on_pick', '_toggle_cursor_mode', 

43 'cursor_annotation', 'cursor_connection_id', 'cursor_mode_enabled', 

44 ) 

45 

46 def __init__(self, app): 

47 self._app = app 

48 # マウス操作の各モードの状態(接続 ID は切るときに使う) 

49 self.cursor_mode_enabled = False 

50 self.cursor_connection_id = None 

51 self.cursor_annotation = None 

52 

53 def _toggle_cursor_mode(self, checked: bool): 

54 self.cursor_mode_enabled = checked 

55 

56 if checked: 

57 self._app._deactivate_other_mouse_modes('cursor') 

58 

59 logger.debug("データカーソルモード ON") 

60 self._app.coordinate_label.setText("クリックしてデータを選択") 

61 

62 self.cursor_connection_id = self._app.canvas.mpl_connect( 

63 'pick_event', self._on_pick 

64 ) 

65 

66 # 平滑化の曲線は元のデータの点と対応しないので、ピックできるようにしない 

67 non_pickable_artists = { 

68 ds.artist for ds in self._app.project.datasets 

69 if ds.dataset_id in self._app.canvas._non_pickable_dataset_ids and ds.artist is not None 

70 } 

71 all_valid_axes = [ax for ax in self._app.all_axes + self._app.all_secondary_axes if ax is not None] 

72 for ax in all_valid_axes: 

73 for item in ax.get_lines() + ax.collections: 

74 if item in non_pickable_artists: 

75 continue 

76 try: 

77 item.set_picker(5) 

78 except AttributeError: 

79 logger.warning("オブジェクト %s は set_picker をサポートしていません。", item) 

80 else: 

81 logger.debug("データカーソルモード OFF") 

82 self._app.coordinate_label.setText(COORDINATE_EMPTY_TEXT) 

83 

84 if self.cursor_connection_id: 

85 self._app.canvas.mpl_disconnect(self.cursor_connection_id) 

86 self.cursor_connection_id = None 

87 

88 # set_picker(False) には戻さない。同じ Artist のピックはクリックでの選択(常に有効)も使っている 

89 if self.cursor_annotation: 

90 self._remove_cursor_annotation() 

91 self._app.canvas.draw_idle() 

92 

93 def _remove_cursor_annotation(self): 

94 annotation, self.cursor_annotation = self.cursor_annotation, None 

95 if annotation is None: 

96 return 

97 # 描き直しで Axes ごと消えた注釈は remove() できない 

98 try: 

99 annotation.remove() 

100 except (NotImplementedError, ValueError): 

101 pass 

102 

103 def _on_mouse_move(self, event): 

104 if event.inaxes: 

105 ax = event.inaxes 

106 x, y = event.xdata, event.ydata 

107 

108 try: 

109 ax_index = self._app.all_axes.index(ax) 

110 ax_label = f"P{ax_index+1} · " 

111 except ValueError: 

112 # 第2軸は all_axes に無い 

113 try: 

114 sec_ax_index = self._app.all_secondary_axes.index(ax) 

115 ax_label = f"P{sec_ax_index+1}(Y2) · " 

116 except ValueError: 

117 ax_label = "? · " 

118 

119 self._app.coordinate_label.setText( 

120 coordinate_text(x, y, ax_label) + self._current_dataset_readout(event) 

121 ) 

122 

123 else: 

124 if not self.cursor_mode_enabled: 

125 self._app.coordinate_label.setText(COORDINATE_EMPTY_TEXT) 

126 

127 def _current_dataset_readout(self, event): 

128 """選んでいるデータセットが、マウスのあるサブプロットにずらして(ウォーターフォールで)描かれていれば、 

129 マウス位置をその系列のデータ座標に戻した値。ずらし方は系列ごとに違うので、選んでいる系列についてだけ出す。""" 

130 dataset = self._app._get_current_dataset() 

131 if dataset is None or self._app.canvas.get_waterfall_transform(dataset) is None: 

132 return "" 

133 dataset_ax = getattr(dataset.artist, 'axes', None) 

134 navigation = self._app.view_navigation 

135 if dataset_ax is None or navigation.subplot_index_of(dataset_ax) != navigation.subplot_index_of(event.inaxes): 135 ↛ 136line 135 didn't jump to line 136 because the condition on line 135 was never true

136 return "" 

137 # 第2軸のあるサブプロットではマウスの下の Axes が系列の軸と違うことがあるので、ピクセルから系列の軸で読む 

138 x, y = dataset_ax.transData.inverted().transform((event.x, event.y)) 

139 data_x, data_y = self._app.canvas.display_to_data(dataset, x, y) 

140 return f" | 「{dataset.name}」 " + coordinate_text(data_x, data_y) 

141 

142 def _on_element_pick(self, event): 

143 """グラフの系列やタイトルをクリックして選ぶ(常に有効)。""" 

144 # 注釈モードと自由配置の編集モードでは、クリックはそちらの操作に使う 

145 if getattr(self._app, 'annotation_mode_enabled', False): 

146 return 

147 if getattr(self._app, 'layout_edit_mode_enabled', False): 

148 return 

149 

150 artist = event.artist 

151 

152 # タイトルなら、そのサブプロットを編集対象にする 

153 for ax_index, ax in enumerate(self._app.all_axes): 

154 if artist is ax.title: 

155 if self._app.active_axis_combo.currentIndex() != ax_index: 

156 self._app.active_axis_combo.setCurrentIndex(ax_index) 

157 return 

158 

159 # 系列なら、そのデータセットを一覧で選ぶ。Bar は BarContainer なので、patches に含まれるかで見る 

160 owning_dataset = next( 

161 (ds for ds in self._app.project.datasets 

162 if ds.artist is artist or (hasattr(ds.artist, 'patches') and artist in ds.artist.patches)), 

163 None 

164 ) 

165 if owning_dataset is None: 

166 return 

167 

168 item = self._app._get_dataset_tree_item(owning_dataset) 

169 if item is not None and self._app.ui.dataset_list_widget.currentItem() is not item: 169 ↛ 170line 169 didn't jump to line 170 because the condition on line 169 was never true

170 self._app.ui.dataset_list_widget.setCurrentItem(item) 

171 

172 def _owning_dataset(self, artist): 

173 """artist を描いたデータセット。棒グラフは1本ずつの Rectangle が拾われるので、BarContainer の中も見る。""" 

174 return next( 

175 (ds for ds in self._app.project.datasets 

176 if ds.artist is artist or (hasattr(ds.artist, 'patches') and artist in ds.artist.patches)), 

177 None 

178 ) 

179 

180 def _picked_point(self, artist, event): 

181 """(データセット, 描いた点の番号か None, 表示座標の x, y, 2D マップの値か None)。読めなければ None。""" 

182 mouse = event.mouseevent 

183 ax = getattr(artist, 'axes', None) 

184 if ax is None or mouse.x is None or mouse.y is None: 

185 return None 

186 dataset = self._owning_dataset(artist) 

187 

188 if dataset is not None and dataset.data_kind == '2d_grid': 

189 return self._picked_grid_value(dataset, ax, mouse) 

190 if isinstance(artist, Rectangle): 

191 # 棒は頂上の値を読む(負の値の棒は高さが負なので、y + 高さが値になる) 

192 ind = dataset.artist.patches.index(artist) if dataset is not None else None 

193 return dataset, ind, artist.get_x() + artist.get_width() / 2, artist.get_y() + artist.get_height(), None 

194 if hasattr(artist, 'get_xydata'): 

195 # 単位変換後の数値なので、カテゴリ軸・日付軸の線でも引き算できる 

196 points = np.asarray(artist.get_xydata(), dtype=float) 

197 candidates = np.arange(len(points)) 

198 elif hasattr(artist, 'get_offsets') and len(event.ind) > 0: 198 ↛ 202line 198 didn't jump to line 202 because the condition on line 198 was always true

199 points = np.asarray(artist.get_offsets(), dtype=float) 

200 candidates = np.asarray(event.ind) 

201 else: 

202 return None 

203 

204 ind = _nearest_in_pixels(ax, points, candidates, (mouse.x, mouse.y)) 

205 if ind is None: 205 ↛ 206line 205 didn't jump to line 206 because the condition on line 205 was never true

206 return None 

207 return dataset, ind, points[ind][0], points[ind][1], None 

208 

209 def _picked_row_number(self, dataset, ind): 

210 """クリックした点の、元の表での行の番号(1始まり)。分からなければ None。 

211 

212 ind は描いた点(除外した行を除き、間引いていれば間引いた後)の位置なので、元の行まで戻す。 

213 """ 

214 if dataset is None or ind is None: 

215 return None 

216 try: 

217 index_map = self._app.canvas.downsample_index_map.get(dataset.dataset_id) 

218 if index_map is not None: 

219 ind = index_map[ind] 

220 master_index = dataset.visible_df.index[ind] 

221 return int(dataset.df.index.get_loc(master_index)) + 1 

222 except (IndexError, KeyError, TypeError): 

223 return None 

224 

225 @staticmethod 

226 def _picked_grid_value(dataset, ax, mouse): 

227 """2D マップは、クリックした位置にいちばん近い格子点の値を読む。""" 

228 grid = dataset.z_grid 

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

230 return None 

231 x, y = ax.transData.inverted().transform((mouse.x, mouse.y)) 

232 x_grid = np.asarray(grid['x_grid'], dtype=float) 

233 y_grid = np.asarray(grid['y_grid'], dtype=float) 

234 if x_grid.size == 0 or y_grid.size == 0: 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true

235 return None 

236 col = int(np.argmin(np.abs(x_grid - x))) 

237 row = int(np.argmin(np.abs(y_grid - y))) 

238 return dataset, None, x_grid[col], y_grid[row], grid['z_grid'][row, col] 

239 

240 def _on_pick(self, event): 

241 if not self.cursor_mode_enabled: return 

242 

243 artist = event.artist 

244 picked = self._picked_point(artist, event) 

245 if picked is None: 

246 return 

247 owning_dataset, ind, x, y, z = picked 

248 

249 if x is not None and y is not None: 249 ↛ exitline 249 didn't return from function '_on_pick' because the condition on line 249 was always true

250 # x, y はウォーターフォールのずらしが掛かった表示座標。矢印はそのままの位置に出し、 

251 # 表示する値だけデータ座標に戻す 

252 if owning_dataset is not None: 252 ↛ 255line 252 didn't jump to line 255 because the condition on line 252 was always true

253 data_x, data_y = self._app.canvas.display_to_data(owning_dataset, x, y) 

254 else: 

255 data_x, data_y = x, y 

256 

257 self._remove_cursor_annotation() 

258 

259 ax = artist.axes 

260 row_number = self._picked_row_number(owning_dataset, ind) 

261 text = cursor_label_text(owning_dataset, data_x, data_y, z, row_number, 

262 getattr(self._app, 'ui_mode', ADVANCED)) 

263 # 下のパネルのデータ表でも、その行に印を付ける 

264 bottom_panel.highlight_table_row(self._app, owning_dataset, row_number) 

265 tokens = theme.tokens_for(self._app.canvas.dark_mode, theme.current_theme_color()) 

266 self.cursor_annotation = ax.annotate(text, 

267 xy=(x, y), 

268 xytext=(10, 14), 

269 textcoords="offset points", 

270 color=tokens["text_primary"], 

271 fontsize=9, 

272 bbox=dict(boxstyle="round,pad=0.45,rounding_size=0.5", fc=tokens["surface"], 

273 ec=tokens["border_strong"], lw=0.8), 

274 arrowprops=dict(arrowstyle="-", color=tokens["accent"], lw=1.0) 

275 ) 

276 

277 self._app.canvas.draw_idle() 

278 

279 # データエディタが開いていれば、クリックした点の行を表でも選ぶ 

280 if ind is not None and self._app.data_editor_dialog is not None: 

281 if owning_dataset is not None and self._app.data_editor_dialog.dataset is owning_dataset: 

282 try: 

283 # ind は描いた点(visible_df、間引いていれば間引いた後)の位置。 

284 # 間引いた系列は downsample_index_map で元の位置に戻してから visible_df.index を引く 

285 index_map = self._app.canvas.downsample_index_map.get(owning_dataset.dataset_id) 

286 if index_map is not None: 

287 ind = index_map[ind] 

288 master_index = owning_dataset.visible_df.index[ind] 

289 # 選択の通知は止めてあるので、グラフ側の強調はここで更新する 

290 self._app.data_editor_dialog.select_row_by_master_index(master_index) 

291 self._app.canvas.set_highlighted_points( 

292 owning_dataset, self._app.data_editor_dialog.get_selected_master_indices() 

293 ) 

294 except IndexError: 

295 pass 

296 

297 

298def _nearest_in_pixels(ax, points, candidates, mouse_px): 

299 """candidates(points の番号)のうち、画面上でマウスにいちばん近いもの。 

300 

301 データ座標の距離だと、X と Y の桁が違う(例えば 0〜1000 と 0〜1)とほぼ X だけで決まってしまう。 

302 """ 

303 if len(candidates) == 0: 303 ↛ 304line 303 didn't jump to line 304 because the condition on line 303 was never true

304 return None 

305 pixels = ax.transData.transform(points[candidates]) 

306 distances = np.hypot(pixels[:, 0] - mouse_px[0], pixels[:, 1] - mouse_px[1]) 

307 if np.all(np.isnan(distances)): 307 ↛ 308line 307 didn't jump to line 308 because the condition on line 307 was never true

308 return None 

309 return int(candidates[np.nanargmin(distances)])