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
« 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
6from graphica.gui import theme
7from graphica.gui.shell import bottom_panel
8from graphica.gui.ui_mode import ADVANCED
10logger = logging.getLogger(__name__)
13# ステータスバーの座標(見本: 「X = 7.42 Y = 40.18」。アドバンスドだけ出る)
14COORDINATE_EMPTY_TEXT = "X = --- Y = ---"
17def coordinate_text(x, y, prefix=""):
18 """prefix はどのサブプロットか(「P1 · 」など)。"""
19 return f"{prefix}X = {x:.4g} Y = {y:.4g}"
22def cursor_label_text(dataset, x, y, z=None, row_number=None, mode=ADVANCED):
23 """データカーソルの吹き出しの文字: 「時間_s 7.5 · 温度_degC 41.12」。
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)
37class CursorTool:
38 """状態はこのツールが持ち、PlotterApp の同じ名前は窓口(gui/tools/__init__.py)。"""
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 )
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
53 def _toggle_cursor_mode(self, checked: bool):
54 self.cursor_mode_enabled = checked
56 if checked:
57 self._app._deactivate_other_mouse_modes('cursor')
59 logger.debug("データカーソルモード ON")
60 self._app.coordinate_label.setText("クリックしてデータを選択")
62 self.cursor_connection_id = self._app.canvas.mpl_connect(
63 'pick_event', self._on_pick
64 )
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)
84 if self.cursor_connection_id:
85 self._app.canvas.mpl_disconnect(self.cursor_connection_id)
86 self.cursor_connection_id = None
88 # set_picker(False) には戻さない。同じ Artist のピックはクリックでの選択(常に有効)も使っている
89 if self.cursor_annotation:
90 self._remove_cursor_annotation()
91 self._app.canvas.draw_idle()
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
103 def _on_mouse_move(self, event):
104 if event.inaxes:
105 ax = event.inaxes
106 x, y = event.xdata, event.ydata
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 = "? · "
119 self._app.coordinate_label.setText(
120 coordinate_text(x, y, ax_label) + self._current_dataset_readout(event)
121 )
123 else:
124 if not self.cursor_mode_enabled:
125 self._app.coordinate_label.setText(COORDINATE_EMPTY_TEXT)
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)
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
150 artist = event.artist
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
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
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)
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 )
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)
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
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
209 def _picked_row_number(self, dataset, ind):
210 """クリックした点の、元の表での行の番号(1始まり)。分からなければ None。
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
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]
240 def _on_pick(self, event):
241 if not self.cursor_mode_enabled: return
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
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
257 self._remove_cursor_annotation()
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 )
277 self._app.canvas.draw_idle()
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
298def _nearest_in_pixels(ax, points, candidates, mouse_px):
299 """candidates(points の番号)のうち、画面上でマウスにいちばん近いもの。
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)])