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,361 @@
1
+ """プロジェクトのファイル: 保存・読み込み・未保存の変更の確認・オートセーブと世代・最近使ったファイル。"""
2
+ import logging
3
+ import os
4
+ from PySide6.QtWidgets import QDialog, QMessageBox
5
+ from datetime import datetime
6
+ from graphica.core.i18n import tr
7
+ from graphica.core.app_paths import get_app_data_dir
8
+ from graphica.gui import app_settings, notify
9
+ from graphica.gui.dialogs import AutosaveHistoryDialog
10
+ from graphica.models.project import LEGACY_PICKLE_EXTENSION, PROJECT_FILE_EXTENSION, PROJECT_FILE_EXTENSIONS
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ AUTOSAVE_GENERATIONS = 3 # 最新の autosave.gra を含む
16
+
17
+
18
+ MAX_RECENT_FILES = 10
19
+
20
+
21
+ # 未保存の変更の確認を出すか。テストはモーダルなダイアログで止まるので tests/conftest.py が "0" にする
22
+ UNSAVED_CHANGES_PROMPT_ENV = "GRAPHICA_CONFIRM_UNSAVED_CHANGES"
23
+
24
+
25
+ def _unsaved_changes_prompt_enabled():
26
+ return os.environ.get(UNSAVED_CHANGES_PROMPT_ENV, "1") != "0"
27
+
28
+
29
+ def check_autosave_recovery(app):
30
+ """前回が正常に終わらず、オートセーブが残っていれば、復元するか尋ねる(起動時に1回)。
31
+
32
+ 無ければ、拡張子を .gra に変える前の版が残した autosave.graphica を探す。
33
+ """
34
+ if app._had_clean_exit:
35
+ return
36
+
37
+ autosave_path = app._autosave_filename
38
+ if not os.path.exists(autosave_path):
39
+ legacy_path = os.path.splitext(app._autosave_filename)[0] + '.graphica'
40
+ if os.path.exists(legacy_path):
41
+ autosave_path = legacy_path
42
+ else:
43
+ return
44
+
45
+ reply = notify.question(
46
+ app, "オートセーブからの復元",
47
+ "前回はプロジェクトが正常に終了しなかったようです。\n"
48
+ "自動保存されていたデータを復元しますか?",
49
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
50
+ QMessageBox.StandardButton.Yes
51
+ )
52
+ if reply == QMessageBox.StandardButton.Yes:
53
+ app._load_project_from_path(autosave_path, add_to_recent=False)
54
+
55
+
56
+ def on_show_autosave_history(app):
57
+ """オートセーブの世代を新しい順に見せ、選んだものを読み込む(復元確認と同じく、最近使ったファイルに載せず、上書き保存の対象にもしない)。"""
58
+ base, ext = os.path.splitext(app._autosave_filename)
59
+ candidates = [(app._autosave_filename, "現在(最新)")]
60
+ for gen in range(1, AUTOSAVE_GENERATIONS):
61
+ candidates.append((f"{base}.{gen}{ext}", f"{gen}世代前"))
62
+
63
+ generations = []
64
+ for path, label in candidates:
65
+ if not os.path.exists(path):
66
+ continue
67
+ try:
68
+ mtime_text = datetime.fromtimestamp(os.path.getmtime(path)).strftime("%Y-%m-%d %H:%M:%S")
69
+ except OSError:
70
+ mtime_text = "(更新日時不明)"
71
+ generations.append((path, label, mtime_text))
72
+
73
+ if not generations:
74
+ notify.information(app, "自動バックアップ履歴", "自動バックアップファイルが見つかりませんでした。")
75
+ return
76
+
77
+ dialog = AutosaveHistoryDialog(generations, parent=app)
78
+ if dialog.exec() != QDialog.DialogCode.Accepted:
79
+ return
80
+ selected_path = dialog.get_selected_path()
81
+ if not selected_path:
82
+ return
83
+
84
+ reply = notify.question(
85
+ app, "自動バックアップ履歴",
86
+ "選択した世代の内容で復元します。現在の未保存の変更は失われます。よろしいですか?",
87
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
88
+ QMessageBox.StandardButton.No
89
+ )
90
+ if reply != QMessageBox.StandardButton.Yes:
91
+ return
92
+
93
+ app._load_project_from_path(selected_path, add_to_recent=False)
94
+
95
+
96
+ def update_autosave_path(app):
97
+ """autosave_dir(空なら get_app_data_dir())から保存先を決め、フォルダを作る。
98
+
99
+ カレントディレクトリは使わない(macOS の .app では書き込めない場所になる)。
100
+ """
101
+ autosave_dir = app_settings.AUTOSAVE_DIR.read(app.settings)
102
+ if autosave_dir:
103
+ try:
104
+ os.makedirs(autosave_dir, exist_ok=True)
105
+ except OSError as e:
106
+ logger.warning("オートセーブ保存先フォルダの作成に失敗しました: %s", e)
107
+ autosave_dir = ""
108
+ if not autosave_dir:
109
+ autosave_dir = get_app_data_dir()
110
+ app._autosave_filename = os.path.join(autosave_dir, app._autosave_base_filename)
111
+
112
+
113
+ def rotate_autosave_generations(app):
114
+ """autosave.gra を autosave.1.gra, autosave.2.gra, ... へ押し出す。"""
115
+ base, ext = os.path.splitext(app._autosave_filename)
116
+
117
+ oldest = f"{base}.{AUTOSAVE_GENERATIONS - 1}{ext}"
118
+ if os.path.exists(oldest):
119
+ os.remove(oldest)
120
+
121
+ for gen in range(AUTOSAVE_GENERATIONS - 2, 0, -1):
122
+ src = f"{base}.{gen}{ext}"
123
+ dst = f"{base}.{gen + 1}{ext}"
124
+ if os.path.exists(src):
125
+ os.replace(src, dst)
126
+
127
+ if os.path.exists(app._autosave_filename):
128
+ os.replace(app._autosave_filename, f"{base}.1{ext}")
129
+
130
+
131
+ def sync_project_from_ui(app):
132
+ """UI にしか無い状態(フォルダ構造、サブプロットの行数と列数)を、保存や比較の前に ProjectModel へ移す。"""
133
+ app.project.dataset_group_tree = app._capture_dataset_group_tree()
134
+ app.project.layout_rows = app.subplot_rows_spinbox.value()
135
+ app.project.layout_cols = app.subplot_cols_spinbox.value()
136
+
137
+
138
+ def remember_saved_content(app):
139
+ """いまの内容を保存済みとみなす(保存・読み込みの成功直後に呼ぶ)。"""
140
+ app._sync_project_from_ui()
141
+ app._saved_content_fingerprint = app.project.content_fingerprint()
142
+
143
+
144
+ def document_title(app):
145
+ if app._current_project_path:
146
+ return os.path.basename(app._current_project_path)
147
+ if app._restored_unsaved:
148
+ return "無題のプロジェクト(復元)"
149
+ return "無題のプロジェクト"
150
+
151
+
152
+ def has_unsaved_changes(app):
153
+ """データセットが無く、ファイルとも対応していない新しいタブは False。"""
154
+ if not app.project.datasets and not app._current_project_path:
155
+ return False
156
+ if app._saved_content_fingerprint is None:
157
+ return True
158
+ app._sync_project_from_ui()
159
+ return app.project.content_fingerprint() != app._saved_content_fingerprint
160
+
161
+
162
+ def confirm_unsaved_changes(app, action_text):
163
+ """未保存の変更があれば「保存 / 保存しない / キャンセル」を尋ねる。続けてよければ True。"""
164
+ if not _unsaved_changes_prompt_enabled() or not app.has_unsaved_changes():
165
+ return True
166
+ name = app.document_title()
167
+ box = QMessageBox(app)
168
+ box.setIcon(QMessageBox.Icon.Warning)
169
+ box.setWindowTitle("保存されていない変更")
170
+ box.setText(f"「{name}」には保存されていない変更があります。")
171
+ box.setInformativeText(f"{action_text}前に保存しますか?")
172
+ save_button = box.addButton("保存", QMessageBox.ButtonRole.AcceptRole)
173
+ discard_button = box.addButton("保存しない", QMessageBox.ButtonRole.DestructiveRole)
174
+ cancel_button = box.addButton("キャンセル", QMessageBox.ButtonRole.RejectRole)
175
+ box.setDefaultButton(save_button)
176
+ box.setEscapeButton(cancel_button)
177
+ box.exec()
178
+ clicked = box.clickedButton()
179
+ if clicked is discard_button:
180
+ return True
181
+ if clicked is save_button:
182
+ app.manual_save()
183
+ return not app.has_unsaved_changes()
184
+ return False
185
+
186
+
187
+ def auto_save(app):
188
+ try:
189
+ app._sync_project_from_ui()
190
+ app._rotate_autosave_generations()
191
+ app.project.save_project(app._autosave_filename)
192
+ app.statusBar().showMessage("オートセーブ完了", 3000)
193
+ except Exception as e:
194
+ logger.exception("オートセーブに失敗しました")
195
+ app.statusBar().showMessage(f"オートセーブ失敗: {e}", 3000)
196
+
197
+
198
+ def manual_save(app):
199
+ """保存先が分かっていればそこへ上書きし、無ければ「名前を付けて保存」にする。"""
200
+ if not app._current_project_path:
201
+ app.manual_save_as()
202
+ return
203
+ app._save_project_to_path(app._current_project_path)
204
+
205
+
206
+ def manual_save_as(app):
207
+ filepath, _ = notify.get_save_file_name(
208
+ app, "名前を付けて保存", "", f"Graphica Project (*{PROJECT_FILE_EXTENSION})"
209
+ )
210
+ if filepath:
211
+ # 拡張子を付けないファイルダイアログがある
212
+ if os.path.splitext(filepath)[1].lower() not in PROJECT_FILE_EXTENSIONS:
213
+ filepath += PROJECT_FILE_EXTENSION
214
+ app._save_project_to_path(filepath)
215
+
216
+
217
+ def save_project_to_path(app, filepath):
218
+ try:
219
+ app._sync_project_from_ui()
220
+ app.project.save_project(filepath)
221
+ app._current_project_path = filepath
222
+ app._restored_unsaved = False
223
+ app._saved_content_fingerprint = app.project.content_fingerprint()
224
+ app.statusBar().showMessage(f"保存しました: {filepath}", 3000)
225
+ app._add_recent_file(filepath)
226
+ app.project_state_changed.emit()
227
+ except Exception as e:
228
+ logger.exception("プロジェクトの保存に失敗しました: %s", filepath)
229
+ notify.critical(app, "エラー", f"保存に失敗しました:\n{e}")
230
+
231
+
232
+ def project_file_filter():
233
+ return "Graphica Project (" + " ".join(f"*{ext}" for ext in PROJECT_FILE_EXTENSIONS) + ")"
234
+
235
+
236
+ def manual_load(app):
237
+ if not app.confirm_unsaved_changes("別のプロジェクトを開く"):
238
+ return
239
+ filepath, _ = notify.get_open_file_name(
240
+ app, "プロジェクトを開く", "", project_file_filter()
241
+ )
242
+ if filepath:
243
+ app._load_project_from_path(filepath)
244
+
245
+
246
+ def load_project_from_path(app, filepath, add_to_recent=True):
247
+ """add_to_recent=False はオートセーブからの復元。最近使ったファイルに載せず、上書き保存の対象にもしない。"""
248
+ try:
249
+ app.project.load_project(filepath)
250
+ # 中身はもう入れ替わっている。この先で失敗したとき前のファイルが保存先に
251
+ # 残っていると、上書き保存でそのファイルを別の内容で壊してしまう。
252
+ app._current_project_path = None
253
+ app._saved_content_fingerprint = None
254
+ app._restored_unsaved = False
255
+
256
+ app._rebuild_dataset_tree_widget()
257
+
258
+ app._block_all_signals(True)
259
+ app.subplot_rows_spinbox.setValue(app.project.layout_rows)
260
+ app.subplot_cols_spinbox.setValue(app.project.layout_cols)
261
+ is_free_layout = getattr(app.project, 'layout_mode', 'grid') == 'free'
262
+ app.free_layout_checkbox.setChecked(is_free_layout)
263
+ app.subplot_rows_spinbox.setEnabled(not is_free_layout)
264
+ app.subplot_cols_spinbox.setEnabled(not is_free_layout)
265
+ app.add_free_subplot_button.setEnabled(is_free_layout)
266
+ app.remove_free_subplot_button.setEnabled(is_free_layout)
267
+ app.layout_edit_action.setEnabled(is_free_layout)
268
+ if not is_free_layout and app.layout_edit_action.isChecked():
269
+ app.layout_edit_action.setChecked(False)
270
+ app._toggle_layout_edit_mode(False)
271
+ app.panel_labels_action.setChecked(app.project.panel_labels_enabled)
272
+ app.share_x_checkbox.setChecked(getattr(app.project, 'share_x_axis', False))
273
+ app.share_y_checkbox.setChecked(getattr(app.project, 'share_y_axis', False))
274
+ app.share_x_checkbox.setEnabled(not is_free_layout)
275
+ app.share_y_checkbox.setEnabled(not is_free_layout)
276
+ app._block_all_signals(False)
277
+
278
+ if app.project.all_plot_settings:
279
+ app._apply_settings_to_ui_controls(
280
+ app.project.all_plot_settings[app.project.active_axis_index]
281
+ )
282
+
283
+ # 前の文書へのコマンドは datasets をリストごと差し戻すので、残すと
284
+ # Undo 1回で読み込んだ内容が前の文書に置き換わる。
285
+ app.undo_stack.clear()
286
+
287
+ app.property_panel.update_ui_state()
288
+ app._update_plot()
289
+
290
+ app.statusBar().showMessage("プロジェクトを読み込みました", 3000)
291
+ if add_to_recent:
292
+ app._add_recent_file(filepath)
293
+ app._current_project_path = filepath
294
+ app._remember_saved_content()
295
+ else:
296
+ app._restored_unsaved = True
297
+ app.project_state_changed.emit()
298
+ except Exception as e:
299
+ logger.exception("プロジェクトの読み込みに失敗しました: %s", filepath)
300
+ notify.critical(app, "エラー", f"読み込みに失敗しました:\n{e}")
301
+
302
+
303
+ def get_recent_files(app):
304
+ return app_settings.as_list_keeping_empty_string(app_settings.RECENT_FILES.read(app.settings))
305
+
306
+
307
+ def add_recent_file(app, file_path):
308
+ file_path = os.path.abspath(file_path)
309
+ files = app._get_recent_files()
310
+ if file_path in files:
311
+ files.remove(file_path)
312
+ files.insert(0, file_path)
313
+ files = files[:MAX_RECENT_FILES]
314
+ app_settings.RECENT_FILES.write(app.settings, files)
315
+ app._update_recent_files_menu()
316
+
317
+
318
+ def update_recent_files_menu(app):
319
+ try:
320
+ app.recent_files_menu.clear()
321
+ files = app._get_recent_files()
322
+
323
+ if not files:
324
+ empty_action = app.recent_files_menu.addAction(tr("(履歴なし)"))
325
+ empty_action.setEnabled(False)
326
+ return
327
+
328
+ for file_path in files:
329
+ action = app.recent_files_menu.addAction(file_path)
330
+ action.triggered.connect(lambda checked=False, p=file_path: app._on_open_recent_file(p))
331
+
332
+ app.recent_files_menu.addSeparator()
333
+ clear_action = app.recent_files_menu.addAction(tr("履歴をクリア"))
334
+ clear_action.triggered.connect(app._on_clear_recent_files)
335
+ except RuntimeError:
336
+ # まれにメニューの C++ 側が破棄済みのことがある(PySide6 の回収、原因は未特定)。表示が古いだけなので落とさない
337
+ logger.warning("recent_files_menuの更新に失敗しました(既に破棄されている可能性があります)。", exc_info=True)
338
+
339
+
340
+ def on_open_recent_file(app, file_path):
341
+ if not os.path.exists(file_path):
342
+ notify.warning(app, "エラー", f"ファイルが見つかりません:\n{file_path}")
343
+ files = app._get_recent_files()
344
+ if file_path in files:
345
+ files.remove(file_path)
346
+ app_settings.RECENT_FILES.write(app.settings, files)
347
+ app._update_recent_files_menu()
348
+ return
349
+
350
+ # .pkl は開けないが、データとして読ませずに「対応していない」と知らせる
351
+ if file_path.lower().endswith(PROJECT_FILE_EXTENSIONS + (LEGACY_PICKLE_EXTENSION,)):
352
+ if not app.confirm_unsaved_changes("別のプロジェクトを開く"):
353
+ return
354
+ app._load_project_from_path(file_path)
355
+ else:
356
+ app.load_data(file_path)
357
+
358
+
359
+ def on_clear_recent_files(app):
360
+ app_settings.RECENT_FILES.write(app.settings, [])
361
+ app._update_recent_files_menu()
@@ -0,0 +1,65 @@
1
+ """選んだデータセットの処理の履歴をツリーで見せるドック。
2
+
3
+ 操作の下にその元のデータセットを置き、元にも履歴があれば祖先まで辿る。元が消されていれば「(削除済み)」で止める
4
+ (履歴が親の ID だけでなく名前も持つのはこのため)。
5
+ """
6
+ from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QTreeWidget, QTreeWidgetItem
7
+
8
+ from graphica.core.methods_text import describe_operation
9
+
10
+
11
+ class ProvenancePanel(QWidget):
12
+ def __init__(self, parent=None):
13
+ super().__init__(parent)
14
+ layout = QVBoxLayout(self)
15
+ layout.setContentsMargins(4, 4, 4, 4)
16
+
17
+ self.placeholder_label = QLabel(
18
+ "処理履歴(フィット・ベースライン補正・平滑化等で生成された\n"
19
+ "データセット)を選択すると、ここに処理の流れが表示されます。"
20
+ )
21
+ self.placeholder_label.setWordWrap(True)
22
+ self.placeholder_label.setStyleSheet("color: gray; padding: 12px;")
23
+ layout.addWidget(self.placeholder_label)
24
+
25
+ self.tree = QTreeWidget()
26
+ self.tree.setHeaderHidden(True)
27
+ layout.addWidget(self.tree)
28
+ self.tree.setVisible(False)
29
+
30
+ def refresh(self, dataset, project):
31
+ """dataset は None でもよい。project は祖先を辿るのに使う。"""
32
+ self.tree.clear()
33
+ if dataset is None:
34
+ self.tree.setVisible(False)
35
+ self.placeholder_label.setVisible(True)
36
+ return
37
+
38
+ self.placeholder_label.setVisible(False)
39
+ self.tree.setVisible(True)
40
+
41
+ root_item = QTreeWidgetItem([dataset.name])
42
+ self.tree.addTopLevelItem(root_item)
43
+ self._add_provenance_children(root_item, dataset, project, frozenset())
44
+ self.tree.expandAll()
45
+
46
+ def _add_provenance_children(self, parent_item, dataset, project, visited):
47
+ """dataset を作った操作と元のデータセットを parent_item の下に足し、元も辿る。visited で循環を止める。"""
48
+ if dataset is None or not dataset.provenance or dataset.dataset_id in visited:
49
+ return
50
+ visited = visited | {dataset.dataset_id}
51
+
52
+ operation_item = QTreeWidgetItem([describe_operation(dataset.provenance)])
53
+ parent_item.addChild(operation_item)
54
+
55
+ source_ids = dataset.provenance.get('source_dataset_ids') or []
56
+ source_names = dataset.provenance.get('source_dataset_names') or []
57
+ for i, source_id in enumerate(source_ids):
58
+ source_dataset = next((ds for ds in project.datasets if ds.dataset_id == source_id), None)
59
+ fallback_name = source_names[i] if i < len(source_names) else "不明"
60
+ if source_dataset is None:
61
+ operation_item.addChild(QTreeWidgetItem([f"{fallback_name}(削除済み)"]))
62
+ continue
63
+ source_item = QTreeWidgetItem([source_dataset.name])
64
+ operation_item.addChild(source_item)
65
+ self._add_provenance_children(source_item, source_dataset, project, visited)
@@ -0,0 +1 @@
1
+ """キャンバスの描画の部品。MplCanvas はこれらを組み合わせて描く。"""
@@ -0,0 +1,177 @@
1
+ """注釈(文字・矢印・統計ラベル・範囲の強調・拡大図)とパネルラベルの描画。作り直しを省く鍵もここで作る。"""
2
+ import json
3
+ import logging
4
+ import numpy as np
5
+ from graphica.core.analysis import calculate_lttb_downsample
6
+ from graphica.core.axis_settings import axis_setting
7
+ from graphica.gui.mathtext_preview import JP_CAPABLE_FONT_FAMILIES, families_for_text
8
+ from graphica.gui.rendering.common import (
9
+ DARK_TEXT_COLOR, LIGHT_TEXT_COLOR, LTTB_DOWNSAMPLE_TARGET_POINTS, LTTB_DOWNSAMPLE_THRESHOLD,
10
+ REGION_HIGHLIGHT_DEFAULT_ALPHA, REGION_HIGHLIGHT_DEFAULT_COLOR, _ARROW_STYLE_MAP, _INSET_CORNER_ORIGINS,
11
+ _NO_KEY, _compute_stat_label_text)
12
+ from mpl_toolkits.axes_grid1.inset_locator import mark_inset
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def draw_panel_label(canvas, ax, index):
18
+ """軸の左上に (a)(b)(c)… を描く。文字は保存せず並び順から毎回決めるので、並べ替えても振り直される。"""
19
+ label = canvas._panel_label_for_index(index)
20
+ text_color = DARK_TEXT_COLOR if canvas.dark_mode else LIGHT_TEXT_COLOR
21
+ ax.text(
22
+ -0.12, 1.08, f"({label})", transform=ax.transAxes,
23
+ fontsize=12, fontweight='bold', color=text_color,
24
+ ha='left', va='top', zorder=10,
25
+ )
26
+
27
+
28
+ def panel_label_for_index(index):
29
+ """0->a, …, 25->z, 26->aa, 27->ab, …(Excel の列名と同じ)"""
30
+ letters = []
31
+ n = index
32
+ while True:
33
+ n, remainder = divmod(n, 26)
34
+ letters.append(chr(ord('a') + remainder))
35
+ if n == 0:
36
+ break
37
+ n -= 1
38
+ return ''.join(reversed(letters))
39
+
40
+
41
+ def downsample_for_inset(canvas, x_data, y_data, full_resolution=False):
42
+ """
43
+ 拡大図の点列を本体と同じく LTTB で間引く(注釈は描き直しのたびに作り直すので、大きなデータで重くなる)。
44
+ 拡大図は種類に関わらず線で描くので、散布図を除く本体の条件は当てはまらない。X が昇順のときだけ間引く。
45
+ """
46
+ if full_resolution or len(x_data) <= LTTB_DOWNSAMPLE_THRESHOLD:
47
+ return x_data, y_data
48
+ if not np.all(np.diff(x_data) >= 0):
49
+ return x_data, y_data
50
+ indices = calculate_lttb_downsample(x_data, y_data, LTTB_DOWNSAMPLE_TARGET_POINTS)
51
+ return x_data[indices], y_data[indices]
52
+
53
+
54
+ def annotation_render_key(canvas, axis_index, settings, datasets, full_resolution):
55
+ """
56
+ 注釈の描き直しを省くかどうかのキー。描いた結果に影響するものを全部入れる:
57
+ ダークモード(色が変わる)、統計値ラベルの計算後の文字列、拡大図が描くデータセットの色・線幅・透明度・表示。
58
+ 拡大図のデータそのものは入れない。これを使う update_appearance_only はデータが変わっていない前提の経路
59
+ (データが変わる操作は cla() する別の経路を通り、そこでは使い回さない)。
60
+ """
61
+ annotations = axis_setting(settings, 'annotations')
62
+ parts = [bool(canvas.dark_mode), bool(full_resolution), len(annotations)]
63
+ if not annotations:
64
+ return json.dumps(parts, default=str)
65
+
66
+ datasets_by_id = {ds.dataset_id: ds for ds in (datasets or ())}
67
+ for ann in annotations:
68
+ parts.append(json.dumps(ann, sort_keys=True, default=str))
69
+ ann_type = ann.get('type')
70
+ if ann_type == 'stat':
71
+ dataset = datasets_by_id.get(ann.get('dataset_id'))
72
+ parts.append(_compute_stat_label_text(dataset, ann.get('stat')))
73
+ elif ann_type == 'inset':
74
+ for target_ds in (datasets or ()):
75
+ if target_ds.subplot_target != axis_index or not target_ds.visible:
76
+ continue
77
+ parts.append((target_ds.dataset_id, target_ds.color,
78
+ target_ds.linewidth, target_ds.alpha))
79
+ return json.dumps(parts, default=str)
80
+
81
+
82
+ def draw_annotations(canvas, ax, axis_index, settings, datasets=None, full_resolution=False,
83
+ allow_reuse=False):
84
+ """
85
+ 軸の注釈(テキスト・矢印・領域ハイライト・統計値ラベル・拡大図)を、前回の分を消してから描く。
86
+
87
+ allow_reuse=True なら、内容が前回と同じとき描き直しを省く(拡大図はデータを描き直すので重い)。
88
+ 使ってよいのは update_appearance_only() だけ。ほかの経路は前回の artist が既に消えている
89
+ (fig.clf() / ax.cla() / 新しい軸)ので、使い回すと注釈が消える。既定は False。
90
+ datasets は統計値ラベルと拡大図が使う(省略すると「データセットなし」)。
91
+ """
92
+ render_key = canvas._annotation_render_key(axis_index, settings, datasets, full_resolution)
93
+ if allow_reuse and canvas._annotation_render_keys.get(axis_index, _NO_KEY) == render_key:
94
+ return
95
+
96
+ for artist in canvas._annotation_artists.get(axis_index, []):
97
+ try:
98
+ artist.remove()
99
+ except (ValueError, NotImplementedError):
100
+ pass
101
+
102
+ datasets_by_id = {ds.dataset_id: ds for ds in (datasets or ())}
103
+
104
+ new_artists = []
105
+ for ann in axis_setting(settings, 'annotations'):
106
+ ann_type = ann.get('type')
107
+ text = ann.get('text', '')
108
+ try:
109
+ if ann_type == 'arrow':
110
+ color = canvas._effective_text_color(ann.get('color', '#000000'))
111
+ arrowstyle = _ARROW_STYLE_MAP.get(ann.get('arrow_style', 'single'), '->')
112
+ curvature = ann.get('arrow_curvature', 0.0)
113
+ arrow_props = dict(arrowstyle=arrowstyle, color=color)
114
+ if curvature:
115
+ arrow_props['connectionstyle'] = f'arc3,rad={curvature}'
116
+ artist = ax.annotate(
117
+ text, xy=ann['xy'], xytext=ann['xytext'],
118
+ arrowprops=arrow_props,
119
+ color=color, fontsize=9, family=families_for_text(text, JP_CAPABLE_FONT_FAMILIES)
120
+ )
121
+ elif ann_type in ('vspan', 'hspan'):
122
+ # 利用者が選んだ色をそのまま使う(ダークモードの読み替えはしない)
123
+ lo, hi = ann.get('range', (0, 0))
124
+ color = ann.get('color', REGION_HIGHLIGHT_DEFAULT_COLOR)
125
+ alpha = ann.get('alpha', REGION_HIGHLIGHT_DEFAULT_ALPHA)
126
+ if ann_type == 'vspan':
127
+ artist = ax.axvspan(lo, hi, color=color, alpha=alpha, zorder=0.5)
128
+ else:
129
+ artist = ax.axhspan(lo, hi, color=color, alpha=alpha, zorder=0.5)
130
+ elif ann_type == 'inset':
131
+ # 拡大図は、種類やグラデーションは再現せず、範囲内の点を線で結ぶだけの概観
132
+ x0, y0 = _INSET_CORNER_ORIGINS.get(ann.get('corner', '右上'), (0.55, 0.55))
133
+ size = ann.get('size', 0.4)
134
+ x_min, x_max = ann.get('zoom_x_range', (0, 1))
135
+ color = canvas._effective_text_color(ann.get('color', '#000000'))
136
+ inset_ax = ax.inset_axes((x0, y0, size, size))
137
+ for target_ds in (datasets or ()):
138
+ if target_ds.subplot_target != axis_index or not target_ds.visible:
139
+ continue
140
+ tx = np.asarray(target_ds.x_data, dtype=float)
141
+ ty = np.asarray(target_ds.y_data, dtype=float)
142
+ in_range = (tx >= x_min) & (tx <= x_max)
143
+ if in_range.any():
144
+ inset_x, inset_y = canvas._downsample_for_inset(
145
+ tx[in_range], ty[in_range], full_resolution=full_resolution
146
+ )
147
+ inset_ax.plot(inset_x, inset_y, color=target_ds.color,
148
+ linewidth=target_ds.linewidth, alpha=target_ds.alpha)
149
+ inset_ax.set_xlim(x_min, x_max)
150
+ inset_ax.tick_params(labelsize=7)
151
+ pp, p1, p2 = mark_inset(ax, inset_ax, loc1=ann.get('loc1', 2), loc2=ann.get('loc2', 4),
152
+ fc="none", ec=color)
153
+ new_artists.extend([inset_ax, pp, p1, p2])
154
+ artist = None
155
+ elif ann_type == 'stat':
156
+ # 軸に対する位置なので、拡大・移動しても同じ場所に留まる
157
+ color = canvas._effective_text_color(ann.get('color', '#000000'))
158
+ xy = ann.get('xy', (0.05, 0.95))
159
+ dataset = datasets_by_id.get(ann.get('dataset_id'))
160
+ label_text = _compute_stat_label_text(dataset, ann.get('stat'))
161
+ artist = ax.text(
162
+ xy[0], xy[1], label_text, transform=ax.transAxes,
163
+ color=color, fontsize=9, family=families_for_text(label_text, JP_CAPABLE_FONT_FAMILIES),
164
+ va='top', ha='left', zorder=10,
165
+ )
166
+ else:
167
+ color = canvas._effective_text_color(ann.get('color', '#000000'))
168
+ xy = ann.get('xy', (0, 0))
169
+ artist = ax.text(xy[0], xy[1], text, color=color, fontsize=9,
170
+ family=families_for_text(text, JP_CAPABLE_FONT_FAMILIES))
171
+ if artist is not None:
172
+ new_artists.append(artist)
173
+ except Exception:
174
+ logger.exception("注釈の描画に失敗しました: %s", ann)
175
+ canvas._annotation_artists[axis_index] = new_artists
176
+ # 使い回さない経路でもキーは更新する(直後の update_appearance_only が無駄に描き直さないように)
177
+ canvas._annotation_render_keys[axis_index] = render_key