graphica-plot 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. graphica/Graphica.ico +0 -0
  2. graphica/__init__.py +1 -0
  3. graphica/__main__.py +111 -0
  4. graphica/assets/__init__.py +0 -0
  5. graphica/assets/icons/__init__.py +0 -0
  6. graphica/assets/icons/arrow-right.svg +21 -0
  7. graphica/assets/icons/bold.svg +20 -0
  8. graphica/assets/icons/calculator.svg +26 -0
  9. graphica/assets/icons/chart-histogram.svg +24 -0
  10. graphica/assets/icons/chart-line.svg +20 -0
  11. graphica/assets/icons/chevron-down.svg +19 -0
  12. graphica/assets/icons/chevron-right.svg +19 -0
  13. graphica/assets/icons/color-swatch.svg +22 -0
  14. graphica/assets/icons/column-insert-right.svg +21 -0
  15. graphica/assets/icons/column-remove.svg +21 -0
  16. graphica/assets/icons/copy.svg +20 -0
  17. graphica/assets/icons/download.svg +21 -0
  18. graphica/assets/icons/edit.svg +21 -0
  19. graphica/assets/icons/eye-off.svg +21 -0
  20. graphica/assets/icons/eye.svg +20 -0
  21. graphica/assets/icons/file-plus.svg +22 -0
  22. graphica/assets/icons/folder-plus.svg +21 -0
  23. graphica/assets/icons/folder.svg +19 -0
  24. graphica/assets/icons/highlight.svg +22 -0
  25. graphica/assets/icons/history.svg +20 -0
  26. graphica/assets/icons/italic.svg +21 -0
  27. graphica/assets/icons/layout-grid.svg +22 -0
  28. graphica/assets/icons/math-function.svg +22 -0
  29. graphica/assets/icons/message-2.svg +21 -0
  30. graphica/assets/icons/mountain.svg +20 -0
  31. graphica/assets/icons/palette.svg +22 -0
  32. graphica/assets/icons/pointer.svg +19 -0
  33. graphica/assets/icons/refresh.svg +20 -0
  34. graphica/assets/icons/row-insert-bottom.svg +21 -0
  35. graphica/assets/icons/row-remove.svg +21 -0
  36. graphica/assets/icons/search.svg +20 -0
  37. graphica/assets/icons/select-all.svg +35 -0
  38. graphica/assets/icons/subscript.svg +20 -0
  39. graphica/assets/icons/superscript.svg +20 -0
  40. graphica/assets/icons/table.svg +21 -0
  41. graphica/assets/icons/trash.svg +23 -0
  42. graphica/assets/icons/typography.svg +23 -0
  43. graphica/assets/icons/x.svg +20 -0
  44. graphica/core/__init__.py +0 -0
  45. graphica/core/analysis.py +1278 -0
  46. graphica/core/app_paths.py +35 -0
  47. graphica/core/axis_settings.py +128 -0
  48. graphica/core/caption_export.py +47 -0
  49. graphica/core/color_palettes.py +56 -0
  50. graphica/core/commands.py +226 -0
  51. graphica/core/cvd_simulation.py +49 -0
  52. graphica/core/dataset.py +443 -0
  53. graphica/core/diagnostics.py +105 -0
  54. graphica/core/excel_utils.py +58 -0
  55. graphica/core/fit_models.py +379 -0
  56. graphica/core/grid_data.py +128 -0
  57. graphica/core/i18n.py +44 -0
  58. graphica/core/json_utils.py +27 -0
  59. graphica/core/label_utils.py +21 -0
  60. graphica/core/methods_text.py +115 -0
  61. graphica/core/named_colors.py +126 -0
  62. graphica/core/plugin_api.py +515 -0
  63. graphica/core/plugin_context.py +167 -0
  64. graphica/core/plugin_install.py +82 -0
  65. graphica/core/plugin_manifest.py +67 -0
  66. graphica/core/plugin_testing.py +213 -0
  67. graphica/core/plugin_types.py +118 -0
  68. graphica/core/provenance.py +19 -0
  69. graphica/core/report_export.py +51 -0
  70. graphica/core/safe_eval.py +194 -0
  71. graphica/core/script_export.py +272 -0
  72. graphica/core/translations_en.py +530 -0
  73. graphica/core/unit_conversion.py +51 -0
  74. graphica/core/update_check.py +43 -0
  75. graphica/core/version.py +14 -0
  76. graphica/gui/__init__.py +0 -0
  77. graphica/gui/app_settings.py +119 -0
  78. graphica/gui/axis_bindings.py +173 -0
  79. graphica/gui/binding.py +135 -0
  80. graphica/gui/builders/__init__.py +1 -0
  81. graphica/gui/builders/axis_panel.py +431 -0
  82. graphica/gui/builders/canvas_area.py +176 -0
  83. graphica/gui/builders/common.py +120 -0
  84. graphica/gui/builders/dataset_panel.py +433 -0
  85. graphica/gui/builders/property_sections.py +208 -0
  86. graphica/gui/canvas.py +519 -0
  87. graphica/gui/color_history.py +47 -0
  88. graphica/gui/color_picker_widget.py +198 -0
  89. graphica/gui/crash_handler.py +77 -0
  90. graphica/gui/cvd_preview.py +23 -0
  91. graphica/gui/data_editor.py +761 -0
  92. graphica/gui/data_import_flow.py +355 -0
  93. graphica/gui/dataset_bindings.py +107 -0
  94. graphica/gui/dataset_style_icon.py +80 -0
  95. graphica/gui/datasets/__init__.py +1 -0
  96. graphica/gui/datasets/actions_menu.py +114 -0
  97. graphica/gui/datasets/colors.py +218 -0
  98. graphica/gui/datasets/fitting.py +70 -0
  99. graphica/gui/datasets/host.py +162 -0
  100. graphica/gui/datasets/operations/__init__.py +1 -0
  101. graphica/gui/datasets/operations/fitting.py +468 -0
  102. graphica/gui/datasets/operations/peaks.py +109 -0
  103. graphica/gui/datasets/operations/processing.py +659 -0
  104. graphica/gui/datasets/operations/runner.py +148 -0
  105. graphica/gui/datasets/operations/transfer.py +227 -0
  106. graphica/gui/datasets/order.py +310 -0
  107. graphica/gui/datasets/overlays.py +74 -0
  108. graphica/gui/datasets/peaks.py +21 -0
  109. graphica/gui/datasets/plugin_runs.py +99 -0
  110. graphica/gui/datasets/processing.py +68 -0
  111. graphica/gui/datasets/property_panel.py +538 -0
  112. graphica/gui/datasets/transfer.py +36 -0
  113. graphica/gui/detached_canvas_window.py +22 -0
  114. graphica/gui/dialogs/__init__.py +137 -0
  115. graphica/gui/dialogs/analysis.py +1322 -0
  116. graphica/gui/dialogs/app.py +1015 -0
  117. graphica/gui/dialogs/appearance.py +851 -0
  118. graphica/gui/dialogs/data_edit.py +577 -0
  119. graphica/gui/dialogs/data_import.py +679 -0
  120. graphica/gui/dialogs/export.py +433 -0
  121. graphica/gui/dock_layout.py +176 -0
  122. graphica/gui/export_preview_panel.py +321 -0
  123. graphica/gui/export_settings.py +16 -0
  124. graphica/gui/file_association.py +97 -0
  125. graphica/gui/icon_utils.py +44 -0
  126. graphica/gui/main_app_window.py +210 -0
  127. graphica/gui/main_window.py +1151 -0
  128. graphica/gui/mathtext_preview.py +168 -0
  129. graphica/gui/menu_bar.py +320 -0
  130. graphica/gui/minimap_widget.py +151 -0
  131. graphica/gui/mixins/__init__.py +0 -0
  132. graphica/gui/mixins/export_mixin.py +520 -0
  133. graphica/gui/mixins/help_mixin.py +127 -0
  134. graphica/gui/mixins/project_io_mixin.py +289 -0
  135. graphica/gui/mixins/quick_access_mixin.py +194 -0
  136. graphica/gui/mixins/ui_setup_mixin.py +206 -0
  137. graphica/gui/notify.py +61 -0
  138. graphica/gui/panels/__init__.py +43 -0
  139. graphica/gui/panels/axis_settings.py +605 -0
  140. graphica/gui/panels/dataset_tree.py +424 -0
  141. graphica/gui/plot_type_drawers.py +131 -0
  142. graphica/gui/plugin_context.py +119 -0
  143. graphica/gui/project_files.py +361 -0
  144. graphica/gui/provenance_panel.py +65 -0
  145. graphica/gui/rendering/__init__.py +1 -0
  146. graphica/gui/rendering/annotations.py +177 -0
  147. graphica/gui/rendering/appearance.py +368 -0
  148. graphica/gui/rendering/common.py +316 -0
  149. graphica/gui/rendering/data_1d.py +376 -0
  150. graphica/gui/rendering/data_2d.py +66 -0
  151. graphica/gui/residual_panel.py +62 -0
  152. graphica/gui/resources.py +16 -0
  153. graphica/gui/single_instance.py +141 -0
  154. graphica/gui/splash.py +83 -0
  155. graphica/gui/task_runner.py +41 -0
  156. graphica/gui/theme.py +864 -0
  157. graphica/gui/tools/__init__.py +56 -0
  158. graphica/gui/tools/annotation.py +275 -0
  159. graphica/gui/tools/cursor.py +259 -0
  160. graphica/gui/tools/layout_edit.py +285 -0
  161. graphica/gui/tools/manager.py +78 -0
  162. graphica/gui/tools/peak_placement.py +132 -0
  163. graphica/gui/tools/range_select.py +191 -0
  164. graphica/gui/tools/region_highlight.py +222 -0
  165. graphica/gui/tools/slice_extraction.py +200 -0
  166. graphica/gui/widget_translation.py +46 -0
  167. graphica/gui/workers.py +280 -0
  168. graphica/models/__init__.py +0 -0
  169. graphica/models/project.py +197 -0
  170. graphica/plugin/__init__.py +20 -0
  171. graphica/plugin/testing.py +57 -0
  172. graphica/sample_data/__init__.py +0 -0
  173. graphica/sample_data/cooling_curve_sample.csv +42 -0
  174. graphica/ui_main_window.py +581 -0
  175. graphica_plot-2.0.0.dist-info/METADATA +317 -0
  176. graphica_plot-2.0.0.dist-info/RECORD +181 -0
  177. graphica_plot-2.0.0.dist-info/WHEEL +5 -0
  178. graphica_plot-2.0.0.dist-info/entry_points.txt +2 -0
  179. graphica_plot-2.0.0.dist-info/licenses/LICENSE +30 -0
  180. graphica_plot-2.0.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +91 -0
  181. graphica_plot-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,285 @@
1
+ """自由配置のレイアウト: サブプロットをドラッグか数値で好きな位置と大きさに置く。
2
+
3
+ 位置は all_plot_settings[i]['free_rect'] に (left, bottom, width, height)(Figure に対する 0〜1)で持つ。
4
+ ドラッグも数値の入力も、ax.set_position() と free_rect への書き込みという同じ経路を通す(食い違わないように)。
5
+ """
6
+ import logging
7
+ from graphica.core.axis_settings import axis_setting
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ # 右下の角をつかんでいるとみなす距離
12
+ RESIZE_HANDLE_TOLERANCE_PX = 12
13
+ MIN_FREE_RECT_SIZE = 0.05
14
+
15
+
16
+ class LayoutEditTool:
17
+ """状態はこのツールが持ち、PlotterApp の同じ名前は窓口(gui/tools/__init__.py)。"""
18
+
19
+ # PlotterApp から同じ名前で読み書きできるもの(テスト・メニュー・ほかの mixin が使う)
20
+ EXPOSED_NAMES = (
21
+ '_find_axis_at_point', '_layout_drag_state', '_layout_edit_leave_cid', '_layout_edit_motion_cid',
22
+ '_layout_edit_press_cid', '_layout_edit_release_cid', '_layout_selected_axis_index', '_on_add_free_subplot',
23
+ '_on_free_layout_position_spinbox_changed', '_on_layout_motion', '_on_layout_press', '_on_layout_release',
24
+ '_on_remove_free_subplot', '_on_toggle_free_layout', '_sync_canvas_axes_state_and_side_panels',
25
+ '_sync_free_layout_position_controls', '_toggle_layout_edit_mode', 'layout_edit_mode_enabled',
26
+ )
27
+
28
+ def __init__(self, app):
29
+ self._app = app
30
+ self.layout_edit_mode_enabled = False
31
+ self._layout_edit_press_cid = None
32
+ self._layout_edit_motion_cid = None
33
+ self._layout_edit_release_cid = None
34
+ self._layout_edit_leave_cid = None # ドラッグ中に図の外へ出たとき用
35
+ self._layout_drag_state = None
36
+ # クリックで選んだ軸(ドラッグとは別)。位置と大きさの数値欄の対象
37
+ self._layout_selected_axis_index = None
38
+
39
+ def _on_toggle_free_layout(self, checked):
40
+ self._app.project.layout_mode = 'free' if checked else 'grid'
41
+
42
+ self._app.subplot_rows_spinbox.setEnabled(not checked)
43
+ self._app.subplot_cols_spinbox.setEnabled(not checked)
44
+ self._app.add_free_subplot_button.setEnabled(checked)
45
+ self._app.remove_free_subplot_button.setEnabled(checked)
46
+ self._app.layout_edit_action.setEnabled(checked)
47
+ # 自由配置には「同じ行・列」が無いので軸の共有は使えない(値は残し、グリッドに戻せばそのまま効く)
48
+ self._app.share_x_checkbox.setEnabled(not checked)
49
+ self._app.share_y_checkbox.setEnabled(not checked)
50
+
51
+ if not checked and self._app.layout_edit_action.isChecked():
52
+ self._app.layout_edit_action.setChecked(False)
53
+ self._toggle_layout_edit_mode(False)
54
+
55
+ if not checked:
56
+ self._layout_selected_axis_index = None
57
+ self._app.free_layout_position_group.setVisible(False)
58
+
59
+ if checked:
60
+ # 矩形をまだ持たないサブプロットには既定の矩形を割り当てる
61
+ for i, settings in enumerate(self._app.project.all_plot_settings):
62
+ if not axis_setting(settings, 'free_rect'):
63
+ settings['free_rect'] = self._app.canvas._default_free_rect(i)
64
+
65
+ self._app._update_plot()
66
+
67
+ def _on_add_free_subplot(self):
68
+ """空の Axes を1つ足すだけなので、全体を描き直さず canvas.add_free_axis() で足りる。"""
69
+ default_settings = self._app._gather_settings_from_ui()
70
+ new_settings = default_settings.copy()
71
+ # 注釈などは引き継がない。浅いコピーなので、差し替えないと全部で共有してしまう
72
+ new_settings['annotations'] = []
73
+ new_settings['legend_order'] = []
74
+ index = len(self._app.project.all_plot_settings)
75
+ new_settings['free_rect'] = self._app.canvas._default_free_rect(index)
76
+ self._app.project.all_plot_settings.append(new_settings)
77
+
78
+ self._app._update_subplot_combos()
79
+ self._app.canvas.add_free_axis(
80
+ self._app.project.datasets, new_settings, panel_labels_enabled=self._app.project.panel_labels_enabled,
81
+ )
82
+ self._sync_canvas_axes_state_and_side_panels()
83
+
84
+ def _on_remove_free_subplot(self):
85
+ """末尾のサブプロットを消す(最低1つは残す)。ほかの番号は変わらないので、全体を描き直さずに済む。"""
86
+ if len(self._app.project.all_plot_settings) <= 1:
87
+ return
88
+ self._app.project.all_plot_settings.pop()
89
+ new_total = len(self._app.project.all_plot_settings)
90
+ if self._app.project.active_axis_index >= new_total:
91
+ self._app.project.active_axis_index = new_total - 1
92
+
93
+ # 消えたサブプロットの系列はどこにも描かれなくなるので、新しい末尾に移す
94
+ for dataset in self._app.project.datasets:
95
+ if dataset.subplot_target >= new_total:
96
+ dataset.subplot_target = new_total - 1
97
+
98
+ self._app._update_subplot_combos()
99
+ self._app._apply_settings_to_ui_controls(self._app.project.all_plot_settings[self._app.project.active_axis_index])
100
+
101
+ self._app.canvas.remove_last_free_axis(self._app.project.datasets)
102
+ new_last_index = new_total - 1
103
+ self._app.canvas.update_single_axis(
104
+ new_last_index, self._app.project.datasets, self._app.project.all_plot_settings[new_last_index],
105
+ rows=0, cols=0, panel_labels_enabled=self._app.project.panel_labels_enabled,
106
+ )
107
+ self._sync_canvas_axes_state_and_side_panels()
108
+
109
+ if (self._layout_selected_axis_index is not None and
110
+ self._layout_selected_axis_index >= len(self._app.project.all_plot_settings)):
111
+ self._layout_selected_axis_index = None
112
+ self._sync_free_layout_position_controls()
113
+
114
+ def _sync_canvas_axes_state_and_side_panels(self):
115
+ """Axes を足したり消したりした後、_update_plot() がしている UI の同期を、全体を描き直さずに行う。"""
116
+ is_secondary_visible = any(sa is not None for sa in self._app.canvas.all_secondary_axes)
117
+ self._app.tick_direction_y2_label.setVisible(is_secondary_visible)
118
+ self._app.major_tick_direction_y2_combo.setVisible(is_secondary_visible)
119
+ self._app.minor_tick_direction_y2_combo.setVisible(is_secondary_visible)
120
+ self._app.y2_label_text_label.setVisible(is_secondary_visible)
121
+ self._app.y2_label_text_edit.setVisible(is_secondary_visible)
122
+
123
+ self._app.all_axes = self._app.canvas.all_axes
124
+ self._app.all_secondary_axes = self._app.canvas.all_secondary_axes
125
+
126
+ if hasattr(self._app, 'export_preview_panel'):
127
+ self._app.export_preview_panel.refresh_preview()
128
+ self._app._reapply_editor_row_highlight()
129
+ self._app._refresh_minimap()
130
+
131
+ def _toggle_layout_edit_mode(self, checked):
132
+ self.layout_edit_mode_enabled = checked
133
+
134
+ if checked:
135
+ self._app._deactivate_other_mouse_modes('layout_edit')
136
+
137
+ self._layout_edit_press_cid = self._app.canvas.mpl_connect('button_press_event', self._on_layout_press)
138
+ self._layout_edit_motion_cid = self._app.canvas.mpl_connect('motion_notify_event', self._on_layout_motion)
139
+ self._layout_edit_release_cid = self._app.canvas.mpl_connect('button_release_event', self._on_layout_release)
140
+ # 図の外でボタンを離すと button_release_event が届かず、ボタンを押していないのに動かし続けるので、
141
+ # 図から出た時点で離したことにする
142
+ self._layout_edit_leave_cid = self._app.canvas.mpl_connect('figure_leave_event', self._on_layout_release)
143
+ self._app.statusBar().showMessage(
144
+ "レイアウト編集モード: プロット内部をドラッグで移動、右下端をドラッグでリサイズします", 5000
145
+ )
146
+ else:
147
+ for cid_attr in ('_layout_edit_press_cid', '_layout_edit_motion_cid', '_layout_edit_release_cid',
148
+ '_layout_edit_leave_cid'):
149
+ cid = getattr(self, cid_attr, None)
150
+ if cid is not None:
151
+ self._app.canvas.mpl_disconnect(cid)
152
+ setattr(self, cid_attr, None)
153
+ self._layout_drag_state = None
154
+ self._layout_selected_axis_index = None
155
+ self._app.free_layout_position_group.setVisible(False)
156
+
157
+ def _find_axis_at_point(self, x_px, y_px):
158
+ """Figure 内のピクセル座標(原点は左下)にある軸の番号。無ければ None。"""
159
+ for index, ax in enumerate(self._app.canvas.all_axes):
160
+ bbox = ax.bbox
161
+ if bbox.x0 <= x_px <= bbox.x1 and bbox.y0 <= y_px <= bbox.y1:
162
+ return index
163
+ return None
164
+
165
+ def _on_layout_press(self, event):
166
+ if not self.layout_edit_mode_enabled or event.x is None or event.y is None:
167
+ return
168
+ axis_index = self._find_axis_at_point(event.x, event.y)
169
+ if axis_index is None:
170
+ self._layout_selected_axis_index = None
171
+ self._sync_free_layout_position_controls()
172
+ return
173
+
174
+ # ドラッグしなくても選択にする(数値の欄に今の矩形を出す)
175
+ self._layout_selected_axis_index = axis_index
176
+ self._sync_free_layout_position_controls()
177
+
178
+ ax = self._app.canvas.all_axes[axis_index]
179
+ bbox = ax.bbox
180
+ near_corner = (
181
+ abs(event.x - bbox.x1) <= RESIZE_HANDLE_TOLERANCE_PX and
182
+ abs(event.y - bbox.y0) <= RESIZE_HANDLE_TOLERANCE_PX
183
+ )
184
+ pos = ax.get_position()
185
+ self._layout_drag_state = {
186
+ 'axis_index': axis_index,
187
+ 'mode': 'resize' if near_corner else 'move',
188
+ 'start_mouse': (event.x, event.y),
189
+ 'start_rect': (pos.x0, pos.y0, pos.width, pos.height),
190
+ }
191
+
192
+ def _on_layout_motion(self, event):
193
+ if not self.layout_edit_mode_enabled or self._layout_drag_state is None:
194
+ return
195
+ if event.x is None or event.y is None:
196
+ return
197
+
198
+ state = self._layout_drag_state
199
+ fig_width_px, fig_height_px = self._app.canvas.fig.get_size_inches() * self._app.canvas.fig.dpi
200
+ if fig_width_px <= 0 or fig_height_px <= 0:
201
+ return
202
+
203
+ delta_x = (event.x - state['start_mouse'][0]) / fig_width_px
204
+ delta_y = (event.y - state['start_mouse'][1]) / fig_height_px
205
+ left, bottom, width, height = state['start_rect']
206
+
207
+ if state['mode'] == 'move':
208
+ new_left = left + delta_x
209
+ new_bottom = bottom + delta_y
210
+ new_width, new_height = width, height
211
+ else:
212
+ # 右下の角のドラッグは、左上を固定して伸縮する
213
+ new_width = max(MIN_FREE_RECT_SIZE, width + delta_x)
214
+ new_height = max(MIN_FREE_RECT_SIZE, height - delta_y)
215
+ new_left = left
216
+ new_bottom = bottom + height - new_height
217
+
218
+ ax = self._app.canvas.all_axes[state['axis_index']]
219
+ ax.set_position([new_left, new_bottom, new_width, new_height])
220
+ self._app.canvas.draw_idle()
221
+
222
+ self._sync_free_layout_position_controls()
223
+
224
+ def _on_layout_release(self, event):
225
+ """最終的な位置を設定に保存する。"""
226
+ if not self.layout_edit_mode_enabled or self._layout_drag_state is None:
227
+ return
228
+
229
+ axis_index = self._layout_drag_state['axis_index']
230
+ self._layout_drag_state = None
231
+
232
+ if axis_index >= len(self._app.canvas.all_axes) or axis_index >= len(self._app.project.all_plot_settings):
233
+ return
234
+ ax = self._app.canvas.all_axes[axis_index]
235
+ pos = ax.get_position()
236
+ self._app.project.all_plot_settings[axis_index]['free_rect'] = (pos.x0, pos.y0, pos.width, pos.height)
237
+
238
+ self._sync_free_layout_position_controls()
239
+
240
+ def _sync_free_layout_position_controls(self):
241
+ """選んだサブプロットの矩形を数値の欄に出す。自由配置でないか、選択が無ければ欄ごと隠す。
242
+
243
+ 値を入れる間は通知を止める(止めないと _on_free_layout_position_spinbox_changed が呼ばれて循環する)。
244
+ """
245
+ is_free_layout = getattr(self._app.project, 'layout_mode', 'grid') == 'free'
246
+ axis_index = self._layout_selected_axis_index
247
+
248
+ if (not is_free_layout or axis_index is None or
249
+ axis_index >= len(self._app.canvas.all_axes)):
250
+ self._app.free_layout_position_group.setVisible(False)
251
+ return
252
+
253
+ ax = self._app.canvas.all_axes[axis_index]
254
+ pos = ax.get_position()
255
+ for spinbox, value in (
256
+ (self._app.free_layout_x_spinbox, pos.x0),
257
+ (self._app.free_layout_y_spinbox, pos.y0),
258
+ (self._app.free_layout_width_spinbox, pos.width),
259
+ (self._app.free_layout_height_spinbox, pos.height),
260
+ ):
261
+ spinbox.blockSignals(True)
262
+ spinbox.setValue(value)
263
+ spinbox.blockSignals(False)
264
+ self._app.free_layout_position_group.setVisible(True)
265
+
266
+ def _on_free_layout_position_spinbox_changed(self, _value=None):
267
+ """数値の欄から、ドラッグと同じ経路(set_position → draw_idle → free_rect)で更新する。"""
268
+ axis_index = self._layout_selected_axis_index
269
+ if (axis_index is None or
270
+ axis_index >= len(self._app.canvas.all_axes) or
271
+ axis_index >= len(self._app.project.all_plot_settings)):
272
+ return
273
+
274
+ new_left = self._app.free_layout_x_spinbox.value()
275
+ new_bottom = self._app.free_layout_y_spinbox.value()
276
+ new_width = max(MIN_FREE_RECT_SIZE, self._app.free_layout_width_spinbox.value())
277
+ new_height = max(MIN_FREE_RECT_SIZE, self._app.free_layout_height_spinbox.value())
278
+
279
+ ax = self._app.canvas.all_axes[axis_index]
280
+ ax.set_position([new_left, new_bottom, new_width, new_height])
281
+ self._app.canvas.draw_idle()
282
+
283
+ self._app.project.all_plot_settings[axis_index]['free_rect'] = (
284
+ new_left, new_bottom, new_width, new_height
285
+ )
@@ -0,0 +1,78 @@
1
+ """マウスの7つのモード(データカーソル / 注釈 / 自由配置 / 範囲選択 / ピーク配置 / スライス抽出 / 領域強調)を、
2
+ どれか1つだけが有効になるよう MOUSE_MODES の表でまとめて管理する。
3
+
4
+ モードを足すときは MOUSE_MODES に1行足す。各モードの _toggle_*_mode は、有効になるときの先頭で
5
+ self._deactivate_other_mouse_modes('<名前>') を呼ぶだけでよい。QAction は toggled ではなく triggered に
6
+ つないであるので、setChecked(False) だけではスロットが呼ばれない。解除ではチェックを外すのと
7
+ _toggle_*_mode(False) の呼び出しの両方をする。
8
+ """
9
+ import logging
10
+ from collections import namedtuple
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ # name: モードの識別子(_deactivate_other_mouse_modes に渡す名前)
16
+ # flag_attr: PlotterApp 上の「有効かどうか」を持つ属性名
17
+ # action_attr: ツールバーの QAction を持つ属性名
18
+ # toggle_method: ON/OFF を切り替えるメソッド名
19
+ MouseMode = namedtuple("MouseMode", "name flag_attr action_attr toggle_method")
20
+
21
+ MOUSE_MODES = (
22
+ MouseMode("cursor", "cursor_mode_enabled",
23
+ "cursor_action", "_toggle_cursor_mode"),
24
+ MouseMode("annotation", "annotation_mode_enabled",
25
+ "annotation_action", "_toggle_annotation_mode"),
26
+ MouseMode("layout_edit", "layout_edit_mode_enabled",
27
+ "layout_edit_action", "_toggle_layout_edit_mode"),
28
+ MouseMode("range_select", "range_select_mode_enabled",
29
+ "range_select_action", "_toggle_range_select_mode"),
30
+ MouseMode("peak_placement", "peak_placement_mode_enabled",
31
+ "peak_placement_action", "_toggle_peak_placement_mode"),
32
+ MouseMode("slice_extraction", "slice_extraction_mode_enabled",
33
+ "slice_extraction_action", "_toggle_slice_extraction_mode"),
34
+ MouseMode("region_highlight", "region_highlight_mode_enabled",
35
+ "region_highlight_action", "_toggle_region_highlight_mode"),
36
+ )
37
+
38
+ MOUSE_MODES_BY_NAME = {mode.name: mode for mode in MOUSE_MODES}
39
+
40
+
41
+ class ToolManager:
42
+ """モードの排他。タブの MOUSE_MODES の表を見て、フラグ・QAction・切り替えはタブの名前(ツールへの窓口)で触る。"""
43
+
44
+ def __init__(self, app):
45
+ self._app = app
46
+
47
+ def deactivate_others(self, active_name):
48
+ """active_name 以外のモードを解除する。有効でないモードには何もしないので、解除から呼び戻されて循環しない。"""
49
+ if active_name not in MOUSE_MODES_BY_NAME:
50
+ # 表への登録漏れ。動作は続けるが、開発中に気づけるようログに残す
51
+ logger.warning(
52
+ "未登録のマウスモード名です: %s (gui/tools/manager.py の "
53
+ "MOUSE_MODES に追加してください)", active_name
54
+ )
55
+
56
+ for mode in self._app.MOUSE_MODES:
57
+ if mode.name == active_name:
58
+ continue
59
+ if not getattr(self._app, mode.flag_attr, False):
60
+ continue
61
+
62
+ action = getattr(self._app, mode.action_attr, None)
63
+ if action is not None:
64
+ action.setChecked(False)
65
+
66
+ toggle = getattr(self._app, mode.toggle_method, None)
67
+ if callable(toggle):
68
+ toggle(False)
69
+ else:
70
+ # メソッドが無くても、フラグは落として「2つ有効」を残さない
71
+ setattr(self._app, mode.flag_attr, False)
72
+
73
+ def active_name(self):
74
+ """有効なモードの名前。無ければ None。"""
75
+ for mode in self._app.MOUSE_MODES:
76
+ if getattr(self._app, mode.flag_attr, False):
77
+ return mode.name
78
+ return None
@@ -0,0 +1,132 @@
1
+ """ピーク配置モード: 左クリックで多峰分離の初期値を置き、右クリックで近いものを消す。
2
+
3
+ _pending_peak_guesses は [{'center', 'height', 'width'}, ...] で、MultiPeakFitDialog の initial_guesses にそのまま渡す。
4
+ ダイアログを閉じたら(OK でもキャンセルでも)消す。仮のマーカーは redraw_all() で消えていることがあるので、
5
+ 消すときの例外は無視する。
6
+ """
7
+ import logging
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ # 仮の幅は表示中の X の範囲に対する割合(ダイアログで直せるので大まかでよい)
12
+ PEAK_PLACEMENT_DEFAULT_WIDTH_FRACTION = 0.05
13
+ PEAK_PLACEMENT_DELETE_TOLERANCE_PX = 15
14
+
15
+
16
+ class PeakPlacementTool:
17
+ """状態はこのツールが持ち、PlotterApp の同じ名前は窓口(gui/tools/__init__.py)。"""
18
+
19
+ # PlotterApp から同じ名前で読み書きできるもの(テスト・メニュー・ほかの mixin が使う)
20
+ EXPOSED_NAMES = (
21
+ '_clear_pending_peak_guesses', '_draw_pending_peak_marker', '_on_peak_placement_press',
22
+ '_peak_placement_press_cid', '_pending_peak_guesses', '_pending_peak_markers',
23
+ '_remove_nearest_pending_peak_guess', '_toggle_peak_placement_mode', 'peak_placement_mode_enabled',
24
+ )
25
+
26
+ def __init__(self, app):
27
+ self._app = app
28
+ self.peak_placement_mode_enabled = False
29
+ self._peak_placement_press_cid = None
30
+ self._pending_peak_guesses = [] # [{'center':, 'height':, 'width':}, ...]
31
+ self._pending_peak_markers = [] # [(guess, axvline, plot point), ...]
32
+
33
+ def _toggle_peak_placement_mode(self, checked):
34
+ self.peak_placement_mode_enabled = checked
35
+
36
+ if checked:
37
+ self._app._deactivate_other_mouse_modes('peak_placement')
38
+
39
+ self._peak_placement_press_cid = self._app.canvas.mpl_connect(
40
+ 'button_press_event', self._on_peak_placement_press
41
+ )
42
+ self._app.statusBar().showMessage(
43
+ "ピーク配置モード: クリックで多峰分離フィットの初期値(中心・高さ)を追加、"
44
+ "右クリックで直近のマーカーを削除します", 5000
45
+ )
46
+ else:
47
+ if getattr(self, '_peak_placement_press_cid', None) is not None:
48
+ self._app.canvas.mpl_disconnect(self._peak_placement_press_cid)
49
+ self._peak_placement_press_cid = None
50
+
51
+ def _on_peak_placement_press(self, event):
52
+ if not getattr(self, 'peak_placement_mode_enabled', False):
53
+ return
54
+ if event.inaxes is None or event.xdata is None or event.ydata is None:
55
+ return
56
+
57
+ if event.button == 3: # 右クリックは近いマーカーを消す
58
+ self._remove_nearest_pending_peak_guess(event)
59
+ return
60
+ if event.button != 1:
61
+ return
62
+
63
+ ax = event.inaxes
64
+ x_min, x_max = ax.get_xlim()
65
+ # X のずらしは平行移動なので、幅は表示座標でもデータ座標でも同じ
66
+ width = abs(x_max - x_min) * PEAK_PLACEMENT_DEFAULT_WIDTH_FRACTION or 1.0
67
+
68
+ # 初期値はフィットでデータ座標と突き合わされるので、クリック位置(表示座標)をデータ座標に戻す。
69
+ # マーカーはクリックした場所に出す
70
+ dataset = self._app._get_current_dataset()
71
+ data_x, data_y = self._app.canvas.display_to_data(
72
+ dataset, float(event.xdata), float(event.ydata)
73
+ ) if dataset is not None else (float(event.xdata), float(event.ydata))
74
+
75
+ guess = {'center': float(data_x), 'height': float(data_y), 'width': float(width)}
76
+ self._pending_peak_guesses.append(guess)
77
+ self._draw_pending_peak_marker(ax, guess, float(event.xdata), float(event.ydata))
78
+ self._app.statusBar().showMessage(
79
+ f"ピーク初期値を追加しました({len(self._pending_peak_guesses)}件、"
80
+ f"X={guess['center']:.4g}, Y={guess['height']:.4g})", 3000
81
+ )
82
+
83
+ def _draw_pending_peak_marker(self, ax, guess, display_x, display_y):
84
+ """マーカーは表示座標に描く(guess はデータ座標で持つ)。"""
85
+ line = ax.axvline(display_x, color='#E4572E', linestyle=':', linewidth=1, zorder=100)
86
+ point, = ax.plot(
87
+ [display_x], [display_y],
88
+ marker='x', color='#E4572E', markersize=8, zorder=101, linestyle='None',
89
+ )
90
+ self._pending_peak_markers.append((guess, line, point))
91
+ self._app.canvas.draw_idle()
92
+
93
+ def _remove_nearest_pending_peak_guess(self, event):
94
+ if not self._pending_peak_guesses:
95
+ return
96
+
97
+ ax = event.inaxes
98
+ click_px = ax.transData.transform((event.xdata, event.ydata))
99
+
100
+ best_i, best_distance = None, None
101
+ for i, (_guess, _line, point) in enumerate(self._pending_peak_markers):
102
+ # guess はデータ座標なので、描いたマーカーの位置(表示座標)で比べる
103
+ marker_x = point.get_xdata()[0]
104
+ marker_y = point.get_ydata()[0]
105
+ pos_px = ax.transData.transform((marker_x, marker_y))
106
+ distance = ((pos_px[0] - click_px[0]) ** 2 + (pos_px[1] - click_px[1]) ** 2) ** 0.5
107
+ if best_distance is None or distance < best_distance:
108
+ best_distance, best_i = distance, i
109
+
110
+ if best_i is None or best_distance > PEAK_PLACEMENT_DELETE_TOLERANCE_PX:
111
+ return
112
+
113
+ guess, line, point = self._pending_peak_markers.pop(best_i)
114
+ self._pending_peak_guesses.remove(guess)
115
+ for artist in (line, point):
116
+ try:
117
+ artist.remove()
118
+ except (ValueError, NotImplementedError):
119
+ pass
120
+ self._app.canvas.draw_idle()
121
+
122
+ def _clear_pending_peak_guesses(self):
123
+ """ダイアログを閉じたら(OK でもキャンセルでも)仮のマーカーごと消す。"""
124
+ for _guess, line, point in self._pending_peak_markers:
125
+ for artist in (line, point):
126
+ try:
127
+ artist.remove()
128
+ except (ValueError, NotImplementedError):
129
+ pass
130
+ self._pending_peak_markers = []
131
+ self._pending_peak_guesses = []
132
+ self._app.canvas.draw_idle()