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.
- graphica/Graphica.ico +0 -0
- graphica/__init__.py +1 -0
- graphica/__main__.py +111 -0
- graphica/assets/__init__.py +0 -0
- graphica/assets/icons/__init__.py +0 -0
- graphica/assets/icons/arrow-right.svg +21 -0
- graphica/assets/icons/bold.svg +20 -0
- graphica/assets/icons/calculator.svg +26 -0
- graphica/assets/icons/chart-histogram.svg +24 -0
- graphica/assets/icons/chart-line.svg +20 -0
- graphica/assets/icons/chevron-down.svg +19 -0
- graphica/assets/icons/chevron-right.svg +19 -0
- graphica/assets/icons/color-swatch.svg +22 -0
- graphica/assets/icons/column-insert-right.svg +21 -0
- graphica/assets/icons/column-remove.svg +21 -0
- graphica/assets/icons/copy.svg +20 -0
- graphica/assets/icons/download.svg +21 -0
- graphica/assets/icons/edit.svg +21 -0
- graphica/assets/icons/eye-off.svg +21 -0
- graphica/assets/icons/eye.svg +20 -0
- graphica/assets/icons/file-plus.svg +22 -0
- graphica/assets/icons/folder-plus.svg +21 -0
- graphica/assets/icons/folder.svg +19 -0
- graphica/assets/icons/highlight.svg +22 -0
- graphica/assets/icons/history.svg +20 -0
- graphica/assets/icons/italic.svg +21 -0
- graphica/assets/icons/layout-grid.svg +22 -0
- graphica/assets/icons/math-function.svg +22 -0
- graphica/assets/icons/message-2.svg +21 -0
- graphica/assets/icons/mountain.svg +20 -0
- graphica/assets/icons/palette.svg +22 -0
- graphica/assets/icons/pointer.svg +19 -0
- graphica/assets/icons/refresh.svg +20 -0
- graphica/assets/icons/row-insert-bottom.svg +21 -0
- graphica/assets/icons/row-remove.svg +21 -0
- graphica/assets/icons/search.svg +20 -0
- graphica/assets/icons/select-all.svg +35 -0
- graphica/assets/icons/subscript.svg +20 -0
- graphica/assets/icons/superscript.svg +20 -0
- graphica/assets/icons/table.svg +21 -0
- graphica/assets/icons/trash.svg +23 -0
- graphica/assets/icons/typography.svg +23 -0
- graphica/assets/icons/x.svg +20 -0
- graphica/core/__init__.py +0 -0
- graphica/core/analysis.py +1278 -0
- graphica/core/app_paths.py +35 -0
- graphica/core/axis_settings.py +128 -0
- graphica/core/caption_export.py +47 -0
- graphica/core/color_palettes.py +56 -0
- graphica/core/commands.py +226 -0
- graphica/core/cvd_simulation.py +49 -0
- graphica/core/dataset.py +443 -0
- graphica/core/diagnostics.py +105 -0
- graphica/core/excel_utils.py +58 -0
- graphica/core/fit_models.py +379 -0
- graphica/core/grid_data.py +128 -0
- graphica/core/i18n.py +44 -0
- graphica/core/json_utils.py +27 -0
- graphica/core/label_utils.py +21 -0
- graphica/core/methods_text.py +115 -0
- graphica/core/named_colors.py +126 -0
- graphica/core/plugin_api.py +515 -0
- graphica/core/plugin_context.py +167 -0
- graphica/core/plugin_install.py +82 -0
- graphica/core/plugin_manifest.py +67 -0
- graphica/core/plugin_testing.py +213 -0
- graphica/core/plugin_types.py +118 -0
- graphica/core/provenance.py +19 -0
- graphica/core/report_export.py +51 -0
- graphica/core/safe_eval.py +194 -0
- graphica/core/script_export.py +272 -0
- graphica/core/translations_en.py +530 -0
- graphica/core/unit_conversion.py +51 -0
- graphica/core/update_check.py +43 -0
- graphica/core/version.py +14 -0
- graphica/gui/__init__.py +0 -0
- graphica/gui/app_settings.py +119 -0
- graphica/gui/axis_bindings.py +173 -0
- graphica/gui/binding.py +135 -0
- graphica/gui/builders/__init__.py +1 -0
- graphica/gui/builders/axis_panel.py +431 -0
- graphica/gui/builders/canvas_area.py +176 -0
- graphica/gui/builders/common.py +120 -0
- graphica/gui/builders/dataset_panel.py +433 -0
- graphica/gui/builders/property_sections.py +208 -0
- graphica/gui/canvas.py +519 -0
- graphica/gui/color_history.py +47 -0
- graphica/gui/color_picker_widget.py +198 -0
- graphica/gui/crash_handler.py +77 -0
- graphica/gui/cvd_preview.py +23 -0
- graphica/gui/data_editor.py +761 -0
- graphica/gui/data_import_flow.py +355 -0
- graphica/gui/dataset_bindings.py +107 -0
- graphica/gui/dataset_style_icon.py +80 -0
- graphica/gui/datasets/__init__.py +1 -0
- graphica/gui/datasets/actions_menu.py +114 -0
- graphica/gui/datasets/colors.py +218 -0
- graphica/gui/datasets/fitting.py +70 -0
- graphica/gui/datasets/host.py +162 -0
- graphica/gui/datasets/operations/__init__.py +1 -0
- graphica/gui/datasets/operations/fitting.py +468 -0
- graphica/gui/datasets/operations/peaks.py +109 -0
- graphica/gui/datasets/operations/processing.py +659 -0
- graphica/gui/datasets/operations/runner.py +148 -0
- graphica/gui/datasets/operations/transfer.py +227 -0
- graphica/gui/datasets/order.py +310 -0
- graphica/gui/datasets/overlays.py +74 -0
- graphica/gui/datasets/peaks.py +21 -0
- graphica/gui/datasets/plugin_runs.py +99 -0
- graphica/gui/datasets/processing.py +68 -0
- graphica/gui/datasets/property_panel.py +538 -0
- graphica/gui/datasets/transfer.py +36 -0
- graphica/gui/detached_canvas_window.py +22 -0
- graphica/gui/dialogs/__init__.py +137 -0
- graphica/gui/dialogs/analysis.py +1322 -0
- graphica/gui/dialogs/app.py +1015 -0
- graphica/gui/dialogs/appearance.py +851 -0
- graphica/gui/dialogs/data_edit.py +577 -0
- graphica/gui/dialogs/data_import.py +679 -0
- graphica/gui/dialogs/export.py +433 -0
- graphica/gui/dock_layout.py +176 -0
- graphica/gui/export_preview_panel.py +321 -0
- graphica/gui/export_settings.py +16 -0
- graphica/gui/file_association.py +97 -0
- graphica/gui/icon_utils.py +44 -0
- graphica/gui/main_app_window.py +210 -0
- graphica/gui/main_window.py +1151 -0
- graphica/gui/mathtext_preview.py +168 -0
- graphica/gui/menu_bar.py +320 -0
- graphica/gui/minimap_widget.py +151 -0
- graphica/gui/mixins/__init__.py +0 -0
- graphica/gui/mixins/export_mixin.py +520 -0
- graphica/gui/mixins/help_mixin.py +127 -0
- graphica/gui/mixins/project_io_mixin.py +289 -0
- graphica/gui/mixins/quick_access_mixin.py +194 -0
- graphica/gui/mixins/ui_setup_mixin.py +206 -0
- graphica/gui/notify.py +61 -0
- graphica/gui/panels/__init__.py +43 -0
- graphica/gui/panels/axis_settings.py +605 -0
- graphica/gui/panels/dataset_tree.py +424 -0
- graphica/gui/plot_type_drawers.py +131 -0
- graphica/gui/plugin_context.py +119 -0
- graphica/gui/project_files.py +361 -0
- graphica/gui/provenance_panel.py +65 -0
- graphica/gui/rendering/__init__.py +1 -0
- graphica/gui/rendering/annotations.py +177 -0
- graphica/gui/rendering/appearance.py +368 -0
- graphica/gui/rendering/common.py +316 -0
- graphica/gui/rendering/data_1d.py +376 -0
- graphica/gui/rendering/data_2d.py +66 -0
- graphica/gui/residual_panel.py +62 -0
- graphica/gui/resources.py +16 -0
- graphica/gui/single_instance.py +141 -0
- graphica/gui/splash.py +83 -0
- graphica/gui/task_runner.py +41 -0
- graphica/gui/theme.py +864 -0
- graphica/gui/tools/__init__.py +56 -0
- graphica/gui/tools/annotation.py +275 -0
- graphica/gui/tools/cursor.py +259 -0
- graphica/gui/tools/layout_edit.py +285 -0
- graphica/gui/tools/manager.py +78 -0
- graphica/gui/tools/peak_placement.py +132 -0
- graphica/gui/tools/range_select.py +191 -0
- graphica/gui/tools/region_highlight.py +222 -0
- graphica/gui/tools/slice_extraction.py +200 -0
- graphica/gui/widget_translation.py +46 -0
- graphica/gui/workers.py +280 -0
- graphica/models/__init__.py +0 -0
- graphica/models/project.py +197 -0
- graphica/plugin/__init__.py +20 -0
- graphica/plugin/testing.py +57 -0
- graphica/sample_data/__init__.py +0 -0
- graphica/sample_data/cooling_curve_sample.csv +42 -0
- graphica/ui_main_window.py +581 -0
- graphica_plot-2.0.0.dist-info/METADATA +317 -0
- graphica_plot-2.0.0.dist-info/RECORD +181 -0
- graphica_plot-2.0.0.dist-info/WHEEL +5 -0
- graphica_plot-2.0.0.dist-info/entry_points.txt +2 -0
- graphica_plot-2.0.0.dist-info/licenses/LICENSE +30 -0
- graphica_plot-2.0.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +91 -0
- graphica_plot-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from scipy import stats as scipy_stats
|
|
7
|
+
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QPushButton,
|
|
8
|
+
QTableWidget, QTableWidgetItem, QMenuBar,
|
|
9
|
+
QDialogButtonBox)
|
|
10
|
+
from PySide6.QtGui import QUndoStack, QKeySequence, QColor
|
|
11
|
+
from PySide6.QtCore import Signal, Qt
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
from graphica.gui import notify
|
|
16
|
+
from graphica.core.commands import (EditCellCommand, AddRowCommand, DeleteRowsCommand,
|
|
17
|
+
AddColumnCommand, DeleteColumnCommand, SetMaskedRowsCommand,
|
|
18
|
+
RenameColumnCommand)
|
|
19
|
+
from graphica.core.safe_eval import safe_eval_column_formula
|
|
20
|
+
from graphica.gui.dialogs import (ColumnCalculatorDialog, ReplicateErrorDialog, ColumnStringOpsDialog,
|
|
21
|
+
ColumnVisibilityDialog, FindReplaceDialog)
|
|
22
|
+
from graphica.gui import icon_utils
|
|
23
|
+
from graphica.gui import theme
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _masked_row_background():
|
|
27
|
+
"""マスク済み行の背景色を、現在のテーマトークンから解決する。"""
|
|
28
|
+
return QColor(theme.current_tokens()["surface_2"])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _nan_cell_background():
|
|
32
|
+
"""欠損値のセルの背景。マスクした行(行全体の印)とは別の色にする。"""
|
|
33
|
+
return QColor(theme.current_tokens()["warning_soft"])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DataEditorDialog(QDialog):
|
|
37
|
+
"""dataset.df の表示と編集。編集は Undo できる(列の計算など一部を除く)。"""
|
|
38
|
+
|
|
39
|
+
# データが変わったことを PlotterApp に知らせる
|
|
40
|
+
dataChanged = Signal()
|
|
41
|
+
|
|
42
|
+
# 選んだ行の df.index ラベル(空なら選択なし)。グラフ側で強調するため
|
|
43
|
+
rowsHighlighted = Signal(list)
|
|
44
|
+
|
|
45
|
+
def __init__(self, dataset, parent=None):
|
|
46
|
+
super().__init__(parent)
|
|
47
|
+
# 独立したウィンドウにして、タスクバーや Alt+Tab から呼び戻せるようにする。親は残すので本体と一緒に閉じる
|
|
48
|
+
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
49
|
+
self.setWindowTitle(f"データエディタ: {dataset.name}")
|
|
50
|
+
self.resize(800, 600)
|
|
51
|
+
|
|
52
|
+
self.dataset = dataset
|
|
53
|
+
|
|
54
|
+
# 並べ替えは表示用のコピーに対して行い、dataset.df は変えない
|
|
55
|
+
self.view_df = self.dataset.df.copy()
|
|
56
|
+
self.sort_state = (None, True) # (並べ替え中の列名, 昇順か)
|
|
57
|
+
|
|
58
|
+
# 隠した列の名前(表示だけで dataset.df は変えない)
|
|
59
|
+
self._hidden_columns = set()
|
|
60
|
+
|
|
61
|
+
# 開いたままにして、「次を検索」を前回の続きから探す
|
|
62
|
+
self._find_replace_dialog = None
|
|
63
|
+
self._last_search_query = None
|
|
64
|
+
self._last_search_index = -1
|
|
65
|
+
|
|
66
|
+
self.undo_stack = QUndoStack(self)
|
|
67
|
+
# コマンドは GUI を知らないので、表の描き直しと通知はここでする
|
|
68
|
+
self.undo_stack.indexChanged.connect(self._on_undo_stack_changed)
|
|
69
|
+
|
|
70
|
+
self.table_widget = QTableWidget()
|
|
71
|
+
self.table_widget.setSortingEnabled(False) # 並べ替えは view_df で自前で行う
|
|
72
|
+
self.table_widget.horizontalHeader().setSectionsClickable(True)
|
|
73
|
+
self.table_widget.horizontalHeader().sectionClicked.connect(self._on_header_clicked)
|
|
74
|
+
self.table_widget.horizontalHeader().sectionDoubleClicked.connect(self._on_header_double_clicked)
|
|
75
|
+
# 列の並べ替えも見た目だけ(dataset.df の列順は変えない)
|
|
76
|
+
self.table_widget.horizontalHeader().setSectionsMovable(True)
|
|
77
|
+
self.table_widget.itemSelectionChanged.connect(self._on_table_selection_changed)
|
|
78
|
+
self._populate_table()
|
|
79
|
+
|
|
80
|
+
button_layout = QHBoxLayout()
|
|
81
|
+
self.add_row_button = QPushButton("行を追加")
|
|
82
|
+
self.delete_row_button = QPushButton("選択行を削除")
|
|
83
|
+
self.mask_rows_button = QPushButton("選択行を除外/解除")
|
|
84
|
+
self.add_col_button = QPushButton("列を追加")
|
|
85
|
+
self.delete_col_button = QPushButton("選択列を削除")
|
|
86
|
+
self.calc_button = QPushButton("列の計算...")
|
|
87
|
+
self.replicate_error_button = QPushButton("誤差の自動計算...")
|
|
88
|
+
self.string_ops_button = QPushButton("文字列操作...")
|
|
89
|
+
self.column_visibility_button = QPushButton("列の表示/非表示...")
|
|
90
|
+
self.find_replace_button = QPushButton("検索/置換...")
|
|
91
|
+
self.jump_to_row_button = QPushButton("行へ移動...")
|
|
92
|
+
self.save_csv_button = QPushButton("CSVとして保存...")
|
|
93
|
+
|
|
94
|
+
_button_icons = (
|
|
95
|
+
(self.add_row_button, "row-insert-bottom"),
|
|
96
|
+
(self.delete_row_button, "row-remove"),
|
|
97
|
+
(self.mask_rows_button, "eye-off"),
|
|
98
|
+
(self.add_col_button, "column-insert-right"),
|
|
99
|
+
(self.delete_col_button, "column-remove"),
|
|
100
|
+
(self.calc_button, "calculator"),
|
|
101
|
+
(self.replicate_error_button, "math-function"),
|
|
102
|
+
(self.string_ops_button, "typography"),
|
|
103
|
+
(self.column_visibility_button, "eye"),
|
|
104
|
+
(self.find_replace_button, "search"),
|
|
105
|
+
(self.jump_to_row_button, "arrow-right"),
|
|
106
|
+
(self.save_csv_button, "download"),
|
|
107
|
+
)
|
|
108
|
+
self.mask_rows_button.setToolTip(
|
|
109
|
+
"行を削除せず、フィット/プロットの対象から除外(または解除)します(非破壊的)"
|
|
110
|
+
)
|
|
111
|
+
for button, icon_name in _button_icons:
|
|
112
|
+
if not button.toolTip():
|
|
113
|
+
button.setToolTip(button.text())
|
|
114
|
+
button.setText("")
|
|
115
|
+
button.setIcon(icon_utils.icon(icon_name, size=18))
|
|
116
|
+
button.setProperty("iconOnly", True)
|
|
117
|
+
button.setFixedSize(34, 34)
|
|
118
|
+
# フォーカスが残ると :focus の枠が次の操作まで出たままになる
|
|
119
|
+
button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
|
120
|
+
|
|
121
|
+
button_layout.addWidget(self.add_row_button)
|
|
122
|
+
button_layout.addWidget(self.delete_row_button)
|
|
123
|
+
button_layout.addWidget(self.mask_rows_button)
|
|
124
|
+
button_layout.addStretch()
|
|
125
|
+
button_layout.addWidget(self.add_col_button)
|
|
126
|
+
button_layout.addWidget(self.delete_col_button)
|
|
127
|
+
button_layout.addStretch()
|
|
128
|
+
button_layout.addWidget(self.calc_button)
|
|
129
|
+
button_layout.addWidget(self.replicate_error_button)
|
|
130
|
+
button_layout.addWidget(self.string_ops_button)
|
|
131
|
+
button_layout.addStretch()
|
|
132
|
+
button_layout.addWidget(self.column_visibility_button)
|
|
133
|
+
button_layout.addWidget(self.find_replace_button)
|
|
134
|
+
button_layout.addWidget(self.jump_to_row_button)
|
|
135
|
+
button_layout.addStretch()
|
|
136
|
+
button_layout.addWidget(self.save_csv_button)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
main_layout = QVBoxLayout(self)
|
|
140
|
+
|
|
141
|
+
menu_bar = QMenuBar(self)
|
|
142
|
+
edit_menu = menu_bar.addMenu("編集")
|
|
143
|
+
|
|
144
|
+
undo_action = self.undo_stack.createUndoAction(self, "元に戻す")
|
|
145
|
+
undo_action.setShortcut(QKeySequence.StandardKey.Undo)
|
|
146
|
+
|
|
147
|
+
redo_action = self.undo_stack.createRedoAction(self, "やり直し")
|
|
148
|
+
redo_action.setShortcut(QKeySequence.StandardKey.Redo)
|
|
149
|
+
|
|
150
|
+
edit_menu.addAction(undo_action)
|
|
151
|
+
edit_menu.addAction(redo_action)
|
|
152
|
+
|
|
153
|
+
main_layout.setMenuBar(menu_bar)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
main_layout.addLayout(button_layout)
|
|
157
|
+
main_layout.addWidget(self.table_widget)
|
|
158
|
+
|
|
159
|
+
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
|
160
|
+
button_box.rejected.connect(self.reject)
|
|
161
|
+
main_layout.addWidget(button_box)
|
|
162
|
+
|
|
163
|
+
self.table_widget.cellChanged.connect(self._on_cell_changed)
|
|
164
|
+
|
|
165
|
+
self.add_row_button.clicked.connect(self._on_add_row)
|
|
166
|
+
self.delete_row_button.clicked.connect(self._on_delete_rows)
|
|
167
|
+
self.mask_rows_button.clicked.connect(self._on_toggle_mask_rows)
|
|
168
|
+
self.add_col_button.clicked.connect(self._on_add_column)
|
|
169
|
+
self.delete_col_button.clicked.connect(self._on_delete_column)
|
|
170
|
+
self.calc_button.clicked.connect(self._on_calculate_column)
|
|
171
|
+
self.replicate_error_button.clicked.connect(self._on_calculate_replicate_error)
|
|
172
|
+
self.string_ops_button.clicked.connect(self._on_column_string_ops)
|
|
173
|
+
self.column_visibility_button.clicked.connect(self._on_toggle_column_visibility)
|
|
174
|
+
self.find_replace_button.clicked.connect(self._on_open_find_replace)
|
|
175
|
+
self.jump_to_row_button.clicked.connect(self._on_jump_to_row)
|
|
176
|
+
self.save_csv_button.clicked.connect(self._on_save_as_csv)
|
|
177
|
+
|
|
178
|
+
def _populate_table(self):
|
|
179
|
+
"""view_df を表に入れる。NaN / NaT は空欄で出す。"""
|
|
180
|
+
df = self.view_df
|
|
181
|
+
|
|
182
|
+
# 入れている間に cellChanged が出ないように
|
|
183
|
+
self.table_widget.blockSignals(True)
|
|
184
|
+
|
|
185
|
+
self.table_widget.setRowCount(len(df))
|
|
186
|
+
self.table_widget.setColumnCount(len(df.columns))
|
|
187
|
+
self.table_widget.setHorizontalHeaderLabels(df.columns)
|
|
188
|
+
|
|
189
|
+
# 行の見出しは df.index のラベル(並べ替えてもどの行か分かる)
|
|
190
|
+
self.table_widget.setVerticalHeaderLabels([str(i) for i in df.index])
|
|
191
|
+
|
|
192
|
+
for i in range(len(df)):
|
|
193
|
+
is_masked = df.index[i] in self.dataset.masked_row_indices
|
|
194
|
+
for j in range(len(df.columns)):
|
|
195
|
+
value = df.iloc[i, j]
|
|
196
|
+
|
|
197
|
+
is_nan = pd.isna(value)
|
|
198
|
+
item_text = "" if is_nan else str(value)
|
|
199
|
+
|
|
200
|
+
item = QTableWidgetItem(item_text)
|
|
201
|
+
if is_masked:
|
|
202
|
+
item.setBackground(_masked_row_background())
|
|
203
|
+
item.setToolTip("この行はフィット/プロットから除外されています")
|
|
204
|
+
elif is_nan:
|
|
205
|
+
# マスクした行は行全体に色が付いているので重ねない
|
|
206
|
+
item.setBackground(_nan_cell_background())
|
|
207
|
+
item.setToolTip("欠損値(NaN)です")
|
|
208
|
+
self.table_widget.setItem(i, j, item)
|
|
209
|
+
|
|
210
|
+
self.table_widget.resizeColumnsToContents()
|
|
211
|
+
|
|
212
|
+
# 表を作り直すたびに隠す列を当て直す(並べ替え・列の追加・Undo の後も消えないように)
|
|
213
|
+
for col_idx, col_name in enumerate(df.columns):
|
|
214
|
+
self.table_widget.setColumnHidden(col_idx, col_name in self._hidden_columns)
|
|
215
|
+
|
|
216
|
+
sort_col, sort_ascending = self.sort_state
|
|
217
|
+
header = self.table_widget.horizontalHeader()
|
|
218
|
+
if sort_col is not None and sort_col in df.columns:
|
|
219
|
+
header.setSortIndicatorShown(True)
|
|
220
|
+
header.setSortIndicator(
|
|
221
|
+
df.columns.get_loc(sort_col),
|
|
222
|
+
Qt.SortOrder.AscendingOrder if sort_ascending else Qt.SortOrder.DescendingOrder
|
|
223
|
+
)
|
|
224
|
+
else:
|
|
225
|
+
header.setSortIndicatorShown(False)
|
|
226
|
+
|
|
227
|
+
self.table_widget.blockSignals(False)
|
|
228
|
+
|
|
229
|
+
def _on_header_clicked(self, logical_index):
|
|
230
|
+
"""その列で並べ替える(同じ列なら昇順と降順を入れ替える)。表示だけで、データと Undo には影響しない。"""
|
|
231
|
+
col_name = self.view_df.columns[logical_index]
|
|
232
|
+
current_col, current_ascending = self.sort_state
|
|
233
|
+
ascending = (not current_ascending) if current_col == col_name else True
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
# 安定な並べ替え(同じ値の行の順を保つ)
|
|
237
|
+
self.view_df = self.view_df.sort_values(by=col_name, ascending=ascending, kind='mergesort')
|
|
238
|
+
except TypeError:
|
|
239
|
+
# 型の混ざった列は並べ替えられないことがある
|
|
240
|
+
notify.warning(self, "ソートエラー", f"列 '{col_name}' はソートできませんでした。")
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
self.sort_state = (col_name, ascending)
|
|
244
|
+
self._populate_table()
|
|
245
|
+
|
|
246
|
+
def _on_header_double_clicked(self, logical_index):
|
|
247
|
+
old_name = self.view_df.columns[logical_index]
|
|
248
|
+
new_name, ok = notify.get_text(self, "列名の変更", "新しい列名:", text=old_name)
|
|
249
|
+
if not ok:
|
|
250
|
+
return
|
|
251
|
+
new_name = new_name.strip()
|
|
252
|
+
if not new_name or new_name == old_name:
|
|
253
|
+
return
|
|
254
|
+
if new_name in self.dataset.df.columns:
|
|
255
|
+
notify.warning(self, "列名の変更", f"列名 '{new_name}' は既に使用されています。")
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
command = RenameColumnCommand(self.dataset, old_name, new_name)
|
|
259
|
+
self.undo_stack.push(command)
|
|
260
|
+
|
|
261
|
+
def get_selected_master_indices(self):
|
|
262
|
+
"""選んだ行の dataset.df の index ラベル。view_df は並べ替えてあるので view_df.index を通す。"""
|
|
263
|
+
rows = sorted({index.row() for index in self.table_widget.selectionModel().selectedRows()})
|
|
264
|
+
return [self.view_df.index[r] for r in rows if r < len(self.view_df.index)]
|
|
265
|
+
|
|
266
|
+
def _on_table_selection_changed(self):
|
|
267
|
+
self.rowsHighlighted.emit(self.get_selected_master_indices())
|
|
268
|
+
|
|
269
|
+
def select_row_by_master_index(self, master_index):
|
|
270
|
+
"""グラフの点から表の行を選ぶ(逆向きの強調)。通知が往復しないよう itemSelectionChanged を止める。"""
|
|
271
|
+
matches = np.where(self.view_df.index == master_index)[0]
|
|
272
|
+
if len(matches) == 0:
|
|
273
|
+
return
|
|
274
|
+
row = int(matches[0])
|
|
275
|
+
|
|
276
|
+
self.table_widget.blockSignals(True)
|
|
277
|
+
self.table_widget.clearSelection()
|
|
278
|
+
self.table_widget.selectRow(row)
|
|
279
|
+
self.table_widget.blockSignals(False)
|
|
280
|
+
|
|
281
|
+
item = self.table_widget.item(row, 0)
|
|
282
|
+
if item is not None:
|
|
283
|
+
self.table_widget.scrollToItem(item)
|
|
284
|
+
|
|
285
|
+
def closeEvent(self, event):
|
|
286
|
+
self.rowsHighlighted.emit([])
|
|
287
|
+
super().closeEvent(event)
|
|
288
|
+
|
|
289
|
+
def _on_cell_changed(self, row, column):
|
|
290
|
+
try:
|
|
291
|
+
# row は表示上の行番号なので、view_df.index で df のラベルにする
|
|
292
|
+
original_index = self.view_df.index[row]
|
|
293
|
+
col_name = self.view_df.columns[column]
|
|
294
|
+
|
|
295
|
+
old_value = self.dataset.df.loc[original_index, col_name]
|
|
296
|
+
|
|
297
|
+
new_value_str = self.table_widget.item(row, column).text()
|
|
298
|
+
|
|
299
|
+
new_value = None
|
|
300
|
+
|
|
301
|
+
if new_value_str == "":
|
|
302
|
+
new_value = np.nan
|
|
303
|
+
else:
|
|
304
|
+
original_dtype = self.dataset.df[col_name].dtype
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
# bool 列は np.bool_("False") が True になるので自前で読む
|
|
308
|
+
if np.issubdtype(original_dtype, np.bool_):
|
|
309
|
+
normalized = new_value_str.strip().lower()
|
|
310
|
+
if normalized in ("true", "1", "yes"):
|
|
311
|
+
new_value = True
|
|
312
|
+
elif normalized in ("false", "0", "no"):
|
|
313
|
+
new_value = False
|
|
314
|
+
else:
|
|
315
|
+
raise ValueError(f"'{new_value_str}' を真偽値として解釈できません")
|
|
316
|
+
else:
|
|
317
|
+
new_value = np.dtype(original_dtype).type(new_value_str)
|
|
318
|
+
except (ValueError, TypeError):
|
|
319
|
+
# 数値と bool の列は NaN にする(文字列のまま入れると列全体が object 型になる)
|
|
320
|
+
if np.issubdtype(original_dtype, np.number) or np.issubdtype(original_dtype, np.bool_):
|
|
321
|
+
new_value = np.nan
|
|
322
|
+
else:
|
|
323
|
+
new_value = new_value_str
|
|
324
|
+
|
|
325
|
+
# NaN 同士は != で比べられない
|
|
326
|
+
is_nan_old = pd.isna(old_value)
|
|
327
|
+
is_nan_new = pd.isna(new_value)
|
|
328
|
+
|
|
329
|
+
if (is_nan_old and not is_nan_new) or \
|
|
330
|
+
(not is_nan_old and is_nan_new) or \
|
|
331
|
+
(not is_nan_old and not is_nan_new and old_value != new_value):
|
|
332
|
+
command = EditCellCommand(self.dataset, original_index, col_name, old_value, new_value)
|
|
333
|
+
self.undo_stack.push(command)
|
|
334
|
+
|
|
335
|
+
else:
|
|
336
|
+
# 変わっていなくても表示を整える("1.0" を "1.0" に編集したときなど)
|
|
337
|
+
self.table_widget.blockSignals(True)
|
|
338
|
+
item_text = "" if is_nan_old else str(old_value)
|
|
339
|
+
self.table_widget.item(row, column).setText(item_text)
|
|
340
|
+
self.table_widget.blockSignals(False)
|
|
341
|
+
|
|
342
|
+
except Exception:
|
|
343
|
+
logger.exception("セル編集コマンド作成エラー")
|
|
344
|
+
# 元の値を表示し直す。シグナルは必ず戻す(止めたままだと以後の編集が無視される)。
|
|
345
|
+
self.table_widget.blockSignals(True)
|
|
346
|
+
try:
|
|
347
|
+
original_index = self.view_df.index[row]
|
|
348
|
+
col_name = self.view_df.columns[column]
|
|
349
|
+
original_value = self.dataset.df.loc[original_index, col_name]
|
|
350
|
+
item_text = "" if pd.isna(original_value) else str(original_value)
|
|
351
|
+
self.table_widget.item(row, column).setText(item_text)
|
|
352
|
+
except Exception:
|
|
353
|
+
logger.exception("セルの表示を元の値に戻せませんでした")
|
|
354
|
+
finally:
|
|
355
|
+
self.table_widget.blockSignals(False)
|
|
356
|
+
|
|
357
|
+
def _reset_view(self):
|
|
358
|
+
"""view_df を dataset.df から取り直し、並べ替えを戻して描き直す。"""
|
|
359
|
+
self.view_df = self.dataset.df.copy()
|
|
360
|
+
self.sort_state = (None, True)
|
|
361
|
+
self._populate_table()
|
|
362
|
+
|
|
363
|
+
def _on_undo_stack_changed(self, index):
|
|
364
|
+
self._reset_view()
|
|
365
|
+
self.dataChanged.emit()
|
|
366
|
+
|
|
367
|
+
def _on_add_row(self):
|
|
368
|
+
command = AddRowCommand(self.dataset)
|
|
369
|
+
self.undo_stack.push(command)
|
|
370
|
+
|
|
371
|
+
def _on_delete_rows(self):
|
|
372
|
+
selected_items = self.table_widget.selectedItems()
|
|
373
|
+
if not selected_items: return
|
|
374
|
+
|
|
375
|
+
view_rows = sorted(list(set(item.row() for item in selected_items)))
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
original_indices_attempt = [self.view_df.index[row] for row in view_rows]
|
|
379
|
+
except IndexError:
|
|
380
|
+
notify.warning(self, "削除エラー", "行インデックスの取得に失敗しました。")
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
# view_df と dataset.df の index がずれていた場合に備える
|
|
384
|
+
valid_indices_to_delete = [
|
|
385
|
+
idx for idx in original_indices_attempt
|
|
386
|
+
if idx in self.dataset.df.index
|
|
387
|
+
]
|
|
388
|
+
|
|
389
|
+
if not valid_indices_to_delete:
|
|
390
|
+
notify.warning(self, "削除エラー", "削除対象のデータがマスターに見つかりませんでした。")
|
|
391
|
+
return
|
|
392
|
+
|
|
393
|
+
# Undo のため、消す前に写しを取る
|
|
394
|
+
deleted_data = self.dataset.df.loc[valid_indices_to_delete].copy()
|
|
395
|
+
|
|
396
|
+
command = DeleteRowsCommand(self.dataset, valid_indices_to_delete, deleted_data)
|
|
397
|
+
self.undo_stack.push(command)
|
|
398
|
+
|
|
399
|
+
def _on_toggle_mask_rows(self):
|
|
400
|
+
"""選んだ行のマスクを反転する(行は消さない。Undo できる)。"""
|
|
401
|
+
selected_items = self.table_widget.selectedItems()
|
|
402
|
+
if not selected_items:
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
view_rows = sorted(set(item.row() for item in selected_items))
|
|
406
|
+
try:
|
|
407
|
+
master_indices = [self.view_df.index[row] for row in view_rows]
|
|
408
|
+
except IndexError:
|
|
409
|
+
notify.warning(self, "操作エラー", "行インデックスの取得に失敗しました。")
|
|
410
|
+
return
|
|
411
|
+
master_indices = [idx for idx in master_indices if idx in self.dataset.df.index]
|
|
412
|
+
if not master_indices:
|
|
413
|
+
return
|
|
414
|
+
|
|
415
|
+
old_masked = list(self.dataset.masked_row_indices)
|
|
416
|
+
new_masked = list(old_masked)
|
|
417
|
+
for idx in master_indices:
|
|
418
|
+
if idx in new_masked:
|
|
419
|
+
new_masked.remove(idx)
|
|
420
|
+
else:
|
|
421
|
+
new_masked.append(idx)
|
|
422
|
+
|
|
423
|
+
command = SetMaskedRowsCommand(self.dataset, old_masked, new_masked)
|
|
424
|
+
self.undo_stack.push(command)
|
|
425
|
+
|
|
426
|
+
def _on_add_column(self):
|
|
427
|
+
col_name, ok = notify.get_text(self, "列の追加", "新しい列名を入力してください:")
|
|
428
|
+
|
|
429
|
+
if ok and col_name:
|
|
430
|
+
if col_name in self.dataset.df.columns:
|
|
431
|
+
notify.warning(self, "エラー", f"列名 '{col_name}' は既に存在します。")
|
|
432
|
+
return
|
|
433
|
+
|
|
434
|
+
command = AddColumnCommand(self.dataset, col_name)
|
|
435
|
+
self.undo_stack.push(command)
|
|
436
|
+
|
|
437
|
+
def _on_delete_column(self):
|
|
438
|
+
current_col_index = self.table_widget.currentColumn()
|
|
439
|
+
if current_col_index == -1:
|
|
440
|
+
notify.warning(self,"エラー", "削除する列が選択されていません。")
|
|
441
|
+
return
|
|
442
|
+
|
|
443
|
+
col_name = self.view_df.columns[current_col_index]
|
|
444
|
+
|
|
445
|
+
# 描画に使っている列は消させない
|
|
446
|
+
if (col_name == self.dataset.x_col_name or
|
|
447
|
+
col_name == self.dataset.y_col_name):
|
|
448
|
+
|
|
449
|
+
notify.warning(self, "削除不可",
|
|
450
|
+
f"列 '{col_name}' は現在プロットに使用されているため削除できません。")
|
|
451
|
+
return
|
|
452
|
+
|
|
453
|
+
deleted_column_data = self.dataset.df[col_name].copy()
|
|
454
|
+
|
|
455
|
+
command = DeleteColumnCommand(self.dataset, col_name, deleted_column_data)
|
|
456
|
+
self.undo_stack.push(command)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _on_calculate_column(self):
|
|
460
|
+
"""列の計算。Undo できない。"""
|
|
461
|
+
|
|
462
|
+
dialog = ColumnCalculatorDialog(self.dataset.df.columns.tolist(), self)
|
|
463
|
+
|
|
464
|
+
if dialog.exec() == QDialog.DialogCode.Accepted:
|
|
465
|
+
output_col, formula = dialog.get_formula()
|
|
466
|
+
|
|
467
|
+
if not output_col or not formula:
|
|
468
|
+
notify.warning(self, "入力エラー", "出力列または計算式が空です。")
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
try:
|
|
472
|
+
self.dataset.df[output_col] = safe_eval_column_formula(self.dataset.df, formula)
|
|
473
|
+
self.dataset.invalidate_visible_df_cache()
|
|
474
|
+
|
|
475
|
+
logger.info("計算完了: %s = %s", output_col, formula)
|
|
476
|
+
|
|
477
|
+
self._reset_view()
|
|
478
|
+
|
|
479
|
+
self.dataChanged.emit()
|
|
480
|
+
|
|
481
|
+
except Exception as e:
|
|
482
|
+
logger.exception("計算エラー")
|
|
483
|
+
notify.critical(self, "計算エラー",
|
|
484
|
+
f"計算式の実行に失敗しました:\n\n{e}\n\n"
|
|
485
|
+
"列名 (A, B など) や関数 (log(A) など) が正しいか確認してください。")
|
|
486
|
+
|
|
487
|
+
def _on_calculate_replicate_error(self):
|
|
488
|
+
"""反復測定の列から行ごとの平均と誤差(SD / SEM / 95%CI)を2つの列として足す。Undo できない。"""
|
|
489
|
+
dialog = ReplicateErrorDialog(self.dataset.df.columns.tolist(), self)
|
|
490
|
+
if dialog.exec() != QDialog.DialogCode.Accepted:
|
|
491
|
+
return
|
|
492
|
+
|
|
493
|
+
selected_cols, stat_type, base_name = dialog.get_settings()
|
|
494
|
+
if len(selected_cols) < 2:
|
|
495
|
+
notify.warning(self, "入力エラー", "反復測定として扱う列を2つ以上選択してください。")
|
|
496
|
+
return
|
|
497
|
+
if not base_name:
|
|
498
|
+
notify.warning(self, "入力エラー", "出力列名のベースが空です。")
|
|
499
|
+
return
|
|
500
|
+
|
|
501
|
+
error_suffix = {"SD": "SD", "SEM": "SEM", "95%CI": "CI95"}[stat_type]
|
|
502
|
+
mean_col_name = f"{base_name}_mean"
|
|
503
|
+
error_col_name = f"{base_name}_{error_suffix}"
|
|
504
|
+
|
|
505
|
+
if mean_col_name in self.dataset.df.columns or error_col_name in self.dataset.df.columns:
|
|
506
|
+
notify.warning(
|
|
507
|
+
self, "エラー",
|
|
508
|
+
f"列名 '{mean_col_name}' または '{error_col_name}' は既に存在します。"
|
|
509
|
+
"別のベース名を指定してください。"
|
|
510
|
+
)
|
|
511
|
+
return
|
|
512
|
+
|
|
513
|
+
try:
|
|
514
|
+
values = self.dataset.df[selected_cols].apply(pd.to_numeric, errors='coerce')
|
|
515
|
+
mean = values.mean(axis=1)
|
|
516
|
+
std = values.std(axis=1, ddof=1)
|
|
517
|
+
n = values.notna().sum(axis=1)
|
|
518
|
+
|
|
519
|
+
if stat_type == "SD":
|
|
520
|
+
error = std
|
|
521
|
+
elif stat_type == "SEM":
|
|
522
|
+
error = std / np.sqrt(n)
|
|
523
|
+
else: # 95%CI は t 分布で(反復が少ないと正規近似より正確)
|
|
524
|
+
dof = (n - 1).clip(lower=1)
|
|
525
|
+
t_crit = pd.Series(scipy_stats.t.ppf(0.975, dof), index=dof.index)
|
|
526
|
+
error = t_crit * std / np.sqrt(n)
|
|
527
|
+
|
|
528
|
+
self.dataset.df[mean_col_name] = mean
|
|
529
|
+
self.dataset.df[error_col_name] = error
|
|
530
|
+
self.dataset.invalidate_visible_df_cache()
|
|
531
|
+
|
|
532
|
+
logger.info(
|
|
533
|
+
"誤差自動計算完了: %s, %s (元列: %s, 統計量: %s)",
|
|
534
|
+
mean_col_name, error_col_name, selected_cols, stat_type
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
self._reset_view()
|
|
538
|
+
self.dataChanged.emit()
|
|
539
|
+
|
|
540
|
+
except Exception as e:
|
|
541
|
+
logger.exception("誤差自動計算エラー")
|
|
542
|
+
notify.critical(self, "計算エラー", f"誤差の計算に失敗しました:\n{e}")
|
|
543
|
+
|
|
544
|
+
def _on_column_string_ops(self):
|
|
545
|
+
"""列の分割・結合・数値の抽出。結果は新しい列に入れる(既存の列は上書きしない)。Undo できない。"""
|
|
546
|
+
dialog = ColumnStringOpsDialog(self.dataset.df.columns.tolist(), self)
|
|
547
|
+
if dialog.exec() != QDialog.DialogCode.Accepted:
|
|
548
|
+
return
|
|
549
|
+
|
|
550
|
+
mode = dialog.get_mode()
|
|
551
|
+
|
|
552
|
+
if mode == ColumnStringOpsDialog.MODE_SPLIT:
|
|
553
|
+
source_col, delimiter, prefix = dialog.get_split_settings()
|
|
554
|
+
if not delimiter:
|
|
555
|
+
notify.warning(self, "入力エラー", "区切り文字が空です。")
|
|
556
|
+
return
|
|
557
|
+
prefix = prefix or source_col
|
|
558
|
+
try:
|
|
559
|
+
split_result = self.dataset.df[source_col].astype(str).str.split(delimiter, expand=True)
|
|
560
|
+
except Exception as e:
|
|
561
|
+
logger.exception("列の分割エラー")
|
|
562
|
+
notify.critical(self, "列の分割エラー", f"分割に失敗しました:\n{e}")
|
|
563
|
+
return
|
|
564
|
+
new_names = []
|
|
565
|
+
for i in range(split_result.shape[1]):
|
|
566
|
+
name = f"{prefix}_{i + 1}"
|
|
567
|
+
while name in self.dataset.df.columns or name in new_names:
|
|
568
|
+
name = f"{name}_2"
|
|
569
|
+
new_names.append(name)
|
|
570
|
+
for name, col_idx in zip(new_names, split_result.columns):
|
|
571
|
+
self.dataset.df[name] = split_result[col_idx]
|
|
572
|
+
self.dataset.invalidate_visible_df_cache()
|
|
573
|
+
logger.info("列の分割完了: %s -> %s (区切り文字: %r)", source_col, new_names, delimiter)
|
|
574
|
+
|
|
575
|
+
elif mode == ColumnStringOpsDialog.MODE_MERGE:
|
|
576
|
+
selected_cols, separator, output_col = dialog.get_merge_settings()
|
|
577
|
+
if len(selected_cols) < 2:
|
|
578
|
+
notify.warning(self, "入力エラー", "結合する列を2つ以上選択してください。")
|
|
579
|
+
return
|
|
580
|
+
if not output_col:
|
|
581
|
+
notify.warning(self, "入力エラー", "出力列名が空です。")
|
|
582
|
+
return
|
|
583
|
+
if output_col in self.dataset.df.columns:
|
|
584
|
+
notify.warning(self, "入力エラー", f"列名 '{output_col}' は既に存在します。")
|
|
585
|
+
return
|
|
586
|
+
try:
|
|
587
|
+
merged = self.dataset.df[selected_cols[0]].astype(str)
|
|
588
|
+
for col in selected_cols[1:]:
|
|
589
|
+
merged = merged + separator + self.dataset.df[col].astype(str)
|
|
590
|
+
except Exception as e:
|
|
591
|
+
logger.exception("列の結合エラー")
|
|
592
|
+
notify.critical(self, "列の結合エラー", f"結合に失敗しました:\n{e}")
|
|
593
|
+
return
|
|
594
|
+
self.dataset.df[output_col] = merged
|
|
595
|
+
self.dataset.invalidate_visible_df_cache()
|
|
596
|
+
logger.info("列の結合完了: %s -> %s (区切り文字: %r)", selected_cols, output_col, separator)
|
|
597
|
+
|
|
598
|
+
else:
|
|
599
|
+
source_col, pattern, output_col = dialog.get_extract_settings()
|
|
600
|
+
if not pattern:
|
|
601
|
+
notify.warning(self, "入力エラー", "正規表現が空です。")
|
|
602
|
+
return
|
|
603
|
+
if not output_col:
|
|
604
|
+
notify.warning(self, "入力エラー", "出力列名が空です。")
|
|
605
|
+
return
|
|
606
|
+
if output_col in self.dataset.df.columns:
|
|
607
|
+
notify.warning(self, "入力エラー", f"列名 '{output_col}' は既に存在します。")
|
|
608
|
+
return
|
|
609
|
+
try:
|
|
610
|
+
extracted = self.dataset.df[source_col].astype(str).str.extract(f"({pattern})", expand=False)
|
|
611
|
+
values = pd.to_numeric(extracted, errors='coerce')
|
|
612
|
+
except Exception as e:
|
|
613
|
+
logger.exception("数値抽出エラー")
|
|
614
|
+
notify.critical(self, "数値抽出エラー", f"正規表現が不正です:\n{e}")
|
|
615
|
+
return
|
|
616
|
+
self.dataset.df[output_col] = values
|
|
617
|
+
self.dataset.invalidate_visible_df_cache()
|
|
618
|
+
logger.info("数値抽出完了: %s -> %s (パターン: %r)", source_col, output_col, pattern)
|
|
619
|
+
|
|
620
|
+
self._reset_view()
|
|
621
|
+
self.dataChanged.emit()
|
|
622
|
+
|
|
623
|
+
def _on_toggle_column_visibility(self):
|
|
624
|
+
"""チェックを外した列を隠す(表示だけ)。"""
|
|
625
|
+
dialog = ColumnVisibilityDialog(self.view_df.columns.tolist(), self._hidden_columns, self)
|
|
626
|
+
if dialog.exec() != QDialog.DialogCode.Accepted:
|
|
627
|
+
return
|
|
628
|
+
self._hidden_columns = set(dialog.get_hidden_columns())
|
|
629
|
+
for col_idx, col_name in enumerate(self.view_df.columns):
|
|
630
|
+
self.table_widget.setColumnHidden(col_idx, col_name in self._hidden_columns)
|
|
631
|
+
|
|
632
|
+
def _on_open_find_replace(self):
|
|
633
|
+
"""開いていれば前面に出すだけ。"""
|
|
634
|
+
if self._find_replace_dialog is None:
|
|
635
|
+
self._find_replace_dialog = FindReplaceDialog(self.dataset.df.columns.tolist(), self)
|
|
636
|
+
self._find_replace_dialog.find_next_button.clicked.connect(self._on_find_next)
|
|
637
|
+
self._find_replace_dialog.replace_all_button.clicked.connect(self._on_replace_all)
|
|
638
|
+
self._find_replace_dialog.show()
|
|
639
|
+
self._find_replace_dialog.raise_()
|
|
640
|
+
self._find_replace_dialog.activateWindow()
|
|
641
|
+
|
|
642
|
+
def _on_find_next(self):
|
|
643
|
+
"""前回見つけた位置の次から、行優先で大文字小文字を区別せず部分一致で探す。検索語が変われば最初から。"""
|
|
644
|
+
dialog = self._find_replace_dialog
|
|
645
|
+
query = dialog.get_search_text()
|
|
646
|
+
if not query:
|
|
647
|
+
dialog.set_status("検索文字列を入力してください")
|
|
648
|
+
return
|
|
649
|
+
|
|
650
|
+
target_col = dialog.get_target_column()
|
|
651
|
+
df = self.view_df
|
|
652
|
+
columns = [target_col] if target_col else list(df.columns)
|
|
653
|
+
if not columns or len(df) == 0:
|
|
654
|
+
dialog.set_status("検索対象のデータがありません")
|
|
655
|
+
return
|
|
656
|
+
|
|
657
|
+
if self._last_search_query != query:
|
|
658
|
+
self._last_search_index = -1
|
|
659
|
+
self._last_search_query = query
|
|
660
|
+
|
|
661
|
+
cells = [(r, c) for r in range(len(df)) for c in columns]
|
|
662
|
+
total = len(cells)
|
|
663
|
+
for offset in range(1, total + 1):
|
|
664
|
+
idx = (self._last_search_index + offset) % total
|
|
665
|
+
row, col_name = cells[idx]
|
|
666
|
+
value = df.iloc[row][col_name]
|
|
667
|
+
if pd.isna(value):
|
|
668
|
+
continue
|
|
669
|
+
if query.lower() in str(value).lower():
|
|
670
|
+
col_index = df.columns.get_loc(col_name)
|
|
671
|
+
self.table_widget.setCurrentCell(row, col_index)
|
|
672
|
+
item = self.table_widget.item(row, col_index)
|
|
673
|
+
if item is not None:
|
|
674
|
+
self.table_widget.scrollToItem(item)
|
|
675
|
+
self._last_search_index = idx
|
|
676
|
+
dialog.set_status(f"見つかりました(表示上の{row + 1}行目、列「{col_name}」)")
|
|
677
|
+
return
|
|
678
|
+
|
|
679
|
+
dialog.set_status("見つかりませんでした")
|
|
680
|
+
|
|
681
|
+
def _on_replace_all(self):
|
|
682
|
+
"""一致する全セルを置き換え、1つの Undo にまとめる。
|
|
683
|
+
|
|
684
|
+
置き換えた値は文字列なので、対象は文字列の列だけ(数値などの列は丸ごと object 型になってしまう)。
|
|
685
|
+
"""
|
|
686
|
+
dialog = self._find_replace_dialog
|
|
687
|
+
query = dialog.get_search_text()
|
|
688
|
+
replacement = dialog.get_replace_text()
|
|
689
|
+
if not query:
|
|
690
|
+
dialog.set_status("検索文字列を入力してください")
|
|
691
|
+
return
|
|
692
|
+
|
|
693
|
+
target_col = dialog.get_target_column()
|
|
694
|
+
all_columns = [target_col] if target_col else list(self.dataset.df.columns)
|
|
695
|
+
columns = [c for c in all_columns if self.dataset.df[c].dtype == object]
|
|
696
|
+
skipped_non_string = len(all_columns) - len(columns)
|
|
697
|
+
|
|
698
|
+
matches = [] # (行ラベル, 列名, 旧値, 新値)
|
|
699
|
+
for col_name in columns:
|
|
700
|
+
for row_label, value in self.dataset.df[col_name].items():
|
|
701
|
+
if pd.isna(value):
|
|
702
|
+
continue
|
|
703
|
+
text = str(value)
|
|
704
|
+
new_text = re.sub(re.escape(query), replacement, text, flags=re.IGNORECASE)
|
|
705
|
+
if new_text != text:
|
|
706
|
+
matches.append((row_label, col_name, value, new_text))
|
|
707
|
+
|
|
708
|
+
if not matches:
|
|
709
|
+
note = "(数値/真偽値/日付列は置換対象外です)" if skipped_non_string else ""
|
|
710
|
+
dialog.set_status(f"置換対象が見つかりませんでした{note}")
|
|
711
|
+
return
|
|
712
|
+
|
|
713
|
+
self.undo_stack.beginMacro(f"検索/置換 ({len(matches)}件)")
|
|
714
|
+
for row_label, col_name, old_value, new_value in matches:
|
|
715
|
+
self.undo_stack.push(EditCellCommand(self.dataset, row_label, col_name, old_value, new_value))
|
|
716
|
+
self.undo_stack.endMacro()
|
|
717
|
+
|
|
718
|
+
note = "(数値/真偽値/日付列は置換対象外です)" if skipped_non_string else ""
|
|
719
|
+
dialog.set_status(f"{len(matches)}件を置換しました{note}")
|
|
720
|
+
|
|
721
|
+
def _on_jump_to_row(self):
|
|
722
|
+
"""表示上の行番号(1始まり)の行を選んで見せる。"""
|
|
723
|
+
if len(self.view_df) == 0:
|
|
724
|
+
notify.information(self, "行へ移動", "テーブルにデータがありません。")
|
|
725
|
+
return
|
|
726
|
+
row_number, ok = notify.get_int(
|
|
727
|
+
self, "行へ移動", f"移動先の行番号 (1〜{len(self.view_df)}):",
|
|
728
|
+
1, 1, len(self.view_df)
|
|
729
|
+
)
|
|
730
|
+
if not ok:
|
|
731
|
+
return
|
|
732
|
+
row_index = row_number - 1
|
|
733
|
+
self.table_widget.setCurrentCell(row_index, 0)
|
|
734
|
+
item = self.table_widget.item(row_index, 0)
|
|
735
|
+
if item is not None:
|
|
736
|
+
self.table_widget.scrollToItem(item)
|
|
737
|
+
|
|
738
|
+
def _on_save_as_csv(self):
|
|
739
|
+
base_name = os.path.splitext(self.dataset.name)[0]
|
|
740
|
+
base_name = base_name.split(' (')[0]
|
|
741
|
+
suggested_name = f"{base_name}_edited.csv"
|
|
742
|
+
|
|
743
|
+
file_path, _ = notify.get_save_file_name(
|
|
744
|
+
self,
|
|
745
|
+
"CSVとして保存",
|
|
746
|
+
suggested_name,
|
|
747
|
+
"CSV Files (*.csv);;All Files (*)"
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
if not file_path:
|
|
751
|
+
return
|
|
752
|
+
|
|
753
|
+
try:
|
|
754
|
+
# utf-8-sig でないと Excel で開いたとき日本語が化ける
|
|
755
|
+
self.dataset.df.to_csv(file_path, index=False, encoding='utf-8-sig')
|
|
756
|
+
|
|
757
|
+
notify.information(self, "保存完了", f"データをCSVファイルとして保存しました:\n{file_path}")
|
|
758
|
+
|
|
759
|
+
except Exception as e:
|
|
760
|
+
logger.exception("CSV保存エラー")
|
|
761
|
+
notify.warning(self, "保存エラー", f"CSVファイルの保存中にエラーが発生しました:\n{e}")
|