Coverage for graphica/gui/tools/peak_placement.py: 92.7%
81 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"""ピーク配置モード: 左クリックで多峰分離の初期値を置き、右クリックで近いものを消す。
3_pending_peak_guesses は [{'center', 'height', 'width'}, ...] で、MultiPeakFitDialog の initial_guesses にそのまま渡す。
4ダイアログを閉じたら(OK でもキャンセルでも)消す。仮のマーカーは redraw_all() で消えていることがあるので、
5消すときの例外は無視する。
6"""
7import logging
9from graphica.gui.tools.pointer import legend_at
11logger = logging.getLogger(__name__)
13# 仮の幅は表示中の X の範囲に対する割合(ダイアログで直せるので大まかでよい)
14PEAK_PLACEMENT_DEFAULT_WIDTH_FRACTION = 0.05
15PEAK_PLACEMENT_DELETE_TOLERANCE_PX = 15
18class PeakPlacementTool:
19 """状態はこのツールが持ち、PlotterApp の同じ名前は窓口(gui/tools/__init__.py)。"""
21 # PlotterApp から同じ名前で読み書きできるもの(テスト・メニュー・ほかの mixin が使う)
22 EXPOSED_NAMES = (
23 '_clear_pending_peak_guesses', '_draw_pending_peak_marker', '_on_peak_placement_press',
24 '_peak_placement_press_cid', '_pending_peak_guesses', '_pending_peak_markers',
25 '_remove_nearest_pending_peak_guess', '_toggle_peak_placement_mode', 'peak_placement_mode_enabled',
26 )
28 def __init__(self, app):
29 self._app = app
30 self.peak_placement_mode_enabled = False
31 self._peak_placement_press_cid = None
32 self._pending_peak_guesses = [] # [{'center':, 'height':, 'width':}, ...]
33 self._pending_peak_markers = [] # [(guess, axvline, plot point), ...]
35 def _toggle_peak_placement_mode(self, checked):
36 self.peak_placement_mode_enabled = checked
38 if checked:
39 self._app._deactivate_other_mouse_modes('peak_placement')
41 self._peak_placement_press_cid = self._app.canvas.mpl_connect(
42 'button_press_event', self._on_peak_placement_press
43 )
44 self._app.statusBar().showMessage(
45 "ピーク配置モード: クリックで多峰分離フィットの初期値(中心・高さ)を追加、"
46 "右クリックで直近のマーカーを削除します", 5000
47 )
48 else:
49 if getattr(self, '_peak_placement_press_cid', None) is not None:
50 self._app.canvas.mpl_disconnect(self._peak_placement_press_cid)
51 self._peak_placement_press_cid = None
53 def _on_peak_placement_press(self, event):
54 if not getattr(self, 'peak_placement_mode_enabled', False):
55 return
56 if event.inaxes is None or event.xdata is None or event.ydata is None:
57 return
58 if legend_at(self._app.canvas, event) is not None:
59 return
61 if event.button == 3: # 右クリックは近いマーカーを消す
62 self._remove_nearest_pending_peak_guess(event)
63 return
64 if event.button != 1: 64 ↛ 65line 64 didn't jump to line 65 because the condition on line 64 was never true
65 return
67 ax = event.inaxes
68 x_min, x_max = ax.get_xlim()
69 # X のずらしは平行移動なので、幅は表示座標でもデータ座標でも同じ
70 width = abs(x_max - x_min) * PEAK_PLACEMENT_DEFAULT_WIDTH_FRACTION or 1.0
72 # 初期値はフィットでデータ座標と突き合わされるので、クリック位置(表示座標)をデータ座標に戻す。
73 # マーカーはクリックした場所に出す
74 dataset = self._app._get_current_dataset()
75 data_x, data_y = self._app.canvas.display_to_data(
76 dataset, float(event.xdata), float(event.ydata)
77 ) if dataset is not None else (float(event.xdata), float(event.ydata))
79 guess = {'center': float(data_x), 'height': float(data_y), 'width': float(width)}
80 self._pending_peak_guesses.append(guess)
81 self._draw_pending_peak_marker(ax, guess, float(event.xdata), float(event.ydata))
82 self._app.statusBar().showMessage(
83 f"ピーク初期値を追加しました({len(self._pending_peak_guesses)}件、"
84 f"X={guess['center']:.4g}, Y={guess['height']:.4g})", 3000
85 )
87 def _draw_pending_peak_marker(self, ax, guess, display_x, display_y):
88 """マーカーは表示座標に描く(guess はデータ座標で持つ)。"""
89 line = ax.axvline(display_x, color='#E4572E', linestyle=':', linewidth=1, zorder=100)
90 point, = ax.plot(
91 [display_x], [display_y],
92 marker='x', color='#E4572E', markersize=8, zorder=101, linestyle='None',
93 )
94 self._pending_peak_markers.append((guess, line, point))
95 self._app.canvas.draw_idle()
97 def _remove_nearest_pending_peak_guess(self, event):
98 if not self._pending_peak_guesses:
99 return
101 ax = event.inaxes
102 click_px = ax.transData.transform((event.xdata, event.ydata))
104 best_i, best_distance = None, None
105 for i, (_guess, _line, point) in enumerate(self._pending_peak_markers):
106 # guess はデータ座標なので、描いたマーカーの位置(表示座標)で比べる
107 marker_x = point.get_xdata()[0]
108 marker_y = point.get_ydata()[0]
109 pos_px = ax.transData.transform((marker_x, marker_y))
110 distance = ((pos_px[0] - click_px[0]) ** 2 + (pos_px[1] - click_px[1]) ** 2) ** 0.5
111 if best_distance is None or distance < best_distance:
112 best_distance, best_i = distance, i
114 if best_i is None or best_distance > PEAK_PLACEMENT_DELETE_TOLERANCE_PX: 114 ↛ 115line 114 didn't jump to line 115 because the condition on line 114 was never true
115 return
117 guess, line, point = self._pending_peak_markers.pop(best_i)
118 self._pending_peak_guesses.remove(guess)
119 for artist in (line, point):
120 try:
121 artist.remove()
122 except (ValueError, NotImplementedError):
123 pass
124 self._app.canvas.draw_idle()
126 def _clear_pending_peak_guesses(self):
127 """ダイアログを閉じたら(OK でもキャンセルでも)仮のマーカーごと消す。"""
128 for _guess, line, point in self._pending_peak_markers:
129 for artist in (line, point):
130 try:
131 artist.remove()
132 except (ValueError, NotImplementedError):
133 pass
134 self._pending_peak_markers = []
135 self._pending_peak_guesses = []
136 self._app.canvas.draw_idle()