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,679 @@
1
+ """データの取り込みのダイアログ。呼び出し側は `from graphica.gui.dialogs import X` で参照する。"""
2
+
3
+ import logging
4
+ import re
5
+ import pandas as pd
6
+ from PySide6.QtWidgets import (
7
+ QCheckBox,
8
+ QComboBox,
9
+ QDialog,
10
+ QDialogButtonBox,
11
+ QFormLayout,
12
+ QLabel,
13
+ QLineEdit,
14
+ QListWidget,
15
+ QListWidgetItem,
16
+ QPushButton,
17
+ QSpinBox,
18
+ QTableWidget,
19
+ QTableWidgetItem,
20
+ QVBoxLayout,
21
+ )
22
+ from PySide6.QtCore import Qt
23
+ from graphica.gui import notify
24
+ from graphica.gui.theme import apply_form_spacing
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def _mixes_text_and_other_values(column):
30
+ """文字だけの列はカテゴリとして描けるが、文字と数値・空欄が混ざると描けない。"""
31
+ if column.dtype != object:
32
+ return False
33
+ has_text = has_other = False
34
+ for value in column:
35
+ if isinstance(value, str):
36
+ has_text = True
37
+ else:
38
+ has_other = True
39
+ if has_text and has_other:
40
+ return True
41
+ return False
42
+
43
+
44
+ class ColumnPreviewDialog(QDialog):
45
+ """読み込む前に内容を見せて X/Y の列を選ばせる(列の多いファイルで意図しない列が選ばれないように)。
46
+
47
+ Excel はシートとヘッダー行、CSV は文字コード・区切り文字・ヘッダー行・固定長を変えられ、変えるたびにファイルから読み直す。
48
+ """
49
+
50
+ # 表示名 -> read_csv の sep。「空白」は空白の連続を区切りとする正規表現なので python エンジンで読む
51
+ _DELIMITER_CHOICES = {
52
+ "カンマ (,)": ",",
53
+ "タブ": "\t",
54
+ "セミコロン (;)": ";",
55
+ "空白": r"\s+",
56
+ }
57
+ _DELIMITER_CUSTOM_LABEL = "その他..."
58
+
59
+ _ENCODING_CHOICES = {
60
+ "UTF-8": "utf-8",
61
+ "UTF-8 (BOM付き)": "utf-8-sig",
62
+ "Shift_JIS": "cp932",
63
+ "Latin-1": "latin-1",
64
+ "UTF-16": "utf-16",
65
+ }
66
+ _AUTO_LABEL = "自動判定"
67
+
68
+ def __init__(self, df, file_name, parent=None, file_path=None):
69
+ """df は先頭のシート・1行目をヘッダーとして読んだもの。file_path は設定を変えたときの読み直しに使う。"""
70
+ super().__init__(parent)
71
+ self.setWindowTitle(f"列の選択: {file_name}")
72
+ self.resize(600, 480)
73
+
74
+ self.file_path = file_path
75
+ self.current_df = df
76
+ from graphica.gui.workers import is_delimited_text_file, is_excel_file
77
+ self.is_excel = is_excel_file(file_path)
78
+ # 組み込みの CSV の読み込みを通ったファイルだけ(プラグインで読んだ形式や貼り付けは対象外)。.txt も CSV と同じ
79
+ self.is_csv = not self.is_excel and is_delimited_text_file(file_path)
80
+ # {列名: "数値" / "文字列" / "日付"}
81
+ self.type_overrides = {}
82
+ self._numeric_table_note = ""
83
+
84
+ self.sheet_names = []
85
+ if self.is_excel:
86
+ try:
87
+ from graphica.gui.workers import excel_engine_for
88
+ self.sheet_names = pd.ExcelFile(file_path, engine=excel_engine_for(file_path)).sheet_names
89
+ except Exception as e:
90
+ logger.warning("Excelのシート一覧取得に失敗しました: %s", e)
91
+
92
+ self._detected_encoding = None
93
+ self._detected_delimiter = None
94
+ if self.is_csv:
95
+ from graphica.gui.workers import detect_csv_encoding, detect_csv_delimiter
96
+ try:
97
+ self._detected_encoding = detect_csv_encoding(file_path)
98
+ except Exception as e:
99
+ logger.warning("CSVの文字コード自動判定に失敗しました: %s", e)
100
+ self._detected_encoding = 'utf-8-sig'
101
+ try:
102
+ self._detected_delimiter = detect_csv_delimiter(file_path, self._detected_encoding)
103
+ except Exception as e:
104
+ logger.warning("CSVの区切り文字自動判定に失敗しました: %s", e)
105
+ self._detected_delimiter = ','
106
+
107
+ layout = QVBoxLayout(self)
108
+
109
+ if self.is_excel:
110
+ excel_form = QFormLayout()
111
+ if self.sheet_names:
112
+ self.sheet_combo = QComboBox()
113
+ self.sheet_combo.addItems(self.sheet_names)
114
+ self.sheet_combo.currentIndexChanged.connect(self._on_sheet_or_header_changed)
115
+ excel_form.addRow("シート", self.sheet_combo)
116
+ else:
117
+ self.sheet_combo = None
118
+
119
+ self.header_row_spinbox = QSpinBox()
120
+ self.header_row_spinbox.setRange(1, 100)
121
+ self.header_row_spinbox.setValue(1)
122
+ self.header_row_spinbox.setToolTip("列名として使う行を指定します(データの上に説明行がある場合など)")
123
+ self.header_row_spinbox.valueChanged.connect(self._on_sheet_or_header_changed)
124
+ excel_form.addRow("ヘッダー行", self.header_row_spinbox)
125
+
126
+ # usecols は "A,C:E" のような Excel の列表記をそのまま受け付ける
127
+ self.usecols_edit = QLineEdit()
128
+ self.usecols_edit.setPlaceholderText("例: A,C:E (空欄で全列)")
129
+ self.usecols_edit.setToolTip("読み込む列をExcelの列表記で指定します(空欄なら全列を読み込みます)")
130
+ self.usecols_edit.editingFinished.connect(self._on_sheet_or_header_changed)
131
+ excel_form.addRow("使用する列 (usecols)", self.usecols_edit)
132
+
133
+ self.nrows_spinbox = QSpinBox()
134
+ self.nrows_spinbox.setRange(0, 10_000_000)
135
+ self.nrows_spinbox.setValue(0)
136
+ self.nrows_spinbox.setSpecialValueText("全行")
137
+ self.nrows_spinbox.setToolTip("ヘッダー行より下で読み込む最大行数(0で全行)")
138
+ self.nrows_spinbox.valueChanged.connect(self._on_sheet_or_header_changed)
139
+ excel_form.addRow("読み込む最大行数", self.nrows_spinbox)
140
+
141
+ layout.addLayout(excel_form)
142
+
143
+ self.check_types_button = QPushButton("列の型を確認...")
144
+ self.check_types_button.clicked.connect(self._on_check_column_types)
145
+ layout.addWidget(self.check_types_button)
146
+ else:
147
+ self.sheet_combo = None
148
+ self.header_row_spinbox = None
149
+ self.usecols_edit = None
150
+ self.nrows_spinbox = None
151
+
152
+ if self.is_csv:
153
+ csv_form = QFormLayout()
154
+
155
+ self.encoding_combo = QComboBox()
156
+ self.encoding_combo.addItems([self._AUTO_LABEL] + list(self._ENCODING_CHOICES.keys()))
157
+ self.encoding_combo.setToolTip(
158
+ "文字化けする場合は手動で選択してください(自動判定は"
159
+ f"「{self._detected_encoding}」と判定しました)"
160
+ )
161
+ csv_form.addRow("文字コード", self.encoding_combo)
162
+
163
+ self.delimiter_combo = QComboBox()
164
+ self.delimiter_combo.addItems(
165
+ [self._AUTO_LABEL] + list(self._DELIMITER_CHOICES.keys()) + [self._DELIMITER_CUSTOM_LABEL]
166
+ )
167
+ self.delimiter_combo.setToolTip(
168
+ f"自動判定は「{self._describe_delimiter(self._detected_delimiter)}」と判定しました"
169
+ )
170
+ csv_form.addRow("区切り文字", self.delimiter_combo)
171
+
172
+ self.custom_delimiter_label = QLabel("区切り文字(直接入力)")
173
+ self.custom_delimiter_edit = QLineEdit()
174
+ self.custom_delimiter_edit.setPlaceholderText("区切り文字を入力(例: |)")
175
+ csv_form.addRow(self.custom_delimiter_label, self.custom_delimiter_edit)
176
+ self.custom_delimiter_label.setVisible(False)
177
+ self.custom_delimiter_edit.setVisible(False)
178
+
179
+ self.csv_header_row_spinbox = QSpinBox()
180
+ self.csv_header_row_spinbox.setRange(1, 100)
181
+ self.csv_header_row_spinbox.setValue(1)
182
+ self.csv_header_row_spinbox.setToolTip(
183
+ "列名として使う行を指定します(装置が出力する説明文などの前文がある場合は、"
184
+ "その行数分だけ後ろにずらしてください)"
185
+ )
186
+ csv_form.addRow("ヘッダー行", self.csv_header_row_spinbox)
187
+
188
+ self.fixed_width_checkbox = QCheckBox("固定長フォーマットとして読み込む")
189
+ self.fixed_width_checkbox.setToolTip("区切り文字を使わず、列の文字位置で区切られたデータの場合に選択します")
190
+ csv_form.addRow(self.fixed_width_checkbox)
191
+
192
+ self.fixed_width_label = QLabel("列幅 (固定長)")
193
+ self.fixed_width_edit = QLineEdit()
194
+ self.fixed_width_edit.setPlaceholderText("カンマ区切りで指定(空欄なら自動推測)")
195
+ csv_form.addRow(self.fixed_width_label, self.fixed_width_edit)
196
+ self.fixed_width_label.setVisible(False)
197
+ self.fixed_width_edit.setVisible(False)
198
+
199
+ self.csv_nrows_spinbox = QSpinBox()
200
+ self.csv_nrows_spinbox.setRange(0, 10_000_000)
201
+ self.csv_nrows_spinbox.setValue(0)
202
+ self.csv_nrows_spinbox.setSpecialValueText("全行")
203
+ self.csv_nrows_spinbox.setToolTip("ヘッダー行より下で読み込む最大行数(0で全行)")
204
+ csv_form.addRow("読み込む最大行数", self.csv_nrows_spinbox)
205
+
206
+ layout.addLayout(csv_form)
207
+
208
+ self.check_types_button = QPushButton("列の型を確認...")
209
+ self.check_types_button.clicked.connect(self._on_check_column_types)
210
+ layout.addWidget(self.check_types_button)
211
+
212
+ self.encoding_combo.currentIndexChanged.connect(self._reload_csv_preview)
213
+ self.delimiter_combo.currentIndexChanged.connect(self._on_delimiter_combo_changed)
214
+ self.custom_delimiter_edit.editingFinished.connect(self._reload_csv_preview)
215
+ self.csv_header_row_spinbox.valueChanged.connect(self._reload_csv_preview)
216
+ self.fixed_width_checkbox.toggled.connect(self._on_fixed_width_toggled)
217
+ self.fixed_width_edit.editingFinished.connect(self._reload_csv_preview)
218
+ self.csv_nrows_spinbox.valueChanged.connect(self._reload_csv_preview)
219
+ else:
220
+ self.encoding_combo = None
221
+ self.delimiter_combo = None
222
+ self.custom_delimiter_edit = None
223
+ self.csv_header_row_spinbox = None
224
+ self.fixed_width_checkbox = None
225
+ self.fixed_width_edit = None
226
+ self.csv_nrows_spinbox = None
227
+
228
+ self.info_label = QLabel()
229
+ layout.addWidget(self.info_label)
230
+
231
+ self.table = QTableWidget()
232
+ self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
233
+ layout.addWidget(self.table)
234
+
235
+ form = QFormLayout()
236
+ self.x_col_combo = QComboBox()
237
+ self.y_col_combo = QComboBox()
238
+ form.addRow("X軸の列", self.x_col_combo)
239
+ form.addRow("Y軸の列", self.y_col_combo)
240
+ layout.addLayout(form)
241
+
242
+ button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
243
+ QDialogButtonBox.StandardButton.Cancel)
244
+ button_box.accepted.connect(self.accept)
245
+ button_box.rejected.connect(self.reject)
246
+ layout.addWidget(button_box)
247
+
248
+ apply_form_spacing(self)
249
+
250
+ if self.is_csv:
251
+ # 最初に読んだ df はカンマ区切りなので、区切りが違うと1列に潰れている。判定した設定で読み直す
252
+ self._reload_csv_preview()
253
+ else:
254
+ self._rebuild_preview_table()
255
+
256
+ @staticmethod
257
+ def _describe_delimiter(delimiter):
258
+ labels = {',': 'カンマ', '\t': 'タブ', ';': 'セミコロン', r'\s+': '空白'}
259
+ return labels.get(delimiter, repr(delimiter))
260
+
261
+ def _resolve_csv_encoding(self):
262
+ text = self.encoding_combo.currentText()
263
+ if text == self._AUTO_LABEL:
264
+ return self._detected_encoding or 'utf-8-sig'
265
+ return self._ENCODING_CHOICES.get(text, 'utf-8-sig')
266
+
267
+ def _resolve_csv_delimiter(self):
268
+ text = self.delimiter_combo.currentText()
269
+ if text == self._AUTO_LABEL:
270
+ return self._detected_delimiter or ','
271
+ if text == self._DELIMITER_CUSTOM_LABEL:
272
+ return self.custom_delimiter_edit.text() or ','
273
+ return self._DELIMITER_CHOICES.get(text, ',')
274
+
275
+ def _csv_settings_are_automatic(self):
276
+ """区切り・ヘッダー行・固定長を利用者が指定していない(指定したらその通りに読む)。"""
277
+ return (self.delimiter_combo.currentText() == self._AUTO_LABEL
278
+ and self.csv_header_row_spinbox.value() == 1
279
+ and not self.fixed_width_checkbox.isChecked())
280
+
281
+ def _on_delimiter_combo_changed(self, _index=None):
282
+ is_custom = self.delimiter_combo.currentText() == self._DELIMITER_CUSTOM_LABEL
283
+ self.custom_delimiter_label.setVisible(is_custom)
284
+ self.custom_delimiter_edit.setVisible(is_custom)
285
+ self._reload_csv_preview()
286
+
287
+ def _on_fixed_width_toggled(self, checked):
288
+ self.delimiter_combo.setEnabled(not checked)
289
+ self.custom_delimiter_edit.setEnabled(not checked)
290
+ self.fixed_width_label.setVisible(checked)
291
+ self.fixed_width_edit.setVisible(checked)
292
+ self._reload_csv_preview()
293
+
294
+ def _reload_csv_preview(self, *_args):
295
+ """CSV の設定が変わったら読み直す。区切りが正規表現のことがあるので python エンジンで読む。"""
296
+ from graphica.gui.workers import has_numeric_column, read_numeric_table
297
+
298
+ encoding = self._resolve_csv_encoding()
299
+ header_row = self.csv_header_row_spinbox.value() - 1 # 画面は1始まり
300
+ nrows = self.csv_nrows_spinbox.value() or None
301
+ new_df, error = None, None
302
+ try:
303
+ if self.fixed_width_checkbox.isChecked():
304
+ widths_text = self.fixed_width_edit.text().strip()
305
+ read_kwargs = {'header': header_row, 'encoding': encoding, 'nrows': nrows}
306
+ if widths_text:
307
+ read_kwargs['widths'] = [int(w.strip()) for w in widths_text.split(',') if w.strip()]
308
+ new_df = pd.read_fwf(self.file_path, **read_kwargs)
309
+ else:
310
+ from graphica.gui.workers import pandas_separator
311
+ delimiter = self._resolve_csv_delimiter()
312
+ # 推測が「空白」なら、最初の読み込みと同じく空白の連続を1つの区切りとみなす
313
+ sep, _engine = pandas_separator(delimiter) if delimiter == ' ' else (delimiter, 'python')
314
+ new_df = pd.read_csv(
315
+ self.file_path, sep=sep, header=header_row,
316
+ encoding=encoding, engine='python', nrows=nrows
317
+ )
318
+ except Exception as e:
319
+ error = e
320
+
321
+ self._numeric_table_note = ""
322
+ # 設定を変えていないのに読めない・数値の列が無いときは、測定条件などの行に挟まれた数値の表を探す
323
+ if self._csv_settings_are_automatic() and (error is not None or not has_numeric_column(new_df)):
324
+ try:
325
+ found = read_numeric_table(self.file_path, encoding)
326
+ except (OSError, ValueError): # 文字コードの誤りと pandas の ParserError は ValueError
327
+ logger.exception("数値の表を探せませんでした")
328
+ found = None
329
+ if found is not None:
330
+ table_df, table = found
331
+ new_df, error = (table_df.head(nrows) if nrows else table_df), None
332
+ self._numeric_table_note = (
333
+ f"前後の説明の行を除き、{table.first_line + 1}〜{table.stop_line} 行目の数値の表を読み込みました。"
334
+ )
335
+
336
+ if error is not None:
337
+ logger.error("CSV のプレビューを読み込めませんでした", exc_info=error)
338
+ notify.warning(
339
+ self, "読み込みエラー",
340
+ f"指定した条件(文字コード/区切り文字/ヘッダー行/固定長)では読み込めませんでした:\n{error}"
341
+ )
342
+ return
343
+ self.current_df = new_df
344
+ self._apply_type_overrides()
345
+ self._rebuild_preview_table()
346
+
347
+ def _on_sheet_or_header_changed(self):
348
+ """シート・ヘッダー行・使う列・最大行数が変わったら読み直す。"""
349
+ sheet_name = self.sheet_combo.currentText() if self.sheet_combo else 0
350
+ header_row = self.header_row_spinbox.value() - 1 # 画面は1始まり
351
+ usecols = self.usecols_edit.text().strip() or None if self.usecols_edit else None
352
+ nrows = (self.nrows_spinbox.value() or None) if self.nrows_spinbox else None
353
+ try:
354
+ from graphica.gui.workers import excel_engine_for
355
+ new_df = pd.read_excel(
356
+ self.file_path, sheet_name=sheet_name, header=header_row,
357
+ usecols=usecols, nrows=nrows, engine=excel_engine_for(self.file_path)
358
+ )
359
+ except Exception as e:
360
+ logger.exception("Excel のプレビューを読み込めませんでした")
361
+ notify.warning(
362
+ self, "読み込みエラー",
363
+ f"指定した条件(シート/ヘッダー行/使用する列/最大行数)では読み込めませんでした:\n{e}"
364
+ )
365
+ return
366
+ self.current_df = new_df
367
+ self._apply_type_overrides()
368
+ self._rebuild_preview_table()
369
+
370
+ def _on_check_column_types(self):
371
+ dialog = ColumnTypeDialog(self.current_df, self)
372
+ if dialog.exec() == QDialog.DialogCode.Accepted:
373
+ self.type_overrides = dialog.get_overrides()
374
+ self._apply_type_overrides()
375
+ self._rebuild_preview_table()
376
+
377
+ def _apply_type_overrides(self):
378
+ """列の型の上書きを current_df に当てる。読み直した後も、同じ名前の列があれば当て直す。"""
379
+ for col_name, override in self.type_overrides.items():
380
+ if col_name not in self.current_df.columns:
381
+ continue
382
+ try:
383
+ if override == "数値":
384
+ self.current_df[col_name] = pd.to_numeric(self.current_df[col_name], errors='coerce')
385
+ elif override == "文字列":
386
+ self.current_df[col_name] = self.current_df[col_name].astype(str)
387
+ elif override == "日付":
388
+ self.current_df[col_name] = pd.to_datetime(self.current_df[col_name], errors='coerce')
389
+ except Exception as e:
390
+ logger.warning("列「%s」の型変換(%s)に失敗しました: %s", col_name, override, e)
391
+
392
+ def _rebuild_preview_table(self):
393
+ df = self.current_df
394
+ columns = [str(c) for c in df.columns]
395
+
396
+ self.info_label.setText(
397
+ f"{len(df)}行 × {len(columns)}列 が見つかりました。"
398
+ "プレビューを確認し、X軸・Y軸に使う列を選択してください。"
399
+ + (f"\n{self._numeric_table_note}" if self._numeric_table_note else "")
400
+ )
401
+
402
+ preview_row_count = min(len(df), 20)
403
+ self.table.clear()
404
+ self.table.setRowCount(preview_row_count)
405
+ self.table.setColumnCount(len(columns))
406
+ self.table.setHorizontalHeaderLabels(columns)
407
+ for r in range(preview_row_count):
408
+ for c in range(len(columns)):
409
+ value = df.iloc[r, c]
410
+ text = "" if pd.isna(value) else str(value)
411
+ self.table.setItem(r, c, QTableWidgetItem(text))
412
+ self.table.resizeColumnsToContents()
413
+
414
+ # できるだけ前の選択を残し、無ければ先頭の2列
415
+ prev_x = self.x_col_combo.currentText()
416
+ prev_y = self.y_col_combo.currentText()
417
+ self.x_col_combo.blockSignals(True)
418
+ self.y_col_combo.blockSignals(True)
419
+ self.x_col_combo.clear()
420
+ self.y_col_combo.clear()
421
+ self.x_col_combo.addItems(columns)
422
+ self.y_col_combo.addItems(columns)
423
+ if prev_x in columns:
424
+ self.x_col_combo.setCurrentText(prev_x)
425
+ elif len(columns) >= 1:
426
+ self.x_col_combo.setCurrentIndex(0)
427
+ if prev_y in columns:
428
+ self.y_col_combo.setCurrentText(prev_y)
429
+ elif len(columns) >= 2:
430
+ self.y_col_combo.setCurrentIndex(1)
431
+ self.x_col_combo.blockSignals(False)
432
+ self.y_col_combo.blockSignals(False)
433
+
434
+ def accept(self):
435
+ # 文字と数値(や空欄)が混ざった Y の列は、描画の途中で matplotlib が例外を出す。その前に理由を伝える
436
+ _x_col, y_col = self.get_selected_columns()
437
+ if y_col in self.current_df.columns and _mixes_text_and_other_values(self.current_df[y_col]):
438
+ notify.warning(
439
+ self, "Y軸の列を確認してください",
440
+ f"Y軸の列「{y_col}」には、文字と数値(または空欄)が混ざっているため、このままでは描画できません。\n\n"
441
+ "「列の型を確認...」で数値に変換するか、ヘッダー行や別の列を選び直してください。"
442
+ )
443
+ return
444
+ super().accept()
445
+
446
+ def get_selected_columns(self):
447
+ """(X の列名, Y の列名)"""
448
+ return self.x_col_combo.currentText(), self.y_col_combo.currentText()
449
+
450
+ def get_dataframe(self):
451
+ """いま見せている DataFrame(シートなどを変えていればそれを反映したもの)。"""
452
+ return self.current_df
453
+
454
+
455
+ class ColumnTypeDialog(QDialog):
456
+ """自動判定した列の型を見せ、「数値」「文字列」「日付」に直せるようにする(日付が数値として読まれることがある)。"""
457
+
458
+ OVERRIDE_CHOICES = ["自動 (変換しない)", "数値", "文字列", "日付"]
459
+
460
+ def __init__(self, df, parent=None):
461
+ super().__init__(parent)
462
+ self.setWindowTitle("列の型を確認")
463
+ self.resize(480, 400)
464
+
465
+ layout = QVBoxLayout(self)
466
+ info_label = QLabel(
467
+ "各列について、現在検出されている型を表示しています。"
468
+ "日付が数値として読み込まれている場合など、意図と異なる場合は"
469
+ "「上書き後の型」で変換方法を選択してください。"
470
+ )
471
+ info_label.setWordWrap(True)
472
+ layout.addWidget(info_label)
473
+
474
+ self.table = QTableWidget()
475
+ self.table.setColumnCount(3)
476
+ self.table.setHorizontalHeaderLabels(["列名", "検出された型", "上書き後の型"])
477
+ self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
478
+ self.table.setRowCount(len(df.columns))
479
+
480
+ self._override_combos = {}
481
+ for row, col_name in enumerate(df.columns):
482
+ self.table.setItem(row, 0, QTableWidgetItem(str(col_name)))
483
+
484
+ detected_item = QTableWidgetItem(str(df[col_name].dtype))
485
+ detected_item.setFlags(detected_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
486
+ self.table.setItem(row, 1, detected_item)
487
+
488
+ combo = QComboBox()
489
+ combo.addItems(self.OVERRIDE_CHOICES)
490
+ self.table.setCellWidget(row, 2, combo)
491
+ self._override_combos[str(col_name)] = combo
492
+
493
+ self.table.resizeColumnsToContents()
494
+ layout.addWidget(self.table)
495
+
496
+ button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
497
+ QDialogButtonBox.StandardButton.Cancel)
498
+ button_box.accepted.connect(self.accept)
499
+ button_box.rejected.connect(self.reject)
500
+ layout.addWidget(button_box)
501
+
502
+ def get_overrides(self):
503
+ """「自動」以外にした列だけの {列名: "数値" / "文字列" / "日付"}。"""
504
+ overrides = {}
505
+ for col_name, combo in self._override_combos.items():
506
+ text = combo.currentText()
507
+ if text != "自動 (変換しない)":
508
+ overrides[col_name] = text
509
+ return overrides
510
+
511
+
512
+ class ExcelMultiSheetDialog(QDialog):
513
+ """取り込むシートを選ぶ。複数選ぶとシートごとに別のデータセットになる。"""
514
+
515
+ def __init__(self, sheet_names, parent=None):
516
+ super().__init__(parent)
517
+ self.setWindowTitle("シートの選択")
518
+ self.resize(320, 380)
519
+
520
+ layout = QVBoxLayout(self)
521
+ info_label = QLabel(
522
+ "読み込むシートを選択してください(複数選択可)。\n"
523
+ "2つ以上選択すると、シートごとに別々のデータセットとして追加されます。"
524
+ )
525
+ info_label.setWordWrap(True)
526
+ layout.addWidget(info_label)
527
+
528
+ self.sheet_list = QListWidget()
529
+ self.sheet_list.setSelectionMode(QListWidget.SelectionMode.NoSelection)
530
+ for i, name in enumerate(sheet_names):
531
+ item = QListWidgetItem(str(name))
532
+ item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
533
+ item.setCheckState(Qt.CheckState.Checked if i == 0 else Qt.CheckState.Unchecked)
534
+ self.sheet_list.addItem(item)
535
+ layout.addWidget(self.sheet_list)
536
+
537
+ button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
538
+ QDialogButtonBox.StandardButton.Cancel)
539
+ button_box.accepted.connect(self.accept)
540
+ button_box.rejected.connect(self.reject)
541
+ layout.addWidget(button_box)
542
+
543
+ def get_selected_sheets(self):
544
+ """チェックしたシート名(シートの順)。"""
545
+ result = []
546
+ for i in range(self.sheet_list.count()):
547
+ item = self.sheet_list.item(i)
548
+ if item.checkState() == Qt.CheckState.Checked:
549
+ result.append(item.text())
550
+ return result
551
+
552
+
553
+ class FolderImportDialog(QDialog):
554
+ """フォルダ一括取り込みの対象の一覧と、ファイル名から条件を取り出す正規表現。読み込みはドラッグ&ドロップと同じ待ち行列。"""
555
+
556
+ def __init__(self, folder_path, file_names, parent=None):
557
+ super().__init__(parent)
558
+ self.setWindowTitle("フォルダから一括インポート")
559
+ self.resize(480, 340)
560
+
561
+ layout = QVBoxLayout(self)
562
+ folder_label = QLabel(f"フォルダ: {folder_path}")
563
+ folder_label.setWordWrap(True)
564
+ layout.addWidget(folder_label)
565
+ layout.addWidget(QLabel(f"対象ファイル: {len(file_names)}件"))
566
+
567
+ self.file_list = QListWidget()
568
+ self.file_list.addItems(file_names)
569
+ self.file_list.setMaximumHeight(130)
570
+ layout.addWidget(self.file_list)
571
+
572
+ layout.addWidget(QLabel(
573
+ "ファイル名から抽出する正規表現(任意、名前付きグループが新しい列になります):"
574
+ ))
575
+ self.regex_edit = QLineEdit()
576
+ self.regex_edit.setPlaceholderText(r"例: (?P<temp>\d+)C_(?P<run>\d+)\.csv")
577
+ layout.addWidget(self.regex_edit)
578
+
579
+ self.preview_label = QLabel("")
580
+ self.preview_label.setWordWrap(True)
581
+ self.preview_label.setStyleSheet("font-size: 9pt; color: gray;")
582
+ layout.addWidget(self.preview_label)
583
+
584
+ self._first_file_name = file_names[0] if file_names else ""
585
+ self.regex_edit.textChanged.connect(self._update_preview)
586
+ self._update_preview()
587
+
588
+ button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
589
+ QDialogButtonBox.StandardButton.Cancel)
590
+ button_box.accepted.connect(self.accept)
591
+ button_box.rejected.connect(self.reject)
592
+ layout.addWidget(button_box)
593
+
594
+ apply_form_spacing(self)
595
+
596
+ def _update_preview(self):
597
+ """先頭のファイル名で取り出した結果をその場で見せる。"""
598
+ pattern = self.regex_edit.text().strip()
599
+ if not pattern:
600
+ self.preview_label.setText("(正規表現が未入力のため、列は追加されません)")
601
+ return
602
+ try:
603
+ match = re.search(pattern, self._first_file_name)
604
+ except re.error as e:
605
+ self.preview_label.setText(f"正規表現エラー: {e}")
606
+ return
607
+ if match is None:
608
+ self.preview_label.setText(f"「{self._first_file_name}」にマッチしません")
609
+ return
610
+ groups = match.groupdict()
611
+ if not groups:
612
+ self.preview_label.setText("名前付きグループ (?P<name>...) がありません(列は追加されません)")
613
+ return
614
+ preview = ", ".join(f"{k}={v}" for k, v in groups.items())
615
+ self.preview_label.setText(f"「{self._first_file_name}」からの抽出例: {preview}")
616
+
617
+ def get_regex_pattern(self):
618
+ """正規表現。空なら None。"""
619
+ pattern = self.regex_edit.text().strip()
620
+ return pattern if pattern else None
621
+
622
+
623
+ class NewDatasetDialog(QDialog):
624
+ """名前・列名・行数だけで空のデータセットを作る(作った後はデータエディタで打ち込む)。"""
625
+
626
+ def __init__(self, parent=None):
627
+ super().__init__(parent)
628
+ from graphica.core.i18n import tr
629
+
630
+ self.setWindowTitle(tr("新規データセットを作成"))
631
+ self.resize(360, 180)
632
+
633
+ layout = QFormLayout(self)
634
+
635
+ self.name_edit = QLineEdit(tr("新規データセット"))
636
+ layout.addRow(tr("データセット名"), self.name_edit)
637
+
638
+ self.columns_edit = QLineEdit("X, Y")
639
+ self.columns_edit.setToolTip(tr("カンマ区切りで列名を入力してください(例: X, Y, 誤差)"))
640
+ layout.addRow(tr("列名 (カンマ区切り)"), self.columns_edit)
641
+
642
+ self.rows_spinbox = QSpinBox()
643
+ self.rows_spinbox.setRange(0, 10000)
644
+ self.rows_spinbox.setValue(5)
645
+ layout.addRow(tr("初期の空行数"), self.rows_spinbox)
646
+
647
+ button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
648
+ QDialogButtonBox.StandardButton.Cancel)
649
+ button_box.accepted.connect(self._on_accept)
650
+ button_box.rejected.connect(self.reject)
651
+ layout.addRow(button_box)
652
+
653
+ apply_form_spacing(self)
654
+
655
+ def _on_accept(self):
656
+ from graphica.core.i18n import tr
657
+ if not self.get_dataset_name():
658
+ notify.warning(self, tr("新規データセットを作成"), tr("データセット名を入力してください。"))
659
+ return
660
+ if not self.get_column_names():
661
+ notify.warning(self, tr("新規データセットを作成"), tr("列名を1つ以上入力してください。"))
662
+ return
663
+ self.accept()
664
+
665
+ def get_dataset_name(self):
666
+ return self.name_edit.text().strip()
667
+
668
+ def get_column_names(self):
669
+ """カンマ区切りを列名のリストにする(重複と空は除く)。"""
670
+ raw = self.columns_edit.text()
671
+ names = [c.strip() for c in raw.split(',') if c.strip()]
672
+ unique_names = []
673
+ for name in names:
674
+ if name not in unique_names:
675
+ unique_names.append(name)
676
+ return unique_names
677
+
678
+ def get_row_count(self):
679
+ return self.rows_spinbox.value()