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,355 @@
1
+ """データファイルの読み込みの流れ: ドロップ・複数ファイルの待ち行列・バックグラウンドの読み込み・列の選択・Excel のシート・フォルダからの一括読み込み・クリップボード。"""
2
+ import io
3
+ import logging
4
+ import os
5
+ import pandas as pd
6
+ import re
7
+ from PySide6.QtWidgets import QApplication, QDialog, QMessageBox
8
+ from graphica.core.dataset import Dataset
9
+ from graphica.core.plugin_api import get_registered_importer_extensions
10
+ from graphica.gui import notify
11
+ from graphica.gui.dialogs import ColumnPreviewDialog, ExcelMultiSheetDialog, FolderImportDialog
12
+ from graphica.gui.task_runner import TaskRunner
13
+ from graphica.models.project import PROJECT_FILE_EXTENSIONS
14
+ from graphica.gui.workers import BUILTIN_DATA_FILE_EXTENSIONS, excel_engine_for, is_excel_file, load_data_file_task
15
+ from pathlib import Path
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ SUPPORTED_DATA_FILE_EXTENSIONS = BUILTIN_DATA_FILE_EXTENSIONS
21
+
22
+
23
+ def find_unevaluated_formula_cells(file_path, sheet_name=None, max_examples=5, max_scan_cells=200_000):
24
+ """openpyxl の読み込み(約350ms)を起動時に払わないよう、呼ぶときに import する。
25
+
26
+ テストがこのモジュール属性を monkeypatch で差し替えるので、名前を変えない。
27
+ """
28
+ from graphica.core.excel_utils import find_unevaluated_formula_cells as _impl
29
+ return _impl(file_path, sheet_name, max_examples, max_scan_cells)
30
+
31
+
32
+ def dragEnterEvent(app, event):
33
+ if event.mimeData().hasUrls():
34
+ event.acceptProposedAction()
35
+
36
+
37
+ def dropEvent(app, event):
38
+ urls = event.mimeData().urls()
39
+ file_paths = [url.toLocalFile() for url in urls if url.toLocalFile()]
40
+ project_paths = [p for p in file_paths if p.lower().endswith(PROJECT_FILE_EXTENSIONS)]
41
+ data_paths = [p for p in file_paths if not p.lower().endswith(PROJECT_FILE_EXTENSIONS)]
42
+ # 先にプロジェクトを開く。一緒に落としたデータはそのプロジェクトに加わる
43
+ if project_paths:
44
+ open_dropped_project(app, project_paths)
45
+ if data_paths:
46
+ app._queue_data_files(data_paths)
47
+
48
+
49
+ def open_dropped_project(app, project_paths):
50
+ """1 つのタブに開けるプロジェクトは 1 つなので、2 つ目以降は開かずに知らせる。"""
51
+ if len(project_paths) > 1:
52
+ notify.information(
53
+ app, "プロジェクトを開く",
54
+ "一度に開けるプロジェクトは 1 つです。次のファイルだけを開きます:\n"
55
+ + os.path.basename(project_paths[0])
56
+ )
57
+ if not app.confirm_unsaved_changes("別のプロジェクトを開く"):
58
+ return
59
+ app._load_project_from_path(project_paths[0])
60
+
61
+
62
+ def all_supported_data_file_extensions(app):
63
+ """組み込みの拡張子に、プラグインの読み込み機能の拡張子を足したもの。"""
64
+ extensions = list(SUPPORTED_DATA_FILE_EXTENSIONS)
65
+ for ext in get_registered_importer_extensions():
66
+ if ext not in extensions:
67
+ extensions.append(ext)
68
+ return tuple(extensions)
69
+
70
+
71
+ def queue_data_files(app, file_paths):
72
+ """対応していない拡張子はまとめて1回だけ知らせ、残りを待ち行列に積む。"""
73
+ valid_paths = []
74
+ skipped_names = []
75
+ allowed_extensions = app._all_supported_data_file_extensions()
76
+ for file_path in file_paths:
77
+ if file_path.lower().endswith(allowed_extensions):
78
+ valid_paths.append(file_path)
79
+ else:
80
+ skipped_names.append(os.path.basename(file_path))
81
+
82
+ if skipped_names:
83
+ notify.warning(
84
+ app, "非対応のファイル形式",
85
+ "以下のファイルは対応していない形式のため読み込みをスキップしました:\n"
86
+ + "\n".join(skipped_names)
87
+ )
88
+
89
+ if not valid_paths:
90
+ return
91
+
92
+ app._data_load_queue.extend(valid_paths)
93
+ app._data_load_queue_total += len(valid_paths)
94
+
95
+ if app._data_load_task_runner is None:
96
+ app._process_next_queued_file()
97
+
98
+
99
+ def process_next_queued_file(app):
100
+ """読み込みの成否によらず、1件終わるたびに呼ばれる。"""
101
+ if not app._data_load_queue:
102
+ app._data_load_queue_total = 0
103
+ app._data_load_queue_done = 0
104
+ # 待ち行列を使い切ったら戻す(この後の普通の取り込みに引き継がない)
105
+ app._batch_import_filename_regex = None
106
+ return
107
+
108
+ next_path = app._data_load_queue.pop(0)
109
+ app._data_load_queue_done += 1
110
+ app.load_data(next_path, queue_progress=(app._data_load_queue_done, app._data_load_queue_total))
111
+
112
+
113
+ def load_data(app, file_path, queue_progress=None):
114
+ """別スレッドで読み込み、終わったらデータセットとして加える(大きいファイルで画面を止めない)。
115
+
116
+ queue_progress=(何件目, 総数) はステータスバーの表示に使う。
117
+ """
118
+ if app._data_load_task_runner is not None:
119
+ notify.information(app, "読み込み中", "他のファイルを読み込み中です。完了までお待ちください。")
120
+ return
121
+
122
+ app.ui.add_dataset_button.setEnabled(False)
123
+ if queue_progress is not None:
124
+ done, total = queue_progress
125
+ app.statusBar().showMessage(f"読み込み中 ({done}/{total}): {os.path.basename(file_path)} ...")
126
+ else:
127
+ app.statusBar().showMessage(f"読み込み中: {file_path} ...")
128
+
129
+ runner = TaskRunner(load_data_file_task, file_path, parent=app)
130
+ runner.succeeded.connect(lambda df: app._on_data_load_succeeded(df, file_path))
131
+ runner.failed.connect(lambda msg: app._on_data_load_failed(msg, file_path))
132
+ app._data_load_task_runner = runner
133
+ runner.start()
134
+
135
+
136
+ def on_data_load_succeeded(app, df, file_path):
137
+ """途中でキャンセルされても、必ず待ち行列の次へ進む。"""
138
+ app._cleanup_data_load_task_runner()
139
+ try:
140
+ app._import_loaded_dataframe(df, file_path)
141
+ finally:
142
+ app._process_next_queued_file()
143
+
144
+
145
+ def import_loaded_dataframe(app, df, file_path):
146
+ """Excel でシートを複数選ぶと、シートごとに別のデータセットにする。"""
147
+ dataset_name = os.path.basename(file_path)
148
+ is_excel = is_excel_file(file_path)
149
+
150
+ sheet_names = []
151
+ if is_excel:
152
+ try:
153
+ sheet_names = pd.ExcelFile(file_path, engine=excel_engine_for(file_path)).sheet_names
154
+ except Exception as e:
155
+ logger.warning("Excelのシート一覧取得に失敗しました: %s", e)
156
+
157
+ # None は「読み込み済みの df をそのまま使う」
158
+ sheets_to_import = [None]
159
+ if is_excel and len(sheet_names) > 1:
160
+ multi_dialog = ExcelMultiSheetDialog(sheet_names, app)
161
+ if multi_dialog.exec() != QDialog.DialogCode.Accepted:
162
+ app.statusBar().showMessage("読み込みをキャンセルしました", 3000)
163
+ return
164
+ selected_sheets = multi_dialog.get_selected_sheets()
165
+ if not selected_sheets:
166
+ app.statusBar().showMessage("シートが選択されなかったため読み込みをキャンセルしました", 3000)
167
+ return
168
+ sheets_to_import = selected_sheets
169
+
170
+ target_folder = app._get_target_folder_for_new_dataset()
171
+ added_count = 0
172
+
173
+ for sheet_name in sheets_to_import:
174
+ if sheet_name is None:
175
+ sheet_df = df
176
+ preview_name = dataset_name
177
+ else:
178
+ try:
179
+ sheet_df = pd.read_excel(file_path, sheet_name=sheet_name, engine=excel_engine_for(file_path))
180
+ except Exception as e:
181
+ logger.exception("シート「%s」の読み込みに失敗しました", sheet_name)
182
+ notify.warning(app, "読み込みエラー", f"シート「{sheet_name}」の読み込みに失敗しました:\n{e}")
183
+ continue
184
+ if sheet_df.shape[1] < 2:
185
+ notify.warning(
186
+ app, "読み込みエラー",
187
+ f"シート「{sheet_name}」には少なくとも2列必要です。スキップします。"
188
+ )
189
+ continue
190
+ preview_name = f"{dataset_name} [{sheet_name}]" if len(sheets_to_import) > 1 else dataset_name
191
+
192
+ if is_excel:
193
+ checked_sheet = sheet_name if sheet_name is not None else (sheet_names[0] if sheet_names else None)
194
+ found, examples, scanned_all = find_unevaluated_formula_cells(file_path, checked_sheet)
195
+ if found:
196
+ example_text = "\n".join(examples)
197
+ more_note = "" if scanned_all else "\n(他にも存在する可能性があります)"
198
+ reply = notify.warning(
199
+ app, "数式セルの警告",
200
+ f"シート「{checked_sheet}」に、計算済みの値を持たない数式セルが見つかりました:\n"
201
+ f"{example_text}{more_note}\n\n"
202
+ "これらのセルは空欄(NaN)として読み込まれます。Excelで開いて再計算・保存してから"
203
+ "読み込み直すことをお勧めします。このまま続行しますか?",
204
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
205
+ QMessageBox.StandardButton.Yes
206
+ )
207
+ if reply != QMessageBox.StandardButton.Yes:
208
+ continue
209
+
210
+ # 列の多いファイルで意図しない列が選ばれないよう、プレビューを見せて X/Y の列を選ばせる
211
+ preview_dialog = ColumnPreviewDialog(sheet_df, preview_name, app, file_path=file_path)
212
+ if sheet_name is not None and preview_dialog.sheet_combo is not None:
213
+ preview_dialog.sheet_combo.blockSignals(True)
214
+ preview_dialog.sheet_combo.setCurrentText(sheet_name)
215
+ preview_dialog.sheet_combo.blockSignals(False)
216
+
217
+ if preview_dialog.exec() != QDialog.DialogCode.Accepted:
218
+ continue
219
+
220
+ x_col, y_col = preview_dialog.get_selected_columns()
221
+ final_df = preview_dialog.get_dataframe()
222
+
223
+ # 再読み込みのために元ファイルとシートを持つ。シートはダイアログで最後に選ばれたもの
224
+ source_sheet = (
225
+ preview_dialog.sheet_combo.currentText()
226
+ if (is_excel and preview_dialog.sheet_combo is not None) else None
227
+ )
228
+ if app._batch_import_filename_regex:
229
+ final_df = app._apply_filename_regex_columns(
230
+ final_df, file_path, app._batch_import_filename_regex
231
+ )
232
+ new_dataset = Dataset(
233
+ name=preview_name, df=final_df, x_col_name=x_col, y_col_name=y_col,
234
+ source_file=os.path.abspath(file_path), source_sheet=source_sheet,
235
+ )
236
+ app._add_dataset(new_dataset, target_folder)
237
+ added_count += 1
238
+
239
+ if added_count > 0:
240
+ app.statusBar().showMessage(f"読み込み完了: {file_path} ({added_count}件)", 3000)
241
+ app._add_recent_file(file_path)
242
+ else:
243
+ app.statusBar().showMessage("読み込みをキャンセルしました", 3000)
244
+
245
+
246
+ def apply_filename_regex_columns(df, file_path, pattern):
247
+ """フォルダ一括取り込みの正規表現の名前付きグループを、ファイル名から取り出して列にする。
248
+
249
+ 数値にできれば float の列。パターンが合わなくても取り込みは続けたいので、そのときは df をそのまま返す。
250
+ """
251
+ try:
252
+ match = re.search(pattern, os.path.basename(file_path))
253
+ except re.error as e:
254
+ logger.warning("正規表現が不正なため、ファイル名からの列抽出をスキップしました: %s", e)
255
+ return df
256
+ if match is None:
257
+ return df
258
+ groups = match.groupdict()
259
+ if not groups:
260
+ return df
261
+
262
+ df = df.copy()
263
+ for name, value in groups.items():
264
+ if value is None:
265
+ continue
266
+ try:
267
+ df[name] = float(value)
268
+ except (TypeError, ValueError):
269
+ df[name] = value
270
+ return df
271
+
272
+
273
+ def on_import_folder(app):
274
+ """フォルダ内(サブフォルダは除く)の対応ファイルを、確認させてからドラッグ&ドロップと同じ待ち行列に積む。"""
275
+ dir_path = notify.get_existing_directory(app, "フォルダから一括インポート", "")
276
+ if not dir_path:
277
+ return
278
+
279
+ allowed_extensions = app._all_supported_data_file_extensions()
280
+ try:
281
+ file_paths = sorted(
282
+ str(p) for p in Path(dir_path).iterdir()
283
+ if p.is_file() and p.suffix.lower() in allowed_extensions
284
+ )
285
+ except OSError as e:
286
+ notify.warning(app, "フォルダから一括インポート", f"フォルダの読み取りに失敗しました:\n{e}")
287
+ return
288
+
289
+ if not file_paths:
290
+ notify.information(
291
+ app, "フォルダから一括インポート",
292
+ "対応する形式のファイルがフォルダ内に見つかりませんでした。"
293
+ )
294
+ return
295
+
296
+ file_names = [os.path.basename(p) for p in file_paths]
297
+ dialog = FolderImportDialog(dir_path, file_names, parent=app)
298
+ if dialog.exec() != QDialog.DialogCode.Accepted:
299
+ return
300
+
301
+ app._batch_import_filename_regex = dialog.get_regex_pattern()
302
+ app._queue_data_files(file_paths)
303
+
304
+
305
+ def on_paste_data_from_clipboard(app):
306
+ """区切り文字はファイル読み込みと同じ判定で決める(Excel からのコピーはタブ区切り)。"""
307
+ text = QApplication.clipboard().text()
308
+ if not text.strip():
309
+ notify.information(app, "クリップボードから貼り付け", "クリップボードにテキストデータがありません。")
310
+ return
311
+
312
+ from graphica.gui.workers import detect_clipboard_delimiter
313
+ delimiter = detect_clipboard_delimiter(text)
314
+ try:
315
+ df = pd.read_csv(io.StringIO(text), sep=delimiter, engine='python')
316
+ except Exception as e:
317
+ logger.exception("クリップボードの内容を表として読めませんでした")
318
+ notify.warning(
319
+ app, "貼り付けエラー",
320
+ f"クリップボードの内容を表として解釈できませんでした:\n{e}"
321
+ )
322
+ return
323
+
324
+ if df.shape[1] < 2:
325
+ notify.warning(app, "貼り付けエラー", "クリップボードのデータには少なくとも2列必要です。")
326
+ return
327
+
328
+ app._clipboard_paste_counter = getattr(app, '_clipboard_paste_counter', 0) + 1
329
+ dataset_name = f"クリップボード貼り付け {app._clipboard_paste_counter}"
330
+
331
+ preview_dialog = ColumnPreviewDialog(df, dataset_name, app, file_path=None)
332
+ if preview_dialog.exec() != QDialog.DialogCode.Accepted:
333
+ app.statusBar().showMessage("貼り付けをキャンセルしました", 3000)
334
+ return
335
+
336
+ x_col, y_col = preview_dialog.get_selected_columns()
337
+ final_df = preview_dialog.get_dataframe()
338
+ new_dataset = Dataset(name=dataset_name, df=final_df, x_col_name=x_col, y_col_name=y_col)
339
+ app._add_dataset(new_dataset, app._get_target_folder_for_new_dataset())
340
+ app.statusBar().showMessage("クリップボードからデータを貼り付けました", 3000)
341
+
342
+
343
+ def on_data_load_failed(app, error_message, file_path):
344
+ app._cleanup_data_load_task_runner()
345
+ app.statusBar().clearMessage()
346
+ notify.critical(app, "エラー", f"読み込みエラー: {error_message}")
347
+ app._process_next_queued_file()
348
+
349
+
350
+ def cleanup_data_load_task_runner(app):
351
+ app.ui.add_dataset_button.setEnabled(True)
352
+ if app._data_load_task_runner is not None:
353
+ app._data_load_task_runner.wait()
354
+ app._data_load_task_runner.deleteLater()
355
+ app._data_load_task_runner = None
@@ -0,0 +1,107 @@
1
+ """データセットのプロパティ(Dataset の属性)とプロパティ欄の対応表。欄を 1 つ足すときは表に 1 行足す。
2
+
3
+ 表の順は選んだデータセットを欄に戻す順。欄から値を読む行(read がある行)は、変わったらその属性だけを
4
+ 選択中のデータセットすべてに当てる(DatasetPropertyPanel.on_property_changed)。read が無い行は戻すだけで、
5
+ 変更は専用の処理が受ける(凡例名・色・第 2 Y 軸・描画先・2D・値域)。
6
+ """
7
+ from graphica.core.dataset import linestyle_name
8
+ from graphica.gui.binding import WATCH, Binding, check, found_text, item_data, number, shown_text
9
+
10
+ # 誤差列の欄で「誤差列を使わない」を表す選択肢
11
+ NO_ERROR_COLUMN_LABEL = "(なし)"
12
+ # データ点ラベルの内容の欄で「Y値そのもの」を表す選択肢
13
+ POINT_LABEL_Y_VALUE_LABEL = "Y値"
14
+
15
+ _PANEL = 'property_panel.'
16
+
17
+
18
+ def _linestyle(key, widget):
19
+ """保存値は '--' と 'dashed' のような表記ゆれを含むので表示名に揃えて選ぶ。線を描かない値は選択なしにする
20
+ (直前のデータセットの表示が残って誤解されないように)。"""
21
+ def write(_owner, combo, value):
22
+ shown = linestyle_name(value)
23
+ if shown is None:
24
+ combo.setCurrentIndex(-1)
25
+ else:
26
+ combo.setCurrentText(shown)
27
+ return Binding(key, widget, lambda _o, w: w.currentText(), write, 'currentTextChanged', (WATCH,))
28
+
29
+
30
+ def _marker(key, widget):
31
+ return Binding(key, widget, lambda _o, w: None if w.currentText() == 'None' else w.currentText(),
32
+ lambda _o, w, v: w.setCurrentText(v if v is not None else 'None'), 'currentTextChanged', (WATCH,))
33
+
34
+
35
+ def _color(key, widget, slot):
36
+ return Binding(key, widget, None, lambda _o, w, v: w.set_color(v), 'colorChanged', (slot,))
37
+
38
+
39
+ def _point_label_column(key, widget):
40
+ """選択肢はデータセットの列なので、戻すたびに作り直す。"""
41
+ def write(_owner, combo, value):
42
+ name, columns = value
43
+ combo.clear()
44
+ combo.addItems([POINT_LABEL_Y_VALUE_LABEL] + columns)
45
+ combo.setCurrentText(name or POINT_LABEL_Y_VALUE_LABEL)
46
+
47
+ def read(_owner, combo):
48
+ return None if combo.currentText() == POINT_LABEL_Y_VALUE_LABEL else combo.currentText()
49
+ return Binding(key, widget, read, write, 'currentTextChanged', (WATCH,),
50
+ load=lambda ds: (ds.point_label_col_name, ds.df.columns.tolist()))
51
+
52
+
53
+ def _value_range(key, widget, empty_value):
54
+ """値域が自動(None)のときは欄に仮の値を入れておく。"""
55
+ return Binding(key, widget, None, lambda _o, w, v: w.setValue(v if v is not None else empty_value),
56
+ 'valueChanged', (_PANEL + 'on_2d_value_range_changed',))
57
+
58
+
59
+ DATASET_PROPERTY_BINDINGS = (
60
+ # textChanged だと1文字ごとに描き直して重いので editingFinished
61
+ Binding('name', 'ui.legend_name_edit', None, lambda _o, w, v: w.setText(v), 'editingFinished',
62
+ (_PANEL + 'on_legend_name_changed',)),
63
+ # 種類によって意味のある欄が変わるので、種類が変わるたびに出し入れし直す
64
+ shown_text('plot_type', 'ui.plot_type_combo',
65
+ (WATCH, _PANEL + 'update_gradient_controls_visibility', _PANEL + 'update_smoothing_control_visibility',
66
+ _PANEL + 'update_error_display_control_items')),
67
+ _linestyle('linestyle', 'ui.linestyle_combo'),
68
+ number('linewidth', 'ui.linewidth_spinbox', (WATCH,)),
69
+ _marker('marker', 'ui.marker_combo'),
70
+ number('markersize', 'ui.markersize_spinbox', (WATCH,)),
71
+ _color('color', 'color_picker_widget', 'colors.on_color_changed'),
72
+ # 平滑化のチェックで手法の欄の有効/無効も切り替える
73
+ check('smoothing', 'ui.smoothing_checkbox', (WATCH, _PANEL + 'update_smoothing_control_visibility')),
74
+ item_data('smoothing_method', 'smoothing_method_combo', (WATCH,)),
75
+ number('alpha', 'alpha_spinbox', (WATCH,)),
76
+ check('gradient_enabled', 'gradient_checkbox', (WATCH, _PANEL + 'update_gradient_controls_visibility'),
77
+ signal='toggled'),
78
+ _color('gradient_color2', 'gradient_color2_picker', 'colors.on_gradient_color2_changed'),
79
+ item_data('gradient_target', 'gradient_target_combo', (WATCH,)),
80
+ check('waterfall_enabled', 'waterfall_checkbox', (WATCH, _PANEL + 'update_waterfall_controls_visibility'),
81
+ signal='toggled'),
82
+ number('waterfall_offset_x', 'waterfall_offset_x_spinbox', (WATCH,)),
83
+ number('waterfall_offset_y', 'waterfall_offset_y_spinbox', (WATCH,)),
84
+ check('waterfall_occlusion_enabled', 'waterfall_occlusion_checkbox', (WATCH,), signal='toggled'),
85
+ check('waterfall_depth_shrink_enabled', 'waterfall_depth_checkbox',
86
+ (WATCH, _PANEL + 'update_waterfall_controls_visibility'), signal='toggled'),
87
+ number('waterfall_depth_shrink_ratio', 'waterfall_depth_ratio_spinbox', (WATCH,)),
88
+ # 点ラベルは上限の説明の更新もするので専用のハンドラ(中で on_property_changed を呼ぶ)
89
+ check('show_point_labels', 'point_labels_checkbox', (_PANEL + 'on_point_labels_toggled',), signal='toggled'),
90
+ _point_label_column('point_label_col_name', 'point_label_col_combo'),
91
+ Binding('use_secondary_y', 'use_secondary_y_checkbox', None, lambda _o, w, v: w.setChecked(v), 'stateChanged',
92
+ (_PANEL + 'on_secondary_y_changed',)),
93
+ Binding('subplot_target', 'subplot_target_combo', None, lambda _o, w, v: w.setCurrentIndex(v),
94
+ 'currentIndexChanged', (_PANEL + 'on_subplot_target_changed',)),
95
+ item_data('error_display', 'error_display_combo', (WATCH,)),
96
+ item_data('nan_policy', 'nan_policy_combo', (WATCH,)),
97
+ Binding('data_kind', 'data_2d_checkbox', None, lambda _o, w, v: w.setChecked(v == '2d_grid'), 'toggled',
98
+ (_PANEL + 'on_data_2d_toggled',)),
99
+ found_text('colormap', 'colormap_combo', (WATCH,)),
100
+ item_data('map_display_mode', 'map_display_mode_combo', (WATCH,)),
101
+ number('contour_levels', 'contour_levels_spinbox', (WATCH,)),
102
+ found_text('grid_interp_method', 'grid_interp_method_combo', (WATCH,)),
103
+ Binding('color_range_auto', 'color_range_auto_checkbox', None, lambda _o, w, v: w.setChecked(v), 'toggled',
104
+ (_PANEL + 'on_2d_value_range_changed',), load=lambda ds: ds.vmin is None and ds.vmax is None),
105
+ _value_range('vmin', 'vmin_spinbox', 0.0),
106
+ _value_range('vmax', 'vmax_spinbox', 1.0),
107
+ )
@@ -0,0 +1,80 @@
1
+ """データセット一覧の各項目のアイコン(見た目の小さなプレビューと、表示/非表示の目)。
2
+
3
+ main_window と dataset_mixin の両方が使うので、循環 import を避けて別のモジュールにしている。
4
+ """
5
+ from PySide6.QtCore import Qt
6
+ from PySide6.QtGui import QPixmap, QPainter, QPen, QColor, QBrush, QIcon
7
+
8
+ from graphica.core.dataset import COLOR_BY_COLUMN_PLOT_TYPE, linestyle_name
9
+ from graphica.gui.icon_utils import icon as _icon_from_svg
10
+
11
+ _STYLE_ICON_SIZE = (28, 14)
12
+
13
+ # 列0はアイコンと名前、列1は表示/非表示の目
14
+ DATASET_TREE_NAME_COLUMN = 0
15
+ DATASET_TREE_VISIBILITY_COLUMN = 1
16
+
17
+ # 線種の表示名(linestyle_name で揃えたもの)から Qt のペン。記号('--')だけ見ると 'dashed' が実線になる
18
+ _LINESTYLE_TO_QT_PEN = {
19
+ 'solid': Qt.PenStyle.SolidLine,
20
+ 'dashed': Qt.PenStyle.DashLine,
21
+ 'dashdot': Qt.PenStyle.DashDotLine,
22
+ 'dotted': Qt.PenStyle.DotLine,
23
+ }
24
+
25
+
26
+ def make_dataset_style_icon(dataset):
27
+ """色・線種・線幅・マーカー・透明度を映した小さな画像。選ばなくても一覧で見分けられるように。"""
28
+ width, height = _STYLE_ICON_SIZE
29
+ pixmap = QPixmap(width, height)
30
+ pixmap.fill(Qt.GlobalColor.transparent)
31
+
32
+ painter = QPainter(pixmap)
33
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
34
+
35
+ color = QColor(dataset.color)
36
+ # 透明度が低くても一覧では見えるよう下限を設ける
37
+ color.setAlphaF(max(0.2, min(dataset.alpha, 1.0)))
38
+
39
+ show_line = dataset.plot_type in ('Line', 'Line+Scatter', 'Step')
40
+ show_marker = dataset.plot_type in (
41
+ 'Scatter', 'Line+Scatter', 'Density Scatter', COLOR_BY_COLUMN_PLOT_TYPE)
42
+ y = height // 2
43
+
44
+ if show_line:
45
+ pen = QPen(color, max(1.0, min(dataset.linewidth, 3.0)))
46
+ pen.setStyle(_LINESTYLE_TO_QT_PEN.get(linestyle_name(dataset.linestyle), Qt.PenStyle.SolidLine))
47
+ painter.setPen(pen)
48
+ painter.drawLine(2, y, width - 2, y)
49
+
50
+ if show_marker:
51
+ painter.setPen(Qt.PenStyle.NoPen)
52
+ painter.setBrush(QBrush(color))
53
+ r = 3
54
+ painter.drawEllipse(width // 2 - r, y - r, r * 2, r * 2)
55
+
56
+ painter.end()
57
+
58
+ # Selected モードにも同じ画像を入れる(入れないと Fusion が選択時の画像を作り、塗りつぶした四角になる)
59
+ icon = QIcon()
60
+ icon.addPixmap(pixmap, QIcon.Mode.Normal)
61
+ icon.addPixmap(pixmap, QIcon.Mode.Selected)
62
+ return icon
63
+
64
+
65
+ # 目の列の固定幅(DATASET_TREE_VISIBILITY_COLUMN_WIDTH)に収まる大きさ
66
+ _VISIBILITY_ICON_SIZE = 16
67
+
68
+
69
+ def make_dataset_visibility_icon(dataset):
70
+ name = "eye" if getattr(dataset, "visible", True) else "eye-off"
71
+ return _icon_from_svg(name, size=_VISIBILITY_ICON_SIZE)
72
+
73
+
74
+ def apply_dataset_visibility_text_style(item, dataset, column=0):
75
+ """隠した系列の名前を text_muted の色にする。表示に戻すときは色を消してテーマの文字色に任せる。"""
76
+ if getattr(dataset, "visible", True):
77
+ item.setData(column, Qt.ItemDataRole.ForegroundRole, None)
78
+ else:
79
+ from graphica.gui import theme
80
+ item.setForeground(column, QBrush(QColor(theme.current_tokens()["text_muted"])))
@@ -0,0 +1 @@
1
+ """データセットに対する操作を機能ごとに分けたクラス。どれも DatasetHost だけを通して本体(タブ)を扱う。"""
@@ -0,0 +1,114 @@
1
+ """データセット操作メニュー(ツリーの右クリックとツールバーのボタンで同じものを出す)。"""
2
+ from PySide6.QtCore import Qt
3
+ from PySide6.QtWidgets import QMenu
4
+
5
+ from graphica.core.i18n import tr
6
+
7
+
8
+ def populate_dataset_actions_menu(app, menu):
9
+ """
10
+ app(1つのタブ)の今の選択に合わせて menu の中身を作り直す。
11
+
12
+ 選択に応じて出し入れする項目があるので、開くたびに作り直す。PySide6 が途中で
13
+ サブメニューを回収しないよう、Python 側の参照はメニュー自身に持たせる(_graphica_submenus)。
14
+ """
15
+ menu.clear()
16
+ submenus = []
17
+
18
+ def add_submenu(title):
19
+ """空のまま出さないよう、項目が入ったものだけ最後に menu へ繋ぐ。"""
20
+ sub = QMenu(title, menu)
21
+ submenus.append(sub)
22
+ return sub
23
+
24
+ data_proc_menu = add_submenu(tr("データ処理"))
25
+ analysis_menu = add_submenu(tr("解析・注釈"))
26
+ multi_menu = add_submenu(tr("複数データセット"))
27
+ export_menu = add_submenu(tr("エクスポート"))
28
+ tab_menu = add_submenu(tr("タブ操作"))
29
+
30
+ menu.addAction(tr("新しいフォルダ")).triggered.connect(app._on_new_folder)
31
+ if app.project.datasets:
32
+ menu.addAction(tr("すべて表示")).triggered.connect(app._on_show_all_datasets)
33
+ menu.addAction(tr("すべて非表示")).triggered.connect(app._on_hide_all_datasets)
34
+
35
+ current_item = app.ui.dataset_list_widget.currentItem()
36
+ if current_item is not None and current_item.data(0, Qt.ItemDataRole.UserRole) is None:
37
+ menu.addAction(tr("フォルダ名を変更...")).triggered.connect(app._on_rename_dataset_folder)
38
+ menu.addAction(tr("フォルダ内を全て表示")).triggered.connect(app._on_show_all_in_folder)
39
+ menu.addAction(tr("フォルダ内を全て非表示")).triggered.connect(app._on_hide_all_in_folder)
40
+
41
+ current = app._get_current_dataset()
42
+ if current is not None:
43
+ menu.addSeparator()
44
+ menu.addAction(tr("スタイルをコピー")).triggered.connect(app.transfer.copy_style)
45
+ paste_style_action = menu.addAction(tr("スタイルを貼り付け"))
46
+ paste_style_action.setEnabled(app.transfer.copied_style is not None)
47
+ paste_style_action.triggered.connect(app.transfer.paste_style)
48
+ # 元ファイルを持たないデータセット(貼り付け・演算・プラグインの生成物など)では使えない
49
+ reload_action = menu.addAction(tr("元ファイルから再読み込み"))
50
+ reload_action.setEnabled(bool(current.source_file))
51
+ reload_action.triggered.connect(app.transfer.reload_from_source)
52
+
53
+ processing = app.processing
54
+ for title, handler in (
55
+ ("規格化(ノーマライズ)...", processing.normalize),
56
+ ("Savitzky-Golayフィルタ(平滑化/微分)...", processing.savgol_smooth),
57
+ ("ベースライン補正...", processing.baseline_correction),
58
+ ("区間積分(台形則/Simpson則)...", processing.interval_integral),
59
+ ("累積積分(台形則/Simpson則)...", processing.cumulative_integral),
60
+ ("共通X格子へのリサンプリング/補間...", processing.resample),
61
+ ("重複X値の検出...", processing.detect_duplicate_x),
62
+ ("行フィルタ...", processing.filter_rows),
63
+ ("列の値で系列に分割...", processing.split_by_column),
64
+ ("外れ値検出(Z-score/IQR)...", processing.detect_outliers),
65
+ ):
66
+ data_proc_menu.addAction(tr(title)).triggered.connect(handler)
67
+
68
+ for title, handler in (
69
+ ("統計値アンカーラベルを追加...", app.overlays.add_stat_label),
70
+ ("インセット(拡大図)を追加...", app.overlays.add_inset),
71
+ ("ピーク位置に自動ラベルを追加...", app.peaks.add_peak_labels),
72
+ ("ヒストグラム / KDE...", processing.histogram_or_kde),
73
+ ):
74
+ analysis_menu.addAction(tr(title)).triggered.connect(handler)
75
+
76
+ # 対象外でも隠さずグレーアウトする(ある機能に気づけるように)
77
+ export_fit_action = export_menu.addAction(tr("フィット結果のエクスポート..."))
78
+ export_fit_action.setEnabled(current.fit_result is not None)
79
+ export_fit_action.triggered.connect(app.fitting.show_fit_result)
80
+ copy_methods_text_action = export_menu.addAction(tr("「方法」文をコピー..."))
81
+ copy_methods_text_action.setEnabled(current.provenance is not None)
82
+ copy_methods_text_action.triggered.connect(app.transfer.copy_methods_text)
83
+
84
+ selected = app._get_selected_datasets()
85
+ if len(selected) >= 2:
86
+ if len(selected) == 2:
87
+ multi_menu.addAction(tr("データセット間演算...")).triggered.connect(app.processing.arithmetic)
88
+ multi_menu.addAction(tr("X軸アライメント(相互相関)...")).triggered.connect(app.processing.align_selected)
89
+ multi_menu.addAction(tr("平均±SD生成...")).triggered.connect(app.processing.mean_and_sd_of_selected)
90
+ multi_menu.addAction(tr("バッチ列計算...")).triggered.connect(app.processing.batch_column_calculate)
91
+ multi_menu.addAction(tr("バッチカーブフィット...")).triggered.connect(app.fitting.batch_fit_selected)
92
+
93
+ if selected:
94
+ export_menu.addAction(tr("データ表をファイルに書き出す...")).triggered.connect(app.transfer.export_data)
95
+ # 他のタブが無くても出しておき、選んだときに案内する
96
+ tab_menu.addAction(tr("別のタブへコピー...")).triggered.connect(
97
+ lambda: app.transfer.copy_or_move_to_tab(move=False))
98
+ tab_menu.addAction(tr("別のタブへ移動...")).triggered.connect(
99
+ lambda: app.transfer.copy_or_move_to_tab(move=True))
100
+
101
+ attached_any = False
102
+ for sub in submenus:
103
+ if sub.actions():
104
+ if not attached_any:
105
+ menu.addSeparator()
106
+ attached_any = True
107
+ menu.addMenu(sub)
108
+
109
+ if app.ui.dataset_list_widget.selectedItems():
110
+ menu.addSeparator()
111
+ menu.addAction(tr("削除")).triggered.connect(app._on_remove_dataset)
112
+
113
+ menu._graphica_submenus = submenus
114
+ return menu