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,443 @@
1
+ import uuid
2
+ import warnings
3
+ import numpy as np
4
+ import pandas as pd
5
+ from dataclasses import dataclass, field, fields, MISSING
6
+ from typing import Any
7
+
8
+ # 線種は短い記号('-')とコンボの表示名('solid')の両方で保存されている。保存値は書き換えず
9
+ # (既存のプロジェクトのため)、表示と比較の直前に揃える。
10
+ LINESTYLE_NAMES = ('solid', 'dashed', 'dotted', 'dashdot')
11
+ _LINESTYLE_ALIASES = {
12
+ '-': 'solid', 'solid': 'solid',
13
+ '--': 'dashed', 'dashed': 'dashed',
14
+ ':': 'dotted', 'dotted': 'dotted',
15
+ '-.': 'dashdot', 'dashdot': 'dashdot',
16
+ }
17
+
18
+
19
+ def linestyle_name(value: Any) -> str | None:
20
+ """プロパティ欄の表示名に揃える。線を描かない値や未知の値は None。"""
21
+ if value is None:
22
+ return None
23
+ return _LINESTYLE_ALIASES.get(str(value).strip().lower())
24
+
25
+ # z_col_name の値で点を配色する散布図。値は保存されるので変えない(変えると既存のプロジェクトが Line に戻る)。
26
+ # 種類の欄の幅は最長の項目で決まり、長くするとドックに横スクロールバーが出るので 15 文字に収めている。
27
+ COLOR_BY_COLUMN_PLOT_TYPE = 'Z-Color Scatter'
28
+
29
+
30
+ @dataclass
31
+ class Dataset:
32
+ """1つの系列のデータと見た目。"""
33
+
34
+ name: str # 凡例に出る名前
35
+ df: pd.DataFrame
36
+ x_col_name: str
37
+ y_col_name: str
38
+
39
+
40
+ def __setattr__(self, name: str, value: Any) -> None:
41
+ """df と masked_row_indices の再代入で visible_df のキャッシュを捨てる。
42
+
43
+ df をその場で書き換えたとき(dataset.df[col] = ...)はここを通らないので、invalidate_visible_df_cache() を呼ぶ。
44
+ """
45
+ object.__setattr__(self, name, value)
46
+ if name in ('df', 'masked_row_indices'):
47
+ self.invalidate_visible_df_cache()
48
+
49
+ def invalidate_visible_df_cache(self) -> None:
50
+ """df をその場で書き換えた後に呼ぶ(再代入なら自動で捨てられる)。"""
51
+ self.__dict__['_version'] = self.__dict__.get('_version', 0) + 1
52
+
53
+ @property
54
+ def visible_df(self) -> pd.DataFrame:
55
+ """masked_row_indices の行を除いた DataFrame。x_data などはすべてここを通る。
56
+
57
+ 1回の描画で x_data / y_data / 誤差が別々に呼ぶので、版が同じ間はキャッシュを使う。
58
+ """
59
+ version = self.__dict__.get('_version', 0)
60
+ if self.__dict__.get('_visible_df_cache_version') != version:
61
+ if not self.masked_row_indices:
62
+ cache = self.df
63
+ else:
64
+ cache = self.df[~self.df.index.isin(self.masked_row_indices)]
65
+ self.__dict__['_visible_df_cache'] = cache
66
+ self.__dict__['_visible_df_cache_version'] = version
67
+ return self.__dict__['_visible_df_cache']
68
+
69
+ @property
70
+ def z_grid(self) -> dict[str, Any] | None:
71
+ """data_kind='2d_grid' の格子({'x_grid', 'y_grid', 'z_grid', 'is_regular'})。それ以外は None。
72
+
73
+ 散在データの補間は重いのでキャッシュする。列名や補間の設定は版番号を上げないので、キャッシュのキーに含める。
74
+ """
75
+ if self.data_kind != '2d_grid' or not self.z_col_name:
76
+ return None
77
+
78
+ from graphica.core.grid_data import compute_z_grid, GridDataError
79
+
80
+ resolution_key = tuple(self.grid_resolution) if self.grid_resolution else None
81
+ version = self.__dict__.get('_version', 0)
82
+ cache_key = (
83
+ version, self.x_col_name, self.y_col_name, self.z_col_name,
84
+ self.grid_interp_method, resolution_key,
85
+ )
86
+ if self.__dict__.get('_z_grid_cache_key') != cache_key:
87
+ df = self.visible_df
88
+ if self.z_col_name not in df.columns:
89
+ grid = None
90
+ else:
91
+ try:
92
+ grid = compute_z_grid(
93
+ df[self.x_col_name].values, df[self.y_col_name].values,
94
+ df[self.z_col_name].values,
95
+ interp_method=self.grid_interp_method, resolution=resolution_key,
96
+ )
97
+ except GridDataError:
98
+ grid = None
99
+ self.__dict__['_z_grid_cache'] = grid
100
+ self.__dict__['_z_grid_cache_key'] = cache_key
101
+ return self.__dict__['_z_grid_cache']
102
+
103
+ @property
104
+ def x_data(self) -> np.ndarray:
105
+ """X の列の値(マスクした行を除く)。"""
106
+ return self.visible_df[self.x_col_name].values
107
+
108
+ @property
109
+ def y_data(self) -> np.ndarray:
110
+ """Y の列の値(マスクした行を除く)。"""
111
+ return self.visible_df[self.y_col_name].values
112
+
113
+ @property
114
+ def x_err_data(self) -> np.ndarray | None:
115
+ """X の誤差の列の値。列が未設定なら None(エラーバーなし)。x_data と長さが揃う。"""
116
+ if self.x_err_col_name and self.x_err_col_name in self.df.columns:
117
+ return self.visible_df[self.x_err_col_name].values
118
+ return None
119
+
120
+ @property
121
+ def z_data(self) -> np.ndarray | None:
122
+ """Z の列の値。未設定か列が無ければ None。
123
+
124
+ 2D マップと 'Z-Color Scatter' の色が使う。visible_df を通すので、マスクしても点と色がずれない。
125
+ """
126
+ if self.z_col_name and self.z_col_name in self.df.columns:
127
+ return self.visible_df[self.z_col_name].values
128
+ return None
129
+
130
+ @property
131
+ def y_err_data(self) -> np.ndarray | None:
132
+ """Y の誤差の列の値。x_err_data と同じ。"""
133
+ if self.y_err_col_name and self.y_err_col_name in self.df.columns:
134
+ return self.visible_df[self.y_err_col_name].values
135
+ return None
136
+
137
+ # 'Line', 'Scatter', 'Line+Scatter', 'Area', 'Bar', 'Step', 'Density Scatter', 'Z-Color Scatter'、またはプラグインの種類
138
+ plot_type: str = 'Line'
139
+ color: str = '#1f77b4'
140
+ linestyle: str = '-'
141
+ linewidth: float = 1.5
142
+ marker: str = 'o'
143
+ markersize: float = 6.0
144
+ smoothing: bool = False
145
+ # 'cubic_spline'(200点に補間)/ 'moving_average' / 'median' / 'gaussian'(点の数はそのまま)
146
+ smoothing_method: str = 'cubic_spline'
147
+ alpha: float = 1.0
148
+
149
+ # 線の色を color から gradient_color2 へ変える。'fill' と 'both' は Area の塗りにも効く
150
+ gradient_enabled: bool = False
151
+ gradient_color2: str = '#ffffff'
152
+ gradient_target: str = 'line' # 'line' / 'fill' / 'both'
153
+
154
+ # ウォーターフォール(積み重ね)。種類とは独立で、同じサブプロットの有効な系列を順に offset ずつずらして重ねる
155
+ waterfall_enabled: bool = False
156
+ waterfall_offset_x: float = 0.0
157
+ waterfall_offset_y: float = 1.0
158
+ # 手前の系列の下を背景色で塗り、奥の系列を隠す
159
+ waterfall_occlusion_enabled: bool = True
160
+
161
+ # 奥の系列ほど Y をわずかに縮めて奥行きを出す
162
+ waterfall_depth_shrink_enabled: bool = False
163
+ # 1段あたりの縮小率(下限は canvas の WATERFALL_DEPTH_SHRINK_MIN_SCALE)
164
+ waterfall_depth_shrink_ratio: float = 0.03
165
+
166
+ show_point_labels: bool = False
167
+ # None なら Y の値をラベルにする
168
+ point_label_col_name: str | None = field(default=None)
169
+
170
+ # None ならその軸のエラーバーは描かない
171
+ x_err_col_name: str | None = field(default=None)
172
+ y_err_col_name: str | None = field(default=None)
173
+
174
+ # 'bar' / 'band' / 'both'
175
+ error_display: str = 'bar'
176
+
177
+ # 描画・フィット・解析から除く行。位置ではなく df.index のラベル
178
+ masked_row_indices: list = field(default_factory=list)
179
+
180
+ fit_info: str | None = field(default=None) # 表示用のフィット結果の文字列
181
+
182
+ # フィット結果(fit_type, params, param_errors, covariance, r_squared, residuals など)。
183
+ # pickle と JSON の両方で往復できるよう、Python の素の型だけにする。
184
+ fit_result: dict | None = field(default=None)
185
+
186
+ # "confidence" / "prediction" / None。df の 'y_lower' / 'y_upper' 列を帯として描く
187
+ fit_band_display: str | None = field(default=None)
188
+
189
+ # プラグインの処理・解析が作った系列なら、そのプラグイン名
190
+ source_plugin: str | None = field(default=None)
191
+
192
+ # 読み込んだファイルの絶対パス(「再読み込み」用)。ファイルから作っていない系列は None。
193
+ # source_sheet は Excel のシート名(CSV と単一シートでは None)
194
+ source_file: str | None = field(default=None)
195
+ source_sheet: str | None = field(default=None)
196
+
197
+ # 欠損値の描き方。描くときだけ効き、データは変えない。
198
+ # 'gap'(線を切る)/ 'ffill'(前の値で埋める)/ 'drop'(除いてつなぐ)
199
+ nan_policy: str = field(default='gap')
200
+
201
+ # この系列を作った直近1回の操作({operation, params, source_dataset_ids, source_dataset_names, timestamp})。
202
+ # 全体の履歴は source_dataset_ids を辿って組み立てる。読み込んだデータは None
203
+ provenance: dict | None = field(default=None)
204
+
205
+ use_secondary_y: bool = field(default=False)
206
+ subplot_target: int = field(default=0) # 描画先のサブプロット(0始まり)
207
+
208
+ # '2d_grid' なら df は x/y/z の長形式で、z_grid が格子を組み立てる
209
+ data_kind: str = field(default='1d')
210
+ z_col_name: str | None = field(default=None)
211
+ # 散在データの補間: 'linear' / 'cubic' / 'nearest'
212
+ grid_interp_method: str = field(default='linear')
213
+ # [nx, ny]、None なら自動。JSON で tuple が list になるので最初から list
214
+ grid_resolution: list | None = field(default=None)
215
+ # vmin / vmax が None ならデータの最小・最大
216
+ colormap: str = field(default='viridis')
217
+ vmin: float | None = field(default=None)
218
+ vmax: float | None = field(default=None)
219
+
220
+ # 'heatmap' / 'contour' / 'contour_filled' / 'heatmap_contour'
221
+ map_display_mode: str = field(default='heatmap')
222
+ contour_levels: int = field(default=10)
223
+
224
+ # False なら描画とエクスポートから外す(系列は残る)
225
+ visible: bool = field(default=True)
226
+
227
+ artist: object = field(default=None, repr=False)
228
+
229
+ # 名前に依らずに系列を特定する ID
230
+ dataset_id: str = field(default_factory=lambda: uuid.uuid4().hex, repr=False)
231
+
232
+ # df はこのクラスのメソッドで変える(Undo のコマンドがこれを呼ぶ)
233
+
234
+ def set_cell(self, row_idx: Any, col_name: str, value: Any) -> None:
235
+ # 列の型に入らない値(bool 列の NaN、日時列の文字列など)は、列を object 型に
236
+ # してから入れる。pandas 3 は暗黙の型変換をやめて例外を出す。
237
+ with warnings.catch_warnings():
238
+ warnings.simplefilter("error", FutureWarning)
239
+ try:
240
+ self.df.loc[row_idx, col_name] = value
241
+ except (FutureWarning, TypeError):
242
+ self.df[col_name] = self.df[col_name].astype(object)
243
+ self.df.loc[row_idx, col_name] = value
244
+ self.invalidate_visible_df_cache()
245
+
246
+ def add_row(self) -> None:
247
+ """NaN の行を末尾に足す。
248
+
249
+ 全体を振り直すと delete_rows が残した欠番(restore_rows に要る)が消えるので、新しいラベルを1つだけ割り当てる。
250
+ """
251
+ new_index = (self.df.index.max() + 1) if len(self.df) > 0 else 0
252
+ new_row = pd.Series([np.nan] * len(self.df.columns), index=self.df.columns, name=new_index)
253
+ self.df = pd.concat([self.df, new_row.to_frame().T])
254
+
255
+ def delete_last_row(self) -> None:
256
+ """末尾の行を削除する(add_row の取り消し用)。"""
257
+ if len(self.df) > 0:
258
+ self.df = self.df.drop(self.df.index[-1])
259
+
260
+ def delete_rows(self, row_indices: list[Any]) -> None:
261
+ """行を削除する。index は振り直さない(restore_rows が元のラベルで戻すため)。
262
+
263
+ 削除した行のラベルは masked_row_indices からも消す(残すと、同じラベルで足した新しい行が隠れる)。
264
+ """
265
+ self.df = self.df.drop(row_indices)
266
+ if self.masked_row_indices:
267
+ deleted_set = set(row_indices)
268
+ remaining_mask = [idx for idx in self.masked_row_indices if idx not in deleted_set]
269
+ if len(remaining_mask) != len(self.masked_row_indices):
270
+ self.masked_row_indices = remaining_mask
271
+
272
+ def restore_rows(self, deleted_data: pd.DataFrame) -> None:
273
+ """delete_rows で消した行を元のラベルで戻す。
274
+
275
+ 振り直すと、ほかの削除で空いた欠番がずれ、後続の Undo が別の行を触る。
276
+ """
277
+ restored_df = pd.concat([self.df, deleted_data])
278
+ self.df = restored_df.sort_index()
279
+
280
+ def is_column_in_use(self, col_name: str) -> bool:
281
+ """X/Y、または誤差の列として使っているか。"""
282
+ return col_name in (self.x_col_name, self.y_col_name, self.x_err_col_name, self.y_err_col_name)
283
+
284
+ def add_column(self, col_name: str) -> None:
285
+ if col_name not in self.df.columns:
286
+ self.df[col_name] = np.nan
287
+ self.invalidate_visible_df_cache()
288
+
289
+ def remove_column(self, col_name: str) -> None:
290
+ if col_name in self.df.columns:
291
+ self.df = self.df.drop(columns=[col_name])
292
+
293
+ def rename_column(self, old_name: str, new_name: str) -> None:
294
+ """列名を変える。X/Y・誤差・点のラベルの列として使っていれば、そちらも新しい名前にする。"""
295
+ if old_name not in self.df.columns or old_name == new_name:
296
+ return
297
+ self.df = self.df.rename(columns={old_name: new_name})
298
+ if self.x_col_name == old_name:
299
+ self.x_col_name = new_name
300
+ if self.y_col_name == old_name:
301
+ self.y_col_name = new_name
302
+ if self.x_err_col_name == old_name:
303
+ self.x_err_col_name = new_name
304
+ if self.y_err_col_name == old_name:
305
+ self.y_err_col_name = new_name
306
+ if self.point_label_col_name == old_name:
307
+ self.point_label_col_name = new_name
308
+
309
+ def restore_column(self, col_name: str, column_data: pd.Series) -> None:
310
+ """remove_column で消した列を末尾に戻す。"""
311
+ if col_name not in self.df.columns:
312
+ self.df[col_name] = column_data
313
+ self.invalidate_visible_df_cache()
314
+
315
+
316
+ def to_dict(self) -> dict:
317
+ """JSON に保存できる dict にする。artist は含めない。"""
318
+ result = {}
319
+ for f in fields(self):
320
+ if f.name in ('artist', 'df'):
321
+ continue
322
+ value = getattr(self, f.name)
323
+ if f.name == 'masked_row_indices':
324
+ # numpy.int64 が混ざることがある
325
+ value = [int(v) for v in value]
326
+ result[f.name] = value
327
+ result['df'] = self._df_to_dict(self.df)
328
+ return result
329
+
330
+ @classmethod
331
+ def from_dict(cls, data: dict) -> 'Dataset':
332
+ """to_dict() の逆。古いファイルに無いフィールドは既定値で補う。"""
333
+ obj = cls.__new__(cls)
334
+ state = {}
335
+ df_data = data.get('df')
336
+ state['df'] = cls._df_from_dict(df_data) if df_data is not None else pd.DataFrame()
337
+ state['artist'] = None
338
+ for f in fields(cls):
339
+ if f.name in ('df', 'artist'):
340
+ continue
341
+ if f.name in data:
342
+ value = data[f.name]
343
+ if f.name == 'masked_row_indices' and value is not None:
344
+ value = [int(v) for v in value]
345
+ state[f.name] = value
346
+ elif f.default is not MISSING:
347
+ state[f.name] = f.default
348
+ elif f.default_factory is not MISSING:
349
+ state[f.name] = f.default_factory()
350
+ else:
351
+ # 既定値の無いフィールドが欠けていれば、後の関係ない場所で落ちる前にここで止める
352
+ raise ValueError(
353
+ f"Datasetの復元に失敗しました: 必須フィールド '{f.name}' がありません。"
354
+ "壊れているか、対応していない形式のファイルの可能性があります。"
355
+ )
356
+ # 古いファイルの plot_type 'Waterfall' は、今のウォーターフォールのフラグに読み替える
357
+ if state.get('plot_type') == 'Waterfall':
358
+ state['plot_type'] = 'Line'
359
+ state['waterfall_enabled'] = True
360
+
361
+ obj.__dict__.update(state)
362
+ return obj
363
+
364
+ @staticmethod
365
+ def _df_to_dict(df: pd.DataFrame) -> dict:
366
+ """dtype を保ったまま JSON にできる dict にする。
367
+
368
+ datetime64 は ISO 8601 の文字列(NaT は None)。float の NaN は json が NaN のまま往復する。
369
+ """
370
+ dtypes = {col: str(dtype) for col, dtype in df.dtypes.items()}
371
+ data = {}
372
+ for col in df.columns:
373
+ series = df[col]
374
+ if pd.api.types.is_datetime64_any_dtype(series):
375
+ data[col] = [
376
+ None if pd.isna(v) else pd.Timestamp(v).isoformat()
377
+ for v in series
378
+ ]
379
+ else:
380
+ data[col] = series.tolist()
381
+ return {
382
+ 'columns': list(df.columns),
383
+ 'index': list(df.index),
384
+ 'index_dtype': str(df.index.dtype),
385
+ 'data': data,
386
+ 'dtypes': dtypes,
387
+ }
388
+
389
+ @staticmethod
390
+ def _df_from_dict(d: dict) -> pd.DataFrame:
391
+ """_df_to_dict() の逆。"""
392
+ columns = d.get('columns', [])
393
+ index = d.get('index', [])
394
+ data = d.get('data', {})
395
+ dtypes = d.get('dtypes', {})
396
+ index_dtype = d.get('index_dtype')
397
+
398
+ df = pd.DataFrame(index=index)
399
+ if index_dtype:
400
+ # 0 行だと index=[] の dtype が object になって元と食い違う
401
+ try:
402
+ df.index = df.index.astype(index_dtype)
403
+ except (TypeError, ValueError):
404
+ pass
405
+ for col in columns:
406
+ col_data = data.get(col, [])
407
+ dtype_str = dtypes.get(col)
408
+ if dtype_str and dtype_str.startswith('datetime64'):
409
+ series = pd.to_datetime(pd.Series(col_data, index=index))
410
+ else:
411
+ series = pd.Series(col_data, index=index)
412
+ if dtype_str:
413
+ try:
414
+ series = series.astype(dtype_str)
415
+ except (TypeError, ValueError):
416
+ pass
417
+ df[col] = series
418
+ if columns:
419
+ df = df[columns]
420
+ return df
421
+
422
+ def __getstate__(self) -> dict[str, Any]:
423
+ """artist は描くたびに作り直す Figure への参照なので保存しない。"""
424
+ state = self.__dict__.copy()
425
+ state['artist'] = None
426
+ for cache_key in ('_visible_df_cache', '_visible_df_cache_version', '_version',
427
+ '_z_grid_cache', '_z_grid_cache_key'):
428
+ state.pop(cache_key, None)
429
+ return state
430
+
431
+ def __setstate__(self, state: dict[str, Any]) -> None:
432
+ self.__dict__.update(state)
433
+ # 状態に無いフィールドは既定値で補う(copy.deepcopy もここを通る)
434
+ for f in fields(self):
435
+ if f.name not in self.__dict__:
436
+ if f.default is not MISSING:
437
+ self.__dict__[f.name] = f.default
438
+ elif f.default_factory is not MISSING:
439
+ self.__dict__[f.name] = f.default_factory()
440
+ # from_dict と同じ読み替え
441
+ if self.__dict__.get('plot_type') == 'Waterfall':
442
+ self.__dict__['plot_type'] = 'Line'
443
+ self.__dict__['waterfall_enabled'] = True
@@ -0,0 +1,105 @@
1
+ # core/diagnostics.py
2
+ """
3
+ バグ報告時に添付できる診断情報バンドル(zip)を作る (C-1201)。
4
+
5
+ core/ の他モジュール同様GUI(PySide6)には依存しない: QSettingsの内容は
6
+ 呼び出し側(gui/mixins/help_mixin.py)が {key: value} のプレーンなdictに
7
+ 変換してから渡す。
8
+ """
9
+ import logging
10
+ import os
11
+ import platform
12
+ import sys
13
+ import zipfile
14
+ from datetime import datetime
15
+
16
+ from graphica.core.app_paths import get_app_data_dir
17
+ from graphica.core.version import APP_NAME, LOG_FILE_NAME, __version__
18
+ from typing import Any
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # バージョン情報を収集する対象の依存パッケージ
23
+ _DEPENDENCY_MODULES = ('PySide6', 'matplotlib', 'numpy', 'pandas', 'scipy', 'openpyxl', 'xlrd')
24
+
25
+
26
+ def _collect_environment_info() -> str:
27
+ lines = [
28
+ f"{APP_NAME} {__version__}",
29
+ f"生成日時: {datetime.now().astimezone().isoformat()}",
30
+ f"OS: {platform.platform()}",
31
+ f"Python: {sys.version}",
32
+ "",
33
+ "--- 依存パッケージ ---",
34
+ ]
35
+ for module_name in _DEPENDENCY_MODULES:
36
+ try:
37
+ module = __import__(module_name)
38
+ version = getattr(module, '__version__', '(バージョン不明)')
39
+ except Exception:
40
+ version = "未インストール、または読み込み失敗"
41
+ lines.append(f"{module_name}: {version}")
42
+ return "\n".join(lines)
43
+
44
+
45
+ def _collect_plugin_info() -> str:
46
+ from graphica.core.plugin_api import get_loaded_plugin_records, get_plugin_registration_errors
47
+ records = get_loaded_plugin_records()
48
+ if records is None:
49
+ return "プラグインは未読み込みです。"
50
+ if not records:
51
+ return "読み込まれたプラグインはありません。"
52
+ lines = []
53
+ for record in records:
54
+ name = record.get("name", "(不明)")
55
+ info = record.get("info") or {}
56
+ version = info.get("version", "?")
57
+ error = record.get("error")
58
+ status = "OK" if error is None else f"読み込み失敗: {error}"
59
+ lines.append(f"- {name} (v{version}): {status}")
60
+
61
+ # プラグイン全体としては読み込みに成功していても、個別のregister_xxx呼び出しが
62
+ # 失敗している場合はrecordのerrorには現れないため、別途一覧化する(フェーズA-2)。
63
+ registration_errors = get_plugin_registration_errors()
64
+ if registration_errors:
65
+ lines.append("")
66
+ lines.append("--- フック単位の登録失敗 ---")
67
+ for err in registration_errors:
68
+ lines.append(f"- [{err.plugin_name}] {err.hook_kind.value}: {err.message}")
69
+
70
+ return "\n".join(lines)
71
+
72
+
73
+ def _collect_settings_info(settings_dict: dict[str, Any] | None) -> str:
74
+ if not settings_dict:
75
+ return "(設定値なし)"
76
+ lines = [f"{key} = {value!r}" for key, value in sorted(settings_dict.items())]
77
+ return "\n".join(lines)
78
+
79
+
80
+ def build_diagnostic_bundle(out_path: str, settings_dict: dict[str, Any] | None = None) -> None:
81
+ """
82
+ 診断情報バンドル(zip)を out_path に書き出す。
83
+
84
+ 含まれる内容:
85
+ - environment.txt: アプリ/OS/Pythonバージョン、主要依存パッケージのバージョン
86
+ - plugins.txt: 読み込み済みプラグインの一覧と成否
87
+ - settings.txt: QSettingsの内容(呼び出し側がdictとして渡した場合のみ)
88
+ - graphica.log (存在すれば): アプリのログファイル
89
+
90
+ Args:
91
+ out_path (str): 出力先のzipファイルパス。
92
+ settings_dict (dict | None): QSettingsの内容を {key: value} の
93
+ プレーンなdictに変換したもの。Noneならsettings.txtは省略する。
94
+ """
95
+ with zipfile.ZipFile(out_path, 'w', zipfile.ZIP_DEFLATED) as zf:
96
+ zf.writestr("environment.txt", _collect_environment_info())
97
+ zf.writestr("plugins.txt", _collect_plugin_info())
98
+ if settings_dict is not None:
99
+ zf.writestr("settings.txt", _collect_settings_info(settings_dict))
100
+
101
+ log_path = os.path.join(get_app_data_dir(), LOG_FILE_NAME)
102
+ if os.path.exists(log_path):
103
+ zf.write(log_path, arcname=LOG_FILE_NAME)
104
+ else:
105
+ zf.writestr("log_not_found.txt", f"ログファイルが見つかりませんでした: {log_path}")
@@ -0,0 +1,58 @@
1
+ """Excel の読み込みの補助。
2
+
3
+ pandas は数式セルの計算済みの値を読むので、Excel 以外で書いたファイルや手動計算のまま保存したファイルでは
4
+ 値が無く空になる。欠損に見えて気づきにくいので、読む前に見つけて知らせる。
5
+ """
6
+ import logging
7
+
8
+ import openpyxl
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def find_unevaluated_formula_cells(file_path: str, sheet_name: str | None = None, max_examples: int = 5,
14
+ max_scan_cells: int = 200_000) -> tuple[bool, list[str], bool]:
15
+ """数式なのに計算済みの値を持たないセルを探す。(見つかったか, 例のリスト, 全部を見たか)。
16
+
17
+ 大きなファイルで重くならないよう max_scan_cells で打ち切る。
18
+ """
19
+ # .xls は openpyxl で開けず、xlrd は計算済みの値しか持たないので確かめようがない
20
+ if not str(file_path).lower().endswith('.xlsx'):
21
+ return False, [], True
22
+
23
+ wb_formulas = None
24
+ wb_values = None
25
+ try:
26
+ # 検査に失敗したら「見つからなかった」扱いにする約束なので、開くところから try に入れる
27
+ wb_formulas = openpyxl.load_workbook(file_path, data_only=False, read_only=True)
28
+ wb_values = openpyxl.load_workbook(file_path, data_only=True, read_only=True)
29
+
30
+ sheets = [sheet_name] if sheet_name else wb_formulas.sheetnames
31
+ examples = []
32
+ scanned = 0
33
+
34
+ for sname in sheets:
35
+ if sname not in wb_formulas.sheetnames or sname not in wb_values.sheetnames:
36
+ continue
37
+ ws_formulas = wb_formulas[sname]
38
+ ws_values = wb_values[sname]
39
+
40
+ for row_f, row_v in zip(ws_formulas.iter_rows(), ws_values.iter_rows()):
41
+ for cell_f, cell_v in zip(row_f, row_v):
42
+ scanned += 1
43
+ if cell_f.data_type == 'f' and cell_v.value is None:
44
+ examples.append(f"{sname}!{cell_f.coordinate}")
45
+ if len(examples) >= max_examples:
46
+ return True, examples, (scanned < max_scan_cells)
47
+ if scanned >= max_scan_cells:
48
+ return bool(examples), examples, False
49
+
50
+ return bool(examples), examples, True
51
+ except Exception:
52
+ logger.exception("数式セルの検査中にエラーが発生しました: %s", file_path)
53
+ return False, [], True
54
+ finally:
55
+ if wb_formulas is not None:
56
+ wb_formulas.close()
57
+ if wb_values is not None:
58
+ wb_values.close()