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

1"""ピーク配置モード: 左クリックで多峰分離の初期値を置き、右クリックで近いものを消す。 

2 

3_pending_peak_guesses は [{'center', 'height', 'width'}, ...] で、MultiPeakFitDialog の initial_guesses にそのまま渡す。 

4ダイアログを閉じたら(OK でもキャンセルでも)消す。仮のマーカーは redraw_all() で消えていることがあるので、 

5消すときの例外は無視する。 

6""" 

7import logging 

8 

9from graphica.gui.tools.pointer import legend_at 

10 

11logger = logging.getLogger(__name__) 

12 

13# 仮の幅は表示中の X の範囲に対する割合(ダイアログで直せるので大まかでよい) 

14PEAK_PLACEMENT_DEFAULT_WIDTH_FRACTION = 0.05 

15PEAK_PLACEMENT_DELETE_TOLERANCE_PX = 15 

16 

17 

18class PeakPlacementTool: 

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

20 

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 ) 

27 

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), ...] 

34 

35 def _toggle_peak_placement_mode(self, checked): 

36 self.peak_placement_mode_enabled = checked 

37 

38 if checked: 

39 self._app._deactivate_other_mouse_modes('peak_placement') 

40 

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 

52 

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 

60 

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 

66 

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 

71 

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)) 

78 

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 ) 

86 

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() 

96 

97 def _remove_nearest_pending_peak_guess(self, event): 

98 if not self._pending_peak_guesses: 

99 return 

100 

101 ax = event.inaxes 

102 click_px = ax.transData.transform((event.xdata, event.ydata)) 

103 

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 

113 

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 

116 

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() 

125 

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()