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