# -*- coding: utf-8 -*- # # Copyright (c) 2025 kamiya-katase laboratory # # This software is released under the MIT License. # https://opensource.org/licenses/MIT import sys import numpy as np import os import logging import json from datetime import datetime import traceback __version__ = "1.0.9" # アプリケーションのバージョン # --- XRD_GUI_libの条件付きインポート --- script_dir = os.path.dirname(os.path.abspath(__file__)) module_path = os.path.join(script_dir, "XRD_GUI_lib.py") if os.path.isfile(module_path): try: import XRD_GUI_lib except ImportError as e: print(f"警告: XRD_GUI_lib.pyが見つかりましたが、インポート中にエラーが発生しました: {e}") XRD_GUI_lib = None except Exception as e: print(f"警告: XRD_GUI_lib.pyのインポート中に予期せぬエラーが発生しました: {e}") XRD_GUI_lib = None else: XRD_GUI_lib = None # Matplotlibのフォント関連のログ出力を抑制 font_logger = logging.getLogger('matplotlib.font_manager') font_logger.setLevel(logging.ERROR) # ERRORレベル以上のログのみ表示するよう設定 (findfont: Font family not found を抑制) from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QLineEdit, QComboBox, QFileDialog, QColorDialog, QGroupBox, QListWidget, QListWidgetItem, QFontDialog, QMessageBox, QDoubleSpinBox, QInputDialog, QScrollArea, QCheckBox ) from PyQt6.QtCore import Qt from PyQt6.QtGui import QFont, QColor import matplotlib import matplotlib.pyplot as plt from matplotlib.gridspec import GridSpec matplotlib.use('QtAgg') # PyQt6とMatplotlibを連携 from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qtagg import NavigationToolbar2QT as NavigationToolbar from matplotlib.figure import Figure class MplCanvas(FigureCanvas): """Matplotlibのグラフを描画するためのキャンバスクラス""" def __init__(self, parent=None, width=5, height=4, dpi=100): self.fig = Figure(figsize=(width, height), dpi=dpi) self.axes = self.fig.add_subplot(111) # T. Kamiya # spec = GridSpec(10, 1, figure=self.fig) # 全体を10分割 # self.axes = self.fig.add_subplot(spec[:8, 0], sharex=self.fig.add_subplot(spec[8:, 0])) # self.axes.tick_params(axis="x", labelbottom=False) # x軸目盛文字を削除 # self.axes2 = self.fig.add_subplot(spec[8:, 0], sharex=self.axes) # self.fig.subplots_adjust(hspace=0) super().__init__(self.fig) self.setParent(parent) self.fig.tight_layout() # レイアウト自動調整 class GenericGraphApp(QMainWindow): def __init__(self): super().__init__() # --- Matplotlibのデフォルトスタイル設定 (論文風を目指す) --- # ユーザー提供のrcParamsを参考に設定 matplotlib.rcParams['xtick.direction'] = 'in' matplotlib.rcParams['ytick.direction'] = 'in' matplotlib.rcParams["xtick.minor.visible"] = True matplotlib.rcParams["ytick.minor.visible"] = True matplotlib.rcParams['xtick.top'] = True matplotlib.rcParams['ytick.right'] = True # 軸の太さや目盛りのスタイル (デフォルトより少し強調) matplotlib.rcParams["axes.linewidth"] = 0.8 # 軸の枠線の太さ matplotlib.rcParams["xtick.major.width"] = 0.8 matplotlib.rcParams["ytick.major.width"] = 0.8 matplotlib.rcParams["xtick.minor.width"] = 0.6 matplotlib.rcParams["ytick.minor.width"] = 0.6 matplotlib.rcParams["xtick.major.size"] = 6.0 # 目盛りの長さ matplotlib.rcParams["ytick.major.size"] = 6.0 matplotlib.rcParams["xtick.minor.size"] = 3.0 matplotlib.rcParams["ytick.minor.size"] = 3.0 # 凡例のスタイル matplotlib.rcParams["legend.fancybox"] = False # 角丸OFF matplotlib.rcParams["legend.framealpha"] = 0.8 # 背景の透明度 (1で不透明、0で透明) matplotlib.rcParams["legend.edgecolor"] = 'black' # 枠線の色 # グリッドはデフォルトではオフにする (論文スタイルでは使わないか、薄くすることが多い) matplotlib.rcParams['axes.grid'] = False # --- スタイル設定ここまで --- self.setWindowTitle("汎用グラフ表示ソフト") self.setGeometry(100, 100, 1300, 800) self.datasets = [] self.references = [] # フォントは apply_font_to_all_widgets でrcParamsにも反映させる self.current_font = QFont("Times New Roman", 12) if sys.platform == "win32" else QFont("sans-serif", 12) # デフォルトフォント変更 self.base_font_size = self.current_font.pointSize() # pointSizeF() の方が正確な場合もある self.color_cycle_index = 0 self.current_y_offset_increment = 0.0 # 現在の自動オフセットの累積値を保持 self.auto_offset_step = 0 # 自動オフセットの基本ステップ量 (後で調整) self.default_linear_offset_step = 10.0 # 線形スケール時の自動オフセットのデフォルトステップ量 self.default_log_offset_exponent_step = 1.0 # 対数スケール時の自動オフセットのデフォルト指数ステップ (10倍ずつ) self.default_linewidth = 0.5 # デフォルトの線幅 self.reference_max_peak_display_height = 120.0 # 線形/sqrt/symlog用 (ユーザー指定の値に更新) self.reference_log_display_decades = 1.0 # 対数スケール時、最大ピークが見た目上何桁分の高さになるか self.x_axis_is_locked = False # X軸が手動でロックされているか self.locked_x_range = None # ロックされたX軸の範囲 (min, max) self.x_tick_interval = None # X軸の主目盛間隔 (Noneは自動設定) self.y_tick_labels_visible = True # Y軸の数値ラベルの表示状態 self.legend_visible = True # 凡例の表示状態 self.is_initial_plot = True # 最初の描画かを判定するフラグ self.previous_data_y_scale_text = "線形" # データ用スケールの前の値を保持 # ★★★ ここに属性を追加 ★★★ self.peak_annotation = None # 注釈オブジェクトを保持する変数 self.currently_annotated_peak_info = None # 現在どのピークに注釈を付けているか ({"pos": (float, float), "hkl_text": str}) # 縦横比の選択肢 (ラベルと (幅比, 高さ比) または figsizeタプル) self.aspect_ratios = { "カスタム": None, "1:1 (正方形)": (1, 1), "4:3 (標準)": (4, 3), "16:9 (ワイド)": (16, 9), "3:4 (縦長)": (3, 4), "黄金比 (1:1.618)": (1, 1.618), "白銀比 (1:1.414)": (1, 1.414), } self.fixed_paper_sizes = { "論文デフォルト (幅8cm)": (8/2.54, 6/2.54), # ← この行を追加 "幅8cm x 高さ6cm (4:3)": (8/2.54, 6/2.54), "幅8cm x 高さ8cm (1:1)": (8/2.54, 8/2.54), "幅12cm x 高さ9cm (4:3)": (12/2.54, 9/2.54), } self.default_custom_size_cm = (15.0, 10.0) # デフォルトのカスタムサイズ (幅, 高さ) in cm # 利用可能なカラースケール名のリストを取得 # try-exceptをif-elseで処理 if hasattr(matplotlib, 'colormaps'): self.available_colormaps = sorted(list(matplotlib.colormaps.keys())) elif hasattr(matplotlib.cm, 'cmap_d'): self.available_colormaps = sorted(list(matplotlib.cm.cmap_d.keys())) else: self.available_colormaps = [] # Fallback if no colormaps found self.init_ui() # self.previous_y_scale_text の初期化 self.previous_data_y_scale_text = self.combo_data_y_scale.currentText() # init_ui で combo_data_y_scale が作成された後に設定 # 初期フォントを適用 (rcParamsにも反映) self._apply_font_to_qt_widgets(self.current_font) # UIウィジェットにも初期フォントを適用 self.apply_font_to_graph(self.current_font, update_plot=False) # init_ui後にプロットするのでFalse self.update_plot() def init_ui(self): main_widget = QWidget(self) self.setCentralWidget(main_widget) main_layout = QHBoxLayout(main_widget) control_panel_contents_widget = QWidget() # selfを渡して親子関係を明確に self.control_panel_layout = QVBoxLayout(control_panel_contents_widget) control_panel_contents_widget.setFixedWidth(400) scroll_area = QScrollArea() scroll_area.setWidget(control_panel_contents_widget) scroll_area.setWidgetResizable(True) scroll_area.setFixedWidth(400 + 20) # (コンテンツウィジェットがスクロールエリアの幅に合わせてリサイズされるようにする) graph_widget = QWidget(self) # selfを渡して親子関係を明確に graph_layout = QVBoxLayout(graph_widget) self.canvas = MplCanvas(self, width=8, height=6, dpi=100) self.toolbar = NavigationToolbar(self.canvas, self) graph_layout.addWidget(self.toolbar) graph_layout.addWidget(self.canvas) main_layout.addWidget(scroll_area) main_layout.addWidget(graph_widget, 1) # === コントロールパネルの要素 === # 1. ファイル操作グループ (修正あり) file_group = QGroupBox("ファイル操作") file_layout = QVBoxLayout() # 「リファレンス読み込み」ボタン self.btn_load_xrd_data = QPushButton("XRDデータ読み込み (.txt)") self.btn_load_xrd_data.clicked.connect(self.load_xrd_data_dialog) file_layout.addWidget(self.btn_load_xrd_data) # 「リファレンス読み込み」ボタン self.btn_load_reference = QPushButton("リファレンス読み込み (.txt)") self.btn_load_reference.clicked.connect(self.load_reference_dialog) file_layout.addWidget(self.btn_load_reference) self.btn_save_graph = QPushButton("グラフ保存") self.btn_save_graph.clicked.connect(self.save_graph_dialog) file_layout.addWidget(self.btn_save_graph) file_group.setLayout(file_layout) # セッション保存・読み込みボタン session_layout = QHBoxLayout() self.btn_save_session = QPushButton("セッション保存 (.json)") self.btn_save_session.clicked.connect(self.save_session) session_layout.addWidget(self.btn_save_session) self.btn_load_session = QPushButton("セッション読み込み (.json)") self.btn_load_session.clicked.connect(self.load_session) session_layout.addWidget(self.btn_load_session) file_layout.addLayout(session_layout) # 2. データリストと操作グループ data_list_group = QGroupBox("データリストと操作") data_list_layout = QVBoxLayout() self.list_widget_data = QListWidget() self.list_widget_data.setMinimumHeight(150) self.list_widget_data.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) # 複数選択は維持 # ★★★ ドラッグ&ドロップによる移動を有効化 ★★★ self.list_widget_data.setDragDropMode(QListWidget.DragDropMode.InternalMove) self.list_widget_data.setDefaultDropAction(Qt.DropAction.MoveAction) self.list_widget_data.itemSelectionChanged.connect(self.on_data_selection_changed) self.list_widget_data.itemDoubleClicked.connect( lambda item: self.toggle_item_visibility(item, self.datasets, "データセット") ) # ★★★ 並べ替え完了後にデータ順序を同期するメソッドを接続 ★★★ self.list_widget_data.model().rowsMoved.connect( lambda parent, start, end, dest, row: self._sync_data_order_from_widget(self.list_widget_data, self.datasets, "データセット") ) data_list_layout.addWidget(QLabel("データリスト (ドラッグ&ドロップで順序変更可):")) data_list_layout.addWidget(self.list_widget_data) data_linewidth_layout = QHBoxLayout() data_linewidth_layout.addWidget(QLabel("線の太さ:")) self.spinbox_data_linewidth = QDoubleSpinBox() self.spinbox_data_linewidth.setRange(0.1, 10.0) self.spinbox_data_linewidth.setSingleStep(0.1) self.spinbox_data_linewidth.setDecimals(1) self.spinbox_data_linewidth.setEnabled(False) self.spinbox_data_linewidth.editingFinished.connect( lambda: self.apply_item_linewidth(self.list_widget_data, self.datasets, self.spinbox_data_linewidth) ) data_linewidth_layout.addWidget(self.spinbox_data_linewidth) data_list_layout.addLayout(data_linewidth_layout) # 個別色変更ボタン data_op_layout_1 = QHBoxLayout() self.btn_set_color = QPushButton("色変更") self.btn_set_color.clicked.connect(lambda: self.set_selected_item_color(self.list_widget_data, self.datasets, "データセット")) data_op_layout_1.addWidget(self.btn_set_color) # 「名前を変更」ボタンを個別色変更の隣に追加 self.btn_rename_dataset = QPushButton("名前を変更") self.btn_rename_dataset.clicked.connect(lambda: self.rename_selected_item(self.list_widget_data, self.datasets, "データセット")) self.btn_rename_dataset.setEnabled(False) # 初期状態は無効 data_op_layout_1.addWidget(self.btn_rename_dataset) data_list_layout.addLayout(data_op_layout_1) # 手動Yオフセット調整UI (ラベルは後で動的に変更するので汎用的に) manual_offset_group_layout = QHBoxLayout() self.manual_offset_label = QLabel("Yオフセット調整:") # ★ ラベルをインスタンス変数に self.spinbox_manual_y_offset = QDoubleSpinBox(self) # 範囲やステップは update_manual_offset_spinbox_config で設定するのでここでは汎用的な初期値 self.spinbox_manual_y_offset.setRange(-1e4, 1e4) # 広めの範囲 self.spinbox_manual_y_offset.setSingleStep(1.0) # 小さめのステップ self.spinbox_manual_y_offset.setDecimals(2) self.spinbox_manual_y_offset.setEnabled(False) self.spinbox_manual_y_offset.editingFinished.connect(self.apply_manual_offset_from_spinbox) manual_offset_group_layout.addWidget(self.manual_offset_label) manual_offset_group_layout.addWidget(self.spinbox_manual_y_offset) data_list_layout.addLayout(manual_offset_group_layout) # 2.5. リファレンスリストと操作グループ (新規追加) ref_list_group = QGroupBox("リファレンスリストと操作") ref_list_layout = QVBoxLayout() self.list_widget_references = QListWidget() self.list_widget_references.setMinimumHeight(150) self.list_widget_references.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) # ドラッグ&ドロップによる移動を有効化 self.list_widget_references.setDragDropMode(QListWidget.DragDropMode.InternalMove) self.list_widget_references.setDefaultDropAction(Qt.DropAction.MoveAction) self.list_widget_references.itemSelectionChanged.connect(self.on_reference_selection_changed) self.list_widget_references.itemDoubleClicked.connect( lambda item: self.toggle_item_visibility(item, self.references, "リファレンス") ) # 並べ替え完了後にデータ順序を同期するメソッドを接続 self.list_widget_references.model().rowsMoved.connect( lambda parent, start, end, dest, row: self._sync_data_order_from_widget(self.list_widget_references, self.references, "リファレンス") ) ref_linewidth_layout = QHBoxLayout() ref_linewidth_layout.addWidget(QLabel("線の太さ (Ref):")) self.spinbox_ref_linewidth = QDoubleSpinBox() self.spinbox_ref_linewidth.setRange(0.1, 10.0) self.spinbox_ref_linewidth.setSingleStep(0.1) self.spinbox_ref_linewidth.setDecimals(1) self.spinbox_ref_linewidth.setEnabled(False) self.spinbox_ref_linewidth.editingFinished.connect( lambda: self.apply_item_linewidth(self.list_widget_references, self.references, self.spinbox_ref_linewidth) ) ref_linewidth_layout.addWidget(self.spinbox_ref_linewidth) ref_list_layout.addLayout(ref_linewidth_layout) ref_list_layout.addWidget(QLabel("リファレンスリスト (ドラッグ&ドロップで順序変更可):")) ref_list_layout.addWidget(self.list_widget_references) # リファレンス用 Y位置調整UI ref_y_pos_layout = QHBoxLayout() ref_list_layout.addWidget(QLabel("---------------")) # 区切り線 ref_list_layout.addWidget(QLabel("リファレンス共通ピーク高さスケール:")) # 線形/平方根スケール時の最大ピーク表示高さ ref_linear_height_layout = QHBoxLayout() ref_linear_height_label = QLabel("最大ピーク高さ (線形/平方根時):") self.spinbox_ref_linear_scale_height = QDoubleSpinBox() self.spinbox_ref_linear_scale_height.setRange(0.1, 100000.0) # 表示データのスケールに合わせて調整 self.spinbox_ref_linear_scale_height.setSingleStep(5.0) # 調整ステップ self.spinbox_ref_linear_scale_height.setDecimals(2) # 小数点以下の桁数 self.spinbox_ref_linear_scale_height.setValue(self.reference_max_peak_display_height) # 初期値を設定 self.spinbox_ref_linear_scale_height.editingFinished.connect(self.apply_reference_peak_scale_settings) # 変更時に適用 ref_linear_height_layout.addWidget(ref_linear_height_label) ref_linear_height_layout.addWidget(self.spinbox_ref_linear_scale_height) ref_list_layout.addLayout(ref_linear_height_layout) # 対数スケール時の最大ピーク高さ倍率 ref_log_factor_layout = QHBoxLayout() ref_log_factor_label = QLabel("表示デカード数 (対数時):") # ★★★ ラベル変更 ★★★ self.spinbox_ref_log_scale_factor = QDoubleSpinBox() self.spinbox_ref_log_scale_factor.setRange(0.1, 5.0) # ★★★ 範囲変更 (例: 0.1桁~5桁) ★★★ self.spinbox_ref_log_scale_factor.setSingleStep(0.1) # ★★★ ステップ変更 ★★★ self.spinbox_ref_log_scale_factor.setDecimals(1) self.spinbox_ref_log_scale_factor.setValue(self.reference_log_display_decades) # ★★★ 初期値を新しい属性で設定 ★★★ self.spinbox_ref_log_scale_factor.editingFinished.connect(self.apply_reference_peak_scale_settings) # 変更時に適用 ref_log_factor_layout.addWidget(ref_log_factor_label) ref_log_factor_layout.addWidget(self.spinbox_ref_log_scale_factor) ref_list_layout.addLayout(ref_log_factor_layout) # ref_y_pos_label = QLabel("ベースラインY位置:") self.manual_offset_label_ref = QLabel("ベースラインY位置:") # インスタンス属性に変更 self.spinbox_ref_y_pos = QDoubleSpinBox() self.spinbox_ref_y_pos.setRange(-1e7, 1e7) # 表示するデータのスケールに合わせて調整 self.spinbox_ref_y_pos.setSingleStep(10.0) # おおよその調整ステップ self.spinbox_ref_y_pos.setDecimals(2) # 小数点以下の桁数 self.spinbox_ref_y_pos.setEnabled(False) # 初期状態では無効 (リファレンス選択時に有効化) self.spinbox_ref_y_pos.editingFinished.connect(self.apply_reference_y_pos) # 値の編集完了時に適用 ref_y_pos_layout.addWidget(self.manual_offset_label_ref) ref_y_pos_layout.addWidget(self.spinbox_ref_y_pos) ref_list_layout.addLayout(ref_y_pos_layout) # --- リファレンスリストと操作グループ内のボタン接続 --- ref_op_layout = QHBoxLayout() self.btn_set_reference_color = QPushButton("色変更 (Ref)") self.btn_set_reference_color.clicked.connect(lambda: self.set_selected_item_color(self.list_widget_references, self.references, "リファレンス")) self.btn_set_reference_color.setEnabled(False) ref_op_layout.addWidget(self.btn_set_reference_color) self.btn_rename_reference = QPushButton("名前変更 (Ref)") self.btn_rename_reference.clicked.connect(lambda: self.rename_selected_item(self.list_widget_references, self.references, "リファレンス")) self.btn_rename_reference.setEnabled(False) ref_op_layout.addWidget(self.btn_rename_reference) ref_list_layout.addLayout(ref_op_layout) # リファレンス用のカラースケール適用ボタンと削除ボタン用のレイアウト ref_ops_layout2 = QHBoxLayout() self.btn_apply_colorscale_ref = QPushButton("カラースケール適用 (Ref)") self.btn_apply_colorscale_ref.clicked.connect(lambda: self.apply_colorscale_to_selected_items(self.list_widget_references, self.references, "リファレンス")) self.btn_apply_colorscale_ref.setEnabled(False) ref_ops_layout2.addWidget(self.btn_apply_colorscale_ref) self.btn_remove_reference = QPushButton("選択リファレンスを削除") self.btn_remove_reference.clicked.connect( lambda: self.remove_selected_items( self.list_widget_references, # 操作対象のリストウィジェット self.references, # 操作対象のデータリスト "リファレンス" # ダイアログに表示する名前 ) ) self.btn_remove_reference.setEnabled(False) ref_ops_layout2.addWidget(self.btn_remove_reference) ref_list_layout.addLayout(ref_ops_layout2) ref_list_group.setLayout(ref_list_layout) # カラースケール選択 cmap_selection_layout = QHBoxLayout() cmap_label = QLabel("カラースケール:") self.combo_colormap = QComboBox() if hasattr(self, 'available_colormaps'): self.combo_colormap.addItems(self.available_colormaps) if 'viridis' in self.available_colormaps: self.combo_colormap.setCurrentText('viridis') elif self.available_colormaps: self.combo_colormap.setCurrentIndex(0) cmap_selection_layout.addWidget(cmap_label) cmap_selection_layout.addWidget(self.combo_colormap, 1) data_list_layout.addLayout(cmap_selection_layout) # カラースケール範囲指定 cmap_range_layout = QHBoxLayout() cmap_range_min_label = QLabel("範囲始点(0-1):") self.spinbox_cmap_min = QDoubleSpinBox() self.spinbox_cmap_min.setRange(0.0, 1.0) self.spinbox_cmap_min.setSingleStep(0.05) self.spinbox_cmap_min.setValue(0.0) # デフォルト始点 cmap_range_max_label = QLabel("範囲終点(0-1):") self.spinbox_cmap_max = QDoubleSpinBox() self.spinbox_cmap_max.setRange(0.0, 1.0) self.spinbox_cmap_max.setSingleStep(0.05) self.spinbox_cmap_max.setValue(1.0) # デフォルト終点 cmap_range_layout.addWidget(cmap_range_min_label) cmap_range_layout.addWidget(self.spinbox_cmap_min) cmap_range_layout.addWidget(cmap_range_max_label) cmap_range_layout.addWidget(self.spinbox_cmap_max) data_list_layout.addLayout(cmap_range_layout) # カラースケール適用ボタン self.btn_apply_colorscale = QPushButton("カラースケール適用") self.btn_apply_colorscale.clicked.connect(lambda: self.apply_colorscale_to_selected_items(self.list_widget_data, self.datasets, "データセット")) data_list_layout.addWidget(self.btn_apply_colorscale) # 適用ボタンを範囲指定の下に配置 # データ削除ボタン self.btn_remove_data = QPushButton("選択データを削除") self.btn_remove_data.clicked.connect(lambda: self.remove_selected_items(self.list_widget_data, self.datasets, "データセット")) data_list_layout.addWidget(self.btn_remove_data) data_list_group.setLayout(data_list_layout) # 3. グラフ設定グループ graph_settings_group = QGroupBox("グラフ設定") graph_settings_layout = QVBoxLayout() # --- データY軸スケール --- data_y_scale_layout = QHBoxLayout() data_y_scale_label = QLabel("データY軸スケール:") self.combo_data_y_scale = QComboBox() self.combo_data_y_scale.addItems(["線形", "対数 (log10)", "平方根 (sqrt)"]) # 以前の self.combo_y_scale の接続先をこちらに付け替え self.combo_data_y_scale.currentTextChanged.connect(self.on_y_scale_changed_update_plot_and_ui) data_y_scale_layout.addWidget(data_y_scale_label) data_y_scale_layout.addWidget(self.combo_data_y_scale) graph_settings_layout.addLayout(data_y_scale_layout) # --- リファレンスY軸スケール --- ref_y_scale_layout = QHBoxLayout() ref_y_scale_label = QLabel("リファレンスYスケール:") self.combo_ref_y_scale = QComboBox() self.combo_ref_y_scale.addItems(["線形", "平方根 (sqrt)", "対数 (log10)"]) self.combo_ref_y_scale.currentTextChanged.connect(self.update_plot) # 変更時に再描画 ref_y_scale_layout.addWidget(ref_y_scale_label) ref_y_scale_layout.addWidget(self.combo_ref_y_scale) graph_settings_layout.addLayout(ref_y_scale_layout) visibility_layout = QHBoxLayout() # チェックボックス用の水平レイアウト # Y軸数値ラベルの表示/非表示チェックボックス self.check_y_ticks_visible = QCheckBox("Y軸数値を表示") self.check_y_ticks_visible.setChecked(self.y_tick_labels_visible) self.check_y_ticks_visible.stateChanged.connect(self.on_y_tick_visibility_changed) visibility_layout.addWidget(self.check_y_ticks_visible) # 凡例の表示/非表示チェックボックス self.check_legend_visible = QCheckBox("凡例を表示") self.check_legend_visible.setChecked(self.legend_visible) self.check_legend_visible.stateChanged.connect(self.on_legend_visibility_changed) visibility_layout.addWidget(self.check_legend_visible) graph_settings_layout.addLayout(visibility_layout) # 縦横比選択の追加 aspect_ratio_layout = QHBoxLayout() aspect_ratio_label = QLabel("グラフ縦横比:") self.combo_aspect_ratio = QComboBox() self.combo_aspect_ratio.addItems(list(self.aspect_ratios.keys()) + list(self.fixed_paper_sizes.keys())) self.combo_aspect_ratio.currentTextChanged.connect(self.on_aspect_ratio_changed) aspect_ratio_layout.addWidget(aspect_ratio_label) aspect_ratio_layout.addWidget(self.combo_aspect_ratio) graph_settings_layout.addLayout(aspect_ratio_layout) # グラフサイズ直接指定 (cm) size_cm_layout = QHBoxLayout() size_cm_layout.addWidget(QLabel("サイズ指定(cm):")) self.spin_width_cm = QDoubleSpinBox() self.spin_width_cm.setRange(1.0, 50.0) self.spin_width_cm.setDecimals(1) self.spin_width_cm.setSuffix(" cm (幅)") self.spin_width_cm.setValue(self.default_custom_size_cm[0]) self.spin_height_cm = QDoubleSpinBox() self.spin_height_cm.setRange(1.0, 50.0) self.spin_height_cm.setDecimals(1) self.spin_height_cm.setSuffix(" cm (高)") self.spin_height_cm.setValue(self.default_custom_size_cm[1]) self.btn_apply_size_cm = QPushButton("サイズ適用") self.btn_apply_size_cm.clicked.connect(self.apply_custom_size_cm) size_cm_layout.addWidget(self.spin_width_cm) size_cm_layout.addWidget(self.spin_height_cm) size_cm_layout.addWidget(self.btn_apply_size_cm) graph_settings_layout.addLayout(size_cm_layout) # X軸範囲指定 x_range_layout = QHBoxLayout() self.edit_x_min = QLineEdit() self.edit_x_max = QLineEdit() self.btn_apply_x_zoom = QPushButton("X範囲適用") self.btn_apply_x_zoom.clicked.connect(self.apply_manual_x_zoom) x_range_layout.addWidget(QLabel("X範囲:")) x_range_layout.addWidget(self.edit_x_min) x_range_layout.addWidget(QLabel("-")) x_range_layout.addWidget(self.edit_x_max) x_range_layout.addWidget(self.btn_apply_x_zoom) graph_settings_layout.addLayout(x_range_layout) # X軸目盛間隔指定 x_tick_layout = QHBoxLayout() x_tick_layout.addWidget(QLabel("X主目盛間隔:")) self.edit_x_tick_interval = QLineEdit() self.edit_x_tick_interval.setPlaceholderText("例: 10") # 入力例を表示 self.btn_apply_x_tick = QPushButton("目盛適用") self.btn_apply_x_tick.clicked.connect(self.apply_x_tick_interval) x_tick_layout.addWidget(self.edit_x_tick_interval) x_tick_layout.addWidget(self.btn_apply_x_tick) graph_settings_layout.addLayout(x_tick_layout) # Y軸範囲指定 y_range_layout = QHBoxLayout() self.edit_y_min = QLineEdit() self.edit_y_max = QLineEdit() self.btn_apply_y_zoom = QPushButton("Y範囲適用") self.btn_apply_y_zoom.clicked.connect(self.apply_manual_y_zoom) y_range_layout.addWidget(QLabel("Y範囲:")) y_range_layout.addWidget(self.edit_y_min) y_range_layout.addWidget(QLabel("-")) y_range_layout.addWidget(self.edit_y_max) y_range_layout.addWidget(self.btn_apply_y_zoom) graph_settings_layout.addLayout(y_range_layout) self.btn_reset_zoom = QPushButton("ズームリセット") self.btn_reset_zoom.clicked.connect(self.reset_zoom) graph_settings_layout.addWidget(self.btn_reset_zoom) graph_settings_group.setLayout(graph_settings_layout) # 4. アプリケーション設定グループ app_settings_group = QGroupBox("表示設定") app_settings_layout = QVBoxLayout() self.btn_set_font = QPushButton("グラフのフォント設定") # ボタンのテキストを変更 self.btn_set_font.clicked.connect(self.set_graph_font_dialog) # 接続先メソッドを変更 app_settings_layout.addWidget(self.btn_set_font) app_settings_group.setLayout(app_settings_layout) self.control_panel_layout.addStretch(1) self.control_panel_layout.addWidget(file_group) # ★ 1番目: ファイル操作 self.control_panel_layout.addWidget(data_list_group) # ★ 2番目: データリストと操作 self.control_panel_layout.addWidget(ref_list_group) # ★ 3番目: リファレンスリストと操作 self.control_panel_layout.addWidget(graph_settings_group) # ★ 4番目: グラフ設定 self.control_panel_layout.addWidget(app_settings_group) # ★ 5番目: 表示設定 self.control_panel_layout.addStretch(1) # 伸縮可能なスペースを最後に追加 # Matplotlibのイベントと連携して軸範囲入力フィールドを更新 self.canvas.axes.callbacks.connect('xlim_changed', self.on_axes_limits_changed) self.canvas.axes.callbacks.connect('ylim_changed', self.on_axes_limits_changed) # ★★★ マウス移動イベントに関数を接続 ★★★ self.canvas.mpl_connect('motion_notify_event', self.on_mouse_move_on_graph) def on_aspect_ratio_changed(self, selected_text): """グラフの縦横比が変更されたときに呼び出される""" print(f"デバッグ: 縦横比変更 -> {selected_text}") # 固定サイズ指定の場合 if selected_text in self.fixed_paper_sizes: width_inch, height_inch = self.fixed_paper_sizes[selected_text] print(f"デバッグ: 固定サイズ指定: width={width_inch:.2f} inch, height={height_inch:.2f} inch") # Figureのサイズを直接インチで設定 self.canvas.fig.set_size_inches(width_inch, height_inch, forward=True) # 必要に応じてウィンドウ自体のサイズも調整検討 (今回はFigureのみ) # 比率指定の場合 elif selected_text in self.aspect_ratios and self.aspect_ratios[selected_text] is not None: ratio_w, ratio_h = self.aspect_ratios[selected_text] print(f"デバッグ: 比率指定: {ratio_w}:{ratio_h}") base_fig_width_inch = 6.0 new_fig_width_inch = base_fig_width_inch new_fig_height_inch = base_fig_width_inch * (ratio_h / ratio_w) print(f"デバッグ: 計算サイズ: width={new_fig_width_inch:.2f} inch, height={new_fig_height_inch:.2f} inch") self.canvas.fig.set_size_inches(new_fig_width_inch, new_fig_height_inch, forward=True) self.update_plot() # スタイルなどを再適用して再描画 def _apply_font_to_qt_widgets(self, font): """指定されたフォントをQtウィジェット (アプリケーション全体、メインウィンドウ、コントロールパネル内) に適用する""" QApplication.setFont(font) self.setFont(font) # コントロールパネル内のウィジェットにフォントを再帰的に適用 if hasattr(self, 'control_panel_layout'): def apply_font_recursive_to_children(parent_widget_or_layout_item): widget = None if isinstance(parent_widget_or_layout_item, QWidget): widget = parent_widget_or_layout_item elif hasattr(parent_widget_or_layout_item, 'widget') and parent_widget_or_layout_item.widget(): widget = parent_widget_or_layout_item.widget() if widget: widget.setFont(font) if isinstance(widget, QGroupBox): # QGroupBoxなら中の子ウィジェットも for child_widget in widget.findChildren(QWidget): child_widget.setFont(font) if hasattr(parent_widget_or_layout_item, 'layout') and parent_widget_or_layout_item.layout(): layout = parent_widget_or_layout_item.layout() for i in range(layout.count()): apply_font_recursive_to_children(layout.itemAt(i)) for i in range(self.control_panel_layout.count()): apply_font_recursive_to_children(self.control_panel_layout.itemAt(i)) if hasattr(self, 'list_widget_data'): self.list_widget_data.setFont(font) if hasattr(self, 'list_widget_references'): self.list_widget_references.setFont(font) def apply_font_to_graph(self, font, update_plot=True): """グラフのフォント(ファミリー、サイズ)を設定し、再描画する""" print(f"デバッグ: グラフのフォントを '{font.family()}' ({font.pointSizeF()}pt) に設定します。") # MatplotlibのrcParamsにフォント設定を反映する selected_font_family = font.family() # 日本語表示のためのフォント候補リスト (優先順位順) # ユーザー選択フォント、Windows標準、macOS標準、Linux標準的なもの、最後の手段 jp_font_candidates = [ selected_font_family, # ユーザーが選択したフォントを最優先 'Yu Gothic', 'MS Gothic', 'Meiryo', # Windowsで一般的な日本語フォント 'IPAexGothic', # クロスプラットフォームで使える可能性のある日本語フォント 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', # macOSで一般的な日本語フォント 'Noto Sans CJK JP', 'TakaoPGothic', # Linuxなどで利用可能な日本語フォント 'sans-serif' # Matplotlibの汎用フォールバック (日本語が出るとは限らない) ] matplotlib.rcParams['font.family'] = jp_font_candidates # if-elseに修正 if hasattr(font, 'pointSizeF'): matplotlib.rcParams['font.size'] = font.pointSizeF() else: # 古いPyQt/Pythonの場合のフォールバック matplotlib.rcParams['font.size'] = font.pointSize() self.base_font_size = matplotlib.rcParams['font.size'] # 更新されたサイズを保持 if update_plot: self.update_plot() # スタイルが変更されたので、再描画 def set_graph_font_dialog(self): """グラフのフォント選択ダイアログを開く""" font, ok = QFontDialog.getFont(self.current_font, self, "グラフのフォント選択") if ok: # self.current_font を更新する必要はないかもしれませんが、次回ダイアログを開くときのために保持 self.current_font = font self.apply_font_to_graph(self.current_font) # 新しいメソッドを呼び出し def get_next_color(self): """デフォルトの色を順番に提供する""" # Matplotlibのデフォルトカラーサイクル (Tableau Colors) qt_colors = [ QColor(31, 119, 180), QColor(255, 127, 14), QColor(44, 160, 44), QColor(214, 39, 40), QColor(148, 103, 189), QColor(140, 86, 75), QColor(227, 119, 194), QColor(127, 127, 127), QColor(188, 189, 34), QColor(23, 190, 207) ] color = qt_colors[self.color_cycle_index % len(qt_colors)] self.color_cycle_index += 1 return color def add_dataset(self, name, x_data, y_data, color=None): """新しいデータセットをリストに追加し、リストウィジェットにも表示する (自動オフセット調整)""" # ユニークな名前を生成する処理 unique_name = self._generate_unique_name(name) if color is None: color = self.get_next_color() y_offset_for_new_dataset = 0.0 # オフセット値を格納するキーを現在のY軸スケールに応じて決定 ref_scale_text = self.combo_data_y_scale.currentText() # 現在のY軸スケールを取得 offset_key_to_use = "y_offset_linear" # デフォルトは線形オフセット if ref_scale_text == "対数 (log10)" or ref_scale_text == "symlog": if self.datasets: # 最初のデータセットではない場合 num_log_offset_datasets = 0 # 既に存在する対数オフセットされたデータ数を数える (簡易的な方法) for ds_item_check in self.datasets: if ds_item_check.get("y_offset_log_exponent", 0.0) != 0.0: num_log_offset_datasets +=1 # 新しい対数オフセット指数を計算 (例: 0, -1, -2 ...) # self.default_log_offset_exponent_step は __init__ で 1.0 と定義済み y_offset_log_exponent_for_new_dataset = -num_log_offset_datasets * self.default_log_offset_exponent_step # y_offset_for_new_dataset はここでは使わず、後で dataset 辞書作成時に直接キーを指定する offset_key_to_use = "y_offset_log_exponent" # ↓ y_offset_for_new_dataset に指数そのものを入れる y_offset_for_new_dataset = y_offset_log_exponent_for_new_dataset print(f"デバッグ: 新規データ '{unique_name}' (対数/symlog系) のための自動オフセット指数: {y_offset_for_new_dataset:.1f}") else: # 最初のデータセットの場合 y_offset_for_new_dataset = 0.0 # 指数も0 elif self.datasets: # 線形または平方根スケールで、かつ最初のデータセットではない場合 min_existing_offset = 0 for ds_item in self.datasets: min_existing_offset = min(min_existing_offset, ds_item.get("y_offset_linear", 0.0)) current_data_y_range = 0 if len(y_data) > 0: y_min_val, y_max_val = np.min(y_data), np.max(y_data) current_data_y_range = y_max_val - y_min_val offset_step_for_this_data = current_data_y_range * 1.0 if offset_step_for_this_data < 1e-6 : if np.max(np.abs(y_data)) > 1e-6: offset_step_for_this_data = np.max(np.abs(y_data)) * 0.5 else: offset_step_for_this_data = self.default_linear_offset_step # __init__で定義したデフォルトステップ print(f"デバッグ: 新規データ '{unique_name}' (線形/sqrt系) のための自動オフセットステップ量: {offset_step_for_this_data:.2f}") y_offset_for_new_dataset = min_existing_offset - offset_step_for_this_data # dataset辞書の作成 dataset = { "name": unique_name, "x": np.asarray(x_data), "original_y": np.asarray(y_data), "processed_y": np.copy(np.asarray(y_data)), # 値を変更しない_process_y_dataを想定 "color": color, "group": None, "visible": True, "line_object": None, "linewidth": self.default_linewidth, "y_offset_linear": 0.0, # 線形/平方根/symlog用オフセットの初期値 "y_offset_log_exponent": 0.0 # 対数(log10)用オフセット指数(10^0=1倍)の初期値 } # 計算したオフセット値を適切なキーに設定 if ref_scale_text == "対数 (log10)": dataset["y_offset_log_exponent"] = y_offset_for_new_dataset print(f"デバッグ: データセット '{unique_name}' に y_offset_log_exponent: {dataset['y_offset_log_exponent']:.2f} を設定。") else: # 線形, symlog, 平方根 dataset["y_offset_linear"] = y_offset_for_new_dataset print(f"デバッグ: データセット '{unique_name}' に y_offset_linear: {dataset['y_offset_linear']:.2f} を設定。") self.datasets.append(dataset) item = QListWidgetItem(unique_name) item.setData(Qt.ItemDataRole.UserRole, dataset) # ここで渡すdatasetは上記でオフセット設定済みのもの item.setForeground(dataset["color"]) item.setFont(self.current_font) if not dataset["visible"]: font = item.font() font.setStrikeOut(True) item.setFont(font) item.setForeground(QColor("gray")) self.list_widget_data.addItem(item) return dataset def parse_xrd_file(self, filepath): """XRDデータファイル(TXT形式)を解析し、サンプル名、角度、強度を抽出する""" # --- XRD_GUI_lib が利用可能であれば、そちらのパーサーを使用 --- # ユーザー指定の関数名 `parse_xrd` を使用 if XRD_GUI_lib is not None and hasattr(XRD_GUI_lib, "parse_xrd"): try: print("デバッグ: 外部ライブラリの XRD_GUI_lib.parse_xrd() を使用します。") # 外部ライブラリの関数を呼び出し、結果をそのまま返す return XRD_GUI_lib.parse_xrd(filepath) except Exception as e: # 外部ライブラリでのエラーは致命的ではないため、警告として出力し、内蔵パーサーにフォールバック QMessageBox.warning(self, "外部パーサーエラー", f"外部ライブラリ (XRD_GUI_lib.py) でのXRDデータ解析中にエラーが発生しました。\n内蔵パーサーを試行します。\nエラー詳細: {e}") # エラー時は、この後の内蔵パーサーに処理を継続させる pass sample_name = os.path.splitext(os.path.basename(filepath))[0] # ファイル名をデフォルト名に angles = [] intensities = [] # データ読み取りを開始するキーワード(このキーワードを含む行の次からデータを読み取る) start_reading_keywords = ["Step", "ScanSpeed"] reading_data = False # 数値データ部分を読み取り中かどうかのフラグ try: with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: for line_number, line in enumerate(f): line = line.strip() if not line: # 空行はスキップ continue # reading_data フラグが False の間はヘッダーとして処理 if not reading_data: if line.startswith("Sample"): parts = line.split('\t', 1) if len(parts) > 1: sample_name = parts[1].strip() # ヘッダーの終わりを示すキーワードを探す if any(keyword in line for keyword in start_reading_keywords): print(f"デバッグ: データ読み取り開始トリガーを検出 (行 {line_number+1}): {line}") reading_data = True # 次の行からデータ読み取り開始 continue # ヘッダー行はここで処理終了 # reading_data フラグが True になったら、データ行として処理を試みる parts = line.split() # スペースやタブで分割 if len(parts) >= 2: # 少なくとも2列あるか # if-elseに修正 if parts[0].replace('.', '', 1).isdigit() and parts[1].replace('.', '', 1).isdigit(): # 数値に変換可能かチェック angle = float(parts[0]) intensity = float(parts[1]) angles.append(angle) intensities.append(intensity) else: # データ部分の途中で数値に変換できない行があれば、そこで終了する print(f"デバッグ: 数値変換エラー、データ読み取り終了 (行 {line_number+1}): {line}") break else: # データ部分の途中で列数が不足する行があれば、そこで終了する print(f"デバッグ: データ行の列数不足、読み取り終了 (行 {line_number+1}): {line}") break if not angles or not intensities: QMessageBox.warning(self, "パースエラー", f"ファイル '{os.path.basename(filepath)}' から有効な数値データを抽出できませんでした。\nファイルの形式を確認してください。") return None, None, None print(f"デバッグ: '{sample_name}' のパース成功。{len(angles)}点のデータを抽出。") return sample_name, np.array(angles), np.array(intensities) except FileNotFoundError: QMessageBox.critical(self, "ファイルが見つかりません", f"指定されたファイルが見つかりません:\n{filepath}") return None, None, None except Exception as e: # その他の予期せぬファイル読み込みエラー QMessageBox.critical(self, "ファイル読み込みエラー", f"ファイルの読み込み中に予期せぬエラーが発生しました:\n{e}\nファイル: {filepath}") return None, None, None def _generate_unique_name(self, desired_name): """ 指定された名前が self.datasets と self.references の中でユニークかチェックし、 重複している場合は " (2)", " (3)" ... を付けてユニークな名前を生成して返す。 """ all_existing_names = {d.get("name") for d in self.datasets} | {r.get("name") for r in self.references} if desired_name not in all_existing_names: # そもそも重複していなければ、そのままの名前を返す return desired_name # 重複している場合は、新しい名前を試す counter = 2 while True: new_name = f"{desired_name} ({counter})" if new_name not in all_existing_names: print(f"デバッグ: 名前 '{desired_name}' が重複しているため、'{new_name}' を生成しました。") return new_name counter += 1 def load_dummy_data(self): num_existing_datasets = len(self.datasets) x = np.linspace(0, 2 * np.pi, 200) y_sin = np.sin(x + num_existing_datasets * 0.3) + np.random.normal(0, 0.1, 200) y_cos = np.cos(x + num_existing_datasets * 0.3) + np.random.normal(0, 0.1, 200) self.add_dataset(f"サイン波 {num_existing_datasets + 1}", x, y_sin) self.add_dataset(f"コサイン波 {num_existing_datasets + 1}", x, y_cos) self.update_plot_data_and_redraw() def _update_selection_dependent_ui(self, list_widget, data_list, offset_spinbox, offset_label, linewidth_spinbox, rename_button, set_color_button, apply_colorscale_button, remove_button, item_type_name_for_offset_label): """リストウィジェットの選択状態に応じて関連UIを更新する共通ヘルパー""" selected_data, selected_items = self._get_selected_items(list_widget, data_list) has_selection = bool(selected_data) single_selection = len(selected_data) == 1 # ボタンの有効/無効を設定 if set_color_button: set_color_button.setEnabled(has_selection) if apply_colorscale_button: apply_colorscale_button.setEnabled(has_selection) if remove_button: remove_button.setEnabled(has_selection) if rename_button: rename_button.setEnabled(single_selection) # オフセットと線幅のUIを更新 if offset_spinbox: offset_spinbox.setEnabled(single_selection) if linewidth_spinbox: linewidth_spinbox.setEnabled(has_selection) ref_scale_text = self.combo_data_y_scale.currentText() # スピンボックスのラベルと設定をスケールに応じて変更 if offset_label and offset_spinbox: if ref_scale_text == "対数 (log10)": offset_label.setText(f"Yオフセット (10^X, {item_type_name_for_offset_label}):") offset_spinbox.setRange(-10, 10) offset_spinbox.setSingleStep(0.1) offset_spinbox.setDecimals(1) else: # 線形, 平方根 offset_label.setText(f"Yオフセット (加算, {item_type_name_for_offset_label}):") offset_spinbox.setRange(-1e7, 1e7) offset_spinbox.setSingleStep(100.0) # Consider making this dynamic or a class attribute offset_spinbox.setDecimals(2) if single_selection and offset_spinbox: # オフセットは単一選択時のみ表示 item_data_dict = selected_data[0] offset_val = 0.0 if ref_scale_text == "対数 (log10)": offset_val = item_data_dict.get("y_offset_log_exponent" if item_type_name_for_offset_label == "データ" else "offset_y_log_exponent", 0.0) else: offset_val = item_data_dict.get("y_offset_linear" if item_type_name_for_offset_label == "データ" else "offset_y_linear", 0.0) offset_spinbox.blockSignals(True) offset_spinbox.setValue(offset_val) offset_spinbox.blockSignals(False) elif offset_spinbox: # No selection or multiple selection for offset offset_spinbox.blockSignals(True) offset_spinbox.setValue(0.0) # Reset to default or keep disabled offset_spinbox.blockSignals(False) if has_selection and linewidth_spinbox: # 線幅は複数選択でも最初のアイテムの値を表示 item_data_dict = selected_data[0] # Show linewidth of the first selected item linewidth = item_data_dict.get("linewidth", self.default_linewidth) linewidth_spinbox.blockSignals(True) linewidth_spinbox.setValue(linewidth) linewidth_spinbox.blockSignals(False) elif linewidth_spinbox: # No selection for linewidth linewidth_spinbox.blockSignals(True) linewidth_spinbox.setValue(self.default_linewidth) # Reset to default linewidth_spinbox.blockSignals(False) def on_data_selection_changed(self): """データリストの選択が変更されたときに呼び出される""" self._update_selection_dependent_ui( list_widget=self.list_widget_data, data_list=self.datasets, offset_spinbox=self.spinbox_manual_y_offset, offset_label=self.manual_offset_label, linewidth_spinbox=self.spinbox_data_linewidth, rename_button=self.btn_rename_dataset, set_color_button=self.btn_set_color, apply_colorscale_button=self.btn_apply_colorscale, remove_button=self.btn_remove_data, item_type_name_for_offset_label="データ" ) def apply_manual_offset_from_spinbox(self): """手動オフセット用スピンボックスの値が変更されたときに呼び出される""" print("デバッグ: apply_manual_offset_from_spinbox が呼び出されました。") selected_items = self.list_widget_data.selectedItems() if len(selected_items) == 1: item = selected_items[0] # UserRoleからデータセット名を取得するのが最も確実 dataset_info_from_item = item.data(Qt.ItemDataRole.UserRole) if not (dataset_info_from_item and "name" in dataset_info_from_item): print("デバッグ: 手動オフセット適用時、itemからデータセット名が取得できませんでした。") return dataset_name_to_update = dataset_info_from_item["name"] new_offset_input_value = 0.0 if hasattr(self, 'spinbox_manual_y_offset'): new_offset_input_value = self.spinbox_manual_y_offset.value() print(f"デバッグ: スピンボックスから取得した新しいオフセット入力値: {new_offset_input_value}") else: print("デバッグ: 手動オフセット適用時、spinbox_manual_y_offset が見つかりません。") return dataset_updated = False ref_scale_text = self.combo_data_y_scale.currentText() for original_dataset in self.datasets: if original_dataset.get("name") == dataset_name_to_update: if ref_scale_text == "対数 (log10)": offset_key = "y_offset_log_exponent" previous_offset = original_dataset.get(offset_key, 0.0) # スピンボックスの値はそのまま指数として使う value_to_store = new_offset_input_value else: # 線形または平方根 offset_key = "y_offset_linear" previous_offset = original_dataset.get(offset_key, 0.0) value_to_store = new_offset_input_value if abs(previous_offset - value_to_store) > 1e-9: # 値が実際に変わった場合のみ更新 original_dataset[offset_key] = value_to_store # ★★★ self.datasets の値を直接更新 ★★★ print(f"デバッグ: '{original_dataset['name']}' のオフセットキー '{offset_key}' を {value_to_store:.2f} に手動変更しました。 (変更前: {previous_offset:.2f})") dataset_updated = True else: print(f"デバッグ: '{original_dataset['name']}' のオフセットキー '{offset_key}' は既に {value_to_store:.2f} です。更新不要。") break if dataset_updated: print("デバッグ: オフセット変更によりグラフを更新します (update_plot_data_and_redraw呼び出し)。") self.update_plot_data_and_redraw() elif not any(ds.get("name") == dataset_name_to_update for ds in self.datasets): print(f"デバッグ: 手動オフセット適用時、データセット '{dataset_name_to_update}' が self.datasets に見つかりません。") else: print("デバッグ: apply_manual_offset_from_spinbox が単一選択でない、または選択なしで呼び出されました。") def toggle_item_visibility(self, item, data_list, item_type_name): """ 指定されたリストウィジェットのアイテムの表示/非表示を切り替える汎用メソッド item: ダブルクリックされた QListWidgetItem data_list: self.datasets または self.references item_type_name: "データセット" または "リファレンス" (デバッグ用) """ item_info = item.data(Qt.ItemDataRole.UserRole) if not (item_info and "name" in item_info): return # data_list から該当するマスターデータを名前で検索して更新 target_item_data = None for d in data_list: if d.get("name") == item_info.get("name"): target_item_data = d break if target_item_data: # "visible" キーの値を反転 (True -> False, False -> True) target_item_data["visible"] = not target_item_data.get("visible", True) print(f"デバッグ: {item_type_name} '{target_item_data['name']}' の表示状態を {target_item_data['visible']} に変更。") # リストの見た目とグラフを更新 self._update_all_list_items_visuals() self.update_plot() else: # この警告は、リストとデータの同期が取れていない場合に表示される可能性があります print(f"デバッグ: 警告 - toggle_item_visibility で '{item_info.get('name')}' が {item_type_name} リストに見つかりません。") def save_graph_dialog(self): if not self.datasets: QMessageBox.information(self, "情報", "保存するグラフデータがありません。") return file_path, selected_filter = QFileDialog.getSaveFileName( self, "グラフを名前を付けて保存", "", # 初期ディレクトリ "PNG (*.png);;JPEG (*.jpg *.jpeg);;SVG (*.svg);;PDF (*.pdf);;All Files (*)" ) if file_path: try: self.canvas.fig.savefig(file_path, dpi=300) # 高解像度で保存 QMessageBox.information(self, "成功", f"グラフを {file_path} に保存しました。") except Exception as e: # QMessageBox.critical(self, "エラー", f"グラフ保存中にエラーが発生しました:\n{e}") # 詳細なエラーはhandle_exceptionで self.show_error_message("グラフ保存エラー", f"グラフ保存中にエラーが発生しました。", e) def save_session(self): """現在のセッション(データ、設定)をJSONファイルに保存する""" if not self.datasets and not self.references: QMessageBox.information(self, "情報", "保存するデータがありません。") return # 保存するデータの準備 session_data = { "saved_at": datetime.now().isoformat(), "ui_settings": { "y_scale": self.combo_data_y_scale.currentText(), "graph_font_family": self.current_font.family(), "graph_font_size": self.current_font.pointSizeF() if hasattr(self.current_font, 'pointSizeF') else self.current_font.pointSize(), "ui_font_family": QApplication.font().family(), "ui_font_size": QApplication.font().pointSizeF() if hasattr(QApplication.font(), 'pointSizeF') else QApplication.font().pointSize(), "x_axis_locked": self.x_axis_is_locked, "locked_x_range": self.locked_x_range, "locked_y_range": self.canvas.axes.get_ylim() if self.x_axis_is_locked else None, # Y軸範囲も保存 "ref_common_settings": { "max_peak_height": self.reference_max_peak_display_height, "log_display_decades": self.reference_log_display_decades }, "colormap_settings": { "current_colormap": self.combo_colormap.currentText(), "cmap_min_val": self.spinbox_cmap_min.value(), "cmap_max_val": self.spinbox_cmap_max.value() }, "aspect_ratio": self.combo_aspect_ratio.currentText(), "axis_limits_text": { # ユーザーが入力した軸範囲テキストも保存 "x_min": self.edit_x_min.text(), "x_max": self.edit_x_max.text(), "y_min": self.edit_y_min.text(), "y_max": self.edit_y_max.text() } }, "datasets": [], "references": [] } # データセット情報の保存 (Numpy配列をリストに変換) for ds in self.datasets: # line_objectは保存できないので除外 serializable_ds = {k: v for k, v in ds.items() if k != "line_object"} serializable_ds["x"] = serializable_ds["x"].tolist() serializable_ds["original_y"] = serializable_ds["original_y"].tolist() serializable_ds["processed_y"] = serializable_ds["processed_y"].tolist() # QColorをHEX文字列に変換 serializable_ds["color"] = serializable_ds["color"].name() session_data["datasets"].append(serializable_ds) # リファレンス情報の保存 for ref in self.references: serializable_ref = {} for key, value in ref.items(): if key in ["positions", "intensities"] and isinstance(value, np.ndarray): serializable_ref[key] = value.tolist() elif key == "y_tops_last_plot" and isinstance(value, np.ndarray): # ★ y_tops_last_plot をリストに変換 serializable_ref[key] = value.tolist() elif key == "color" and isinstance(value, QColor): serializable_ref[key] = value.name() elif key == "line_object": # line_object は保存しない continue else: # name, visible, linewidth, hkls, offset_y_linear, offset_y_log_exponent など serializable_ref[key] = value session_data["references"].append(serializable_ref) # ファイルダイアログで保存先を選択 filepath, _ = QFileDialog.getSaveFileName(self, "セッションを保存", "", "JSON Files (*.json)") if not filepath: return try: with open(filepath, 'w', encoding='utf-8') as f: json.dump(session_data, f, indent=4, ensure_ascii=False) QMessageBox.information(self, "成功", f"セッションを正常に保存しました:\n{filepath}") except IOError as e: self.show_error_message("セッション保存エラー", f"セッションの保存中にファイルI/Oエラーが発生しました。", e) except Exception as e: self.show_error_message("セッション保存エラー", f"セッションの保存中に予期せぬエラーが発生しました。", e) def load_session(self): """JSONファイルからセッションを読み込み、状態を復元する""" filepath, _ = QFileDialog.getOpenFileName(self, "セッションを読み込み", "", "JSON Files (*.json)") if not filepath: return try: with open(filepath, 'r', encoding='utf-8') as f: session_data = json.load(f) # --- 状態の復元 --- # まず現在のデータをクリア self.datasets.clear() self.references.clear() self.list_widget_data.clear() self.list_widget_references.clear() # データセットの復元 for ds_data in session_data.get("datasets", []): ds_data["x"] = np.array(ds_data["x"]) ds_data["original_y"] = np.array(ds_data["original_y"]) ds_data["processed_y"] = np.array(ds_data["processed_y"]) ds_data["color"] = QColor(ds_data["color"]) # HEX文字列からQColorに復元 ds_data["line_object"] = None # 読み込み時にはNone self.datasets.append(ds_data) # リストウィジェットにも追加 item = QListWidgetItem(ds_data["name"]) item.setData(Qt.ItemDataRole.UserRole, ds_data) self.list_widget_data.addItem(item) # リファレンスの復元 for ref_data_from_json in session_data.get("references", []): # ループ変数を変更 ref_data = {k:v for k,v in ref_data_from_json.items()} # まずコピー ref_data["positions"] = np.array(ref_data_from_json.get("positions", [])) ref_data["intensities"] = np.array(ref_data_from_json.get("intensities", [])) ref_data["color"] = QColor(ref_data_from_json.get("color", "#808080")) # デフォルト色 if "hkls" in ref_data_from_json: ref_data["hkls"] = ref_data_from_json["hkls"] if "y_tops_last_plot" in ref_data_from_json and isinstance(ref_data_from_json["y_tops_last_plot"], list): ref_data["y_tops_last_plot"] = np.array(ref_data_from_json["y_tops_last_plot"]) # y_tops_last_plot が存在しない、またはリストでない場合は、読み込み後に update_plot で再生成されるので問題なし self.references.append(ref_data) item = QListWidgetItem(ref_data.get("name", "Unknown Reference")) # ここは ref_data でOK item.setData(Qt.ItemDataRole.UserRole, ref_data) item.setForeground(ref_data["color"]) self.list_widget_references.addItem(item) # UI設定の復元 ui_settings = session_data.get("ui_settings", {}) self.combo_data_y_scale.setCurrentText(ui_settings.get("y_scale", "線形")) self.x_axis_is_locked = ui_settings.get("x_axis_locked", False) self.locked_x_range = tuple(ui_settings.get("locked_x_range")) if ui_settings.get("locked_x_range") else None ref_common = ui_settings.get("ref_common_settings", {}) self.reference_max_peak_display_height = ref_common.get("max_peak_height", 120.0) self.reference_log_display_decades = ref_common.get("log_display_decades", 1.0) # フォント設定の復元 graph_font_family = ui_settings.get("graph_font_family", self.current_font.family()) # if-elseに修正 if isinstance(ui_settings.get("graph_font_size"), float): graph_font_size = ui_settings.get("graph_font_size") else: graph_font_size = self.current_font.pointSize() loaded_graph_font = QFont(graph_font_family, int(graph_font_size) if isinstance(graph_font_size, float) and graph_font_size.is_integer() else graph_font_size) # pointSizeF()はfloatを返すことがある self.current_font = loaded_graph_font # self.current_font を更新 self.apply_font_to_graph(self.current_font, update_plot=False) # グラフフォント適用 # if-elseに修正 if isinstance(ui_settings.get("ui_font_size"), float): ui_font_size = ui_settings.get("ui_font_size") else: ui_font_size = QApplication.font().pointSize() ui_font_family = ui_settings.get("ui_font_family", QApplication.font().family()) loaded_ui_font = QFont(ui_font_family, int(ui_font_size) if isinstance(ui_font_size, float) and ui_font_size.is_integer() else ui_font_size) self._apply_font_to_qt_widgets(loaded_ui_font) # UIフォント適用 # スピンボックスの値を復元 self.spinbox_ref_linear_scale_height.setValue(self.reference_max_peak_display_height) self.spinbox_ref_log_scale_factor.setValue(self.reference_log_display_decades) # --- 復元後の再描画 --- self._update_all_list_items_visuals() self.update_plot() # update_plot内でロック状態が考慮される # Y軸範囲も復元 if self.x_axis_is_locked and ui_settings.get("locked_y_range"): self.canvas.axes.set_ylim(tuple(ui_settings.get("locked_y_range"))) self.canvas.draw_idle() # カラースケール設定の復元 cmap_settings = ui_settings.get("colormap_settings", {}) if cmap_settings.get("current_colormap"): self.combo_colormap.setCurrentText(cmap_settings["current_colormap"]) self.spinbox_cmap_min.setValue(cmap_settings.get("cmap_min_val", 0.0)) self.spinbox_cmap_max.setValue(cmap_settings.get("cmap_max_val", 1.0)) # 縦横比の復元 if ui_settings.get("aspect_ratio"): self.combo_aspect_ratio.setCurrentText(ui_settings.get("aspect_ratio")) # 軸範囲入力テキストの復元 (on_axes_limits_changedで更新されるが、手動入力値を優先する場合) axis_texts = ui_settings.get("axis_limits_text", {}) # on_axes_limits_changed が呼ばれるので、ここでは明示的に設定しない QMessageBox.information(self, "成功", "セッションを正常に読み込みました。") except FileNotFoundError: self.show_error_message("セッション読み込みエラー", f"指定されたセッションファイルが見つかりません:\n{filepath}", FileNotFoundError()) except json.JSONDecodeError as e: self.show_error_message("セッション読み込みエラー", f"セッションファイルの解析に失敗しました。\nファイルが破損しているか、無効なJSON形式です。", e) except Exception as e: self.show_error_message("セッション読み込みエラー", f"セッションの読み込み中に予期せぬエラーが発生しました。", e) def apply_manual_x_zoom(self): """X軸の範囲を手動で設定し、その範囲をロックする""" x_min_str = self.edit_x_min.text() x_max_str = self.edit_x_max.text() # 入力がない場合は何もしない(ロックも変更しない) if not x_min_str and not x_max_str: return current_x_lim = self.canvas.axes.get_xlim() x_min = current_x_lim[0] x_max = current_x_lim[1] # if-elseに修正 try: if x_min_str: x_min = float(x_min_str) if x_max_str: x_max = float(x_max_str) except ValueError: QMessageBox.warning(self, "入力エラー", "X軸のズーム範囲には数値を入力してください。") return if x_min < x_max: self.canvas.axes.set_xlim(x_min, x_max) self.x_axis_is_locked = True # ★★★ X軸をロック ★★★ self.locked_x_range = (x_min, x_max) # ★★★ 範囲を保存 ★★★ print(f"デバッグ: X軸の範囲をロックしました: {self.locked_x_range}") self.canvas.draw_idle() else: QMessageBox.warning(self, "入力エラー", "X軸の最小値が最大値以上です。") def apply_manual_y_zoom(self): """Y軸の範囲を手動で設定する""" y_min_str = self.edit_y_min.text() y_max_str = self.edit_y_max.text() if not y_min_str and not y_max_str: return current_y_lim = self.canvas.axes.get_ylim() y_min = current_y_lim[0] y_max = current_y_lim[1] # if-elseに修正 try: if y_min_str: y_min = float(y_min_str) if y_max_str: y_max = float(y_max_str) except ValueError: QMessageBox.warning(self, "入力エラー", "Y軸のズーム範囲には数値を入力してください。") return if y_min < y_max: self.canvas.axes.set_ylim(y_min, y_max) self.canvas.draw_idle() else: QMessageBox.warning(self, "入力エラー", "Y軸の最小値が最大値以上です。") def apply_x_tick_interval(self): """入力された間隔をインスタンス変数に保存し、グラフを更新する""" interval_str = self.edit_x_tick_interval.text() if not interval_str: # 入力が空の場合は自動設定に戻す self.x_tick_interval = None print("デバッグ: X軸の主目盛間隔を自動にリセットします。") self.update_plot() return # if-elseに修正 try: interval = float(interval_str) except ValueError: QMessageBox.warning(self, "入力エラー", "目盛間隔には数値を入力してください。") return if interval <= 0: QMessageBox.warning(self, "入力エラー", "目盛間隔には正の数値を入力してください。") return # 入力値をインスタンス変数に保存 self.x_tick_interval = interval print(f"デバッグ: X軸の主目盛間隔を {self.x_tick_interval} に設定します。") self.update_plot() # 再描画して適用 def reset_zoom(self): """ズームをリセットし、X軸のロックと目盛間隔を解除する""" print("デバッグ: ズームリセットが呼び出されました。") self.x_axis_is_locked = False self.locked_x_range = None # X軸の目盛間隔も自動に戻す self.x_tick_interval = None self.edit_x_tick_interval.clear() # 入力欄もクリア self.update_plot() def apply_custom_size_cm(self): """スピンボックスで指定されたcm単位のサイズをグラフに適用する""" # if-elseに修正 if hasattr(self, 'spin_width_cm') and hasattr(self, 'spin_height_cm'): width_cm = self.spin_width_cm.value() height_cm = self.spin_height_cm.value() # cmをインチに変換 (1 inch = 2.54 cm) width_inch = width_cm / 2.54 height_inch = height_cm / 2.54 print(f"デバッグ: カスタムサイズ適用 -> {width_inch:.2f} inch x {height_inch:.2f} inch") # Figureのサイズを直接インチで設定 self.canvas.fig.set_size_inches(width_inch, height_inch, forward=True) # 適用したことを示すために、縦横比コンボボックスを「カスタム」に戻す self.combo_aspect_ratio.blockSignals(True) self.combo_aspect_ratio.setCurrentText("カスタム") self.combo_aspect_ratio.blockSignals(False) self.update_plot() # 再描画してレイアウトを調整 else: self.show_error_message("サイズ適用エラー", f"カスタムサイズ設定用のスピンボックスが見つかりません。") def on_axes_limits_changed(self, axes): """Matplotlibの軸範囲が変更されたときに呼び出される""" xlim = axes.get_xlim() ylim = axes.get_ylim() self.edit_x_min.setText(f"{xlim[0]:.3g}") # 有効数字3桁程度 self.edit_x_max.setText(f"{xlim[1]:.3g}") self.edit_y_min.setText(f"{ylim[0]:.3g}") self.edit_y_max.setText(f"{ylim[1]:.3g}") def _process_y_data(self, y_original, scale_type): # scale_type引数は残しても良いが、現在は使わない """Yデータをそのまま返す (Y軸スケールはset_yscaleで処理するため)""" # 以前の log10 や sqrt の計算はここでは行わない return np.copy(y_original) # 元のデータを変更しないようにコピーを返す def _get_selected_items(self, list_widget, data_list): """選択されているアイテムのデータ辞書とQListWidgetItemを返す汎用ヘルパー""" selected_items = list_widget.selectedItems() if not selected_items: return [], [] # UserRoleから辞書を取得し、その名前のセットを作成 selected_data_proxies = [item.data(Qt.ItemDataRole.UserRole) for item in selected_items] selected_names = {d.get("name") for d in selected_data_proxies if d} # マスターリスト(data_list)から、選択された名前と一致する最新の辞書オブジェクトを抽出 # これにより、UserRoleのデータが古くなっている可能性を排除できる selected_data_dicts = [d for d in data_list if d.get("name") in selected_names] return selected_data_dicts, selected_items def remove_selected_items(self, list_widget, data_list, item_type_name): """選択されたアイテムを削除する汎用メソッド""" selected_data, selected_items = self._get_selected_items(list_widget, data_list) if not selected_data: return reply = QMessageBox.question(self, "確認", f"{len(selected_data)}個の{item_type_name}を削除しますか?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No) if reply == QMessageBox.StandardButton.Yes: names_to_remove = {d["name"] for d in selected_data} # 元のリストを直接変更(in-place modification) # こうすることで self.datasets や self.references 自体が更新される items_to_keep = [d for d in data_list if d.get("name") not in names_to_remove] data_list.clear() data_list.extend(items_to_keep) # QListWidgetからアイテムを削除 for item in selected_items: list_widget.takeItem(list_widget.row(item)) print(f"デバッグ: {item_type_name}を削除しました。残りの要素数: {len(data_list)}") self.update_plot() # on_..._selection_changed は選択がクリアされると自動で呼ばれる def set_selected_item_color(self, list_widget, data_list, item_type_name): """選択されたアイテムの色を変更する汎用メソッド""" selected_data, _ = self._get_selected_items(list_widget, data_list) if not selected_data: return initial_color = selected_data[0].get("color", QColor("black")) color = QColorDialog.getColor(initial_color, self, f"{item_type_name}の色を選択") if color and color.isValid(): for d in selected_data: d["color"] = color self._update_all_list_items_visuals() # 両方のリストを更新するヘルパーを呼ぶ self.update_plot() def rename_selected_item(self, list_widget, data_list, item_type_name): """選択された単一アイテムの名前を変更する汎用メソッド (重複チェック付き)""" selected_data, selected_items = self._get_selected_items(list_widget, data_list) if len(selected_data) != 1: return item = selected_items[0] original_data = selected_data[0] current_name = original_data.get("name", "") text, ok = QInputDialog.getText(self, f"{item_type_name}名の変更", f"新しい名前を入力 ('{current_name}' から):", QLineEdit.EchoMode.Normal, current_name) if ok and text and text.strip(): new_name = text.strip() if new_name == current_name: return # 名前が変わっていない場合は何もしない # 重複チェックのロジック # チェック対象は、自分自身の現在の名前を除く、すべての名前 all_other_names = ({d.get("name") for d in self.datasets} | {r.get("name") for r in self.references}) - {current_name} if new_name in all_other_names: QMessageBox.warning(self, "名前の重複", f"名前 '{new_name}' はすでに存在します。\n別の名前を入力してください。") return # 重複している場合は処理を中断 # 重複がなければ、名前を更新 original_data["name"] = new_name item.setText(new_name) item.setData(Qt.ItemDataRole.UserRole, original_data) # ★★★ 更新された辞書全体をUserRoleに設定 ★★★ self.update_plot() def apply_colorscale_to_selected_items(self, list_widget, data_list, item_type_name): """選択されたアイテムにカラースケールを適用する汎用メソッド""" selected_data, _ = self._get_selected_items(list_widget, data_list) num_selected = len(selected_data) if num_selected == 0: return # (この部分はデータセットもリファレンスも同じUIを共有するので、self から直接取得) selected_cmap_name = self.combo_colormap.currentText() cmap_min_val = self.spinbox_cmap_min.value() cmap_max_val = self.spinbox_cmap_max.value() if cmap_min_val >= cmap_max_val: QMessageBox.warning(self, "入力エラー", "カラースケール範囲の始点は終点より小さい必要があります。") return # if-elseに修正 if selected_cmap_name in matplotlib.colormaps: cmap = matplotlib.colormaps.get_cmap(selected_cmap_name) else: self.show_error_message("カラースケール取得エラー", f"指定されたカラースケール '{selected_cmap_name}' が見つかりません。", ValueError("Colormap not found")) return for i, d in enumerate(selected_data): normalized_position = i / max(1, num_selected - 1) if num_selected > 1 else 0.5 fraction = cmap_min_val + normalized_position * (cmap_max_val - cmap_min_val) rgba_color = cmap(fraction) d["color"] = QColor.fromRgbF(*rgba_color) self._update_all_list_items_visuals() self.update_plot() def _update_all_list_items_visuals(self): """データリストとリファレンスリストの両方の見た目を現在のデータ状態に合わせて更新する""" # 1. データリスト (self.list_widget_data) の更新 print("デバッグ: データリストの見た目を更新中...") for i in range(self.list_widget_data.count()): item = self.list_widget_data.item(i) # UserRoleには辞書そのものが格納されている想定 data_info = item.data(Qt.ItemDataRole.UserRole) if data_info and "name" in data_info: # self.datasets から最新の情報を取得 latest_data_info = None for ds in self.datasets: if ds.get("name") == data_info.get("name"): latest_data_info = ds break if latest_data_info: # 色を更新 item.setForeground(latest_data_info.get("color", QColor("black"))) # 表示/非表示状態に応じて打ち消し線を設定 font = item.font() is_visible = latest_data_info.get("visible", True) font.setStrikeOut(not is_visible) item.setFont(font) # 非表示の場合は文字色をグレーにする if not is_visible: item.setForeground(QColor("gray")) # 2. リファレンスリスト (self.list_widget_references) の更新 print("デバッグ: リファレンスリストの見た目を更新中...") for i in range(self.list_widget_references.count()): item = self.list_widget_references.item(i) # UserRoleには辞書そのものが格納されている想定 ref_info_from_item = item.data(Qt.ItemDataRole.UserRole) # UserRoleから辞書を取得 if ref_info_from_item and "name" in ref_info_from_item: # self.references から最新の情報を取得 latest_ref_info = None for ref_in_main_list in self.references: if ref_in_main_list.get("name") == ref_info_from_item.get("name"): latest_ref_info = ref_in_main_list break if latest_ref_info: is_visible = latest_ref_info.get("visible", True) # フォントに打ち消し線を設定 font = item.font() # QListWidgetItemから現在のフォントを取得 font.setStrikeOut(not is_visible) item.setFont(font) # 表示状態に応じて文字色を設定 if is_visible: item.setForeground(latest_ref_info.get("color", QColor("black"))) else: item.setForeground(QColor("gray")) # 非表示の場合はグレー else: print(f"デバッグ: リストアイテム {i} のUserRoleに有効なリファレンス情報がありません。") def update_manual_offset_spinbox_config(self): """現在のY軸スケールと選択データに応じて、手動オフセットスピンボックスの表示を更新する""" if not hasattr(self, 'combo_data_y_scale') or not hasattr(self, 'spinbox_manual_y_offset'): return ref_scale_text = self.combo_data_y_scale.currentText() selected_items = self.list_widget_data.selectedItems() single_selection = len(selected_items) == 1 offset_value_to_display = 0.0 # デフォルトまたは選択なしの場合の値 if single_selection: item = selected_items[0] dataset_info_from_item = item.data(Qt.ItemDataRole.UserRole) # UserRoleには辞書全体を格納想定 if dataset_info_from_item and "name" in dataset_info_from_item: dataset_name = dataset_info_from_item["name"] # self.datasets から最新の情報を取得 for ds_in_main in self.datasets: if ds_in_main.get("name") == dataset_name: if ref_scale_text == "対数 (log10)": # "symlog"もこちらで扱う offset_value_to_display = ds_in_main.get("y_offset_log_exponent", 0.0) else: # 線形または平方根 offset_value_to_display = ds_in_main.get("y_offset_linear", 0.0) print(f"デバッグ (update_manual_offset_spinbox_config): データ '{dataset_name}' のオフセット値 '{offset_value_to_display}' をスピンボックスに設定します。") break self.spinbox_manual_y_offset.blockSignals(True) if ref_scale_text == "対数 (log10)": self.manual_offset_label.setText("Yオフセット (10^X のX):") self.spinbox_manual_y_offset.setRange(-10, 10) # 範囲を広げるなど適宜調整 self.spinbox_manual_y_offset.setSingleStep(0.1) self.spinbox_manual_y_offset.setDecimals(1) self.spinbox_manual_y_offset.setValue(offset_value_to_display) # (平方根スケールの設定は省略、線形と同様の加算オフセットとするか、別途検討) else: # 線形 (および現状では平方根もこちら) self.manual_offset_label.setText("Yオフセット (加算):") # データのスケールに応じて範囲やステップを調整する必要がある self.spinbox_manual_y_offset.setRange(-1e7, 1e7) self.spinbox_manual_y_offset.setSingleStep(self.default_linear_offset_step if hasattr(self, 'default_linear_offset_step') else 100.0) self.spinbox_manual_y_offset.setDecimals(2) self.spinbox_manual_y_offset.setValue(offset_value_to_display) self.spinbox_manual_y_offset.setEnabled(single_selection) self.spinbox_manual_y_offset.blockSignals(False) def _update_linewidth_spinbox(self, list_widget, spinbox): """選択状態に応じて線幅スピンボックスの値を更新するヘルパー""" selected_items = list_widget.selectedItems() # 複数選択でも、最初のアイテムの線幅を表示する仕様 if selected_items: item_data = selected_items[0].data(Qt.ItemDataRole.UserRole) if item_data: current_linewidth = item_data.get("linewidth", self.default_linewidth) spinbox.blockSignals(True) spinbox.setValue(current_linewidth) spinbox.blockSignals(False) spinbox.setEnabled(True) else: spinbox.setEnabled(False) spinbox.blockSignals(True) spinbox.setValue(self.default_linewidth) spinbox.blockSignals(False) def apply_item_linewidth(self, list_widget, data_list, spinbox): """スピンボックスの値を、選択されているアイテムの線幅に適用する""" selected_data, _ = self._get_selected_items(list_widget, data_list) if not selected_data: return new_linewidth = spinbox.value() data_updated = False for d in selected_data: if abs(d.get("linewidth", self.default_linewidth) - new_linewidth) > 1e-9: d["linewidth"] = new_linewidth data_updated = True if data_updated: print(f"デバッグ: {len(selected_data)}個のアイテムの線幅を {new_linewidth:.1f} に変更しました。") self.update_plot() def _sync_data_order_from_widget(self, list_widget, data_list, item_type_name): """QListWidgetのアイテム順序変更を内部データリストに同期する""" print(f"デバッグ: {item_type_name}リストの順序が変更されたため、内部データを同期します。") new_ordered_data = [] for i in range(list_widget.count()): item = list_widget.item(i) item_data_proxy = item.data(Qt.ItemDataRole.UserRole) # UserRoleには辞書が格納されている想定 if item_data_proxy and "name" in item_data_proxy: # マスターデータリスト(data_list)から、同じ名前を持つ最新の辞書オブジェクトを検索 # これにより、UserRoleのデータが古い場合でも最新の情報を参照できる found_master_data = next((d for d in data_list if d.get("name") == item_data_proxy.get("name")), None) if found_master_data: new_ordered_data.append(found_master_data) else: # 通常は発生しないはずだが、念のため警告 print(f"デバッグ警告: {item_type_name} '{item_data_proxy.get('name')}' がマスターリストに見つかりません。") new_ordered_data.append(item_data_proxy) # フォールバックとしてプロキシデータを追加 data_list.clear() data_list.extend(new_ordered_data) self.update_plot() # 順序変更がプロットに影響する可能性があるので再描画 def on_y_scale_changed_update_plot_and_ui(self, new_scale_text): """Y軸スケールが変更されたときに呼び出され、UIとグラフを更新する""" print(f"デバッグ: データY軸スケールが '{self.previous_data_y_scale_text}' から '{new_scale_text}' に変更されました。") self.previous_data_y_scale_text = new_scale_text # 選択中のリファレンスがあれば、そのUI表示を更新する self.on_reference_selection_changed() # 選択中のデータセットがあれば、そのUI表示を更新する self.on_data_selection_changed() # グラフ全体を再描画 self.update_plot_data_and_redraw() def update_plot_data_and_redraw(self): """データ処理とグラフ全体の再描画を行う""" current_y_scale = self.combo_data_y_scale.currentText() for data in self.datasets: data["processed_y"] = self._process_y_data(data["original_y"], current_y_scale) self.update_plot() def update_plot_style(self): """プロットのスタイル(フォント、ラベル等)のみ更新。データ処理は行わない。""" self.update_plot(process_data=False) def update_plot(self, process_data=True): """グラフを再描画する (データとリファレンスの個別スケールを適用)""" print("デバッグ: update_plot が呼び出されました。") self.canvas.axes.clear() # 各スケールを取得 data_scale_text = self.combo_data_y_scale.currentText() ref_scale_text = self.combo_ref_y_scale.currentText() print(f"デバッグ: データスケール='{data_scale_text}', リファレンススケール='{ref_scale_text}'") actual_y_min_data = float('inf') actual_y_max_data = float('-inf') data_plotted = False # --- データセットのプロット --- # 処理は data_scale_text に基づいて行う if process_data: for data in self.datasets: data["processed_y"] = self._process_y_data(data["original_y"], data_scale_text) for data in self.datasets: if data.get("visible", False): x_values = data.get("x", np.array([])) y_original_for_offset = np.copy(data.get("processed_y", np.array([]))) if len(x_values) == 0 or len(y_original_for_offset) == 0: continue q_color = data.get("color", QColor("black")) mpl_color = (q_color.redF(), q_color.greenF(), q_color.blueF(), q_color.alphaF()) y_to_plot = np.copy(y_original_for_offset) # データオフセットの適用ロジック (data_scale_text に基づく) if data_scale_text == "対数 (log10)": y_to_plot[y_to_plot <= 1e-9] = 1e-9 log_exponent = data.get("y_offset_log_exponent", 0.0) y_to_plot = y_to_plot * (10**log_exponent) elif data_scale_text == "平方根 (sqrt)": linear_offset = data.get("y_offset_linear", 0.0) # 平方根スケールでは、オフセットを適用してから平方根を取ると歪むため、 # 便宜上、平方根を取った後にオフセットを加算する y_to_plot = np.sqrt(np.maximum(y_to_plot, 0)) + linear_offset else: # 線形 linear_offset = data.get("y_offset_linear", 0.0) y_to_plot = y_to_plot + linear_offset line, = self.canvas.axes.plot(x_values, y_to_plot, label=data.get("name", "N/A"), color=mpl_color, linewidth=data.get("linewidth", self.default_linewidth)) data["line_object"] = line data_plotted = True valid_y_for_lim = y_to_plot[~np.isnan(y_to_plot)] if data_scale_text == "対数 (log10)": valid_y_for_lim = valid_y_for_lim[valid_y_for_lim > 1e-9] if len(valid_y_for_lim) > 0: actual_y_min_data = min(actual_y_min_data, np.min(valid_y_for_lim)) actual_y_max_data = max(actual_y_max_data, np.max(valid_y_for_lim)) # --- リファレンスデータのプロット処理 --- if hasattr(self, 'references') and self.references: baselines_to_draw = [] for ref_idx, ref_data in enumerate(self.references): if ref_data.get("visible", True): positions = ref_data.get("positions", np.array([])) intensities = ref_data.get("intensities", np.array([])) if len(positions) == 0: continue q_color = ref_data.get("color", QColor("gray")) q_color_name_hex = q_color.name() y_coordinates_for_stem_tops = np.array([]) actual_ref_baseline_y = 0.0 # ★★★ ここからが修正後のロジック ★★★ if data_scale_text == "対数 (log10)": # --- データ軸が「対数」の場合 --- if ref_scale_text == "対数 (log10)": # 対数リファレンス on 対数軸:従来通りの乗算スケーリング log_exponent = ref_data.get("offset_y_log_exponent", -1.0) actual_ref_baseline_y = 10**log_exponent with np.errstate(divide='ignore', invalid='ignore'): log_I = np.log10(np.maximum(intensities, 1e-9)) min_log_I, max_log_I = (np.min(log_I), np.max(log_I)) if len(log_I) > 0 else (0, 0) norm_log_I = (log_I - min_log_I) / (max_log_I - min_log_I) if (max_log_I - min_log_I) > 1e-9 else np.zeros_like(log_I) y_coordinates_for_stem_tops = actual_ref_baseline_y * (10**(norm_log_I * self.reference_log_display_decades)) else: # 線形・sqrtリファレンス on 対数軸:ご提案通りの `10**y` 変換 # 1. まず線形軸上での「見た目」の座標を計算 if ref_scale_text == "平方根 (sqrt)": processed_intensities = np.sqrt(np.maximum(intensities, 0)) else: # 線形 processed_intensities = intensities norm_intensities = processed_intensities / np.max(processed_intensities) if np.max(processed_intensities) > 0 else np.zeros_like(processed_intensities) visual_height = norm_intensities * self.reference_max_peak_display_height # UIで設定されたオフセット値(指数)を「見た目」のベースラインとする visual_baseline_y = ref_data.get("offset_y_log_exponent", -1.0) visual_tops_y = visual_baseline_y + visual_height # 2. 「見た目」の座標を `10**` で対数軸用の実座標に変換 actual_ref_baseline_y = 10**visual_baseline_y y_coordinates_for_stem_tops = 10**visual_tops_y else: # --- データ軸が「線形」または「平方根」の場合 --- # このブロックは基本的に変更なし actual_ref_baseline_y = ref_data.get("offset_y_linear", 0.0) if ref_scale_text == "対数 (log10)": # 対数リファレンス on 線形軸 with np.errstate(divide='ignore', invalid='ignore'): log_I = np.log10(np.maximum(intensities, 1e-9)) min_log_I, max_log_I = (np.min(log_I), np.max(log_I)) if len(log_I) > 0 else (0, 0) norm_intensities = (log_I - min_log_I) / (max_log_I - min_log_I) if (max_log_I - min_log_I) > 1e-9 else np.zeros_like(log_I) else: # 線形・sqrtリファレンス on 線形軸 if ref_scale_text == "平方根 (sqrt)": processed_intensities = np.sqrt(np.maximum(intensities, 0)) else: # 線形 processed_intensities = intensities norm_intensities = processed_intensities / np.max(processed_intensities) if np.max(processed_intensities) > 0 else np.zeros_like(processed_intensities) visual_height = norm_intensities * self.reference_max_peak_display_height y_coordinates_for_stem_tops = actual_ref_baseline_y + visual_height if len(y_coordinates_for_stem_tops) == 0: continue # ステムプロットの実行 markerline, stemlines, _ = self.canvas.axes.stem( positions, y_coordinates_for_stem_tops, linefmt='-', markerfmt='', basefmt='', bottom=actual_ref_baseline_y, label=f"{ref_data['name']} (ref)" ) if markerline: markerline.set_color(q_color_name_hex) stemlines.set_color(q_color_name_hex) stemlines.set_linewidth(ref_data.get("linewidth", self.default_linewidth)) ref_data["y_tops_last_plot"] = y_coordinates_for_stem_tops baselines_to_draw.append({ "y": actual_ref_baseline_y, "color": q_color_name_hex }) # ベースラインをまとめて描画 final_xlim = self.canvas.axes.get_xlim() for baseline_info in baselines_to_draw: self.canvas.axes.hlines(baseline_info["y"], final_xlim[0], final_xlim[1], color=baseline_info["color"], linestyle='-') y_label_text = "Intensity $\it{I}$ (arb.unit)" # デフォルトのラベルを定義 if data_scale_text == "対数 (log10)": self.canvas.axes.set_yscale('log') # ... (既存の対数スケール用フォーマッタ設定はそのまま) self.canvas.axes.yaxis.set_major_locator(matplotlib.ticker.LogLocator(base=10.0, numticks=15)) self.canvas.axes.yaxis.set_major_formatter(matplotlib.ticker.LogFormatterSciNotation(labelOnlyBase=False, minor_thresholds=(2, 0.5))) elif data_scale_text == "平方根 (sqrt)": y_label_text = "Intensity $\sqrt{\it{I}}$ (arb.unit)" # if-elseに修正 if hasattr(matplotlib.scale, 'PowerScale'): from matplotlib.scale import PowerScale self.canvas.axes.set_yscale(PowerScale(gamma=0.5)) else: self.canvas.axes.set_yscale('linear') # Fallback if PowerScale not available self.canvas.axes.yaxis.set_major_locator(matplotlib.ticker.AutoLocator()) self.canvas.axes.yaxis.set_major_formatter(matplotlib.ticker.ScalarFormatter()) else: # 線形 self.canvas.axes.set_yscale('linear') self.canvas.axes.yaxis.set_major_locator(matplotlib.ticker.AutoLocator()) self.canvas.axes.yaxis.set_major_formatter(matplotlib.ticker.ScalarFormatter()) # ... (Y軸ラベル非表示、凡例、グリッド、オートスケールなどの残りの部分は既存のコードを流用) ... if not self.y_tick_labels_visible: self.canvas.axes.yaxis.set_major_formatter(matplotlib.ticker.NullFormatter()) self.canvas.axes.yaxis.set_minor_formatter(matplotlib.ticker.NullFormatter()) # by T. Kamiya # self.canvas.axes2.set_xlabel("2θ (deg.)", fontsize=self.base_font_size) self.canvas.axes.set_xlabel("2θ (deg.)", fontsize=self.base_font_size) self.canvas.axes.set_ylabel(y_label_text, fontsize=self.base_font_size) if self.legend_visible: if (data_plotted) or (hasattr(self, 'references') and any(r.get("visible", False) for r in self.references)): self.canvas.axes.legend(fontsize=self.base_font_size * 0.9) else: legend = self.canvas.axes.get_legend() if legend: legend.remove() self.canvas.axes.grid(matplotlib.rcParams.get('axes.grid', False)) if self.x_tick_interval is not None: self.canvas.axes.xaxis.set_major_locator(matplotlib.ticker.MultipleLocator(base=self.x_tick_interval)) else: self.canvas.axes.xaxis.set_major_locator(matplotlib.ticker.AutoLocator()) if data_plotted or (hasattr(self, 'references') and any(r.get("visible", False) for r in self.references)): self.canvas.axes.relim() if self.x_axis_is_locked and self.locked_x_range is not None: self.canvas.axes.autoscale_view(tight=False, scalex=False, scaley=True) self.canvas.axes.set_xlim(self.locked_x_range) else: self.canvas.axes.autoscale_view(tight=False, scalex=True, scaley=True) else: self.canvas.axes.set_xlim(0, 1) default_ylim = (0.1, 10) if data_scale_text == "対数 (log10)" else (0, 1) self.canvas.axes.set_ylim(default_ylim) # if-elseに修正 try: self.canvas.fig.tight_layout() except Exception as e: print(f"レイアウト調整エラー (tight_layout): {e}") # T. Kamiya plt.tight_layout() self.canvas.draw_idle() # 注釈オブジェクトの初期化 self.peak_annotation = self.canvas.axes.annotate( "", xy=(0, 0), xytext=(10, 10), textcoords="offset points", bbox=dict(boxstyle="round,pad=0.3", fc="lemonchiffon", alpha=0.8), arrowprops=dict(arrowstyle="->", connectionstyle="arc3,rad=0.1"), zorder=10 ) self.peak_annotation.set_visible(False) self.on_axes_limits_changed(self.canvas.axes) def load_xrd_data_dialog(self): """ファイルダイアログを開き、選択されたXRDデータを読み込んでプロットする""" # QFileDialog.getOpenFileName は (ファイルパス, 選択されたフィルター) のタプルを返す filepath, _ = QFileDialog.getOpenFileName( self, "XRDデータファイルを開く", "", # 初期ディレクトリ (カレントディレクトリ) "テキストファイル (*.txt);;全てのファイル (*)" # ファイルフィルター ) if filepath: # ファイルが選択された場合 print(f"選択されたファイル: {filepath}") sample_name, x_data, y_data = self.parse_xrd_file(filepath) if sample_name is not None and x_data is not None and y_data is not None: # 汎用GUIライブラリの add_dataset メソッドを使ってデータを追加 # add_dataset は内部で self.datasets への追加とリストウィジェットへの追加を行う self.add_dataset(name=sample_name, x_data=x_data, y_data=y_data) self.update_plot_data_and_redraw() # グラフを更新 print(f"データ '{sample_name}' を読み込みました。") else: print(f"ファイル '{filepath}' の解析に失敗しました。") def parse_reference_file(self, filepath): """XRDピークリファレンスファイル(TXT形式)を解析し、リファレンス名、2θ位置、強度を抽出する""" reference_name = os.path.splitext(os.path.basename(filepath))[0] positions = [] # 2theta intensities = [] # I hkls = [] # ★★★ hklを保存するリストを追加 ★★★ header_skipped = False # if-elseに修正 try: with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: for line_number, line in enumerate(f): line = line.strip() if not line: # 空行はスキップ continue if not header_skipped: parts_check = line.split() # 行が空でないことを確認してからアクセス if len(parts_check) > 0: # 最初の要素が数値に変換可能か、および十分な列数があるかチェック if parts_check[0].replace('.', '', 1).isdigit() and len(parts_check) > 8 and any(char.isalpha() for char in parts_check[7]): print(f"デバッグ: リファレンスファイルのヘッダー行と判断 (行 {line_number+1}): {line}") header_skipped = True continue else: # 数値データが始まる行、または不明なヘッダー print(f"デバッグ: リファレンスファイルのヘッダー行の可能性 (行 {line_number+1}): {line}") header_skipped = True # 一旦ヘッダーをスキップしたとみなし、次の行からデータとして処理 # ただし、この行自体がデータ行の可能性もあるので、`continue`しない elif len(parts_check) == 0: # 空行の場合はスキップ continue # データ行の処理 parts = line.split() if len(parts) >= 9: # if-elseに修正 if parts[0].isdigit() and parts[1].isdigit() and parts[2].isdigit() and \ parts[7].replace('.', '', 1).isdigit() and parts[8].replace('.', '', 1).isdigit(): # ★★★ h, k, l の値を読み取る ★★★ h = int(parts[0]) k = int(parts[1]) l = int(parts[2]) pos_2theta = float(parts[7]) # 8列目 (0-indexed) intensity = float(parts[8]) # 9列目 (0-indexed) positions.append(pos_2theta) intensities.append(intensity) hkls.append(f"({h} {k} {l})") # ★★★ 文字列としてhklを保存 ★★★ else: print(f"デバッグ: リファレンスファイルのデータ行で数値変換エラー (行 {line_number+1}): {line}") continue # 次の行を処理 else: print(f"デバッグ: リファレンスファイルのデータ行の列数が不足 (行 {line_number+1}): {line}") # データ読み取り中に列数不足であれば、それ以降はデータではないと判断して終了 break if not positions or not intensities: QMessageBox.warning(self, "パースエラー", f"ファイル '{os.path.basename(filepath)}' から有効なリファレンスデータを抽出できませんでした。\nファイルの形式を確認してください。") return None, None, None, None # ★★★ 戻り値を追加 ★★★ return reference_name, np.array(positions), np.array(intensities), hkls # ★★★ 戻り値を追加 ★★★ except FileNotFoundError: self.show_error_message("ファイルが見つかりません", f"指定されたリファレンスファイルが見つかりません:\n{filepath}", FileNotFoundError()) return None, None, None, None # ★★★ 戻り値を追加 ★★★ except Exception as e: self.show_error_message("ファイル読み込みエラー", f"リファレンスファイルの読み込み中に予期せぬエラーが発生しました。", e) return None, None, None, None # ★★★ 戻り値を追加 ★★★ def load_reference_dialog(self): """ファイルダイアログを開き、選択されたリファレンスデータを読み込む""" filepath, _ = QFileDialog.getOpenFileName( self, "XRDリファレンスファイルを開く", "", "テキストファイル (*.txt);;全てのファイル (*)" ) if filepath: print(f"選択されたリファレンスファイル: {filepath}") # modified by T. Kamiya: Start # --- XRD_GUI_lib が利用可能であれば、そちらのパーサーを使用 --- # ユーザー指定の関数名 `parse_xrd` を使用 ref_name = None positions = None intensities = None hkls = None if XRD_GUI_lib is not None and hasattr(XRD_GUI_lib, "parse_reference"): try: print("デバッグ: 外部ライブラリの XRD_GUI_lib.parse_reference() を使用します。") # 外部ライブラリの関数を呼び出し、結果をそのまま返す ref_name, positions, intensities, hkls = XRD_GUI_lib.parse_reference(filepath) except Exception as e: # 外部ライブラリでのエラーは致命的ではないため、警告として出力し、内蔵パーサーにフォールバック QMessageBox.warning(self, "外部パーサーエラー", f"外部ライブラリ (XRD_GUI_lib.py) でのリファレンス解析中にエラーが発生しました。\n内蔵パーサーを試行します。\nエラー詳細: {e}") pass # 外部ライブラリが利用できない、またはエラーが発生した場合に内蔵パーサーを使用 if ref_name is None or positions is None or intensities is None or hkls is None: ref_name, positions, intensities, hkls = self.parse_reference_file(filepath) # ★★★ hkls を受け取る ★★★ # ref_name, positions, intensities, hkls = self.parse_reference_file(filepath) # ★★★ hkls を受け取る ★★★ # modified by T. Kamiya: End if ref_name is not None and positions is not None and intensities is not None and hkls is not None: unique_ref_name = self._generate_unique_name(ref_name) # リファレンスデータをリストに追加 (辞書形式で) new_reference = { "name": unique_ref_name, "positions": positions, "intensities": intensities, "hkls": hkls, # ★★★ hkls を辞書に追加 ★★★ "visible": True, "color": self.get_next_color(), "linewidth": self.default_linewidth, "offset_y_linear": 0.0, # 線形用オフセットの初期値 "offset_y_log_exponent": -1.0 # 対数用オフセット(指数)の初期値 (例: 10^-1 = 0.1) } self.references.append(new_reference) print(f"リファレンスデータ '{unique_ref_name}' を読み込みました。ピーク数: {len(positions)}") # ★★★ リストウィジェットにリファレンス名を追加 ★★★ item = QListWidgetItem(unique_ref_name) # UserRole には、後で self.references から該当辞書を特定するための識別子を格納します。 # リファレンス名がユニークであるという前提で名前を格納します。 item.setData(Qt.ItemDataRole.UserRole, new_reference) item.setForeground(new_reference["color"]) self.list_widget_references.addItem(item) self.update_plot_data_and_redraw() # グラフを更新 else: print(f"リファレンスファイル '{filepath}' の解析に失敗しました。") """グラフを再描画する (リファレンスも描画)""" # ... (既存の update_plot の冒頭のデバッグプリントや軸クリア処理はそのまま) ... print("デバッグ update_plot冒頭: 現在のself.datasetsの色情報:") # このデバッグは維持 for idx, ds in enumerate(self.datasets): print(f" self.datasets[{idx}] ('{ds['name']}'): {ds['color'].name()}") if hasattr(self, 'references') and self.references: # リファレンスデータのデバッグ情報も追加 print("デバッグ update_plot冒頭: 現在のself.referencesの色情報:") for idx, ref in enumerate(self.references): print(f" self.references[{idx}] ('{ref['name']}'): {ref['color'].name()}") self.canvas.axes.clear() current_y_scale = self.combo_data_y_scale.currentText() process_data = True if process_data: for data in self.datasets: data["processed_y"] = self._process_y_data(data["original_y"], current_y_scale) for data in self.datasets: if data["visible"]: q_color = data["color"] mpl_color = (q_color.redF(), q_color.greenF(), q_color.blueF(), q_color.alphaF()) line, = self.canvas.axes.plot(data["x"], data["processed_y"], label=data["name"], color=mpl_color) data["line_object"] = line # ★★★ リファレンスデータのプロット処理を追加 ★★★ if hasattr(self, 'references'): # referencesリストが存在するか確認 current_ref_y_offset = 0 # 仮のYオフセット開始値 (あとで調整) y_limits = self.canvas.axes.get_ylim() # 現在のY軸範囲 (データプロット後) y_offset_step = - (y_limits[1] - y_limits[0]) * 0.05 if (y_limits[1] - y_limits[0]) > 1e-6 else -0.1 base_y_for_refs = y_limits[0] # Y軸の下限を基準にする for ref_idx, ref_data in enumerate(self.references): if ref_data["visible"]: positions = ref_data["positions"] intensities = ref_data["intensities"] # 元の強度 (0-100など) q_color = ref_data["color"] ref_mpl_color = (q_color.redF(), q_color.greenF(), q_color.blueF(), q_color.alphaF()) ref_y_base = ref_data.get("base_y_position", 0) # 将来的に設定できるようにするベースY値 plot_height_for_max_ref_intensity = (y_limits[1] - y_limits[0]) * 0.05 if plot_height_for_max_ref_intensity == 0 : plot_height_for_max_ref_intensity = 0.1 # 最小高さ ref_baseline_y = - (ref_idx + 1) * plot_height_for_max_ref_intensity * 1.5 # 0より下に、間隔をあけて配置 # intensities を正規化 (0-1の範囲に) if len(intensities) > 0 and np.max(intensities) > 0: norm_intensities = intensities / np.max(intensities) else: norm_intensities = np.zeros_like(intensities) # 棒の長さを計算 line_heights = norm_intensities * plot_height_for_max_ref_intensity # ベースラインを引く self.canvas.axes.hlines(ref_baseline_y, np.min(positions), np.max(positions), color=ref_mpl_color, linestyle='-', alpha=0.5, label=f"{ref_data['name']} (ref)") # 凡例にrefと追記 q_color_name = q_color.name() # 例: "#1f77b4" markerline, stemlines, baseline = self.canvas.axes.stem( positions, line_heights, linefmt='-', # 線種のみ指定 markerfmt='', basefmt='', bottom=ref_baseline_y ) stemlines.set_color(q_color_name) stemlines.set_linewidth(1.5) plt.setp(stemlines, 'linewidth', 1.5) # 棒の太さ # 凡例の表示 (データとリファレンスの両方が含まれるように) if (self.datasets and any(d["visible"] for d in self.datasets)) or \ (hasattr(self, 'references') and self.references and any(r["visible"] for r in self.references)): legend = self.canvas.axes.legend(fontsize=self.base_font_size * 0.9) if legend: for text in legend.get_texts(): pass # フォントはrcParamsで設定 else: if hasattr(self.canvas.axes, 'legend_') and self.canvas.axes.legend_ is not None: self.canvas.axes.legend_.remove() def on_reference_selection_changed(self): """リファレンスリストの選択が変更されたときに呼び出される""" selected_refs, selected_items = self._get_selected_items(self.list_widget_references, self.references) has_selection = bool(selected_refs) single_selection = len(selected_refs) == 1 # ボタンの有効/無効を設定 self.btn_set_reference_color.setEnabled(has_selection) self.btn_apply_colorscale_ref.setEnabled(has_selection) self.btn_remove_reference.setEnabled(has_selection) self.btn_rename_reference.setEnabled(single_selection) # オフセットと線幅のUIを更新 self.spinbox_ref_y_pos.setEnabled(single_selection) self.spinbox_ref_linewidth.setEnabled(has_selection) ref_scale_text = self.combo_data_y_scale.currentText() if ref_scale_text == "対数 (log10)": self.manual_offset_label_ref.setText("Y位置 (10^X の X):") self.spinbox_ref_y_pos.setRange(-10, 10) self.spinbox_ref_y_pos.setSingleStep(0.1) self.spinbox_ref_y_pos.setDecimals(1) else: self.manual_offset_label_ref.setText("ベースラインY位置:") self.spinbox_ref_y_pos.setRange(-100000.0, 100000.0) self.spinbox_ref_y_pos.setSingleStep(100.0) self.spinbox_ref_y_pos.setDecimals(2) # ★★★ ここからが値の表示を更新するロジック ★★★ if single_selection: ref_data = selected_refs[0] offset_val = 0.0 if ref_scale_text == "対数 (log10)": offset_val = ref_data.get("offset_y_log_exponent", 0.0) else: offset_val = ref_data.get("offset_y_linear", 0.0) self.spinbox_ref_y_pos.blockSignals(True) self.spinbox_ref_y_pos.setValue(offset_val) self.spinbox_ref_y_pos.blockSignals(False) else: self.spinbox_ref_y_pos.blockSignals(True) self.spinbox_ref_y_pos.setValue(0.0) self.spinbox_ref_y_pos.blockSignals(False) if has_selection: ref_data = selected_refs[0] linewidth = ref_data.get("linewidth", self.default_linewidth) self.spinbox_ref_linewidth.blockSignals(True) self.spinbox_ref_linewidth.setValue(linewidth) self.spinbox_ref_linewidth.blockSignals(False) else: self.spinbox_ref_linewidth.blockSignals(True) self.spinbox_ref_linewidth.setValue(self.default_linewidth) self.spinbox_ref_linewidth.blockSignals(False) def apply_reference_y_pos(self): """リファレンスのベースラインY位置/指数スピンボックスの値が変更された際に適用する""" selected_data, _ = self._get_selected_items(self.list_widget_references, self.references) if len(selected_data) != 1: return ref_data_to_update = selected_data[0] new_val_from_spinbox = self.spinbox_ref_y_pos.value() data_updated = False ref_scale_text = self.combo_data_y_scale.currentText() # 現在のスケールに応じて、更新すべきキーを決定 offset_key_to_use = "offset_y_linear" if ref_scale_text == "対数 (log10)": offset_key_to_use = "offset_y_log_exponent" # 値が実際に変更された場合のみ更新処理を実行 if abs(ref_data_to_update.get(offset_key_to_use, 0.0) - new_val_from_spinbox) > 1e-9: ref_data_to_update[offset_key_to_use] = new_val_from_spinbox print(f"デバッグ: Ref '{ref_data_to_update['name']}' の '{offset_key_to_use}' を {new_val_from_spinbox:.2f} に変更。") data_updated = True if data_updated: self.update_plot_data_and_redraw() def apply_reference_peak_scale_settings(self): """リファレンスの共通ピーク高さスケール設定を適用する""" new_linear_height = self.spinbox_ref_linear_scale_height.value() new_log_decades = self.spinbox_ref_log_scale_factor.value() settings_changed = False if abs(self.reference_max_peak_display_height - new_linear_height) > 1e-9: self.reference_max_peak_display_height = new_linear_height print(f"デバッグ: リファレンス線形系最大ピーク高さを {new_linear_height:.1f} に変更。") settings_changed = True # 表示デカード数は例えば0.1以上などのバリデーション effective_new_log_decades = new_log_decades if new_log_decades < 0.1: # 最小値を0.1デカードとする例 effective_new_log_decades = 0.1 self.spinbox_ref_log_scale_factor.blockSignals(True) self.spinbox_ref_log_scale_factor.setValue(effective_new_log_decades) self.spinbox_ref_log_scale_factor.blockSignals(False) if abs(self.reference_log_display_decades - effective_new_log_decades) > 1e-9: self.reference_log_display_decades = effective_new_log_decades # ★★★ この属性を更新 ★★★ print(f"デバッグ: リファレンス対数表示デカード数を {effective_new_log_decades:.1f} に変更。") settings_changed = True if settings_changed: self.update_plot_data_and_redraw() def on_y_tick_visibility_changed(self, state): """Y軸の数値ラベルの表示/非表示チェックボックスが変更されたときに呼び出される""" self.y_tick_labels_visible = (state == Qt.CheckState.Checked.value) print(f"デバッグ: Y軸数値ラベルの表示状態を {self.y_tick_labels_visible} に変更しました。") self.update_plot() # グラフを再描画して変更を適用 def on_legend_visibility_changed(self, state): """凡例の表示/非表示チェックボックスが変更されたときに呼び出される""" self.legend_visible = (state == Qt.CheckState.Checked.value) print(f"デバッグ: 凡例の表示状態を {self.legend_visible} に変更しました。") self.update_plot() # グラフを再描画して変更を適用 def on_mouse_move_on_graph(self, event): """グラフ上でマウスが動いたときに呼び出され、ピークに注釈を表示する""" # 注釈オブジェクトがなければ、何もしない(初回描画時に作成) if not hasattr(self, 'peak_annotation'): return # マウスがグラフエリア外なら、注釈を消して終了 if event.inaxes != self.canvas.axes: if self.peak_annotation.get_visible(): self.peak_annotation.set_visible(False) self.canvas.draw_idle() return min_dist_pixels = float('inf') closest_peak_info = None # すべての表示中リファレンスのピークとの距離を計算 for ref_data in self.references: if ref_data.get("visible", True) and "y_tops_last_plot" in ref_data: positions = ref_data.get("positions", []) y_tops = ref_data.get("y_tops_last_plot", []) hkls = ref_data.get("hkls", []) for i in range(len(positions)): px, py = self.canvas.axes.transData.transform((positions[i], y_tops[i])) dist = np.sqrt((event.x - px)**2 + (event.y - py)**2) if dist < min_dist_pixels: min_dist_pixels = dist closest_peak_info = { "pos": (positions[i], y_tops[i]), "hkl": hkls[i] if i < len(hkls) else "N/A" } # マウスカーソルが15ピクセル以内にピークがあれば注釈を表示/更新 if closest_peak_info and min_dist_pixels < 15: # 注釈の内容と位置を更新 self.peak_annotation.set_text(closest_peak_info["hkl"]) self.peak_annotation.xy = closest_peak_info["pos"] # 注釈が非表示なら表示する if not self.peak_annotation.get_visible(): self.peak_annotation.set_visible(True) self.canvas.draw_idle() # 注釈が表示中の場合は、位置やテキストの変更を反映するために再描画 else: self.canvas.draw_idle() else: # 近くにピークがなければ注釈を非表示 if self.peak_annotation.get_visible(): self.peak_annotation.set_visible(False) self.canvas.draw_idle() def show_error_message(self, title, message, exception=None): """ エラーメッセージダイアログを表示し、必要に応じて詳細な例外情報も表示する。 """ msg_box = QMessageBox(self) msg_box.setIcon(QMessageBox.Icon.Critical) msg_box.setWindowTitle(title) msg_box.setText(message) if exception: # 例外の詳細情報を取得 exc_type, exc_value, exc_traceback = sys.exc_info() if exc_traceback: # exc_traceback が None の場合があるためチェック tb_info = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback)) msg_box.setDetailedText(f"エラータイプ: {type(exception).__name__}\nエラー値: {exception}\n\nトレースバック:\n{tb_info}") else: msg_box.setDetailedText(f"エラータイプ: {type(exception).__name__}\nエラー値: {exception}") msg_box.setStandardButtons(QMessageBox.StandardButton.Ok) msg_box.exec() def handle_exception(exc_type, exc_value, exc_traceback): """ キャッチされなかった例外を処理し、エラーダイアログを表示するグローバルハンドラ """ # トレースバック情報を整形して、コンソールにも出力(デバッグ用) tb_info = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback)) print(tb_info) # ユーザーに表示するエラーメッセージを作成 error_message = f"致命的なエラーが発生しました。処理を中断します。\n\nエラー内容: {exc_value}" # エラーダイアログの作成 msg_box = QMessageBox() msg_box.setIcon(QMessageBox.Icon.Critical) msg_box.setWindowTitle("致命的なエラー") msg_box.setText(error_message) # 詳細なトレースバック情報は「詳細の表示」ボタンで確認できるようにする msg_box.setDetailedText(tb_info) msg_box.setStandardButtons(QMessageBox.StandardButton.Ok) msg_box.exec() def main(): sys.excepthook = handle_exception app = QApplication(sys.argv) # アプリケーションのスタイルシートなどで、よりモダンな外観にすることも可能 app.setStyle("Fusion") main_win = GenericGraphApp() main_win.show() sys.exit(app.exec()) if __name__ == '__main__': main()