XRD.XRD_GUI.XRD_GUI のソースコード

# -*- coding: utf-8 -*-
#
# Copyright (c) 2025 Shunta Kobayashi (Kamiya-Katase Laboratory, Science Tokyo)
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT

"""
XRD_GUI: X線回折データ可視化および解析アプリケーション

概要:
    本アプリケーションは、X線回折 (XRD) データを読み込み、可視化、解析するためのグラフィカルユーザーインターフェースを提供します。
    複数のXRDデータセットとリファレンスパターンを同時に表示し、オフセット、スケーリング、ピーク検出、GIXRD解析支援などの機能を提供します。

詳細説明:
    本ソフトウェアは、PythonのPyQt6、Matplotlib、NumPy、SciPyなどのライブラリを組み合わせて開発されており、
    研究者や学生がXRDデータを効率的に扱うことを目的としています。
    セッションの保存・読み込み、グラフのエクスポート、ピークラベル付け、リファレンスフィルタリングなど、
    多様なカスタマイズオプションを通じて、柔軟なデータ解析環境を提供します。

関連リンク:
    :doc:`XRD_GUI_usage`
    プロジェクトリポジトリ: https://github.com/your_repo_link (デモ用の仮リンク)
"""

__version__ = "1.4.1" # アプリケーションのバージョン

import sys
import os
import importlib
import json
import re
import logging
import traceback
import ast
import operator
import math

from datetime import datetime


[ドキュメント] def check_dependencies(): """ アプリケーションの実行に必要なPythonライブラリの依存関係を確認します。 詳細説明: `required_libraries` に定義された各ライブラリのインポートを試みます。 不足しているライブラリがあった場合、それらをリストアップし、 `pip install` コマンドでインストールする方法をユーザーに提示します。 不足がある場合は、ユーザーの入力待ちの後、プログラムを終了します。 すべてのライブラリが揃っている場合は、続行メッセージを表示します。 :returns: なし。不足がある場合はシステムを終了します。 """ # (チェックするライブラリ名, pipでインストールする際のパッケージ名) required_libraries = [ ('numpy', 'numpy'), ('matplotlib', 'matplotlib'), ('PyQt6', 'PyQt6'), ('pandas', 'pandas'), ('scipy', 'scipy'), ] missing_libraries = [] print("ライブラリの依存関係をチェックしています...") for lib_name, pip_name in required_libraries: try: # ライブラリのインポートを試みる importlib.import_module(lib_name) print(f" [OK] {lib_name}") except ImportError: # インポートに失敗した場合 print(f" [不足] {lib_name}") missing_libraries.append(pip_name) # 不足しているライブラリがあった場合の処理 if missing_libraries: print("\n" + "#"*60) print("#Error: 実行に必要なライブラリが不足しています。") print("#お使いの環境で、以下のコマンドを実行してライブラリをインストールしてください:") for lib in missing_libraries: # ユーザーがコピー&ペーストしやすいようにコマンドを提示 print(f" pip install {lib}") print("#"*60) input("\n確認後、Enterキーを押してプログラムを終了します...") sys.exit(1) # プログラムをエラー終了 else: print("すべてのライブラリが揃っています。\n")
if __name__ == "__main__": check_dependencies() import numpy as np import pandas as pd from scipy.signal import savgol_filter, find_peaks import importlib import sys import os import matplotlib import matplotlib.pyplot as plt from matplotlib.figure import Figure from matplotlib.gridspec import GridSpec from matplotlib.ticker import LogLocator, LogFormatterMathtext, NullFormatter from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qtagg import NavigationToolbar2QT as NavigationToolbar # バックエンド設定とログ抑制 if __name__ == "__main__": matplotlib.use('QtAgg') logging.getLogger('matplotlib.font_manager').setLevel(logging.ERROR) from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QFrame, QSplitter, QScrollArea, QVBoxLayout, QHBoxLayout, QGridLayout, QPushButton, QToolButton, QLineEdit, QComboBox, QDoubleSpinBox, QSlider, QLabel, QGroupBox, QCheckBox, QListWidget, QListWidgetItem, QFileDialog, QColorDialog, QFontDialog, QMessageBox, QInputDialog, QTabWidget ) from PyQt6.QtCore import Qt, QSize from PyQt6.QtGui import QFont, QColor, QIcon, QPixmap, QPainter from core.constants import WAVE_MAP from core import calculations, gixrd from utils import file_parser, session_manager from ui.panels.file_panel import FileControlPanel from ui.panels.xray_panel import XRaySourcePanel from ui.panels.settings_panel import GraphSettingsPanel from ui.panels.data_panel import DataListPanel from ui.panels.ref_panel import ReferenceListPanel # 外部ライブラリの条件付きインポート用 from utils.file_parser import XRD_GUI_lib """インポートここまで"""
[ドキュメント] class MplCanvas(FigureCanvas): """ Matplotlibのグラフを描画するためのキャンバスクラスです。 概要: MatplotlibのFigureオブジェクトをラップし、PyQt6のUIに組み込むためのウィジェットを提供します。 詳細説明: このクラスは、MatplotlibのFigureを初期化し、それを親ウィジェットに設定します。 グラフの幅、高さ、DPIをカスタマイズ可能です。 :param parent: 親ウィジェット。デフォルトはNone。 :type parent: QWidget, optional :param width: グラフの幅 (インチ)。デフォルトは5。 :type width: int, optional :param height: グラフの高さ (インチ)。デフォルトは4。 :type height: int, optional :param dpi: グラフのDPI (Dots Per Inch)。デフォルトは100。 :type dpi: int, optional """ def __init__(self, parent=None, width=5, height=4, dpi=100): self.fig = Figure(figsize=(width, height), dpi=dpi) super().__init__(self.fig) self.setParent(parent)
[ドキュメント] class GenericGraphApp(QMainWindow): """ XRDデータの表示、解析、管理を行うメインアプリケーションクラスです。 概要: PyQt6をベースとしたGUIを提供し、Matplotlibを使用してXRDデータをグラフ表示します。 データセットとリファレンスパターンの読み込み、視覚的なカスタマイズ、 ピーク解析支援、セッション管理などの機能を含みます。 詳細説明: このクラスは、複数のXRDデータセットとリファレンスパターンを同時に管理し、 それらを上下二段または一段のグラフに柔軟に表示します。 ユーザーは、X線源の波長設定、Kα2除去、スムージング、Y軸スケール(線形、対数、平方根)、 オフセット、凡例、軸ラベル、フォント、グラフサイズなどの表示設定を詳細に調整できます。 また、リファレンスデータのフィルタリング機能や、ピークの自動検出および手動ラベル付け機能も提供します。 現在の作業状態をセッションファイルとして保存・読み込みすることも可能です。 """ # アプリケーションのデフォルトX軸範囲をクラス変数として定義 DEFAULT_X_RANGE = (0, 100) # ================================================================ # ### 1.基幹・初期化セクション/アプリケーションの起動と基本的な設定 ### # ================================================================ def __init__(self): """ GenericGraphAppのインスタンスを初期化します。 概要: メインウィンドウ、UI要素、内部データ構造、およびMatplotlibの初期設定を行います。 詳細説明: ウィンドウのタイトルとサイズを設定し、`datasets` (読み込んだXRDデータ)、 `references` (読み込んだリファレンスデータ) などのリストを初期化します。 UIのフォント、グラフのデフォルトスタイル、色サイクル、オフセットステップ、 フィルタリング条件、X軸のロック状態、ラベルテキスト、凡例の表示設定など、 アプリケーション全体の多数の設定値を初期化します。 また、ファイルフィルタの動的生成、GIXRD関連の初期パラメータ設定を行い、 最後にUIの構築とプロットの初期更新を行います。 """ super().__init__() # --- Matplotlibのデフォルトスタイル設定 --- 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 matplotlib.rcParams["legend.framealpha"] = 0.8 matplotlib.rcParams["legend.edgecolor"] = 'black' matplotlib.rcParams['axes.grid'] = False self.setWindowTitle(f"XRD_GUI (ver.{__version__})") self.setGeometry(100, 100, 1300, 850) # フィルタUI追加のため少し縦長に self.datasets = [] self.references = [] self.ui_font = QFont("sans-serif", 12) self.graph_font = QFont("Times New Roman", 12) if sys.platform == "win32" else QFont("sans-serif", 12) self.base_font_size = self.graph_font.pointSize() self.color_cycle_index = 0 self.default_linear_offset_step = 10.0 self.default_log_offset_exponent_step = 1.0 self.default_linewidth = 0.5 # フィルタリング条件保持用 self.current_ref_filter_condition = "" self.x_axis_is_locked = False self.locked_x_range = None self.x_tick_interval = None self.x_label_text = "2θ/θ (deg.)" self.y_tick_labels_visible = True self.legend_visible = True self.ref_y_label_visible = True self.ref_y_tick_labels_visible = True self.data_y_label_text = "Intensity $I$ (arb.unit)" self.is_single_plot_mode = False self.replace_non_positive_with_one = False self.is_labeling_mode = False self.marker_symbols_display = ['▼ (下三角)', '◆ (ひし形)', '● (丸)', '■ (四角)', '| (縦線)'] self.marker_symbol_map = {'▼ (下三角)': 'v', '◆ (ひし形)': 'd', '● (丸)': 'o', '■ (四角)': 's', '| (縦線)': '|'} self.marker_symbols_internal = list(self.marker_symbol_map.keys()) self.default_marker_offset_percent = 5.0 self.hovered_peak_info = None self.peak_annotation_right = None self.peak_annotation_left = None 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) 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 = [] # ファイルフィルタ設定 if XRD_GUI_lib is not None and hasattr(XRD_GUI_lib, 'get_supported_file_filters'): dynamic_filter = XRD_GUI_lib.get_supported_file_filters() self.data_file_filter = dynamic_filter self.reference_file_filter = dynamic_filter self.load_data_button_text = self._generate_button_text("XRDデータ読み込み", self.data_file_filter) self.load_ref_button_text = self._generate_button_text("リファレンス読み込み", self.reference_file_filter) else: self.data_file_filter = "テキストファイル (*.txt);;全てのファイル (*)" self.reference_file_filter = "テキストファイル (*.txt);;全てのファイル (*)" self.load_data_button_text = "XRDデータ読み込み (.txt)" self.load_ref_button_text = "リファレンス読み込み (.txt)" # --- GIXRD距離判定 (FWHM補正オプション) --- self.gixrd_use_fwhm = False self.gixrd_fwhm_out_deg = 0.05 # out-of-plane RC FWHM [deg] self.gixrd_fwhm_in_deg = 0.50 # in-plane FWHM [deg] self.gixrd_nsigma = 2.0 # σ判定の閾値(2σなど) self.init_ui() self.previous_data_y_scale_text = self.settings_panel.combo_data_y_scale.currentText() self._apply_font_to_qt_widgets(self.ui_font) self.apply_font_to_graph(self.graph_font, update_plot=False) self.update_plot()
[ドキュメント] def get_next_color(self) -> QColor: """ Matplotlibのデフォルトカラーサイクルから次の色をQColorオブジェクトとして提供します。 概要: データセットやリファレンスに順次異なる色を割り当てるために使用されます。 詳細説明: MatplotlibのTableau Colorsに基づいた10色のサイクルを保持し、 呼び出されるたびに次の色を返します。サイクルの終わりに達すると、最初に戻ります。 :returns: 次のデフォルトカラーサイクルからの色。 :rtype: QColor """ # 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 _generate_button_text(self, base_text: str, filter_string: str) -> str: """ ファイルフィルター文字列から拡張子を抽出し、ボタンのテキストを生成します。 概要: ファイル選択ボタンに表示されるテキストを、サポートされるファイル形式に基づいて動的に生成します。 詳細説明: 例えば、`"テキストファイル (*.txt);;CSVファイル (*.csv)"` のようなフィルター文字列から、 `.txt, .csv` のような拡張子を抽出し、`base_text` と組み合わせて `"XRDデータ読み込み (.txt, .csv)"` のようなテキストを生成します。 `.txt` が含まれる場合は、優先的に先頭に配置されます。 拡張子が多い場合は、最初の数個と "etc." を表示します。 :param base_text: ボタンの基本となるテキスト (例: "XRDデータ読み込み")。 :type base_text: str :param filter_string: QFileDialogで使用されるファイルフィルター文字列。 :type filter_string: str :returns: 生成されたボタンのテキスト。 :rtype: str """ import re # "*.ext" の形式に一致する拡張子をすべて抽出する extensions = re.findall(r'\*\.([a-zA-Z0-9_]+)', filter_string) # 重複を削除 unique_extensions = sorted(list(set(extensions))) # ".txt" がリスト内に存在すれば、それを先頭に移動させる if "txt" in unique_extensions: unique_extensions.remove("txt") unique_extensions.insert(0, "txt") if not unique_extensions: return f"{base_text} (.txt)" # 拡張子が見つからない場合のフォールバック if len(unique_extensions) > 4: # 4つ以上ある場合は、最初の4つと "etc." を表示 display_text = ", .".join(unique_extensions[:4]) return f"{base_text} (.{display_text}, etc.)" else: # 4つ以下の場合はすべて表示 display_text = ", .".join(unique_extensions) return f"{base_text} (.{display_text})" def _generate_unique_name(self, desired_name: str) -> str: """ 指定された名前が既存のデータセットまたはリファレンスの中でユニークであるかをチェックし、 必要に応じてサフィックスを追加してユニークな名前を生成します。 概要: 新しいデータセットやリファレンスを読み込む際に、既存のアイテムとの名前の重複を避けるために使用されます。 詳細説明: `self.datasets` と `self.references` の両方に存在するすべての名前を確認します。 `desired_name` が既に存在する場合、`" (2)"`, `" (3)"` のように番号を増やしながら ユニークな名前が見つかるまで試行します。 :param desired_name: 希望する名前。 :type desired_name: str :returns: ユニークであることを保証された名前。 :rtype: str """ 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 # ============================================================ # ### 2. UI構築・デザイン関連/ウィジェットの生成やフォント設定 ### # ============================================================
[ドキュメント] def init_ui(self): """ アプリケーションのユーザーインターフェースを初期構築します。 概要: メインウィンドウのレイアウトを定義し、各種コントロールパネル(タブ構造)、 グラフ表示エリア、およびそれらの間の信号接続を設定します。 詳細説明: ファイル操作、データリスト表示、リファレンスリスト表示、グラフ設定、 X線源設定、マーカー操作、アプリケーション設定のためのパネルを作成し、 タブウィジェットに配置します。 Matplotlibのキャンバスとナビゲーションツールバーをグラフエリアに組み込みます。 各UIウィジェットとアプリケーションの内部ロジック間の信号とスロットを接続し、 折りたたみ可能なグループボックス機能も適用します。 最後に、サブプロットの初期設定とマウスイベントハンドラの接続を行います。 """ # --- 1. パネル・グループのインスタンス化 (順序が重要) --- # 各外部パネルクラスに必要な引数を渡して作成します self.file_panel = FileControlPanel(self.load_data_button_text, self.load_ref_button_text) # self.data_panel = DataListPanel(self.available_colormaps) # self.ref_panel = ReferenceListPanel() # self.settings_panel = GraphSettingsPanel(self.aspect_ratios, self.fixed_paper_sizes) # self.xray_panel = XRaySourcePanel() # # 内部メソッドによるグループ作成 self.marker_group = self._create_marker_group() # self.app_settings_group = self._create_app_settings_group() # # --- 2. メインレイアウトとスプリッターの構築 --- main_widget = QWidget(self) self.setCentralWidget(main_widget) main_layout = QHBoxLayout(main_widget) self.main_splitter = QSplitter(Qt.Orientation.Horizontal) main_layout.addWidget(self.main_splitter) # --- 3. 左側:コントロールパネル(タブ構造)の構築 --- self.tabs = QTabWidget() # --- Tab 1: データ管理 --- tab_data_widget = QWidget() layout_data = QVBoxLayout(tab_data_widget) scroll_data = QScrollArea() scroll_data.setWidgetResizable(True) content_data = QWidget() vbox_data = QVBoxLayout(content_data) vbox_data.addWidget(self.file_panel) vbox_data.addWidget(self.data_panel) vbox_data.addWidget(self.ref_panel) vbox_data.addStretch(1) scroll_data.setWidget(content_data) layout_data.addWidget(scroll_data) self.tabs.addTab(tab_data_widget, "データ管理") # --- Tab 2: 解析 --- tab_analysis_widget = QWidget() layout_analysis = QVBoxLayout(tab_analysis_widget) scroll_analysis = QScrollArea() scroll_analysis.setWidgetResizable(True) content_analysis = QWidget() vbox_analysis = QVBoxLayout(content_analysis) vbox_analysis.addWidget(self.marker_group) vbox_analysis.addWidget(self.xray_panel) vbox_analysis.addStretch(1) scroll_analysis.setWidget(content_analysis) layout_analysis.addWidget(scroll_analysis) self.tabs.addTab(tab_analysis_widget, "解析") # --- Tab 3: 設定/描画 --- tab_settings_widget = QWidget() layout_settings = QVBoxLayout(tab_settings_widget) scroll_settings = QScrollArea() scroll_settings.setWidgetResizable(True) content_settings = QWidget() vbox_settings = QVBoxLayout(content_settings) vbox_settings.addWidget(self.settings_panel) vbox_settings.addWidget(self.app_settings_group) vbox_settings.addStretch(1) scroll_settings.setWidget(content_settings) layout_settings.addWidget(scroll_settings) self.tabs.addTab(tab_settings_widget, "設定/描画") # --- 4. 右側:グラフエリアの構築 --- graph_widget = QWidget(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) # スプリッターにタブとグラフを配置 self.main_splitter.addWidget(self.tabs) self.main_splitter.addWidget(graph_widget) self.main_splitter.setSizes([450, 850]) self.main_splitter.setCollapsible(0, False) # --- 5. 信号(シグナル)の接続とエイリアス設定 --- # [File Panel 信号] fp = self.file_panel fp.btn_load_xrd_data.clicked.connect(self.load_xrd_data_dialog) # fp.btn_load_reference.clicked.connect(self.load_reference_dialog) # fp.btn_save_graph.clicked.connect(self.save_graph_dialog) # fp.btn_save_session.clicked.connect(self.save_session) # fp.btn_load_session.clicked.connect(self.load_session) # # [Data List Panel 信号] dp = self.data_panel self.list_widget_data = dp.list_widget_data # エイリアス dp.list_widget_data.itemSelectionChanged.connect(self.on_data_selection_changed) dp.list_widget_data.itemDoubleClicked.connect(lambda item: self.toggle_item_visibility(item, self.datasets, "データセット")) dp.btn_set_color.clicked.connect(lambda: self.set_selected_item_color(dp.list_widget_data, self.datasets, "データセット")) dp.btn_rename_dataset.clicked.connect(lambda: self.rename_selected_item(dp.list_widget_data, self.datasets, "データセット")) dp.btn_apply_colorscale.clicked.connect(lambda: self.apply_colorscale_to_selected_items(dp.list_widget_data, self.datasets, "データセット")) dp.btn_remove_data.clicked.connect(lambda: self.remove_selected_items(dp.list_widget_data, self.datasets, "データセット")) #データ処理ウィジェットを self に紐付ける self.chk_remove_ka2 = dp.chk_remove_ka2 self.spin_ka2_ratio = dp.spin_ka2_ratio self.chk_smooth = dp.chk_smooth self.spin_smooth_window = dp.spin_smooth_window # データ処理 dp.chk_remove_ka2.stateChanged.connect(self.update_plot) dp.spin_ka2_ratio.valueChanged.connect(self.update_plot) dp.chk_smooth.stateChanged.connect(self.update_plot) dp.spin_smooth_window.valueChanged.connect(self.update_plot) # オフセット・太さの同期用エイリアスと接続 self.spinbox_data_offset = dp.spinbox_data_offset self.manual_offset_label = dp.manual_offset_label self.spinbox_data_linewidth = dp.spinbox_data_linewidth dp.spinbox_data_offset.valueChanged.connect(lambda: self.apply_manual_offset_from_spinbox(dp.list_widget_data, self.datasets, dp.spinbox_data_offset)) dp.spinbox_data_linewidth.valueChanged.connect(lambda: self.apply_item_linewidth(dp.list_widget_data, self.datasets, dp.spinbox_data_linewidth)) # 順序同期 dp.list_widget_data.model().rowsMoved.connect(lambda: self._sync_data_order_from_widget(dp.list_widget_data, self.datasets, "データ")) # [Reference List Panel 信号] rp = self.ref_panel self.list_widget_references = rp.list_widget_references # エイリアス rp.list_widget_references.itemSelectionChanged.connect(self.on_reference_selection_changed) rp.list_widget_references.itemDoubleClicked.connect(lambda item: self.toggle_item_visibility(item, self.references, "リファレンス")) rp.btn_set_reference_color.clicked.connect(lambda: self.set_selected_item_color(rp.list_widget_references, self.references, "リファレンス")) rp.btn_rename_reference.clicked.connect(lambda: self.rename_selected_item(rp.list_widget_references, self.references, "リファレンス")) rp.btn_apply_colorscale_ref.clicked.connect(lambda: self.apply_colorscale_to_selected_items(rp.list_widget_references, self.references, "リファレンス")) rp.btn_remove_reference.clicked.connect(lambda: self.remove_selected_items(rp.list_widget_references, self.references, "リファレンス")) self.spinbox_ref_linewidth = rp.spinbox_ref_linewidth rp.spinbox_ref_linewidth.valueChanged.connect(lambda: self.apply_item_linewidth(rp.list_widget_references, self.references, rp.spinbox_ref_linewidth)) # フィルタ機能 rp.btn_apply_filter.clicked.connect(self.apply_reference_filter) rp.edit_ref_filter.returnPressed.connect(self.apply_reference_filter) rp.btn_filter_help.clicked.connect(self.show_ref_filter_help_popup) # 順序同期 rp.list_widget_references.model().rowsMoved.connect(lambda: self._sync_data_order_from_widget(rp.list_widget_references, self.references, "リファレンス")) # [X-Ray Panel 信号] xp = self.xray_panel self.combo_main_wave = xp.combo_main_wave # エイリアス xp.combo_main_wave.currentTextChanged.connect(self.update_plot) xp.chk_ka2.stateChanged.connect(self.update_plot) xp.chk_kb.stateChanged.connect(self.update_plot) xp.chk_wl1.stateChanged.connect(self.update_plot) xp.chk_wl2.stateChanged.connect(self.update_plot) xp.chk_custom_wave.stateChanged.connect(self.update_plot) xp.spin_custom_wave.valueChanged.connect(self.update_plot) xp.btn_higher_order.toggled.connect(self.update_plot) # [Settings Panel 信号とエイリアス] sp = self.settings_panel self.combo_data_y_scale = sp.combo_data_y_scale # エイリアス self.combo_ref_y_scale = sp.combo_ref_y_scale self.spin_scaling_factor = sp.spin_scaling_factor self.spin_axes_linewidth = sp.spin_axes_linewidth self.spin_plot_ratio = sp.spin_plot_ratio self.combo_aspect_ratio = sp.combo_aspect_ratio # チェックボックス self.check_y_ticks_visible = sp.check_y_ticks_visible self.check_ref_y_ticks_visible = sp.check_ref_y_ticks_visible self.check_ref_y_label_visible = sp.check_ref_y_label_visible self.check_legend_visible = sp.check_legend_visible self.check_replace_non_positive = sp.check_replace_non_positive self.check_single_plot_mode = sp.check_single_plot_mode # 入力ウィジェット・エイリアス self.spin_width_cm = sp.spin_width_cm self.spin_height_cm = sp.spin_height_cm self.edit_x_min = sp.edit_x_min self.edit_x_max = sp.edit_x_max self.edit_y_min = sp.edit_y_min self.edit_y_max = sp.edit_y_max self.edit_ref_y_min = sp.edit_ref_y_min self.edit_ref_y_max = sp.edit_ref_y_max self.edit_x_tick_interval = sp.edit_x_tick_interval self.edit_x_label = sp.edit_x_label self.edit_data_y_label = sp.edit_data_y_label # 信号接続 sp.combo_data_y_scale.currentTextChanged.connect(self.on_data_scale_changed) sp.combo_ref_y_scale.currentTextChanged.connect(self.update_plot) sp.spin_scaling_factor.valueChanged.connect(self.update_plot) sp.spin_axes_linewidth.valueChanged.connect(self.update_plot) sp.check_y_ticks_visible.stateChanged.connect(self.on_y_tick_visibility_changed) sp.check_ref_y_ticks_visible.stateChanged.connect(self.on_ref_y_tick_visibility_changed) sp.check_ref_y_label_visible.stateChanged.connect(self.on_ref_y_label_visibility_changed) sp.check_ref_baseline_visible.stateChanged.connect(self.update_plot) sp.check_ref_x_ticks_visible.stateChanged.connect(self.update_plot) sp.check_legend_visible.stateChanged.connect(self.on_legend_visibility_changed) sp.check_hide_divider.stateChanged.connect(self.update_plot) sp.check_replace_non_positive.stateChanged.connect(self.on_replace_non_positive_changed) sp.check_single_plot_mode.stateChanged.connect(self.on_single_plot_mode_changed) sp.spin_plot_ratio.valueChanged.connect(self.apply_plot_ratio) sp.combo_aspect_ratio.currentTextChanged.connect(self.on_aspect_ratio_changed) sp.btn_apply_size_cm.clicked.connect(self.apply_custom_size_cm) sp.btn_apply_x_zoom.clicked.connect(self.apply_manual_x_zoom) sp.btn_apply_y_zoom.clicked.connect(self.apply_manual_y_zoom) sp.btn_apply_ref_y_zoom.clicked.connect(self.apply_manual_ref_y_zoom) sp.btn_apply_x_tick.clicked.connect(self.apply_x_tick_interval) sp.edit_x_label.editingFinished.connect(self.apply_x_label) sp.edit_data_y_label.editingFinished.connect(self.apply_data_y_label) sp.btn_reset_zoom.clicked.connect(self.reset_zoom) # --- 6. 折りたたみ機能の適用 --- self._make_group_collapsible(self.file_panel) self._make_group_collapsible(self.data_panel) self._make_group_collapsible(self.ref_panel) self._make_group_collapsible(self.xray_panel) self._make_group_collapsible(self.settings_panel) self._make_group_collapsible(self.marker_group) self._make_group_collapsible(self.app_settings_group) # --- 7. グラフ描画とマウスイベントの初期化 --- self._setup_subplots() self.canvas.mpl_connect('motion_notify_event', self.on_mouse_move_on_graph) self.canvas.mpl_connect('button_press_event', self.on_plot_click) self.canvas.mpl_connect('scroll_event', self.on_marker_scroll) #
def _make_group_collapsible(self, group_box: QGroupBox): """ QGroupBoxに折りたたみ機能を追加するヘルパーメソッドです。 概要: 指定されたQGroupBoxをチェック可能にし、チェック状態に応じて内部ウィジェットの表示/非表示を切り替えます。 詳細説明: このメソッドは、QGroupBoxの`setCheckable(True)`を設定し、 `toggled`シグナルをカスタムスロットに接続します。 このスロットは、QGroupBoxがチェックされたときに内部のすべてのウィジェットを表示し、 チェックが外れたときに非表示にします。初期状態は開いています。 :param group_box: 折りたたみ機能を適用するQGroupBox。 :type group_box: QGroupBox """ group_box.setCheckable(True) # 最初は開いた状態にする group_box.setChecked(True) # チェックボックスの状態が変わったら、中のウィジェットの表示/非表示を切り替える header_line = group_box.findChild(QFrame) widgets = [group_box.layout().itemAt(i).widget() for i in range(group_box.layout().count()) if group_box.layout().itemAt(i).widget() is not None] def toggle_widgets(checked): for widget in widgets: if widget is not header_line: widget.setVisible(checked) group_box.toggled.connect(toggle_widgets) def _apply_font_to_qt_widgets(self, font: QFont): """ 指定されたフォントをQtウィジェット (アプリケーション全体、メインウィンドウ、コントロールパネル内) に適用します。 概要: アプリケーションのUI要素のフォントスタイルを統一的に変更します。 詳細説明: `QApplication.setFont()` を呼び出してアプリケーション全体のデフォルトフォントを設定し、 メインウィンドウ自体にもフォントを適用します。 さらに、コントロールパネル内のQGroupBoxおよびその子ウィジェットに対して、 再帰的にフォントを適用します。データリストやリファレンスリストも個別に更新します。 :param font: 適用するフォントオブジェクト。 :type font: QFont """ 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, 'data_panel'): self.data_panel.list_widget_data.setFont(font) if hasattr(self, 'ref_panel'): self.ref_panel.list_widget_references.setFont(font) def _create_app_settings_group(self) -> QGroupBox: """ アプリケーション全体の設定(主にグラフのフォント設定)のためのUIグループを作成します。 概要: グラフのフォント設定ボタンを含むQGroupBoxを生成し、レイアウトを設定します。 詳細説明: このグループボックスには、ユーザーがグラフに適用するフォントを選択するための `QPushButton` が含まれています。ボタンがクリックされると、 `set_graph_font_dialog` メソッドが呼び出され、フォント選択ダイアログが表示されます。 :returns: アプリケーション設定を含むQGroupBox。 :rtype: QGroupBox """ group = QGroupBox("表示設定") layout = QVBoxLayout() self.btn_set_font = QPushButton("グラフのフォント設定") self.btn_set_font.clicked.connect(self.set_graph_font_dialog) layout.addWidget(self.btn_set_font) group.setLayout(layout) return group def _create_marker_group(self) -> QGroupBox: """ マーカー操作用のUIグループを作成します。 概要: ピーク付けモードの切り替え、手動マーカー追加、マーカーリスト表示、 選択マーカーの属性(記号、色、オフセット、太さ)変更、自動ピーク検索の機能を統合したQGroupBoxを生成します。 詳細説明: このグループボックスは、以下の主要なセクションで構成されます: 1. **操作モード・手動追加エリア**: ピーク付けモードのON/OFFボタンと手動でマーカーを追加するボタン。 2. **マーカーリストエリア**: 選択されたデータセットに属するマーカーを一覧表示するQListWidget。 3. **個別/一括属性操作エリア**: 選択されたマーカーの記号、色、オフセット、線の太さを変更するためのコンボボックスとボタン、スピンボックス。 4. **自動ピーク検索セクション**: ピークプレビューと閾値設定、自動ピーク検索実行ボタン。 これらのUI要素は、適切なシグナルとスロットで接続され、選択状態に応じて有効/無効が切り替わります。 :returns: マーカー操作のためのQGroupBox。 :rtype: QGroupBox """ group = QGroupBox("マーカー操作 (グラフ上スクロールで高さ調整可)") layout = QVBoxLayout() # --- 1. 操作モード・手動追加エリア --- mode_layout = QHBoxLayout() self.btn_labeling_mode = QPushButton("ピーク付けモード開始") self.btn_labeling_mode.setCheckable(True) self.btn_labeling_mode.setStyleSheet("QPushButton:checked { background-color: #ffcccc; font-weight: bold; }") self.btn_labeling_mode.toggled.connect(self.toggle_labeling_mode) self.btn_add_manual_marker = QPushButton("手動で追加") self.btn_add_manual_marker.clicked.connect(self.add_manual_marker) mode_layout.addWidget(self.btn_labeling_mode) mode_layout.addWidget(self.btn_add_manual_marker) layout.addLayout(mode_layout) # --- 2. マーカーリストエリア --- layout.addWidget(QLabel("マーカーリスト (選択データ):")) self.list_widget_markers = QListWidget() self.list_widget_markers.setFixedHeight(120) # 高さを少し余裕持たせる self.list_widget_markers.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) self.list_widget_markers.itemSelectionChanged.connect(self.on_marker_selection_changed) layout.addWidget(self.list_widget_markers) # --- 3. 個別/一括 属性操作エリア (Grid) --- controls_layout = QGridLayout() # 記号・色・削除 controls_layout.addWidget(QLabel("記号:"), 0, 0) self.combo_marker_symbol = QComboBox() self.combo_marker_symbol.addItems(self.marker_symbols_display) self.combo_marker_symbol.currentTextChanged.connect(self.edit_selected_marker_symbol) controls_layout.addWidget(self.combo_marker_symbol, 0, 1) self.btn_marker_color = QPushButton("色変更") self.btn_marker_color.clicked.connect(self.edit_selected_marker_color) controls_layout.addWidget(self.btn_marker_color, 0, 2) self.btn_remove_marker = QPushButton("削除") self.btn_remove_marker.clicked.connect(self.remove_selected_marker) controls_layout.addWidget(self.btn_remove_marker, 0, 3) # オフセットと太さ controls_layout.addWidget(QLabel("オフセット:"), 1, 0) self.spin_marker_offset = QDoubleSpinBox() self.spin_marker_offset.setRange(0.0, 2000.0) self.spin_marker_offset.setSuffix(" pt") self.spin_marker_offset.valueChanged.connect(self.apply_marker_offset) controls_layout.addWidget(self.spin_marker_offset, 1, 1, 1, 3) controls_layout.addWidget(QLabel("線の太さ:"), 2, 0) self.spin_marker_linewidth = QDoubleSpinBox() self.spin_marker_linewidth.setRange(0.1, 10.0) self.spin_marker_linewidth.setSuffix(" pt") self.spin_marker_linewidth.valueChanged.connect(self.apply_marker_style) controls_layout.addWidget(self.spin_marker_linewidth, 2, 1, 1, 3) layout.addLayout(controls_layout) # --- 4. 自動ピーク検索セクション --- auto_group = QFrame() auto_group.setFrameShape(QFrame.Shape.StyledPanel) auto_layout = QVBoxLayout(auto_group) # 内部は垂直に配置して重なりを防ぐ # 上段:プレビューとしきい値 search_params = QHBoxLayout() self.chk_peak_preview = QCheckBox("プレビュー表示") self.chk_peak_preview.stateChanged.connect(self.update_plot) self.spin_peak_search_threshold = QDoubleSpinBox() self.spin_peak_search_threshold.setRange(0, 1e7) self.spin_peak_search_threshold.setValue(100.0) self.spin_peak_search_threshold.valueChanged.connect(self.update_plot) search_params.addWidget(self.chk_peak_preview) search_params.addWidget(QLabel("しきい値:")) search_params.addWidget(self.spin_peak_search_threshold) auto_layout.addLayout(search_params) # 下段:実行ボタン self.btn_run_auto_search = QPushButton("自動ピーク検索実行") self.btn_run_auto_search.clicked.connect(self.run_auto_peak_search) auto_layout.addWidget(self.btn_run_auto_search) layout.addWidget(auto_group) # 初期状態の設定 self.combo_marker_symbol.setEnabled(False) self.btn_marker_color.setEnabled(False) self.btn_remove_marker.setEnabled(False) group.setLayout(layout) return group
[ドキュメント] def set_graph_font_dialog(self): """ グラフのフォント選択ダイアログを開き、ユーザーが選択したフォントを適用します。 概要: Qtの`QFontDialog`を使用して、グラフのフォントファミリーとサイズを変更するGUIを提供します。 詳細説明: ダイアログでフォントが選択され、[OK]が押された場合、 `self.graph_font` を更新し、`apply_font_to_graph` メソッドを呼び出して、 Matplotlibの描画設定に新しいフォントを反映させ、グラフを再描画します。 """ font, ok = QFontDialog.getFont(self.graph_font, self, "グラフのフォント選択") if ok: self.graph_font = font self.apply_font_to_graph(self.graph_font)
[ドキュメント] def apply_font_to_graph(self, font: QFont, update_plot: bool = True): """ グラフのフォント(ファミリー、サイズ)をMatplotlibに設定し、必要に応じて再描画します。 概要: 指定されたQFontオブジェクトに基づいて、MatplotlibのrcParamsを更新し、グラフの表示フォントを変更します。 詳細説明: ユーザーが選択したフォントファミリーを最優先しつつ、Windows、macOS、Linuxといった 各プラットフォームで一般的な日本語フォントの候補リストを定義し、 Matplotlibが適切なフォントを自動選択できるように`font.family`を設定します。 また、フォントサイズも設定し、`self.base_font_size` を更新します。 `update_plot` がTrueの場合、変更を画面に反映するために `update_plot()` を呼び出します。 :param font: 適用するQFontオブジェクト。 :type font: QFont :param update_plot: グラフを再描画するかどうか。デフォルトはTrue。 :type update_plot: bool, optional """ 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 try: matplotlib.rcParams['font.size'] = font.pointSizeF() # pointSizeF() がより正確 except AttributeError: # 古いPyQt/Pythonの場合のフォールバック matplotlib.rcParams['font.size'] = font.pointSize() self.base_font_size = matplotlib.rcParams['font.size'] # 更新されたサイズを保持 if update_plot: self.update_plot() # スタイルが変更されたので、再描画
# ================================================================ # ### 3. 描画コアエンジン/Matplotlibとの連携や再描画の主要ロジック ### # ================================================================
[ドキュメント] def update_plot(self, process_data: bool = True): """ グラフを再描画するメインロジックです。1画面/2画面モードに対応しています。 概要: データセット、リファレンス、各種UI設定に基づいてMatplotlibのグラフを更新し、表示します。 詳細説明: 1. **サブプロット設定**: 現在の表示モード(1画面/2画面)に応じてサブプロットのレイアウトを再設定します。 2. **X軸範囲決定**: ロックされているX軸範囲があればそれを優先し、なければ全データのX軸範囲から自動調整します。 3. **データ描画**: `_draw_datasets` メソッドを呼び出してXRDデータセットを描画し、Y軸の最小/最大値を取得します。 4. **リファレンス描画**: 2画面モードの場合、`_draw_references` メソッドを呼び出してリファレンスパターンを描画し、Y軸の最小/最大値を取得します。 5. **スタイル適用**: `_apply_data_plot_styles` および `_apply_ref_plot_styles` メソッドを呼び出して、軸スケール、ラベル、目盛、線幅などのスタイルを適用します。 6. **軸範囲設定**: データ描画後に取得したY軸の最小/最大値に基づき、適切なマージンを加えてY軸範囲を設定します。対数スケールの場合は0以下の値を適切に処理します。 7. **後処理**: 高次反射の線、ゴーストライン、ピーク注釈などを初期化または更新し、レイアウト調整とキャンバスの再描画を行います。 8. **ズーム情報の更新**: 軸の制限が変更された際に設定パネルの入力欄を更新するコールバックをトリガーします。 :param process_data: Trueの場合、Kα2除去やスムージングなどのデータ処理を適用します。デフォルトはTrue。 :type process_data: bool, optional """ # --- 1. サブプロットのレイアウトを現在のモードに合わせて再設定 --- self._setup_subplots() # --- 2. X軸の範囲を決定 --- if self.x_axis_is_locked and self.locked_x_range is not None: final_xlim = self.locked_x_range else: x_min_data, x_max_data = float('inf'), float('-inf') data_found = False all_data_sources = self.datasets + self.references for source in all_data_sources: x_values = source.get("x", source.get("positions", [])) if source.get("visible") and len(x_values) > 0: x_min_data = min(x_min_data, np.min(x_values)) x_max_data = max(x_max_data, np.max(x_values)) data_found = True if data_found: display_min = min(self.DEFAULT_X_RANGE[0], x_min_data) display_max = max(self.DEFAULT_X_RANGE[1], x_max_data) margin = (display_max - display_min) * 0.05 if (display_max - display_min) > 1e-9 else 1.0 final_xlim = (display_min - margin, display_max + margin) else: final_xlim = self.DEFAULT_X_RANGE # --- 3. 描画処理 --- data_plotted, y_min, y_max = self._draw_datasets(self.canvas.axes, process_data) # 下のプロットが存在する場合のみ、リファレンス関連の処理を行う if self.canvas.axes2 is not None: ref_plotted, baselines_to_draw, ref_y_min, ref_y_max = self._draw_references(self.canvas.axes2) else: ref_plotted, baselines_to_draw, ref_y_min, ref_y_max = False, [], 0, 0 # --- 4. スタイルの適用 --- self._apply_data_plot_styles(self.canvas.axes, data_plotted) if self.canvas.axes2 is not None: self._apply_ref_plot_styles(self.canvas.axes2, ref_plotted) # --- 5. 軸範囲の設定 --- self.canvas.axes.set_xlim(final_xlim) if self.canvas.axes2 is not None: self.canvas.axes2.set_xlim(final_xlim) # --- [修正版] 軸範囲の設定ロジック --- if data_plotted: scale_text = self.combo_data_y_scale.currentText() # 安全策:y_min が 0 以下の場合は、存在する最小の正の値を探す safe_y_min = y_min if y_min > 0 else 1e-1 if scale_text == "対数": # 上限:ピークの上に2倍の余白を作る(これで「張り付き」を解消) y_floor = safe_y_min / 2.0 self.canvas.axes.set_ylim(y_floor, y_max * 2.0) else: # 線形・平方根:従来の5%マージン y_range = y_max - y_min y_margin = y_range * 0.05 if y_range > 1e-9 else 0.5 self.canvas.axes.set_ylim(y_min - y_margin, y_max + y_margin) else: self.canvas.axes.set_ylim(1, 1000) # リファレンス側(下のグラフ)も同様に修正 if self.canvas.axes2 is not None: if ref_plotted: ref_scale_text = self.combo_ref_y_scale.currentText() safe_ref_min = ref_y_min if ref_y_min > 0 else 1e-10 if ref_scale_text == "対数": ref_floor = safe_ref_min / 2.0 self.canvas.axes2.set_ylim(ref_floor, ref_y_max * 2.0) else: ref_y_range = ref_y_max - ref_y_min ref_y_margin = ref_y_range * 0.05 if ref_y_range > 1e-9 else 0.5 self.canvas.axes2.set_ylim(ref_y_min - ref_y_margin, ref_y_max + ref_y_margin) # --- 6. 描画の後処理 --- if self.canvas.axes2 is not None: # チェックボックスの状態を判定用に取得 is_baseline_visible = self.settings_panel.check_ref_baseline_visible.isChecked() for baseline in baselines_to_draw: line_color = baseline["color"] if is_baseline_visible else "none" self.canvas.axes2.hlines( baseline["y"], final_xlim[0], final_xlim[1], color=line_color, linestyle=baseline["style"] ) self._initialize_peak_annotation(self.canvas.axes2) self.indicator_line, = self.canvas.axes.plot([], [], color='red', linestyle='--', linewidth=1.2, zorder=10, visible=False) # 複数の色付き点線を描画できるように変更 self.ghost_lines = {} ghost_colors = {"CuKα2": "darkorange", "CuKβ": "forestgreen", "W Lα1": "magenta", "W Lα2": "purple", "Custom": "teal"} for name, color in ghost_colors.items(): line, = self.canvas.axes.plot([], [], color=color, linestyle=':', linewidth=1.5, zorder=9, visible=False) # xaxis_transformを使うことで、X座標はデータ座標、Y座標は0(下端)~1(上端)の相対座標で指定可能になる txt = self.canvas.axes.text(0, 0.02, f" {name}", color=color, fontsize=self.base_font_size * 0.85, transform=self.canvas.axes.get_xaxis_transform(), rotation=90, verticalalignment='bottom', visible=False, zorder=10) self.ghost_lines[name] = {"line": line, "text": txt} # --- 高次反射用の線(最大5本分用意) --- self.higher_order_lines = [] for i in range(5): line, = self.canvas.axes.plot([], [], color='dodgerblue', linestyle='-.', linewidth=1.2, zorder=9, visible=False) txt = self.canvas.axes.text(0, 0.05, "", color='dodgerblue', fontsize=self.base_font_size * 0.85, transform=self.canvas.axes.get_xaxis_transform(), rotation=90, verticalalignment='bottom', visible=False, zorder=10) self.higher_order_lines.append({"line": line, "text": txt}) # モードに応じて、適切なAxesにX軸ラベルを設定する if self.is_single_plot_mode: # 1画面モードでは、上のグラフにX軸ラベルを表示 self.canvas.axes.set_xlabel(self.x_label_text, fontsize=self.base_font_size) else: # 2画面モードでは、下のグラフにX軸ラベルを表示 if self.canvas.axes2: self.canvas.axes2.set_xlabel(self.x_label_text, fontsize=self.base_font_size) try: self.canvas.figure.tight_layout() if not self.is_single_plot_mode: self.canvas.figure.subplots_adjust(hspace=0) except Exception as e: print(f"Warning: レイアウト調整に失敗しました: {e}") self.canvas.draw_idle() if hasattr(self, 'toolbar'): self.toolbar.update() if hasattr(self.toolbar, '_nav_stack'): self.toolbar._nav_stack.clear() self.on_axes_limits_changed(self.canvas.axes)
def _setup_subplots(self): """ 現在の表示モード(1画面/2画面)に応じて、グラフのサブプロットを再生成します。 概要: MatplotlibのFigureから既存のAxesをすべて削除し、 `is_single_plot_mode` の状態に基づいて新しいサブプロットを配置します。 詳細説明: `settings_panel.check_single_plot_mode` の状態を確認し、 チェックされている場合は単一のサブプロット(`self.canvas.axes`)を作成します。 チェックされていない場合は、上下に2つのサブプロット(`self.canvas.axes`と`self.canvas.axes2`)を `spin_plot_ratio` で指定された比率で作成します。 2画面モードでは、上のプロットのX軸ラベルは非表示にし、プロット間の垂直方向の隙間をなくします。 軸の制限変更イベントハンドラも再接続されます。 """ # Figureから既存のAxesをすべて削除 for ax in self.canvas.fig.get_axes(): ax.remove() if self.settings_panel.check_single_plot_mode.isChecked(): # --- 1画面モードの場合 --- self.canvas.axes = self.canvas.fig.add_subplot(111) self.canvas.axes2 = None # 下のプロットは存在しない # 1画面なので、X軸の目盛りラベルを再表示 self.canvas.axes.tick_params(axis='x', labelbottom=True) else: # --- 2画面モードの場合 --- top_ratio = self.settings_panel.spin_plot_ratio.value() bottom_ratio = max(100.0 - top_ratio, 1.0) gs = self.canvas.fig.add_gridspec(2, 1, height_ratios=[top_ratio, bottom_ratio]) self.canvas.axes = self.canvas.fig.add_subplot(gs[0]) self.canvas.axes2 = self.canvas.fig.add_subplot(gs[1], sharex=self.canvas.axes) # 上のプロットのX軸ラベルを非表示に self.canvas.axes.tick_params(axis='x', labelbottom=False) # プロット間の隙間をなくす self.canvas.fig.subplots_adjust(hspace=0) # イベントハンドラを再接続 self.canvas.axes.callbacks.connect('xlim_changed', self.on_axes_limits_changed) self.canvas.axes.callbacks.connect('ylim_changed', self.on_axes_limits_changed) if self.canvas.axes2: self.canvas.axes2.callbacks.connect('ylim_changed', self.on_axes_limits_changed) def _draw_datasets(self, ax: plt.Axes, process_data: bool = True) -> tuple[bool, float, float]: """ 指定されたAxesにデータセットを描画します。 概要: 選択されたデータセットのX線回折パターンをプロットし、Kα2除去、スムージング、Y軸オフセット、 非正値の置換などのデータ処理を適用します。 詳細説明: 各データセットについて、その可視性、色、線幅、Y軸オフセット(線形または対数)を考慮して描画します。 `process_data` がTrueの場合、Kα2除去とSavitzky-Golayスムージングが適用されます。 `replace_non_positive_with_one` がTrueの場合、Y値が0以下のデータ点は描画から除外されます(対数スケール対応)。 ピークプレビューが有効な場合は、一時的にピーク検索を行い、グラフ上にマークを表示します。 また、データセットに紐付けられたマーカーも適切な位置とスタイルで描画します。 描画されたデータのY軸範囲(最小値と最大値)を返します。 :param ax: 描画対象のMatplotlib Axesオブジェクト。 :type ax: matplotlib.axes.Axes :param process_data: データ処理(Kα2除去、スムージング)を適用するかどうか。デフォルトはTrue。 :type process_data: bool, optional :returns: (データがプロットされたかどうかのブール値, 実際のY軸最小値, 実際のY軸最大値)。 :rtype: tuple[bool, float, float] """ scale_text = self.settings_panel.combo_data_y_scale.currentText() data_plotted = False actual_y_min, actual_y_max = float('inf'), float('-inf') for data in self.datasets: if not data.get("visible", True): continue x_values = data.get("x", np.array([])) y_original = data.get("original_y", np.array([])) if len(x_values) == 0 or len(y_original) == 0: continue q_color = data.get("color", QColor("black")) mpl_color = (q_color.redF(), q_color.greenF(), q_color.blueF(), q_color.alphaF()) # データを処理するため、コピーを作成 y_processed = np.copy(y_original) # 1. Kα2 除去 (calculations.py の関数を使用) if self.chk_remove_ka2.isChecked(): ratio = self.spin_ka2_ratio.value() y_processed = calculations.strip_ka2(x_values, y_processed, ratio) # 2. スムージング (calculations.py の関数を使用) if self.chk_smooth.isChecked(): window = int(self.spin_smooth_window.value()) y_processed = calculations.apply_smoothing(y_processed, window) # --- 【修正2】描画直前に「最終的な無視」を適用する --- if self.replace_non_positive_with_one: # スムージングで発生した 1.0 付近の「浮き」を再度カット valid_mask = y_processed > 0 x_to_plot = x_values[valid_mask] y_filtered = y_processed[valid_mask] else: x_to_plot = x_values y_filtered = y_processed # y_to_plot の計算に、フィルタリング済みの y_filtered を使用する if scale_text == "対数": offset = data.get("y_offset_log_exponent", 0.0) y_safe = np.where(y_filtered > 0, y_filtered, np.nan) # 0以下をnanに y_to_plot = y_safe * (10**offset) # Y範囲計算はnanを除外して行う valid = y_to_plot[np.isfinite(y_to_plot)] elif scale_text == "平方根": offset = data.get("y_offset_linear", 0.0) y_to_plot = np.sqrt(np.maximum(0, y_filtered)) + offset else: # 線形 offset = data.get("y_offset_linear", 0.0) y_to_plot = y_filtered + offset # フィルタリング済みの配列でプロット ax.plot(x_to_plot, y_to_plot, label=data.get("name"), color=mpl_color, linewidth=data.get("linewidth", self.default_linewidth)) data_plotted = True if self.chk_peak_preview.isChecked(): th = self.spin_peak_search_threshold.value() ns = int(self.data_panel.spin_smooth_window.value()) # 一時的にピーク検索を実行 preview_peaks = calculations.perform_peak_search(x_values, y_processed, nsmooth=ns, threshold=th) for p in preview_peaks: # プレビューなので、目立たない色(グレーなど)で描画 # 対応するy位置を計算 idx = np.searchsorted(x_values, p["x"]) if idx < len(y_to_plot): y_pos = y_to_plot[idx] ax.plot(p["x"], y_pos, marker='x', color='gray', markersize=8, alpha=0.5, linestyle='None') if len(y_to_plot) > 0: finite_vals = y_to_plot[np.isfinite(y_to_plot)] if len(finite_vals) > 0: actual_y_min = min(actual_y_min, np.min(finite_vals)) actual_y_max = max(actual_y_max, np.max(finite_vals)) # --- ここからがマーカー描画処理 --- if data.get("visible", True) and "markers" in data and data["markers"]: from matplotlib.transforms import offset_copy # 座標変換用にインポート for marker in data["markers"]: x_pos = marker.get("x") if x_pos is None: continue # 1. データの高さを取得 closest_idx = np.argmin(np.abs(x_to_plot - x_pos)) # x_to_plot を使う y_base = y_to_plot[closest_idx] # 2. 個別または全体のオフセット/太さを取得 pt_offset = marker.get("offset", data.get("marker_offset_percent", 15.0)) m_linewidth = marker.get("linewidth", self.spin_marker_linewidth.value()) # 3. 記号と色の定義 (ここで変数を確実に定義) display_text = marker.get("symbol", self.marker_symbols_internal[0]) marker_char = display_text.split(' ')[0] q_color = marker.get("color", data["color"]) m_color = (q_color.redF(), q_color.greenF(), q_color.blueF(), q_color.alphaF()) # 4. 描画 if marker_char == "|": # transformを使用して、データ位置から指定ポイント分だけY方向にずらす trans = offset_copy(ax.transData, fig=self.canvas.fig, y=pt_offset, units='points') ax.plot([x_pos], [y_base], marker='|', markersize=20, # 棒の長さ markeredgewidth=m_linewidth, # これで縦棒の太さを制御 color=m_color, transform=trans, # xytextの代わりにこれを使用 clip_on=False, zorder=10) else: # 通常の記号(▼など)は annotate の方が扱いやすい ax.annotate( marker_char, xy=(x_pos, y_base), xytext=(0, pt_offset), textcoords='offset points', ha='center', va='center', color=m_color, fontsize=12, # 太さが大きい時はフォントも太くする fontweight='bold' if m_linewidth > 2.5 else 'normal', zorder=10 ) # --- マーカー描画ここまで --- if not data_plotted: actual_y_min, actual_y_max = 0, 1 return data_plotted, actual_y_min, actual_y_max def _draw_references(self, ax: plt.Axes) -> tuple[bool, list[dict], float, float]: """ 指定されたAxesにリファレンスデータセットを描画します。 概要: リファレンスパターンをステムプロットとして表示し、Y軸スケール(線形、対数、平方根、定数)、 スケーリングファクター、およびフィルタリング条件を適用します。 詳細説明: 各リファレンスデータについて、その可視性、色、線幅を考慮して描画します。 リファレンスは、Y軸の表示スケール(線形、対数、平方根、定数)とスケーリングファクターに応じて、 異なるベースラインと高さで描画されます。 フィルタリング条件が設定されている場合、`_safe_evaluate_filter` を使用して、 hklインデックスと推定された格子定数に基づいて表示するピークを選択します。 各リファレンスのベースラインは、`baselines_to_draw` リストに格納され、 後でグラフの枠線として描画されます。 描画されたリファレンスのY軸範囲(最小値と最大値)を返します。 :param ax: 描画対象のMatplotlib Axesオブジェクト。 :type ax: matplotlib.axes.Axes :returns: (リファレンスがプロットされたかどうかのブール値, 描画されたベースラインのリスト, 実際のY軸最小値, 実際のY軸最大値)。 :rtype: tuple[bool, list[dict], float, float] """ scale_text = self.settings_panel.combo_ref_y_scale.currentText() scale_factor = self.settings_panel.spin_scaling_factor.value() baselines_to_draw = [] ref_plotted = False ref_y_min, ref_y_max = float('inf'), float('-inf') filter_condition = self.current_ref_filter_condition for i, ref_data in enumerate(self.references): if not ref_data.get("visible", True): continue positions = ref_data.get("positions", np.array([])) intensities = ref_data.get("intensities", np.array([])) hkls = ref_data.get("hkls", []) # --- 数値データの復元ロジック (維持) --- raw_indices = ref_data.get("raw_indices", []) if not raw_indices and hkls: reconstructed_indices = [] for hkl_str in hkls: try: clean_str = hkl_str.replace('(', '').replace(')', '') vals = [int(x) for x in clean_str.split() if x.lstrip('-').isdigit()] if len(vals) == 3: h, k, l = vals; i_val = -(h + k) elif len(vals) >= 4: h, k, i_val, l = vals[:4] else: h, k, l, i_val = 0, 0, 0, 0 reconstructed_indices.append({'h': h, 'k': k, 'l': l, 'i': i_val}) except: reconstructed_indices.append({'h': 0, 'k': 0, 'l': 0, 'i': 0}) raw_indices = reconstructed_indices ref_data["raw_indices"] = raw_indices if len(positions) == 0: continue # --- セキュリティ対策済みフィルタリング処理 --- valid_indices = [] if filter_condition and raw_indices: lattice = ref_data.get("lattice") # 格子情報を取得 for idx, indices_dict in enumerate(raw_indices): h = indices_dict.get('h', 0) k = indices_dict.get('k', 0) l = indices_dict.get('l', 0) i_val = indices_dict.get('i', -(h+k)) eval_context = {'h': h, 'k': k, 'l': l, 'i': i_val} if lattice: # 収束していればコンテキストに注入 eval_context.update(lattice) if self._safe_evaluate_filter(filter_condition, eval_context): valid_indices.append(idx) else: valid_indices = list(range(len(positions))) if not valid_indices: continue # フィルタリング適用後のデータ filtered_positions = positions[valid_indices] filtered_intensities = intensities[valid_indices] filtered_hkls = [hkls[j] for j in valid_indices] if hkls else [] ref_plotted = True q_color = ref_data.get("color", QColor("gray")) mpl_color = q_color.name() # --- 正規化 --- i_max = np.max(filtered_intensities) i_max = 1.0 if i_max <= 0 else i_max normalized_i = filtered_intensities / i_max # --- 修正箇所: ログ/線形の描画ロジック分岐 --- if scale_text == "対数": # 対数表示の場合 # 1. 各リファレンスに割り当てるスペース(桁数)を定義 decade_capacity = 3.0 # 2. このリファレンスの「底(フロア)」となるY座標を計算 baseline_y = 10 ** (-(i + 1) * decade_capacity) # 3. 強度の足切り(ダイナミックレンジの制限) min_intensity = 10 ** (-decade_capacity) effective_i = np.maximum(normalized_i, min_intensity) # 4. 高さの計算(べき乗スケーリング) ratio = effective_i / min_intensity # scale_factor を「べき乗」として掛けることで、 y_tops = baseline_y * (ratio ** scale_factor) elif scale_text == "定数": # 定数表示の場合 (元の強度を無視して一定の高さにする) offset = -i * 1.0 baseline_y = offset heights = np.ones_like(normalized_i) * scale_factor y_tops = offset + heights else: # 線形 / 平方根表示の場合 (変更なし) offset = -i * 1.0 baseline_y = offset processed_i = np.sqrt(normalized_i) if scale_text == "平方根" else normalized_i # scale_factor で高さを調整 heights = processed_i * scale_factor y_tops = offset + heights # マウスオーバー判定用に保存 ref_data["display_data"] = { "positions": filtered_positions, "y_tops": y_tops, "hkls": filtered_hkls, "baseline": baseline_y } if len(y_tops) > 0: current_min = min(np.min(y_tops), baseline_y) current_max = max(np.max(y_tops), baseline_y) ref_y_min = min(ref_y_min, current_min) ref_y_max = max(ref_y_max, current_max) container = ax.stem( filtered_positions, y_tops, linefmt='-', markerfmt='', basefmt=' ', bottom=baseline_y, label=ref_data.get("name") ) container.stemlines.set_color(mpl_color) container.stemlines.set_linewidth(ref_data.get("linewidth", self.default_linewidth)) baselines_to_draw.append({"y": baseline_y, "color": mpl_color, "style": '-'}) if not ref_plotted: ref_y_min, ref_y_max = -1, 1 return ref_plotted, baselines_to_draw, ref_y_min, ref_y_max def _apply_data_plot_styles(self, ax: plt.Axes, data_plotted: bool): """ 上側のデータプロットにスタイルを適用します。 概要: Y軸スケール、Y軸目盛ラベルの表示/非表示、凡例の可視性、グリッド、 中央区切り線のスタイル、軸の線幅、目盛の太さを設定します。 詳細説明: `combo_data_y_scale` の選択に応じてY軸スケールを線形または対数に設定し、 対数スケールの場合はMatplotlibのLogLocatorとLogFormatterMathtextを使用して 主要な目盛と補助目盛を適切に表示します。 `y_tick_labels_visible` と `legend_visible` の状態に応じて、 Y軸の数値ラベルと凡例の表示を切り替えます。 `check_hide_divider` がオンの場合、2画面モードでの上側プロットの下部境界線を非表示にし、 `spin_axes_linewidth` の値に基づいて軸の線幅と目盛の太さを調整します。 :param ax: 描画対象のMatplotlib Axesオブジェクト。 :type ax: matplotlib.axes.Axes :param data_plotted: データが実際にプロットされたかどうかを示すブール値。 :type data_plotted: bool """ scale_text = self.settings_panel.combo_data_y_scale.currentText() if scale_text == "対数": ax.set_yscale('log') # --- ここから追加:log目盛りを強制 --- # major: 10^n のみ ax.yaxis.set_major_locator(LogLocator(base=10.0, subs=(1.0,), numticks=12)) ax.yaxis.set_major_formatter(LogFormatterMathtext(base=10.0)) # minor: 2〜9 を必ず出す(片対数の刻みが消える問題を解決) ax.yaxis.set_minor_locator(LogLocator(base=10.0, subs=np.arange(2, 10) * 0.1, numticks=100)) ax.yaxis.set_minor_formatter(NullFormatter()) # minor tick を見えるように(長さ調整) ax.tick_params(axis='y', which='minor', length=3.0) ax.tick_params(axis='y', which='major', length=6.0) # --- 追加ここまで --- ax.set_ylabel(f"{self.data_y_label_text} ") else: ax.set_yscale('linear') if scale_text == "平方根": ax.set_ylabel(f"{self.data_y_label_text} ") else: ax.set_ylabel(self.data_y_label_text) if not self.y_tick_labels_visible: ax.yaxis.set_major_formatter(plt.NullFormatter()) else: ax.yaxis.set_major_formatter(plt.ScalarFormatter()) # 数値を再表示 if self.legend_visible and data_plotted: handles, labels = ax.get_legend_handles_labels() if handles: ax.legend(handles, labels, fontsize=self.base_font_size * 0.9) ax.grid(matplotlib.rcParams.get('axes.grid', False)) # --- 中央区切り線の非表示処理 --- # 1画面モードではなく、かつチェックが入っている場合のみ実行 if not self.is_single_plot_mode and self.settings_panel.check_hide_divider.isChecked(): ax.spines['bottom'].set_color('none') # 底辺を透明に ax.tick_params(axis='x', which='both', bottom=False) # 下側の目盛を消す else: # 元に戻すための処理 lw = self.settings_panel.spin_axes_linewidth.value() ax.spines['bottom'].set_color('black') ax.spines['bottom'].set_linewidth(lw) # 2画面モードの時、上のグラフの数値(labelbottom)は常にFalseなので目盛線だけ戻す ax.tick_params(axis='x', which='both', bottom=True) # --- 枠と目盛の太さを適用 (メソッドの最後に追加) --- lw = self.settings_panel.spin_axes_linewidth.value() for spine in ax.spines.values(): spine.set_linewidth(lw) # 太さに応じて目盛の長さも少し調整します ax.tick_params(which='major', width=lw, length=lw * 7.5) ax.tick_params(which='minor', width=lw * 0.75, length=lw * 3.75) def _apply_ref_plot_styles(self, ax: plt.Axes, has_data: bool): """ 下側のリファレンスプロットにスタイルを適用します。 概要: Y軸スケール、Y軸目盛ラベルの表示/非表示、X軸目盛の表示/非表示、凡例の可視性、 中央区切り線のスタイル、軸の線幅、目盛の太さを設定します。 詳細説明: `combo_ref_y_scale` の選択に応じてY軸スケールを線形または対数に設定し、 対数スケールの場合はMatplotlibのLogLocatorとLogFormatterMathtextを使用して 主要な目盛と補助目盛を適切に表示します。 `ref_y_label_visible`、`ref_y_tick_labels_visible`、`legend_visible` の状態に応じて、 Y軸のラベル、数値ラベル、凡例の表示を切り替えます。 `x_tick_interval` が設定されている場合は、X軸の主目盛間隔を固定します。 `check_ref_x_ticks_visible` の状態に応じてX軸目盛の表示を切り替えます。 `check_hide_divider` がオンの場合、2画面モードでの下側プロットの上部境界線を非表示にし、 `spin_axes_linewidth` の値に基づいて軸の線幅と目盛の太さを調整します。 :param ax: 描画対象のMatplotlib Axesオブジェクト。 :type ax: matplotlib.axes.Axes :param has_data: リファレンスデータが実際にプロットされたかどうかを示すブール値。 :type has_data: bool """ scale_text = self.settings_panel.combo_ref_y_scale.currentText() if scale_text == "対数": ax.set_yscale('log') # --- ここから追加:log目盛りを強制 --- # major: 10^n のみ ax.yaxis.set_major_locator(LogLocator(base=10.0, subs=(1.0,), numticks=12)) ax.yaxis.set_major_formatter(LogFormatterMathtext(base=10.0)) # minor: 2〜9 を必ず出す(片対数の刻みが消える問題を解決) ax.yaxis.set_minor_locator(LogLocator(base=10.0, subs=np.arange(2, 10) * 0.1, numticks=100)) ax.yaxis.set_minor_formatter(NullFormatter()) # minor tick を見えるように(長さ調整) ax.tick_params(axis='y', which='minor', length=3.0) ax.tick_params(axis='y', which='major', length=6.0) # --- 追加ここまで --- ax.set_ylabel("Ref. Int. (log scale)") elif scale_text == "定数": ax.set_yscale('linear') ax.set_ylabel("Ref. Int. (Constant)") else: ax.set_yscale('linear') if scale_text == "平方根": ax.set_ylabel("Ref. Int. (sqrt scale)") else: ax.set_ylabel("Ref. Int. / (arb.unit)") if not self.ref_y_label_visible and ax: ax.set_ylabel("") # Ref Y軸数値の表示/非表示を反映させる if not self.ref_y_tick_labels_visible: ax.yaxis.set_major_formatter(plt.NullFormatter()) else: # 表示する場合は、デフォルトのフォーマッタに戻す ax.yaxis.set_major_formatter(plt.ScalarFormatter()) # 凡例の設定 if self.legend_visible and has_data: ax.legend(fontsize=self.base_font_size * 0.7) # X軸目盛りの設定 if self.x_tick_interval is not None: ax.xaxis.set_major_locator(plt.MultipleLocator(base=self.x_tick_interval)) else: ax.xaxis.set_major_locator(plt.AutoLocator()) if not self.settings_panel.check_ref_x_ticks_visible.isChecked(): ax.tick_params(axis='x', which='both', bottom=False, top=False, labelbottom=True) else: ax.tick_params(axis='x', which='both', bottom=True, top=True, labelbottom=True) # Ref Y軸目盛の表示/非表示(既存の処理を tick_params で強化) if not self.ref_y_tick_labels_visible: ax.tick_params(axis='y', which='both', left=False, right=False, labelleft=False) else: ax.tick_params(axis='y', which='both', left=True, right=True, labelleft=True) # --- 中央区切り線の非表示処理 --- if not self.is_single_plot_mode and self.settings_panel.check_hide_divider.isChecked(): ax.spines['top'].set_color('none') # 天井を透明に ax.tick_params(axis='x', which='both', top=False) # 上側の目盛を消す else: # 元に戻すための処理 lw = self.settings_panel.spin_axes_linewidth.value() ax.spines['top'].set_color('black') ax.spines['top'].set_linewidth(lw) ax.tick_params(axis='x', which='both', top=True) # --- 枠と目盛の太さを適用 (メソッドの最後に追加) --- lw = self.settings_panel.spin_axes_linewidth.value() for spine in ax.spines.values(): spine.set_linewidth(lw) # 太さに応じて目盛の長さも少し調整します ax.tick_params(which='major', width=lw, length=lw * 7.5) ax.tick_params(which='minor', width=lw * 0.75, length=lw * 3.75)
[ドキュメント] def apply_plot_ratio(self): """ スピンボックスで設定された値に基づいて、上下のプロットの高さの比率を変更します。 概要: 2画面モードで、データプロットとリファレンスプロットの相対的な高さを調整します。 詳細説明: `settings_panel.spin_plot_ratio` の値を取得し、 これに基づいてMatplotlibのGridSpecの`height_ratios`を設定します。 これにより、データプロットが占める垂直方向のスペースと、 リファレンスプロットが占めるスペースの比率が変更されます。 変更を反映するために、`tight_layout()` と `subplots_adjust(hspace=0)` を呼び出し、 キャンバスを再描画します。 :returns: なし """ top_ratio = self.settings_panel.spin_plot_ratio.value() bottom_ratio = max(100.0 - top_ratio, 1.0) # 下側がゼロにならないように try: # 既存のGridSpec(レイアウト定義)を取得 gs = self.canvas.axes.get_gridspec() # 高さの比率を直接設定 gs.set_height_ratios([top_ratio, bottom_ratio]) # 1. tight_layout()で、figure全体のレイアウトを強制的に再計算させる self.canvas.figure.tight_layout() # 2. ただし、tight_layout()が作る余白を消すため、隙間ゼロを即座に再設定する self.canvas.figure.subplots_adjust(hspace=0) # 3. キャンバスを再描画して、変更を画面に反映する self.canvas.draw_idle() print(f"デバッグ: プロット比率を [{top_ratio:.1f}% : {bottom_ratio:.1f}%] に変更しました。") except Exception as e: print(f"プロット比率の変更中にエラーが発生しました: {e}")
[ドキュメント] def on_aspect_ratio_changed(self, selected_text: str): """ グラフの縦横比設定が変更されたときに呼び出されます。 概要: 選択された縦横比または固定サイズに基づいて、グラフのMatplotlib Figureサイズを調整します。 詳細説明: `selected_text` の値が `self.fixed_paper_sizes` に含まれる場合、 対応する固定サイズ(インチ単位)をFigureに直接設定します。 `self.aspect_ratios` に含まれる場合は、指定された比率に基づいて幅を基準とした高さを計算し、 Figureのサイズを設定します。 設定後、`update_plot()` を呼び出してグラフを再描画し、変更を反映させます。 :param selected_text: 選択された縦横比または固定サイズのテキスト。 :type selected_text: str """ 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_custom_size_cm(self): """ スピンボックスで指定されたセンチメートル単位のサイズをグラフに適用します。 概要: ユーザーが入力した幅と高さ(cm)に基づいて、Matplotlib Figureのサイズをインチ単位で設定します。 詳細説明: `spin_width_cm` と `spin_height_cm` の値を取得し、 それをインチに変換(1インチ = 2.54 cm)して、`self.canvas.fig.set_size_inches()` でFigureに適用します。 適用後、縦横比コンボボックスを「カスタム」に設定し、`update_plot()` を呼び出してグラフを再描画します。 無効な入力があった場合はエラーメッセージを表示します。 :returns: なし """ try: 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.settings_panel.combo_aspect_ratio.blockSignals(True) self.settings_panel.combo_aspect_ratio.setCurrentText("カスタム") self.settings_panel.combo_aspect_ratio.blockSignals(False) self.update_plot() # 再描画してレイアウトを調整 except Exception as e: QMessageBox.critical(self, "エラー", f"サイズ適用中にエラーが発生しました:\n{e}")
# =================================================================== # ### 4. ファイル・セッション管理/外部ファイルの読み書きに関連する機能 ### # ===================================================================
[ドキュメント] def load_xrd_data_dialog(self): """ ファイルダイアログを開き、選択されたXRDデータファイルを読み込みます。 概要: ユーザーがファイルシステムからXRDデータファイルを選択し、その内容をアプリケーションにロードします。 詳細説明: `QFileDialog.getOpenFileName` を使用してファイル選択ダイアログを表示します。 選択されたファイルのパスを `file_parser.parse_xrd_file` に渡し、 XRDデータを解析します。解析が成功した場合、`add_dataset` メソッドを呼び出して データをアプリケーションのデータリストに追加し、グラフを更新します。 解析エラーが発生した場合は、エラーメッセージをポップアップで表示します。 """ filepath, _ = QFileDialog.getOpenFileName( self, "XRDデータファイルを開く", "", self.data_file_filter ) if filepath: sample_name, x_data, y_data ,error_msg = file_parser.parse_xrd_file(filepath, external_lib=XRD_GUI_lib) if error_msg: QMessageBox.critical(self, "読み込みエラー", error_msg) return if sample_name is not None: self.add_dataset(name=sample_name, x_data=x_data, y_data=y_data) self.update_plot() print(f"データ '{sample_name}' を読み込みました。")
[ドキュメント] def load_reference_dialog(self): """ ファイルダイアログを開き、選択されたXRDリファレンスファイルを読み込みます。 概要: ユーザーがファイルシステムからリファレンスファイルを選択し、その内容をアプリケーションにロードします。 詳細説明: `QFileDialog.getOpenFileName` を使用してファイル選択ダイアログを表示します。 選択されたファイルのパスと現在のX線波長を `file_parser.parse_reference_file` に渡し、 リファレンスデータを解析します。 解析が成功した場合、`_generate_unique_name` でユニークな名前を生成し、 リファレンスデータをアプリケーションのリファレンスリストに追加します。 その際、推定された格子定数情報も保存されます。 最後に、リファレンスリストウィジェットとグラフを更新します。 """ filepath, _ = QFileDialog.getOpenFileName(self, "XRDリファレンスを開く", "", self.reference_file_filter) if filepath: # 現在 UI で選択されている波長名を取得 current_wavelength = self.xray_panel.combo_main_wave.currentText() ref_name, positions, intensities, hkls, raw_indices = file_parser.parse_reference_file( filepath, external_lib=XRD_GUI_lib, wavelength=current_wavelength ) if ref_name is not None: unique_ref_name = self._generate_unique_name(ref_name) new_reference = { "name": unique_ref_name, "positions": positions, "intensities": intensities, "hkls": hkls, "raw_indices": raw_indices, # 数値データを保存 "visible": True, "color": self.get_next_color(), "linewidth": self.default_linewidth } self.references.append(new_reference) item = QListWidgetItem(unique_ref_name) item.setData(Qt.ItemDataRole.UserRole, new_reference) item.setForeground(new_reference["color"]) self.ref_panel.list_widget_references.addItem(item) self.update_plot() print(f"リファレンス '{unique_ref_name}' を読み込みました。")
[ドキュメント] def save_graph_dialog(self): """ 現在のグラフを画像またはベクトル形式でファイルに保存するダイアログを表示します。 概要: ユーザーがグラフの保存形式(PNG, JPEG, SVG, PDF)を選択し、指定したファイルパスにグラフを出力します。 詳細説明: グラフデータが存在しない場合は、情報メッセージを表示して処理を中断します。 `QFileDialog.getSaveFileName` を使用して保存ダイアログを表示し、 ファイルパスと選択されたフィルター(ファイル形式)を取得します。 `self.canvas.fig.savefig` メソッドを使用して、選択された形式(PNGの場合300DPI、透明背景)でグラフを保存します。 保存成功または失敗のメッセージをユーザーに通知します。 :returns: なし """ "グラフを保存するダイアログを表示する" 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: is_png = file_path.lower().endswith('.png') self.canvas.fig.savefig(file_path, dpi=300, transparent=is_png) # 高解像度で保存 QMessageBox.information(self, "成功", f"グラフを {file_path} に保存しました。") except Exception as e: QMessageBox.critical(self, "エラー", f"グラフ保存中にエラーが発生しました:\n{e}")
[ドキュメント] def save_session(self) -> bool: """ 現在のセッションの状態(データセット、リファレンス、UI設定)をJSONファイルに保存します。 概要: アプリケーションの現在の状態をファイルに永続化し、後で復元できるようにします。 詳細説明: 保存するデータが存在しない場合は、情報メッセージを表示して処理を中断します。 `QFileDialog.getSaveFileName` を使用して、JSON形式の保存パスをユーザーに尋ねます。 `session_manager.serialize_session` を使用して、データセット、リファレンス、および 現在のUI設定(Y軸スケール、スケーリングファクター、フィルタ条件、フォント情報など)を JSONシリアライズ可能な形式に変換します。 変換されたデータを `session_manager.save_to_file` を使用してファイルに書き込みます。 保存成功または失敗のメッセージをユーザーに通知します。 :returns: セッションが正常に保存された場合はTrue、それ以外はFalse。 :rtype: bool """ if not self.datasets and not self.references: QMessageBox.information(self, "情報", "保存するデータがありません。") return False ui_settings = { "data_y_scale": self.settings_panel.combo_data_y_scale.currentText(), "ref_y_scale": self.settings_panel.combo_ref_y_scale.currentText(), "scaling_factor": self.settings_panel.spin_scaling_factor.value(), "plot_ratio": self.settings_panel.spin_plot_ratio.value(), "filter_condition": self.ref_panel.edit_ref_filter.text(), "axes_linewidth": self.settings_panel.spin_axes_linewidth.value(), "aspect_ratio": self.settings_panel.combo_aspect_ratio.currentText(), "custom_width_cm": self.settings_panel.spin_width_cm.value(), "custom_height_cm": self.settings_panel.spin_height_cm.value(), #内部変数 "x_axis_is_locked": self.x_axis_is_locked, "locked_x_range": self.locked_x_range, "x_tick_interval": self.x_tick_interval, #チェックボックス関連 "data_y_ticks_visible": self.settings_panel.check_y_ticks_visible.isChecked(), "ref_y_ticks_visible": self.settings_panel.check_ref_y_ticks_visible.isChecked(), "ref_y_label_visible": self.settings_panel.check_ref_y_label_visible.isChecked(), "legend_visible": self.settings_panel.check_legend_visible.isChecked(), # ラベル系 "x_label_text": self.settings_panel.edit_x_label.text(), "data_y_label_text": self.settings_panel.edit_data_y_label.text(), # データ処理設定 (data_panel側) "ka2_remove": self.data_panel.chk_remove_ka2.isChecked(), "ka2_ratio": self.data_panel.spin_ka2_ratio.value(), "smooth_on": self.data_panel.chk_smooth.isChecked(), "smooth_window": self.data_panel.spin_smooth_window.value(), # フォント情報 "graph_font_family": self.graph_font.family(), "graph_font_size": self.graph_font.pointSize(), # コロケーション系 "colormap_settings": { "current_colormap": self.data_panel.combo_colormap.currentText(), "cmap_min_val": self.data_panel.spinbox_cmap_min.value(), "cmap_max_val": self.data_panel.spinbox_cmap_max.value() }, } filepath, _ = QFileDialog.getSaveFileName(self, "セッションを保存", "", "JSON Files (*.json)") if not filepath: return False try: # 3. シリアライズと保存を委譲 data = session_manager.serialize_session(self.datasets, self.references, ui_settings, __version__) session_manager.save_to_file(filepath, data) QMessageBox.information(self, "成功", "保存しました") except Exception as e: QMessageBox.critical(self, "エラー", str(e)) return False return True
[ドキュメント] def load_session(self): """ JSONファイルからセッションを読み込み、アプリケーションの状態を復元します。 概要: 以前に保存されたセッションファイルをロードし、データセット、リファレンス、UI設定を再構築します。 詳細説明: 現在のセッションにデータが存在する場合、ユーザーに保存の確認を促します。 `QFileDialog.getOpenFileName` を使用してJSON形式のファイルパスをユーザーに尋ねます。 `session_manager.load_from_file` を使用してセッションデータを読み込み、 `self.datasets` と `self.references` をクリアして、読み込んだデータで再構築します。 データセットとリファレンスに含まれる色情報やマーカーの色情報も正しくQColorオブジェクトに変換されます。 さらに、保存されていたUI設定(データ処理設定、グラフ表示設定、フォント設定など)を各UIウィジェットに適用し、 グラフを更新します。 読み込み成功または失敗のメッセージをユーザーに通知します。 :returns: なし """ if self.datasets or self.references: reply = QMessageBox.question(self, '確認', "現在のセッションを破棄して新しいセッションを読み込みますか?", QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.Cancel) if reply == QMessageBox.StandardButton.Save: if not self.save_session(): return elif reply == QMessageBox.StandardButton.Cancel: return filepath, _ = QFileDialog.getOpenFileName(self, "セッションを読み込み", "", "JSON Files (*.json)") if not filepath: return try: session_data = session_manager.load_from_file(filepath) ui_settings = session_data.get("ui_settings", {}) # ✅ 1. データ処理設定の復元 self.data_panel.chk_remove_ka2.setChecked(ui_settings.get("ka2_remove", False)) self.data_panel.spin_ka2_ratio.setValue(ui_settings.get("ka2_ratio", 0.5)) self.data_panel.chk_smooth.setChecked(ui_settings.get("smooth_on", False)) self.data_panel.spin_smooth_window.setValue(ui_settings.get("smooth_window", 5)) # ✅ 2. コロケーションの復元 (保存した辞書から読み込む) cmap_s = ui_settings.get("colormap_settings", {}) self.data_panel.combo_colormap.setCurrentText(cmap_s.get("current_colormap", "viridis")) self.data_panel.spinbox_cmap_min.setValue(cmap_s.get("cmap_min_val", 0.0)) self.data_panel.spinbox_cmap_max.setValue(cmap_s.get("cmap_max_val", 1.0)) # --- データをクリア --- self.datasets.clear(); self.references.clear() self.data_panel.list_widget_data.clear(); self.ref_panel.list_widget_references.clear() # データセットの復元 for ds_data in session_data.get("datasets", []): ds_data["x"] = np.array(ds_data.get("x", [])) ds_data["original_y"] = np.array(ds_data.get("original_y", [])) ds_data["color"] = QColor(ds_data.get("color", "#000000")) if "markers" in ds_data: for m in ds_data["markers"]: if isinstance(m.get("color"), str): m["color"] = QColor(m["color"]) self.datasets.append(ds_data) item = QListWidgetItem(ds_data["name"]) item.setData(Qt.ItemDataRole.UserRole, ds_data) self.data_panel.list_widget_data.addItem(item) # リファレンスの復元 main_wave_name = self.xray_panel.combo_main_wave.currentText() lambda_main = WAVE_MAP.get(main_wave_name, 1.5406) for ref_data in session_data.get("references", []): ref_data["positions"] = np.array(ref_data.get("positions", [])) ref_data["intensities"] = np.array(ref_data.get("intensities", [])) ref_data["color"] = QColor(ref_data.get("color", "#808080")) if "lattice" not in ref_data or ref_data["lattice"] is None: ref_data["lattice"] = gixrd.estimate_lattice_from_peaks( ref_data["positions"], ref_data.get("raw_indices", []), lambda_main) self.references.append(ref_data) item = QListWidgetItem(ref_data["name"]) item.setData(Qt.ItemDataRole.UserRole, ref_data) self.ref_panel.list_widget_references.addItem(item) # --- UI設定の復元(settings_panel経由) --- ui_settings = session_data.get("ui_settings", {}) s = self.settings_panel # 短縮名 self.ref_panel.edit_ref_filter.setText(ui_settings.get("filter_condition", "")) self.current_ref_filter_condition = ui_settings.get("filter_condition", "") s.combo_data_y_scale.setCurrentText(ui_settings.get("data_y_scale", "線形")) s.combo_ref_y_scale.setCurrentText(ui_settings.get("ref_y_scale", "線形")) s.spin_scaling_factor.setValue(ui_settings.get("scaling_factor", 0.8)) s.spin_plot_ratio.setValue(ui_settings.get("plot_ratio", 80.0)) s.combo_aspect_ratio.setCurrentText(ui_settings.get("aspect_ratio", "カスタム")) s.spin_width_cm.setValue(ui_settings.get("custom_width_cm", 15.0)) s.spin_height_cm.setValue(ui_settings.get("custom_height_cm", 10.0)) s.spin_axes_linewidth.setValue(ui_settings.get("axes_linewidth", 0.8)) self.x_axis_is_locked = ui_settings.get("x_axis_is_locked", False) self.locked_x_range = ui_settings.get("locked_x_range", None) self.x_tick_interval = ui_settings.get("x_tick_interval", None) s.edit_x_tick_interval.setText(str(self.x_tick_interval) if self.x_tick_interval is not None else "") # 内部変数を直接更新してから UI を同期させる(信号の空振りを防ぐ) self.y_tick_labels_visible = ui_settings.get("data_y_ticks_visible", True) self.ref_y_tick_labels_visible = ui_settings.get("ref_y_ticks_visible", True) self.ref_y_label_visible = ui_settings.get("ref_y_label_visible", True) self.legend_visible = ui_settings.get("legend_visible", True) s.check_y_ticks_visible.setChecked(self.y_tick_labels_visible) s.check_ref_y_ticks_visible.setChecked(self.ref_y_tick_labels_visible) s.check_ref_y_label_visible.setChecked(self.ref_y_label_visible) s.check_legend_visible.setChecked(self.legend_visible) s.edit_x_label.setText(ui_settings.get("x_label_text", "2θ/ω (deg.)")) s.edit_data_y_label.setText(ui_settings.get("data_y_label_text", "Intensity / (arb.unit)")) # フォント復元 f_family = ui_settings.get("graph_font_family", "sans-serif") f_size = ui_settings.get("graph_font_size", 12) self.graph_font = QFont(f_family, int(f_size)) # ✅ 4.サイズ設定を Figure に物理反映させる if s.combo_aspect_ratio.currentText() == "カスタム": self.apply_custom_size_cm() else: self.on_aspect_ratio_changed(s.combo_aspect_ratio.currentText()) # 最終更新 self.apply_plot_ratio() self._update_all_list_items_visuals() print(f'デバッグ:uisettingを読み込みます{ui_settings}') self.update_plot() self.apply_plot_ratio() self._update_all_list_items_visuals() self.apply_font_to_graph(self.graph_font, update_plot=False) QMessageBox.information(self, "成功", "セッションを読み込みました。") except Exception as e: QMessageBox.critical(self, "エラー", f"読み込み失敗: {e}\n{traceback.format_exc()}")
[ドキュメント] def dragEnterEvent(self, event): """ ファイルがアプリケーションにドラッグされたときにカーソル形状を変更します。 概要: ドラッグされたMIMEデータがURL(ファイル)を含んでいる場合、ドロップを受け入れる準備をします。 詳細説明: Qtのドラッグ&ドロップイベントハンドラの一部です。 `event.mimeData().hasUrls()` でMIMEデータがURL(ファイルパス)を含んでいるか確認し、 含まれていれば `event.acceptProposedAction()` を呼び出してドロップを受け入れることを示します。 これにより、カーソルが「ドロップ可能」な形状に変わります。 :param event: ドラッグ&ドロップイベント。 :type event: QDragEnterEvent :returns: なし """ if event.mimeData().hasUrls(): event.acceptProposedAction() else: event.ignore()
[ドキュメント] def dropEvent(self, event): """ ファイルがアプリケーションにドロップされたときに読み込み処理を実行します。 概要: ドラッグ&ドロップされたファイルを、ドロップされたUI要素に基づいてデータセットまたはリファレンスとして読み込みます。 詳細説明: ドロップされたファイルパスのリストを取得し、ドロップされた座標が データリストウィジェットまたはリファレンスリストウィジェットのどちらの上にあるかを判定します。 判定結果に基づき、各ファイルを `_load_data_file` または `_load_reference_file` (後者は通常`load_reference_dialog`が呼ばれるが、ここでは内部ヘルパーを想定)を呼び出して読み込みます。 どちらのリストウィジェット上でもない場合は、デフォルトでデータとして読み込みます。 全てのファイルの読み込み後、グラフを更新します。 :param event: ドラッグ&ドロップイベント。 :type event: QDropEvent :returns: なし """ files = [u.toLocalFile() for u in event.mimeData().urls()] # ドロップされた座標がどちらのリストウィジェットの上にあるか判定 pos = event.position().toPoint() if self.data_panel.list_widget_data.geometry().contains(self.data_panel.list_widget_data.mapFromGlobal(self.mapToGlobal(pos))): target_list_type = "data" elif self.ref_panel.list_widget_references.geometry().contains(self.ref_panel.list_widget_references.mapFromGlobal(self.mapToGlobal(pos))): target_list_type = "reference" else: # どちらでもない場合は、とりあえずデータとして読み込む target_list_type = "data" for f in files: if os.path.isfile(f): print(f"デバッグ: ドロップされたファイル: {f}, ターゲット: {target_list_type}") if target_list_type == "data": self._load_data_file(f) else: self._load_reference_file(f) self.update_plot()
[ドキュメント] def closeEvent(self, event): """ ウィンドウが閉じられるときに呼び出され、セッション保存の確認を促します。 概要: アプリケーション終了前に、未保存の作業がある場合にユーザーにセッションの保存を促すダイアログを表示します。 詳細説明: `self.datasets` または `self.references` にデータが存在する場合、 ユーザーに「保存」「破棄」「キャンセル」の選択肢を提示する `QMessageBox` を表示します。 「はい」が選択された場合、`save_session()` を呼び出し、保存が成功すればウィンドウを閉じます。 「いいえ」が選択された場合、保存せずにウィンドウを閉じます。 「キャンセル」が選択された場合、終了処理を中止し、ウィンドウを閉じません。 データが存在しない場合は、確認なしでウィンドウを閉じます。 :param event: ウィンドウクローズイベント。 :type event: QCloseEvent :returns: なし """ # データセットかリファレンスが1つでも存在するかチェック if self.datasets or self.references: reply = QMessageBox.question(self, '確認', "現在のセッションを保存しますか?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.Cancel) if reply == QMessageBox.StandardButton.Yes: # 「はい」が押されたら保存処理を試みる if self.save_session(): # 保存が成功したか、データがなくて保存不要だった場合 event.accept() else: # ユーザーが保存ダイアログをキャンセルした場合など event.ignore() elif reply == QMessageBox.StandardButton.No: # 「いいえ」が押されたら、何もせずに終了 event.accept() else: # 「キャンセル」が押されたら、終了を中止 event.ignore() else: # 保存するデータがなければ、そのまま終了 event.accept()
# ======================================================================================== # ### 5. 測定データ(Dataset)操作関連/読み込んだXRDデータの管理やオフセット、スケーリング処理 ### # ======================================================================================== def _load_data_file(self, filepath: str): """ 指定されたパスの単一のXRDデータファイルを読み込みます。 概要: ドロップイベントなどから呼び出される内部ヘルパーで、XRDファイルの内容を解析し、データセットとして追加します。 詳細説明: `file_parser.parse_xrd_file` を使用して指定された`filepath`からXRDデータを解析します。 解析が成功した場合、`add_dataset` メソッドを呼び出してデータをアプリケーションに追加します。 エラーが発生した場合は、エラーメッセージをQMessageBoxで表示します。 :param filepath: 読み込むXRDデータファイルのパス。 :type filepath: str :returns: なし """ sample_name, x_data, y_data ,error_msg = file_parser.parse_xrd_file(filepath, external_lib=XRD_GUI_lib) if error_msg: QMessageBox.critical(self, "読み込みエラー", error_msg) return if sample_name is not None: self.add_dataset(name=sample_name, x_data=x_data, y_data=y_data)
[ドキュメント] def add_dataset(self, name: str, x_data: np.ndarray, y_data: np.ndarray, color: QColor = None) -> dict: """ 新しいデータセットをアプリケーションに追加し、データリストウィジェットに表示します。 概要: XRDデータと名前、オプションの色を受け取り、内部の`self.datasets`リストに新しいデータセットとして追加します。 新しいデータセットには自動でY軸オフセットが適用されます。 詳細説明: まず `_generate_unique_name` を呼び出して、指定された `name` が既存のデータセットや リファレンスと重複しないようにユニークな名前を生成します。 `color` が指定されていない場合は、`get_next_color` を使用して次の色を割り当てます。 現在のY軸スケール(線形、対数)に基づいて、新しいデータセットに自動でY軸オフセット値を計算し、設定します。 線形スケールの場合は、既存のデータセットのY範囲を考慮したステップでオフセットを適用し、 対数スケールの場合は、10のべき乗として乗算されるオフセット指数を計算します。 作成されたデータセット辞書は `self.datasets` に追加され、 対応する `QListWidgetItem` が `data_panel.list_widget_data` に追加されます。 :param name: データセットの希望する名前。 :type name: str :param x_data: XRDデータのX軸値 (2θまたはθ) のNumPy配列。 :type x_data: numpy.ndarray :param y_data: XRDデータのY軸値 (強度) のNumPy配列。 :type y_data: numpy.ndarray :param color: データセットの表示色。指定しない場合は自動で割り当てられます。 :type color: PyQt6.QtGui.QColor, optional :returns: 追加されたデータセットの辞書。 :rtype: dict """ # ユニークな名前を生成する処理 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.settings_panel.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倍)の初期値 "markers": [], # マーカーリストを初期化 "marker_offset_percent": self.default_marker_offset_percent # オフセット値を初期化 } # 計算したオフセット値を適切なキーに設定 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.ui_font) if not dataset["visible"]: font = item.font() font.setStrikeOut(True) item.setFont(font) item.setForeground(QColor("gray")) self.data_panel.list_widget_data.addItem(item) return dataset
[ドキュメント] def on_data_selection_changed(self): """ データリストの選択が変更されたときに呼び出されます。 概要: 選択されたデータセットの数に応じて、UI上の関連ウィジェット(オフセット、線幅、名前変更ボタンなど)の 有効/無効状態と表示値を更新します。 詳細説明: `_update_selection_dependent_ui` ヘルパーメソッドを呼び出して、 データリストウィジェットの状態をUIに反映させます。 特に、オフセットスピンボックスは1つのデータセットが選択されている場合のみ有効になり、 選択されたデータセットの現在のオフセット値を表示します。 また、マーカーパネルのUIも選択状態に合わせて更新されます。 :returns: なし """ self._update_selection_dependent_ui( list_widget=self.data_panel.list_widget_data, data_list=self.datasets, offset_spinbox=self.data_panel.spinbox_data_offset, offset_label=self.data_panel.manual_offset_label, linewidth_spinbox=self.data_panel.spinbox_data_linewidth, rename_button=self.data_panel.btn_rename_dataset, set_color_button=self.data_panel.btn_set_color, apply_colorscale_button=self.data_panel.btn_apply_colorscale, remove_button=self.data_panel.btn_remove_data, item_type_name_for_offset_label="データ" ) self.data_panel.spinbox_data_offset.setEnabled(len(self.data_panel.list_widget_data.selectedItems()) == 1) self._update_marker_panel_ui() # マーカーリストを更新
[ドキュメント] def on_data_scale_changed(self, new_scale: str): """ データプロットのY軸スケールが変更されたときに呼び出されます。 概要: Y軸スケールが変更された場合、データセットのオフセットを再計算し、グラフを更新します。 詳細説明: `_recompute_dataset_offsets_for_scale` メソッドを呼び出し、 選択された新しいスケール(線形、対数など)に基づいて、 すべてのデータセットのY軸オフセット値を動的に調整します。 これにより、スケール変更後もデータが適切に積み重なって見えるようにします。 オフセットの再計算後、`update_plot()` を呼び出してグラフを再描画します。 :param new_scale: 選択された新しいY軸スケール名 (例: "線形", "対数")。 :type new_scale: str :returns: なし """ self._recompute_dataset_offsets_for_scale(new_scale) self.update_plot()
def _recompute_dataset_offsets_for_scale(self, scale_text: str): """ Y軸スケール変更時に、積み上げプロットの見かけ上の距離が崩れないように オフセットを「そのスケール用」に作り直します。 概要: 選択されたY軸スケールに基づいて、各データセットのY軸オフセットを動的に調整します。 詳細説明: 可視状態にあるデータセットを対象とし、それぞれのデータについて、 現在のスケール(対数、平方根、線形)での「厚み」を推定します。 この厚みは、データの最大値で正規化された後、各スケール変換を適用して計算されます。 外れ値の影響を減らすため、1パーセンタイルから99パーセンタイルの範囲で厚みを定義します。 推定された代表的な厚みから「見かけ距離」を決定し、 データセットの表示順(リスト順)に基づいて、適切なオフセット値を割り当てます。 切り替わったスケール以外のオフセット値は0にリセットされます。 :param scale_text: 現在選択されているY軸スケール (例: "線形", "対数", "平方根")。 :type scale_text: str :returns: なし """ # 表示順(リスト順)で上から0,1,2...とする visible_datasets = [d for d in self.datasets if d.get("visible", True)] n = len(visible_datasets) if n <= 1: # オフセットが不要な場合、またはデータが1つ以下の場合、既存のオフセットをリセット for d in self.datasets: d["y_offset_linear"] = 0.0 d["y_offset_log_exponent"] = 0.0 return # まず “そのスケールでの厚み” を推定(正規化してから) thicknesses = [] for d in visible_datasets: y = np.asarray(d.get("original_y", []), dtype=float) if y.size == 0: thicknesses.append(1.0) # データがない場合はデフォルトの厚み continue # log用の安全処理(0以下は1に) if scale_text == "対数" or self.replace_non_positive_with_one: y = np.where(y <= 0, 1.0, y) # 各データを最大値で正規化(積み上げの見た目安定の要) ymax = float(np.nanmax(y)) if np.isfinite(np.nanmax(y)) else 1.0 if ymax <= 0: ymax = 1.0 y = y / ymax # スケール変換後の“厚み”を測る if scale_text == "対数": yt = np.log10(y) # 0〜1 → (-inf〜0) になるので、下をクリップ yt = np.maximum(yt, -6.0) # 6桁分だけ表示(好みで調整) elif scale_text == "平方根": yt = np.sqrt(np.maximum(y, 0)) else: # 線形 yt = y # 外れ値に強い厚み(1%〜99%) lo = np.nanpercentile(yt, 1) hi = np.nanpercentile(yt, 99) thicknesses.append(float(max(hi - lo, 1e-6))) # 代表厚みから “見かけ距離” を決める(1.2倍で少し隙間) step = 1.2 * float(np.median(thicknesses)) # スケールごとにオフセットを入れ直す # ここがポイント:切り替えたスケール以外のオフセットは0に戻す for idx, d in enumerate(visible_datasets): if scale_text == "対数": d["y_offset_log_exponent"] = -idx * step # “log10空間”での加算に相当させたいので後述の改善推奨 d["y_offset_linear"] = 0.0 else: d["y_offset_linear"] = -idx * step d["y_offset_log_exponent"] = 0.0
[ドキュメント] def apply_manual_offset_from_spinbox(self, list_widget: QListWidget, data_list: list[dict], spinbox: QDoubleSpinBox): """ スピンボックスで指定された値を、選択されているデータアイテムのY軸オフセットに適用します。 概要: データリストウィジェットで選択されたデータセットのY軸オフセットを手動で調整します。 詳細説明: `_get_selected_items` を使用して現在選択されているデータセットを取得します。 `spinbox` から新しいオフセット値を取得し、現在のY軸スケールに応じて `y_offset_log_exponent` または `y_offset_linear` のどちらかのキーにその値を設定します。 この操作は、選択されているすべてのデータセットに適用されます。 設定後、`update_plot()` を呼び出してグラフを再描画します。 :param list_widget: データリストを表示するQListWidget。 :type list_widget: QListWidget :param data_list: `self.datasets` または `self.references` のリスト。 :type data_list: list[dict] :param spinbox: オフセット値を入力するQDoubleSpinBox。 :type spinbox: QDoubleSpinBox :returns: なし """ selected_data, _ = self._get_selected_items(list_widget, data_list) if not selected_data: return new_offset = spinbox.value() scale_text = self.settings_panel.combo_data_y_scale.currentText() # 選択されているすべてのデータに新しいオフセット値を適用 for d in selected_data: if scale_text == "対数": d["y_offset_log_exponent"] = new_offset else: # 線形 or 平方根 d["y_offset_linear"] = new_offset self.update_plot()
[ドキュメント] def on_replace_non_positive_changed(self, state: int): """ 「0以下の値を1に置換」チェックボックスの状態が変更されたときに呼び出されます。 概要: グラフ描画時にY軸値が0以下のデータをどのように扱うかを設定します。 詳細説明: チェックボックスの新しい状態(Qt.CheckState.CheckedまたはQt.CheckState.Unchecked)を `self.replace_non_positive_with_one` に反映させます。 このフラグは、`_draw_datasets` メソッド内で、対数スケールでプロットする際や、 視覚的に見やすくするために0以下の値をスキップするロジックを制御するために使用されます。 変更後、`update_plot()` を呼び出してグラフを再描画します。 :param state: チェックボックスの新しい状態 (Qt.CheckStateの値)。 :type state: int :returns: なし """ self.replace_non_positive_with_one = (state == Qt.CheckState.Checked.value) self.update_plot()
# ============================================================================ # ### 6. リファレンス関連/リファレンス(文献値)のフィルタリングや格子情報の処理 ### # ============================================================================ def _load_reference_file(self, filepath: str): """ 指定されたパスの単一のリファレンスファイルを読み込みます。 概要: ドロップイベントなどから呼び出される内部ヘルパーで、リファレンスファイルの内容を解析し、リファレンスとして追加します。 同時に、リファレンスのピーク情報から格子定数を推定します。 詳細説明: `file_parser.parse_reference_file` を使用して指定された`filepath`からリファレンスデータを解析します。 解析が成功した場合、`_generate_unique_name` でユニークな名前を生成します。 次に、現在のX線波長とリファレンスのピーク位置、hkl情報に基づいて `gixrd.estimate_lattice_from_peaks` を呼び出し、格子定数を推定します。 推定が成功した場合、格子定数もリファレンスデータに追加されます。 その後、新しいリファレンスを `self.references` リストに追加し、 対応する `QListWidgetItem` を `ref_panel.list_widget_references` に追加します。 :param filepath: 読み込むリファレンスファイルのパス。 :type filepath: str :returns: なし """ ref_name, positions, intensities, hkls, raw_indices = file_parser.parse_reference_file(filepath) if ref_name is None: return unique_ref_name = self._generate_unique_name(ref_name) # 波長を取得して格子定数を推定 main_wave_name = self.xray_panel.combo_main_wave.currentText() lambda_main = WAVE_MAP.get(main_wave_name, 1.5406) # ★★★ どのファイルに対して計算を行っているか表示 ★★★ print(f"\n--- '{unique_ref_name}' の格子定数推定を開始 (計算波長: {lambda_main:.4f} Å) ---") lattice = gixrd.estimate_lattice_from_peaks(positions, raw_indices, lambda_main) if lattice is None: print(f"--- '{unique_ref_name}' の推定: ピーク数不足(6個未満)または収束失敗によりスキップ ---") else: print(f"--- '{unique_ref_name}' の推定完了 ---") new_reference = { "name": unique_ref_name, "positions": np.array(positions), "intensities": np.array(intensities), "hkls": hkls, "raw_indices": raw_indices, "lattice": lattice, "visible": True, "color": self.get_next_color(), "linewidth": self.default_linewidth, } self.references.append(new_reference) item = QListWidgetItem(unique_ref_name) item.setData(Qt.ItemDataRole.UserRole, new_reference) item.setForeground(new_reference["color"]) self.ref_panel.list_widget_references.addItem(item)
[ドキュメント] def on_reference_selection_changed(self): """ リファレンスリストの選択が変更されたときに呼び出されます。 概要: 選択されたリファレンスの数に応じて、リファレンスパネル内の関連UIウィジェットの有効/無効状態と 線幅スピンボックスの表示値を更新します。 詳細説明: `_get_selected_items` を使用して現在選択されているリファレンスを取得し、 それらの数に基づいて、色設定ボタン、名前変更ボタン、削除ボタン、 カラースケール適用ボタン、線幅スピンボックスの有効/無効を切り替えます。 単一のリファレンスが選択されている場合、そのリファレンスの線幅値を 線幅スピンボックスに表示します。選択がない場合はデフォルト値が表示されます。 :returns: なし """ selected_refs, _ = self._get_selected_items(self.ref_panel.list_widget_references, self.references) has_selection = bool(selected_refs) single_selection = len(selected_refs) == 1 # --- 現在存在するUI要素の有効/無効を切り替える --- self.ref_panel.btn_set_reference_color.setEnabled(has_selection) self.ref_panel.btn_rename_reference.setEnabled(single_selection) self.ref_panel.btn_remove_reference.setEnabled(has_selection) self.ref_panel.spinbox_ref_linewidth.setEnabled(has_selection) if hasattr(self, 'btn_apply_colorscale_ref'): # 念のため存在確認 self.ref_panel.btn_apply_colorscale_ref.setEnabled(has_selection) # --- 選択されたアイテムの線幅をスピンボックスに表示する --- if has_selection: linewidth = selected_refs[0].get("linewidth", self.default_linewidth) self.ref_panel.spinbox_ref_linewidth.blockSignals(True) self.ref_panel.spinbox_ref_linewidth.setValue(linewidth) self.ref_panel.spinbox_ref_linewidth.blockSignals(False) else: # 選択がない場合はデフォルト値に戻す self.ref_panel.spinbox_ref_linewidth.blockSignals(True) self.ref_panel.spinbox_ref_linewidth.setValue(self.default_linewidth) self.ref_panel.spinbox_ref_linewidth.blockSignals(False)
[ドキュメント] def apply_reference_filter(self): """ リファレンスのフィルタ条件を適用し、グラフを再描画します。 概要: リファレンスパネルのフィルタ入力欄に記述された条件式を評価し、 表示するリファレンスピークを動的に絞り込みます。 詳細説明: `ref_panel.edit_ref_filter` からフィルタ条件文字列を取得します。 条件が空でない場合、`_safe_evaluate_filter` を呼び出して、 数式に文法エラーや実行時エラーがないかを事前にテストします。 エラーがあった場合、警告メッセージを表示してフィルタ適用を中止します。 エラーがなければ、`self.current_ref_filter_condition` を更新し、 `update_plot()` を呼び出してグラフを再描画します。 `_draw_references` メソッド内でこのフィルタ条件が適用されます。 :returns: なし """ condition = self.ref_panel.edit_ref_filter.text().strip() # --- フィルタの文法&実行時エラーの事前テスト --- if condition: test_context = {'h': 1, 'k': 0, 'l': 0, 'i': -1} # 表示中のリファレンスから格子情報を探してテスト用コンテキストに入れる for ref in self.references: if ref.get("visible", True) and ref.get("lattice"): test_context.update(ref.get("lattice")) break try: # テスト実行 (エラーを握りつぶさずに発生させる) self._safe_evaluate_filter(condition, test_context, raise_error=True) except Exception as e: QMessageBox.warning(self, "フィルタ エラー", f"フィルタの数式に問題があるか、計算に必要な変数が不足しています。\n\n詳細: {e}") # エラー時は適用せず終了(ピークが全消えするのを防ぐ) return self.current_ref_filter_condition = condition print(f"デバッグ: フィルタ条件 '{condition}' を適用します。") self.update_plot()
def _safe_evaluate_filter(self, expr: str, context: dict, raise_error: bool = False) -> bool: """ AST(抽象構文木)を使用してリファレンス表示に使用する数式を安全に評価します。 詳細説明: このメソッドは、`eval()` の使用に伴うセキュリティリスクを回避するため、 Pythonの`ast`モジュールを利用して数式文字列を解析し、 許可された演算子、関数、変数のみを使用して評価を行います。 これにより、悪意のある任意コードの実行やDoS攻撃(巨大な指数計算など)を防ぎます。 特定のGIXRD関連関数(gixrd_oop_dist, gixrd_oop_ndist, gixrd_oop_vis_fwhm)もサポートしており、 必要に応じて現在のリファレンスの格子定数を自動的に引数として補完します。 数式が長すぎる場合や不正な構文、未許可の要素が含まれる場合は、例外を発生させるか(`raise_error=True`)、 Falseを返します。 :param expr: 評価する数式文字列 (例: "h + k == 0 and l < 3")。 :type expr: str :param context: 数式内で使用可能な変数とその値を含む辞書 (例: {'h': 1, 'k': 0, 'l': 0, 'a': 5.43})。 :type context: dict :param raise_error: Trueの場合、評価中に発生したエラーを再raiseします。Falseの場合はログ出力しFalseを返します。デフォルトはFalse。 :type raise_error: bool, optional :returns: 数式が真と評価された場合はTrue、それ以外はFalse。 :rtype: bool :raises ValueError: 許可されていない構文、演算子、関数、変数、またはDoS攻撃と判断される操作があった場合 (raise_error=Trueの場合)。 """ # 1. DoS対策: 文字列長制限 if len(expr) > 3000: if raise_error: raise ValueError("数式が長すぎます") return False def make_gixrd_wrapper(func): def wrapper(h, k, l, *args, **kwargs): if 'a' not in kwargs: if context.get('a') is not None: kwargs['a'] = context['a'] kwargs['b'] = context['b'] kwargs['c'] = context['c'] kwargs['alpha'] = context.get('alpha', 90.0) kwargs['beta'] = context.get('beta', 90.0) kwargs['gamma'] = context.get('gamma', 90.0) else: raise ValueError("このリファレンスは格子定数が自動推定されていないため、関数内で a, b, c を直接指定してください。\n(例: gixrd_oop_dist(h,k,l, a=5.43, b=5.43, c=5.43) < 0.1)") return func(h, k, l, *args, **kwargs) return wrapper allowed_operators = { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv, ast.Mod: operator.mod, ast.Pow: operator.pow, ast.UAdd: operator.pos, ast.USub: operator.neg, ast.Not: operator.not_, ast.Eq: operator.eq, ast.NotEq: operator.ne, ast.Lt: operator.lt, ast.LtE: operator.le, ast.Gt: operator.gt, ast.GtE: operator.ge, ast.And: lambda x, y: x and y, ast.Or: lambda x, y: x or y } allowed_funcs = { "abs": abs, "int": int, "round": round, "min": min, "max": max, "pow": pow, "sqrt": math.sqrt, "sin": math.sin, "cos": math.cos, "tan": math.tan, "gixrd_oop_dist": make_gixrd_wrapper(gixrd.gixrd_oop_dist), "gixrd_oop_ndist": make_gixrd_wrapper(gixrd._gixrd_oop_ndist), "gixrd_oop_vis_fwhm": make_gixrd_wrapper(gixrd.gixrd_oop_vis_fwhm), } allowed_names = {"h", "k", "l", "i", "pi", "a", "b", "c", "alpha", "beta", "gamma"} def _eval_node(node): if isinstance(node, ast.Constant): if isinstance(node.value, (int, float, bool)): return node.value raise ValueError("Unsupported constant type") if isinstance(node, ast.Name): if node.id == "pi": return float(math.pi) if node.id in allowed_names: return float(context.get(node.id, 0)) raise ValueError(f"許可されていない変数です: {node.id}") if isinstance(node, ast.Tuple): if len(node.elts) > 64: raise ValueError("Tuple too large") return tuple(_eval_node(e) for e in node.elts) if isinstance(node, ast.BinOp): op_type = type(node.op) if op_type not in allowed_operators: raise ValueError(f"Unauthorized operator: {op_type}") left = _eval_node(node.left) right = _eval_node(node.right) if op_type == ast.Pow and abs(right) > 100: raise ValueError("Exponent too large") return allowed_operators[op_type](left, right) if isinstance(node, ast.UnaryOp): op_type = type(node.op) if op_type not in allowed_operators: raise ValueError(f"Unauthorized unary operator: {op_type}") return allowed_operators[op_type](_eval_node(node.operand)) if isinstance(node, ast.Compare): left = _eval_node(node.left) for op, comparator in zip(node.ops, node.comparators): op_type = type(op) if op_type not in allowed_operators: raise ValueError(f"Unauthorized comparator: {op_type}") right = _eval_node(comparator) if not allowed_operators[op_type](left, right): return False left = right return True if isinstance(node, ast.BoolOp): op_type = type(node.op) if op_type == ast.And: for v in node.values: if not _eval_node(v): return False return True elif op_type == ast.Or: for v in node.values: if _eval_node(v): return True return False raise ValueError("Unauthorized bool operator") if isinstance(node, ast.Call): if not isinstance(node.func, ast.Name): raise ValueError("Only simple function calls allowed") func_name = node.func.id if func_name not in allowed_funcs: raise ValueError(f"未定義の関数です: {func_name}") args = [_eval_node(arg) for arg in node.args] kwargs = {} for kw in node.keywords: if kw.arg is None: raise ValueError("**kwargs not allowed") kwargs[kw.arg] = _eval_node(kw.value) if func_name == "pow" and len(args) == 2 and abs(args[1]) > 100: raise ValueError("Exponent too large in pow()") return allowed_funcs[func_name](*args, **kwargs) raise ValueError(f"Unsupported syntax: {type(node).__name__}") try: tree = ast.parse(expr, mode='eval') result = _eval_node(tree.body) return bool(result) except Exception as e: if raise_error: raise e print(f"Filter Error: {e}") return False
[ドキュメント] def show_ref_filter_help_popup(self): """ リファレンスフィルタの構文と利用可能な関数についてのヘルプポップアップを表示します。 概要: ユーザーがリファレンスフィルタ機能の使い方を理解できるように、詳細な説明を提供します。 詳細説明: このメソッドは、`QMessageBox` を使用して情報ダイアログを表示します。 ダイアログには、フィルタリング条件式で使用できる基本変数 (`h`, `k`, `l`, `i`, `pi`)、 推定された格子変数 (`a`, `b`, `c`, `alpha`, `beta`, `gamma`)、 許可されている演算子、および数学関数の一覧が含まれます。 特に、GIXRD面外配向判定のためのカスタム関数(`gixrd_oop_dist`, `gixrd_oop_ndist`, `gixrd_oop_vis_fwhm`)の 使用方法と引数についても詳しく説明されます。 :returns: なし """ txt = ( "【ピークフィルタの使い方】\n" "基本変数: h, k, l, i, pi\n" "格子変数: a, b, c, alpha, beta, gamma\n" "(※データから格子定数が自動推定できたリファレンスにのみ使用可能です。)\n\n" "使える演算: + - * / // % **, 比較(==,!=,<,<=,>,>=), and/or/not, 括弧\n" "使える関数: abs, min, max, round, int, pow, sqrt, sin, cos, tan\n\n" "【GIXRD out-of-plane 判定関数(一般結晶系対応)】\n" "※引数の格子定数を省略した場合、データが持つ格子定数が自動的に適用されます。\n" "※デフォルトの面外配向軸は out_h=1, out_k=0, out_l=0 (a軸配向) です。必要に応じて指定してください。\n\n" "1. gixrd_oop_dist(h, k, l, out_h=1, out_k=0, out_l=0)\n" " ・エワルド球(走査線)と逆格子点との「絶対距離 [Å^-1]」を返します。\n" " ・例: gixrd_oop_dist(h,k,l, out_h=0, out_k=0, out_l=1) < 0.05\n\n" "2. gixrd_oop_ndist(h, k, l, fwhm_out_deg=0.05, fwhm_in_deg=0.5, out_h=1, out_k=0, out_l=0)\n" " ・ピークの広がり(半値幅)を考慮し、走査線がピーク中心から「何σ(標準偏差)離れているか」を返します。\n\n" "3. gixrd_oop_vis_fwhm(h, k, l, nsigma=2.0, out_h=1, out_k=0, out_l=0)\n" " ・走査線がピークの nsigma 以内を通る場合(観測可能)に True となる、最も簡単な判定関数です。\n" " ・例: gixrd_oop_vis_fwhm(h,k,l, out_h=0, out_k=0, out_l=1) # c軸配向の場合\n" ) QMessageBox.information(self, "ピークフィルタのヘルプ", txt)
# ====================================================================== # ### 7. マーカー・ピーク解析関連/一括操作や自動検索プレビューに関する機能 ### # ====================================================================== def _update_marker_panel_ui(self): """ マーカーパネル全体のUIを更新するヘルパー関数です。 概要: 選択されたデータセットに基づいてマーカーリストをクリアし、再構築します。 また、関連するUI要素(ボタン、スピンボックス)の有効/無効状態を制御します。 詳細説明: マーカーリストウィジェット (`list_widget_markers`) をクリアし、 現在データリストで選択されているデータセット(単一選択のみ)からマーカー情報を取得します。 取得したマーカー情報 (`x`座標と`label`) を使用してマーカーリストを再構築します。 ピーク付けモードボタン、手動追加ボタン、マーカーオフセットスピンボックスは、 データセットが1つだけ選択されている場合にのみ有効になります。 マーカーオフセットスピンボックスには、選択されたデータセットのデフォルトオフセット値が表示されます。 最終的に、`on_marker_selection_changed` を呼び出して、マーカーリスト自体の選択状態に応じたUI更新を行います。 :returns: なし """ self.list_widget_markers.clear() selected_datasets, _ = self._get_selected_items(self.data_panel.list_widget_data, self.datasets) can_edit = len(selected_datasets) == 1 self.btn_labeling_mode.setEnabled(can_edit) self.btn_add_manual_marker.setEnabled(can_edit) self.spin_marker_offset.setEnabled(can_edit) if can_edit: dataset = selected_datasets[0] # マーカーリストを埋める if "markers" in dataset: for marker in dataset["markers"]: x_pos = marker.get("x", 0) hkl_label = marker.get("label", "") item = QListWidgetItem(f"{hkl_label} @ {x_pos:.3f}") self.list_widget_markers.addItem(item) # オフセットスピンボックスの値を更新 self.spin_marker_offset.blockSignals(True) self.spin_marker_offset.setValue(dataset.get("marker_offset_percent", self.default_marker_offset_percent)) self.spin_marker_offset.blockSignals(False) self.on_marker_selection_changed()
[ドキュメント] def on_marker_selection_changed(self): """ マーカーリストの選択状態に応じてUIを制御します。 概要: マーカーリストウィジェットで選択されているマーカーの数に基づいて、 シンボル変更、色変更、削除ボタンの有効/無効状態を切り替えます。 詳細説明: `list_widget_markers` の選択アイテム数を取得し、 いずれかのマーカーが選択されていれば、`combo_marker_symbol`, `btn_marker_color`, `btn_remove_marker` を有効にします。 また、選択されているマーカーが存在する場合、その最初のマーカーのオフセット値を `spin_marker_offset` に表示します。選択がない場合は、これらのウィジェットを無効にします。 :returns: なし """ selected_items = self.list_widget_markers.selectedItems() is_any_selected = len(selected_items) > 0 self.combo_marker_symbol.setEnabled(is_any_selected) self.btn_marker_color.setEnabled(is_any_selected) self.btn_remove_marker.setEnabled(is_any_selected) if is_any_selected: # 最初のアイテムの値をスピンボックスに反映 selected_ds, _ = self._get_selected_items(self.data_panel.list_widget_data, self.datasets) if selected_ds and "markers" in selected_ds[0]: idx = self.list_widget_markers.row(selected_items[0]) marker = selected_ds[0]["markers"][idx] val = marker.get("offset", selected_ds[0].get("marker_offset_percent", 15.0)) self.spin_marker_offset.blockSignals(True) self.spin_marker_offset.setValue(val) self.spin_marker_offset.blockSignals(False)
[ドキュメント] def toggle_labeling_mode(self, checked: bool): """ 「ピーク付けモード」のON/OFFを切り替えます。 概要: このモードが有効な場合、グラフをクリックすることでピークをマーカーとして追加できるようになります。 詳細説明: `checked` パラメータがTrueの場合、`is_labeling_mode` をTrueに設定し、 ボタンのテキストを「ピーク付けモード終了」に変更し、カーソルを十字カーソルに設定します。 Falseの場合、`is_labeling_mode` をFalseに設定し、ボタンのテキストを「ピーク付けモード開始」に戻し、 カーソルを標準の矢印カーソルに戻します。 このモードを有効にするには、データリストから1つのデータセットが選択されている必要があります。 そうでない場合、警告メッセージが表示され、モードは有効になりません。 :param checked: ボタンがチェックされた状態かどうか (True: ON, False: OFF)。 :type checked: bool :returns: なし """ selected_datasets, _ = self._get_selected_items(self.data_panel.list_widget_data, self.datasets) if len(selected_datasets) != 1 and checked: QMessageBox.warning(self, "注意", "マーカーを追加するデータセットを1つだけ選択してください。") self.btn_labeling_mode.setChecked(False) return self.is_labeling_mode = checked if self.is_labeling_mode: self.btn_labeling_mode.setText("ピーク付けモード終了") self.setCursor(Qt.CursorShape.CrossCursor) else: self.btn_labeling_mode.setText("ピーク付けモード開始") self.setCursor(Qt.CursorShape.ArrowCursor)
[ドキュメント] def add_manual_marker(self): """ 手動でマーカーを追加するためのダイアログを開きます。 概要: ユーザーがX軸の位置を入力して、指定したデータセットに新しいマーカーを追加します。 詳細説明: マーカーを追加するデータセットが1つだけ選択されていることを確認します。 `QInputDialog.getDouble` を使用して、ユーザーにX軸の座標を入力するよう促します。 有効なX軸値が入力された場合、新しいマーカー辞書を作成し、 選択されたデータセットの`markers`リストに追加します。 追加後、`_update_marker_panel_ui` を呼び出してマーカーパネルを更新し、 `update_plot()` でグラフを再描画します。 :returns: なし """ selected_datasets, _ = self._get_selected_items(self.data_panel.list_widget_data, self.datasets) if len(selected_datasets) != 1: QMessageBox.warning(self, "注意", "マーカーを追加するデータセットを1つだけ選択してください。") return dataset = selected_datasets[0] x_val, ok = QInputDialog.getDouble(self, "手動マーカー追加", "X軸の位置を入力してください:", decimals=4) if ok: new_marker = { "x": x_val, "symbol": self.marker_symbols_internal[0], # ← _internal を使う "color": dataset["color"], "label": "Manual" } if "markers" not in dataset: dataset["markers"] = [] dataset["markers"].append(new_marker) self._update_marker_panel_ui() self.update_plot()
[ドキュメント] def on_plot_click(self, event): """ グラフがクリックされたときの処理です。ピークスナップ機能が含まれます。 概要: ピーク付けモードが有効な場合、リファレンスピークにスナップするか、 クリックされた位置にマーカーをデータセットに追加します。 詳細説明: イベントがピーク付けモード中であり、下のグラフ(`self.canvas.axes2`)内で発生し、 かつ左クリックであった場合にのみ処理を続行します。 単一のデータセットが選択されていない場合、警告メッセージを表示します。 マウスが既存のリファレンスピークにホバーしている場合 (`self.hovered_peak_info` がNoneでない場合)、 そのピークの正確な位置とH K Lラベルを使用して新しいマーカーを作成します。 ホバーしていない場合は、クリックされたX軸のデータ座標をそのままマーカーの位置とします。 新しいマーカーは選択されたデータセットの`markers`リストに追加され、 `_update_marker_panel_ui` と `update_plot()` が呼び出されてUIとグラフが更新されます。 :param event: Matplotlibのマウスイベントオブジェクト。 :type event: matplotlib.backend_bases.MouseEvent :returns: なし """ if not self.is_labeling_mode or event.inaxes != self.canvas.axes2 or event.button != 1: return # ピーク付けモード中、下のグラフ、左クリック以外は無視 selected_datasets, _ = self._get_selected_items(self.data_panel.list_widget_data, self.datasets) if len(selected_datasets) != 1: QMessageBox.warning(self, "注意", "マーカーを追加するデータセットを1つだけ選択してください。") return dataset = selected_datasets[0] # --- マーカー情報の決定 --- x_pos_to_add = None label_to_add = "Manual" color_to_add = dataset["color"] # 1. 注釈が表示されているか(=ピークにホバー中か)をチェック if self.hovered_peak_info is not None: # あれば、そのピークの正確な位置とラベルを使用 x_pos_to_add = self.hovered_peak_info["pos"][0] label_to_add = self.hovered_peak_info["hkl"] if self.hovered_peak_info.get("color"): color_to_add = self.hovered_peak_info["color"] else: # なければ、クリックしたカーソルの位置をそのまま使用 x_pos_to_add = event.xdata # --- マーカーの作成と追加 --- new_marker = { "x": x_pos_to_add, "symbol": self.marker_symbols_internal[0], "color": color_to_add, "label": label_to_add } if "markers" not in dataset: dataset["markers"] = [] dataset["markers"].append(new_marker) self._update_marker_panel_ui() self.update_plot()
[ドキュメント] def apply_marker_offset(self): """ 選択されたマーカーのY軸オフセットをスピンボックスの値に適用します。 概要: マーカーリストで選択されているマーカーが存在する場合、そのマーカー個別のオフセットを調整します。 マーカーが選択されていない場合、現在選択されているデータセット全体のデフォルトマーカーオフセットを調整します。 詳細説明: データリストから選択されているデータセット(単一選択のみ)を取得します。 マーカーリストでアイテムが選択されている場合、そのマーカーオブジェクトの`offset`キーに `spin_marker_offset` の値を設定します。 マーカーが選択されていない場合、選択されているデータセットの`marker_offset_percent`キーを更新します。 この値は、そのデータセット内のマーカーに個別オフセットが設定されていない場合に適用されるデフォルト値です。 変更後、`update_plot()` を呼び出してグラフを再描画します。 :returns: なし """ selected_ds, _ = self._get_selected_items(self.data_panel.list_widget_data, self.datasets) if not selected_ds: return ds = selected_ds[0] new_val = self.spin_marker_offset.value() selected_markers = self.list_widget_markers.selectedItems() if selected_markers: # ✅ 個別調整:選択中のマーカーのみ更新 for item in selected_markers: idx = self.list_widget_markers.row(item) ds["markers"][idx]["offset"] = new_val else: # ✅ 一括調整:データセットのデフォルト値を更新 ds["marker_offset_percent"] = new_val # (任意) 全マーカーの個別オフセットを消去して一律にする場合はここで reset する self.update_plot()
[ドキュメント] def apply_marker_style(self): """ 選択されたマーカーの線の太さをスピンボックスの値に適用します。 概要: マーカーリストで選択されているマーカーの線幅を調整します。 詳細説明: データリストから選択されているデータセット(単一選択のみ)を取得します。 マーカーリストでアイテムが選択されている場合、そのマーカーオブジェクトの`linewidth`キーに `spin_marker_linewidth` の値を設定します。 この操作は、選択されているすべてのマーカーに適用されます。 変更後、`update_plot()` を呼び出してグラフを再描画します。 :returns: なし """ selected_ds, _ = self._get_selected_items(self.data_panel.list_widget_data, self.datasets) if not selected_ds: return ds = selected_ds[0] new_width = self.spin_marker_linewidth.value() selected_markers = self.list_widget_markers.selectedItems() if selected_markers: for item in selected_markers: idx = self.list_widget_markers.row(item) ds["markers"][idx]["linewidth"] = new_width self.update_plot()
[ドキュメント] def edit_selected_marker_symbol(self, symbol: str): """ 選択されたすべてのマーカーのシンボルを一括変更します。 概要: マーカーリストで選択されている複数のマーカーに対して、指定されたシンボルを適用します。 詳細説明: データリストから選択されているデータセット(単一選択のみ)と、 マーカーリストで選択されているすべてのマーカーアイテムを取得します。 選択された各マーカーアイテムに対応するマーカーデータ辞書の`symbol`キーを、 `combo_marker_symbol` で選択された新しいシンボル(表示テキスト)に更新します。 変更後、`update_plot()` を呼び出してグラフを再描画します。 :param symbol: 選択された新しいマーカーシンボルを表すテキスト。 :type symbol: str :returns: なし """ selected_datasets, _ = self._get_selected_items(self.data_panel.list_widget_data, self.datasets) selected_marker_items = self.list_widget_markers.selectedItems() if not selected_datasets or not selected_marker_items: return dataset = selected_datasets[0] if "markers" not in dataset: return # 選択されているすべてのマーカーに対して適用 for item in selected_marker_items: marker_index = self.list_widget_markers.row(item) if marker_index < len(dataset["markers"]): dataset["markers"][marker_index]["symbol"] = symbol self.update_plot()
[ドキュメント] def edit_selected_marker_color(self): """ 選択されたすべてのマーカーの色を一括変更します。 概要: マーカーリストで選択されている複数のマーカーに対して、ユーザーが選択した新しい色を適用します。 詳細説明: データリストから選択されているデータセット(単一選択のみ)と、 マーカーリストで選択されているすべてのマーカーアイテムを取得します。 `QColorDialog.getColor` を使用してカラー選択ダイアログを表示し、ユーザーに新しい色を選択させます。 有効な色が選択された場合、選択された各マーカーアイテムに対応するマーカーデータ辞書の`color`キーを更新します。 変更後、`update_plot()` を呼び出してグラフを再描画します。 :returns: なし """ selected_datasets, _ = self._get_selected_items(self.data_panel.list_widget_data, self.datasets) selected_marker_items = self.list_widget_markers.selectedItems() if not selected_datasets or not selected_marker_items: return dataset = selected_datasets[0] if "markers" not in dataset: return # 最初の選択アイテムの色をダイアログの初期値にする first_idx = self.list_widget_markers.row(selected_marker_items[0]) initial_color = dataset["markers"][first_idx].get("color", dataset["color"]) color = QColorDialog.getColor(initial_color, self, "選択したマーカーの色を一括変更") if color.isValid(): for item in selected_marker_items: marker_index = self.list_widget_markers.row(item) if marker_index < len(dataset["markers"]): dataset["markers"][marker_index]["color"] = color self.update_plot()
[ドキュメント] def remove_selected_marker(self): """ 選択されたすべてのマーカーを一括削除します。 概要: マーカーリストで選択されている複数のマーカーを、確認ダイアログの後に削除します。 詳細説明: データリストから選択されているデータセット(単一選択のみ)と、 マーカーリストで選択されているすべてのマーカーアイテムを取得します。 削除の確認ダイアログ (`QMessageBox`) を表示し、ユーザーが「はい」を選択した場合、 インデックスのずれを防ぐために、マーカーリストのアイテムを逆順に並べ替えて削除します。 対応するマーカーデータ辞書 (`dataset["markers"]`) からもマーカーを削除します。 削除後、`_update_marker_panel_ui` を呼び出してマーカーパネルを更新し、 `update_plot()` でグラフを再描画します。 :returns: なし """ selected_datasets, _ = self._get_selected_items(self.data_panel.list_widget_data, self.datasets) selected_marker_items = self.list_widget_markers.selectedItems() if not selected_datasets or not selected_marker_items: return dataset = selected_datasets[0] if "markers" not in dataset: return count = len(selected_marker_items) reply = QMessageBox.question(self, "確認", f"選択した {count} 個のマーカーを削除しますか?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No) if reply == QMessageBox.StandardButton.Yes: # インデックスのズレを防ぐため、行番号を大きい順(逆順)に並べ替えて削除 indices = sorted([self.list_widget_markers.row(item) for item in selected_marker_items], reverse=True) for idx in indices: if idx < len(dataset["markers"]): del dataset["markers"][idx] self._update_marker_panel_ui() self.update_plot()
[ドキュメント] def on_marker_scroll(self, event): """ マウスホイール操作:ピーク付けモード中にグラフ上でマウスホイールを操作すると、 選択中のマーカーのオフセットを調整します。 概要: ピーク付けモードが有効な場合、マウスホイールの上下スクロールによって マーカーのY軸オフセット(垂直方向の位置)を増減させます。 詳細説明: イベントがピーク付けモード中であり、上のグラフ(`self.canvas.axes`)内で発生した場合にのみ処理を続行します。 データリストで1つのデータセットが選択されている必要があります。 マーカーリストで特定のマーカーが選択されている場合、そのマーカー個別の`offset`値を調整します。 マーカーが選択されていない場合、選択されているデータセット全体のデフォルトマーカーオフセット (`marker_offset_percent`) を調整します。 調整後、`spin_marker_offset` の値を更新し、`update_plot()` を呼び出してグラフを再描画します。 :param event: Matplotlibのマウスイベントオブジェクト (スクロールイベント)。 :type event: matplotlib.backend_bases.MouseEvent :returns: なし """ if not self.is_labeling_mode or event.inaxes != self.canvas.axes: return selected_ds, _ = self._get_selected_items(self.data_panel.list_widget_data, self.datasets) if not selected_ds: return ds = selected_ds[0] step = 1.0 if event.button == 'up' else -1.0 selected_markers = self.list_widget_markers.selectedItems() if selected_markers: # ✅ 選択中のマーカーだけを動かす idx = self.list_widget_markers.row(selected_markers[0]) current = ds["markers"][idx].get("offset", ds.get("marker_offset_percent", 15.0)) ds["markers"][idx]["offset"] = max(0, current + step) self.spin_marker_offset.setValue(ds["markers"][idx]["offset"]) else: # データセット全体のデフォルトを動かす current = ds.get("marker_offset_percent", 15.0) ds["marker_offset_percent"] = max(0, current + step) self.spin_marker_offset.setValue(ds["marker_offset_percent"]) self.update_plot()
# ============================================================================ # ### 8. グラフ操作・ズーム・ラベル/軸の範囲変更やマウス移動に伴う動的な注釈処理 ### # ============================================================================
[ドキュメント] def on_mouse_move_on_graph(self, event): """ グラフ上でのマウス移動を検出し、リファレンスピークにマウスがオーバーした際の情報を表示します。 概要: マウスカーソルがリファレンスピークに近づくと、そのH K Lインデックスと角度を注釈で表示し、 関連するX線ゴーストラインや高次反射ラインも同時に表示します。 詳細説明: マウスイベントが下のグラフ(`self.canvas.axes2`)内で発生した場合に処理を行います。 まず、前回の注釈、指示線、ゴーストライン、高次反射ラインを非表示にします。 次に、マウスの位置から最も近いリファレンスピークを検索し、 そのピークから一定ピクセル範囲内(`BAND_PICK_LIMIT`)にある場合、 そのピークに関する情報を集めます。 最も近いピークを中心に、複数の関連ピークのH K Lと角度を整形して、 `peak_annotation_right` または `peak_annotation_left` を使用してグラフ上に注釈として表示します。 同時に、対応するX軸位置に垂直な指示線 (`indicator_line`) を表示し、 設定で有効になっている場合、Kα2、Kβなどのゴーストラインや、高次反射の線も表示します。 マウスがピークから離れるかグラフ外に出た場合、すべての注釈と線は非表示になります。 :param event: Matplotlibのマウスイベントオブジェクト (モーションイベント)。 :type event: matplotlib.backend_bases.MouseEvent :returns: なし """ if not all(hasattr(self, attr) for attr in ['peak_annotation_right', 'peak_annotation_left', 'indicator_line']): return # 変数の定義(エラー回避のため、判定の前に必ず計算する) is_right = self.peak_annotation_right.get_visible() is_left = self.peak_annotation_left.get_visible() is_line = self.indicator_line.get_visible() # --- 修正1: 前回の表示を一旦すべて消去する(ここでは return しない) --- self.peak_annotation_right.set_visible(False) self.peak_annotation_left.set_visible(False) self.indicator_line.set_visible(False) if hasattr(self, 'ghost_lines'): for objs in self.ghost_lines.values(): objs["line"].set_visible(False) objs["text"].set_visible(False) # グラフ外なら消去した状態で終了 if self.canvas.axes2 is None or event.inaxes != self.canvas.axes2: self.canvas.draw_idle() self.hovered_peak_info = None return # --- 修正2: 判定ロジックの改善 --- BAND_PICK_LIMIT = 50 # 判定範囲を 30 -> 50 に拡大(ベースラインが無くても拾いやすくする) band_candidates = [] for ref_data in self.references: if not ref_data.get("visible", True): continue ddata = ref_data.get("display_data") if not ddata: continue positions = ddata.get("positions") y_tops = ddata.get("y_tops") baseline = ddata.get("baseline", 0) # 帯のY範囲(ピクセル単位) low_px = self.canvas.axes2.transData.transform((0, baseline))[1] high_px = self.canvas.axes2.transData.transform((0, np.max(y_tops)))[1] lower, upper = (low_px, high_px) if low_px <= high_px else (high_px, low_px) # マウスが帯の近くにいるか判定 if (lower - 10) <= event.y <= (upper + 10): dy = 0.0 else: dy = min(abs(event.y - lower), abs(event.y - upper)) band_candidates.append((dy, ref_data)) if not band_candidates: self.canvas.draw_idle() return # 最も近いリファレンスを選択 dy, active_ref = min(band_candidates, key=lambda t: t[0]) if dy > BAND_PICK_LIMIT: self.canvas.draw_idle() return # 3 選ばれたリファレンス1本だけでピーク候補を作る candidates = [] ddata = active_ref["display_data"] positions = ddata["positions"] y_tops = ddata["y_tops"] hkls = ddata.get("hkls", []) px_arr, py_arr = self.canvas.axes2.transData.transform( np.column_stack((positions, y_tops)) ).T dists = np.sqrt((event.x - px_arr)**2 + (event.y - py_arr)**2) # 検索半径を少し絞ると誤反応がさらに減る mask = dists < 35 if np.any(mask): idxs = np.where(mask)[0] for idx in idxs: candidates.append({ "dist": float(dists[idx]), "pos": (float(positions[idx]), float(y_tops[idx])), "hkl": hkls[idx] if idx < len(hkls) else "", "color": active_ref.get("color") }) # 2. 表示判定 if candidates: # 距離順にソート candidates.sort(key=lambda x: x["dist"]) primary_peak = candidates[0] min_dist = primary_peak["dist"] # 感度調整 (20ピクセル以内なら表示) if min_dist < 20: # 表示対象リストの作成 display_candidates = [c for c in candidates if c["dist"] < min_dist + 20] # 最大8個に制限 if len(display_candidates) > 8: display_candidates = display_candidates[:8] # X座標順にソート display_candidates.sort(key=lambda x: x["pos"][0]) annotation_lines = [] for p in display_candidates: marker = "◄" if p["pos"] == primary_peak["pos"] else "" angle_text = f"{p['pos'][0]:.2f}°" annotation_lines.append(f"{p['hkl']} {angle_text} {marker}") final_text = "\n".join(annotation_lines) if len(candidates) > 8 and len(display_candidates) == 8: final_text += "\n..." # 注釈位置 xlim = self.canvas.axes2.get_xlim() x_range = xlim[1] - xlim[0] peak_x = primary_peak["pos"][0] active_annotation = self.peak_annotation_left if (peak_x - xlim[0]) / x_range > 0.85 else self.peak_annotation_right inactive_annotation = self.peak_annotation_right if active_annotation == self.peak_annotation_left else self.peak_annotation_left active_annotation.set_text(final_text) active_annotation.xy = primary_peak["pos"] active_annotation.set_visible(True) inactive_annotation.set_visible(False) y_lim_data = self.canvas.axes.get_ylim() self.indicator_line.set_data([peak_x, peak_x], y_lim_data) self.indicator_line.set_visible(True) main_wave_name = self.xray_panel.combo_main_wave.currentText() lambda_main = WAVE_MAP[main_wave_name] active_ghosts = [] if self.xray_panel.chk_ka2.isChecked(): active_ghosts.append("CuKα2") if self.xray_panel.chk_kb.isChecked(): active_ghosts.append("CuKβ") if self.xray_panel.chk_wl1.isChecked(): active_ghosts.append("W Lα1") if self.xray_panel.chk_wl2.isChecked(): active_ghosts.append("W Lα2") if self.xray_panel.chk_custom_wave.isChecked(): active_ghosts.append("Custom") for name, objs in self.ghost_lines.items(): line = objs["line"] txt = objs["text"] if name in active_ghosts: if name == "Custom": lambda_ghost = self.xray_panel.spin_custom_wave.value() display_text = f" Custom ({lambda_ghost:.4f}Å)" else: lambda_ghost = WAVE_MAP[name] display_text = f" {name}" ghost_2th = calculations.calc_ghost_2theta(peak_x, lambda_main, lambda_ghost) if ghost_2th is not None and xlim[0] <= ghost_2th <= xlim[1]: line.set_data([ghost_2th, ghost_2th], y_lim_data) line.set_visible(True) txt.set_text(display_text) # テキストを動的に更新 txt.set_position((ghost_2th, 0.02)) txt.set_visible(True) else: line.set_visible(False) txt.set_visible(False) else: line.set_visible(False) txt.set_visible(False) # --- ここから追加: 高次反射(定数倍)ピークのハイライト --- if hasattr(self, 'higher_order_lines'): for objs in self.higher_order_lines: objs["line"].set_visible(False) objs["text"].set_visible(False) if self.xray_panel.btn_higher_order.isChecked(): # 1次反射(ホバー中)の角度から sin(θ) を取得 theta1_rad = math.radians(peak_x / 2.0) sin_theta1 = math.sin(theta1_rad) ref_positions = active_ref["display_data"]["positions"] line_idx = 0 for n in range(2, 7): # n=2(x2) から n=6(x6) までの高次反射をチェック sin_thetan = n * sin_theta1 if sin_thetan >= 1.0: break # 90度を超えるため回折しない(物理的限界) thetan_rad = math.asin(sin_thetan) peak_xn = math.degrees(thetan_rad) * 2.0 # 計算位置がグラフの表示範囲内かチェック if xlim[0] <= peak_xn <= xlim[1]: # その位置の近傍(±0.5度以内)にリファレンスのピークが存在するか確認 diffs = np.abs(ref_positions - peak_xn) if len(diffs) > 0 and np.min(diffs) < 0.5: if line_idx < len(self.higher_order_lines): objs = self.higher_order_lines[line_idx] # 点線とテキストを表示 objs["line"].set_data([peak_xn, peak_xn], y_lim_data) objs["line"].set_visible(True) objs["text"].set_text(f" x{n}") objs["text"].set_position((peak_xn, 0.05)) # 下から5%の位置 objs["text"].set_visible(True) line_idx += 1 self.hovered_peak_info = primary_peak self.canvas.draw_idle() return # 該当なし if is_right or is_left or is_line: self.peak_annotation_right.set_visible(False) self.peak_annotation_left.set_visible(False) self.indicator_line.set_visible(False) if hasattr(self, 'ghost_lines'): for objs in self.ghost_lines.values(): objs["line"].set_visible(False) objs["text"].set_visible(False) if hasattr(self, 'higher_order_lines'): for objs in self.higher_order_lines: objs["line"].set_visible(False) objs["text"].set_visible(False) self.canvas.draw_idle() self.hovered_peak_info = None
def _initialize_peak_annotation(self, ax: plt.Axes): """ マウスオーバー時のピーク情報注釈を初期化します(左右2パターン作成)。 概要: リファレンスピークにマウスがホバーした際に表示されるテキスト注釈オブジェクトを準備します。 詳細説明: このメソッドは、Matplotlibの`annotate`機能を使用して2つの注釈オブジェクトを初期化します。 一つはグラフの右側に、もう一つは左側に表示されることを想定しており、 テキスト位置と矢印の向きがそれぞれ異なります。 これらの注釈は初期状態で非表示に設定されており、マウスイベントが発生した際に表示されます。 背景色やボーダースタイルも設定されます。 :param ax: 注釈を配置するMatplotlib Axesオブジェクト。 :type ax: matplotlib.axes.Axes :returns: なし """ # --- 右側に表示するデフォルトの注釈 --- self.peak_annotation_right = ax.annotate( "", xy=(0, 0), xytext=(self.base_font_size, 15), textcoords="offset points", bbox=dict(boxstyle="round,pad=0.3", fc="lemonchiffon", alpha=0.8), arrowprops=dict(arrowstyle="->", connectionstyle="arc3,rad=0.15"), zorder=10 ) self.peak_annotation_right.set_visible(False) # --- 左側に表示するための注釈 --- self.peak_annotation_left = ax.annotate( "", xy=(0, 0), xytext=(-4 * self.base_font_size, 15), textcoords="offset points", bbox=dict(boxstyle="round,pad=0.3", fc="lemonchiffon", alpha=0.8), arrowprops=dict(arrowstyle="->", connectionstyle="arc3,rad=-0.15"), # 矢印のカーブも反転 zorder=10 ) self.peak_annotation_left.set_visible(False)
[ドキュメント] def on_axes_limits_changed(self, axes: plt.Axes): """ Matplotlibの軸範囲が変更されたときに呼び出され、設定パネルの入力欄を更新します。 概要: グラフのX軸またはY軸のズーム/パン操作が行われた際、その新しい範囲値をUIに反映します。 詳細説明: 変更されたAxesオブジェクトがデータプロット (`self.canvas.axes`) であるか、 リファレンスプロット (`self.canvas.axes2`) であるかを判定します。 それぞれのAxesから現在のX軸とY軸の制限値 (`xlim`, `ylim`) を取得し、 対応する`settings_panel`のQLineEditウィジェット (`edit_x_min`, `edit_x_max`, `edit_y_min`, `edit_y_max`, `edit_ref_y_min`, `edit_ref_y_max`) に表示します。 表示される数値は小数点以下3桁に丸められます。 :param axes: 軸の制限が変更されたMatplotlib Axesオブジェクト。 :type axes: matplotlib.axes.Axes :returns: なし """ if not hasattr(self, 'settings_panel'): return # パネル生成前のイベントを無視 s = self.settings_panel # 短縮名 # 変更されたのがどちらのAxes(上か下か)か判定 if axes == self.canvas.axes: # 上のデータプロット xlim = axes.get_xlim() ylim = axes.get_ylim() # すべて s. (settings_panel) を経由させる s.edit_x_min.setText(f"{xlim[0]:.3g}") s.edit_x_max.setText(f"{xlim[1]:.3g}") s.edit_y_min.setText(f"{ylim[0]:.3g}") s.edit_y_max.setText(f"{ylim[1]:.3g}") elif self.canvas.axes2 is not None and axes == self.canvas.axes2: # 下のリファレンスプロット ylim = axes.get_ylim() s.edit_ref_y_min.setText(f"{ylim[0]:.3g}") s.edit_ref_y_max.setText(f"{ylim[1]:.3g}")
[ドキュメント] def apply_manual_x_zoom(self): """ X軸の範囲を手動で設定し、その範囲をロックします。 概要: ユーザーが設定パネルの入力欄に指定したX軸の最小値と最大値に基づいて、グラフのX軸範囲を設定します。 この操作は、X軸の自動ズーム/パンを無効化し、指定された範囲に固定します。 詳細説明: `settings_panel.edit_x_min` と `settings_panel.edit_x_max` からX軸の範囲値を取得します。 入力がない場合は現在のX軸範囲が維持されます。 有効な数値が入力され、最小値が最大値より小さい場合、`self.canvas.axes.set_xlim` でX軸範囲を設定します。 同時に、`self.x_axis_is_locked` をTrueに設定し、`self.locked_x_range` に設定された範囲を保存します。 これにより、`update_plot` が呼び出されてもこのX軸範囲が維持されるようになります。 無効な入力があった場合は警告メッセージを表示します。 :returns: なし """ print("DEBUG_ZOOM --- apply_manual_x_zoom called ---") try: x_min_str = self.settings_panel.edit_x_min.text() x_max_str = self.settings_panel.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 = float(x_min_str) if x_min_str else current_x_lim[0] x_max = float(x_max_str) if x_max_str else current_x_lim[1] 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"DEBUG_ZOOM >>> Lock ON. Range set to {self.locked_x_range}") self.canvas.draw_idle() else: QMessageBox.warning(self, "入力エラー", "X軸の最小値が最大値以上です。") except ValueError: QMessageBox.warning(self, "入力エラー", "ズーム範囲には数値を入力してください。")
[ドキュメント] def apply_manual_y_zoom(self): """ Y軸の範囲を手動で設定します。 概要: ユーザーが設定パネルの入力欄に指定したY軸の最小値と最大値に基づいて、グラフのY軸範囲を設定します。 詳細説明: `settings_panel.edit_y_min` と `settings_panel.edit_y_max` からY軸の範囲値を取得します。 入力がない場合は現在のY軸範囲が維持されます。 有効な数値が入力され、最小値が最大値より小さい場合、`self.canvas.axes.set_ylim` でY軸範囲を設定します。 設定後、キャンバスを再描画します。 無効な入力があった場合は警告メッセージを表示します。 :returns: なし """ try: y_min_str = self.settings_panel.edit_y_min.text() y_max_str = self.settings_panel.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 = float(y_min_str) if y_min_str else current_y_lim[0] y_max = float(y_max_str) if y_max_str else current_y_lim[1] if y_min < y_max: self.canvas.axes.set_ylim(y_min, y_max) self.canvas.draw_idle() else: QMessageBox.warning(self, "入力エラー", "Y軸の最小値が最大値以上です。") except ValueError: QMessageBox.warning(self, "入力エラー", "ズーム範囲には数値を入力してください。")
[ドキュメント] def apply_manual_ref_y_zoom(self): """ リファレンスプロットのY軸の範囲を手動で設定します。 概要: ユーザーが設定パネルの入力欄に指定したリファレンスプロットのY軸の最小値と最大値に基づいて、Y軸範囲を設定します。 詳細説明: `settings_panel.edit_ref_y_min` と `settings_panel.edit_ref_y_max` からY軸の範囲値を取得します。 入力がない場合は現在のY軸範囲が維持されます。 有効な数値が入力され、最小値が最大値より小さい場合、`self.canvas.axes2.set_ylim` でY軸範囲を設定します。 設定後、キャンバスを再描画します。 無効な入力があった場合は警告メッセージを表示します。 :returns: なし """ try: y_min_str = self.settings_panel.edit_ref_y_min.text() y_max_str = self.settings_panel.edit_ref_y_max.text() if not y_min_str and not y_max_str: return current_y_lim = self.canvas.axes2.get_ylim() y_min = float(y_min_str) if y_min_str else current_y_lim[0] y_max = float(y_max_str) if y_max_str else current_y_lim[1] if y_min < y_max: self.canvas.axes2.set_ylim(y_min, y_max) self.canvas.draw_idle() else: QMessageBox.warning(self, "入力エラー", "Y軸の最小値が最大値以上です。") except ValueError: QMessageBox.warning(self, "入力エラー", "ズーム範囲には数値を入力してください。")
[ドキュメント] def reset_zoom(self): """ グラフのズーム設定をリセットし、X軸のロックと目盛間隔を解除します。 概要: グラフのX軸とY軸の範囲を自動調整モードに戻し、X軸の固定設定と目盛間隔をクリアします。 詳細説明: `self.x_axis_is_locked` をFalseに、`self.locked_x_range` をNoneに設定してX軸のロックを解除します。 `self.x_tick_interval` をNoneに設定し、対応するUI入力欄をクリアして、X軸の主目盛間隔のカスタム設定を解除します。 最後に `update_plot()` を呼び出して、グラフを初期の自動調整状態に再描画します。 :returns: なし """ print("DEBUG_ZOOM --- reset_zoom called ---") self.x_axis_is_locked = False self.locked_x_range = None print("DEBUG_ZOOM >>> Lock OFF.") self.x_tick_interval = None self.edit_x_tick_interval.clear() self.update_plot()
[ドキュメント] def apply_x_tick_interval(self): """ 入力されたX軸の主目盛間隔を適用し、グラフを更新します。 概要: ユーザーが指定した数値に基づいて、X軸の主目盛間隔を固定します。 入力が空の場合は、自動目盛設定に戻します。 詳細説明: `edit_x_tick_interval` からテキストを取得し、数値に変換を試みます。 入力が空文字列の場合、`self.x_tick_interval` をNoneに設定して自動目盛に切り替えます。 有効な正の数値が入力された場合、その値を `self.x_tick_interval` に保存します。 数値が0以下の場合や数値変換エラーが発生した場合は、警告メッセージを表示します。 設定後、`update_plot()` を呼び出してグラフを再描画し、新しい目盛間隔を適用します。 :returns: なし """ interval_str = self.edit_x_tick_interval.text() if not interval_str: # 入力が空の場合は自動設定に戻す self.x_tick_interval = None print("デバッグ: X軸の主目盛間隔を自動にリセットします。") self.update_plot() return try: interval = float(interval_str) if interval <= 0: QMessageBox.warning(self, "入力エラー", "目盛間隔には正の数値を入力してください。") return # 入力値をインスタンス変数に保存 self.x_tick_interval = interval print(f"デバッグ: X軸の主目盛間隔を {self.x_tick_interval} に設定します。") self.update_plot() # 再描画して適用 except ValueError: QMessageBox.warning(self, "入力エラー", "目盛間隔には数値を入力してください。")
[ドキュメント] def apply_x_label(self): """ QLineEditのテキストをX軸ラベルに適用し、グラフを更新します。 概要: ユーザーが設定パネルで入力したテキストをグラフのX軸ラベルとして設定します。 詳細説明: `settings_panel.edit_x_label` から新しいラベルテキストを取得します。 現在の `self.x_label_text` と異なる場合のみ、`self.x_label_text` を更新し、 `update_plot()` を呼び出してグラフを再描画します。 これにより、変更が即座にグラフに反映されます。 :returns: なし """ new_label = self.settings_panel.edit_x_label.text() # ラベルが実際に変更された場合のみ更新 if self.x_label_text != new_label: self.x_label_text = new_label print(f"デバッグ: X軸ラベルを '{new_label}' に変更しました。") self.update_plot()
[ドキュメント] def apply_data_y_label(self): """ QLineEditのテキストをデータY軸ラベルに適用し、グラフを更新します。 概要: ユーザーが設定パネルで入力したテキストをデータプロットのY軸ラベルとして設定します。 詳細説明: `settings_panel.edit_data_y_label` から新しいラベルテキストを取得します。 現在の `self.data_y_label_text` と異なる場合のみ、`self.data_y_label_text` を更新し、 `update_plot()` を呼び出してグラフを再描画します。 これにより、変更が即座にグラフに反映されます。 :returns: なし """ new_label = self.settings_panel.edit_data_y_label.text() if self.data_y_label_text != new_label: self.data_y_label_text = new_label self.update_plot()
[ドキュメント] def on_y_tick_visibility_changed(self, state: int): """ データプロットのY軸の数値ラベルの表示/非表示チェックボックスの状態が変更されたときに呼び出されます。 概要: Y軸の数値ラベルの可視性を制御する設定を更新し、グラフに適用します。 詳細説明: チェックボックスの新しい状態(Qt.CheckState.CheckedまたはQt.CheckState.Unchecked)を `self.y_tick_labels_visible` に反映させます。 このフラグは `_apply_data_plot_styles` メソッド内で、 Y軸の数値ラベルを表示するかどうかを決定するために使用されます。 変更後、`update_plot()` を呼び出してグラフを再描画します。 :param state: チェックボックスの新しい状態 (Qt.CheckStateの値)。 :type state: int :returns: なし """ self.y_tick_labels_visible = (state == Qt.CheckState.Checked.value) print(f"デバッグ: Y軸数値ラベルの表示状態を {self.y_tick_labels_visible} に変更しました。") self.update_plot() # グラフを再描画して変更を適用
[ドキュメント] def on_ref_y_tick_visibility_changed(self, state: int): """ リファレンスプロットのY軸数値の表示/非表示チェックボックスの状態が変更されたときに呼び出されます。 概要: リファレンスプロットのY軸数値ラベルの可視性を制御する設定を更新し、グラフに適用します。 詳細説明: チェックボックスの新しい状態(Qt.CheckState.CheckedまたはQt.CheckState.Unchecked)を `self.ref_y_tick_labels_visible` に反映させます。 このフラグは `_apply_ref_plot_styles` メソッド内で、 リファレンスプロットのY軸数値ラベルを表示するかどうかを決定するために使用されます。 変更後、`update_plot()` を呼び出してグラフを再描画します。 :param state: チェックボックスの新しい状態 (Qt.CheckStateの値)。 :type state: int :returns: なし """ self.ref_y_tick_labels_visible = (state == Qt.CheckState.Checked.value) self.update_plot()
[ドキュメント] def on_ref_y_label_visibility_changed(self, state: int): """ リファレンスプロットのY軸ラベルの表示/非表示チェックボックスの状態が変更されたときに呼び出されます。 概要: リファレンスプロットのY軸ラベルの可視性を制御する設定を更新し、グラフに適用します。 詳細説明: チェックボックスの新しい状態(Qt.CheckState.CheckedまたはQt.CheckState.Unchecked)を `self.ref_y_label_visible` に反映させます。 このフラグは `_apply_ref_plot_styles` メソッド内で、 リファレンスプロットのY軸ラベルを表示するかどうかを決定するために使用されます。 変更後、`update_plot()` を呼び出してグラフを再描画します。 :param state: チェックボックスの新しい状態 (Qt.CheckStateの値)。 :type state: int :returns: なし """ self.ref_y_label_visible = (state == Qt.CheckState.Checked.value) self.update_plot()
[ドキュメント] def on_legend_visibility_changed(self, state: int): """ 凡例の表示/非表示チェックボックスの状態が変更されたときに呼び出されます。 概要: グラフの凡例の可視性を制御する設定を更新し、グラフに適用します。 詳細説明: チェックボックスの新しい状態(Qt.CheckState.CheckedまたはQt.CheckState.Unchecked)を `self.legend_visible` に反映させます。 このフラグは `_apply_data_plot_styles` および `_apply_ref_plot_styles` メソッド内で、 凡例を表示するかどうかを決定するために使用されます。 変更後、`update_plot()` を呼び出してグラフを再描画します。 :param state: チェックボックスの新しい状態 (Qt.CheckStateの値)。 :type state: int :returns: なし """ self.legend_visible = (state == Qt.CheckState.Checked.value) print(f"デバッグ: 凡例の表示状態を {self.legend_visible} に変更しました。") self.update_plot() # グラフを再描画して変更を適用
[ドキュメント] def on_single_plot_mode_changed(self, state: int): """ 「1画面モード」チェックボックスの状態が変更されたときに呼び出されます。 概要: グラフの表示モードを1画面または2画面で切り替え、それに伴うUIの有効/無効状態を調整します。 詳細説明: チェックボックスの新しい状態(Qt.CheckState.CheckedまたはQt.CheckState.Unchecked)を `self.is_single_plot_mode` に反映させます。 1画面モードが選択された場合、リファレンスプロットの比率調整スピンボックス (`settings_panel.spin_plot_ratio`) を無効にします。 2画面モードに戻った場合は、これを有効に戻します。 変更後、`update_plot()` を呼び出してグラフを再描画し、新しいモードを適用します。 :param state: チェックボックスの新しい状態 (Qt.CheckStateの値)。 :type state: int :returns: なし """ self.is_single_plot_mode = (state == Qt.CheckState.Checked.value) # 1画面モードの場合、リファレンスのプロット比率調整は無意味なので無効化 self.settings_panel.spin_plot_ratio.setEnabled(not self.is_single_plot_mode) self.update_plot()
# =================================================================================== # ### 9. リスト操作共通ユーティリティ/データとリファレンス両方で使い回す内部的なヘルパー ### # =================================================================================== def _get_selected_items(self, list_widget: QListWidget, data_list: list[dict]) -> tuple[list[dict], list[QListWidgetItem]]: """ QListWidgetで選択されているアイテムのデータ辞書とQListWidgetItemを返します。 概要: 汎用的なヘルパーメソッドで、指定されたリストウィジェットから現在選択されているアイテムを取得し、 対応する内部データ辞書とQListWidgetItemのリストを返します。 詳細説明: `list_widget.selectedItems()` を使用して選択されているすべての`QListWidgetItem`を取得します。 各`QListWidgetItem`の`UserRole`に格納されているデータ辞書プロキシから名前を抽出し、 元のマスターリスト (`data_list`) からその名前を持つ最新の辞書オブジェクトを検索して返します。 これにより、`UserRole`に古いデータが格納されている可能性を考慮し、常に最新のデータを参照するようにします。 :param list_widget: 選択アイテムを取得するQListWidget。 :type list_widget: QListWidget :param data_list: データソースとなるリスト (`self.datasets` または `self.references`)。 :type data_list: list[dict] :returns: (選択されたデータ辞書のリスト, 選択されたQListWidgetItemのリスト)。 :rtype: tuple[list[dict], 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 toggle_item_visibility(self, item: QListWidgetItem, data_list: list[dict], item_type_name: str): """ 指定されたリストウィジェットのアイテムの表示/非表示を切り替える汎用メソッドです。 概要: データリストまたはリファレンスリストのアイテムをダブルクリックすることで、 そのアイテムがグラフに表示されるかどうかを切り替えます。 詳細説明: ダブルクリックされた`QListWidgetItem`から対応するデータ辞書を取得します。 `data_list` (例: `self.datasets` または `self.references`) から 該当するデータを見つけ、その`"visible"`キーのブール値を反転させます。 表示状態の変更をUIに反映するため、`_update_all_list_items_visuals()` を呼び出して リストアイテムの見た目(打ち消し線、色)を更新し、 `update_plot()` を呼び出してグラフを再描画します。 :param item: ダブルクリックされたQListWidgetItem。 :type item: QListWidgetItem :param data_list: `self.datasets` または `self.references` のリスト。 :type data_list: list[dict] :param item_type_name: アイテムの種類を示す文字列 (例: "データセット", "リファレンス")。デバッグ出力に使用。 :type item_type_name: str :returns: なし """ 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 set_selected_item_color(self, list_widget: QListWidget, data_list: list[dict], item_type_name: str): """ 選択されたアイテムの色を変更する汎用メソッドです。 概要: データリストまたはリファレンスリストで選択されている1つまたは複数のアイテムの色を、 カラーダイアログを使用して変更します。 詳細説明: `_get_selected_items` を使用して現在選択されているアイテムのリストを取得します。 選択されたアイテムがない場合は何もしません。 `QColorDialog.getColor` を使用してカラー選択ダイアログを表示し、 ユーザーが有効な色を選択した場合、選択されたすべてのアイテムの`"color"`キーを 新しい`QColor`オブジェクトで更新します。 色の変更をUIに反映するため、`_update_all_list_items_visuals()` を呼び出して リストアイテムの見た目を更新し、`update_plot()` を呼び出してグラフを再描画します。 :param list_widget: 選択アイテムが存在するQListWidget。 :type list_widget: QListWidget :param data_list: データソースとなるリスト (`self.datasets` または `self.references`)。 :type data_list: list[dict] :param item_type_name: アイテムの種類を示す文字列 (例: "データセット", "リファレンス")。ダイアログタイトルに使用。 :type item_type_name: str :returns: なし """ 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: QListWidget, data_list: list[dict], item_type_name: str): """ 選択された単一アイテムの名前を変更する汎用メソッドです(重複チェック付き)。 概要: データリストまたはリファレンスリストで選択されている1つのアイテムの名前を、 入力ダイアログでユーザーが指定した新しい名前に変更します。 詳細説明: `_get_selected_items` を使用して現在選択されているアイテムが1つであることを確認します。 `QInputDialog.getText` を使用して、ユーザーに新しい名前を入力するよう促します。 入力された新しい名前が、既存のすべてのデータセットおよびリファレンスの名前と重複しないかチェックします。 重複がある場合、警告メッセージを表示して名前変更を中止します。 重複がなければ、選択されたアイテムのデータ辞書の`"name"`キーと、`QListWidgetItem`のテキストを更新します。 更新された辞書全体を`QListWidgetItem`の`UserRole`に再設定します。 変更後、`update_plot()` を呼び出してグラフを再描画します。 :param list_widget: 選択アイテムが存在するQListWidget。 :type list_widget: QListWidget :param data_list: データソースとなるリスト (`self.datasets` または `self.references`)。 :type data_list: list[dict] :param item_type_name: アイテムの種類を示す文字列 (例: "データセット", "リファレンス")。ダイアログタイトルに使用。 :type item_type_name: str :returns: なし """ 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 remove_selected_items(self, list_widget: QListWidget, data_list: list[dict], item_type_name: str): """ 選択されたアイテムを削除する汎用メソッドです。 概要: データリストまたはリファレンスリストで選択されている1つまたは複数のアイテムを、 確認ダイアログの後に削除します。 詳細説明: `_get_selected_items` を使用して現在選択されているアイテムのリストを取得します。 選択されたアイテムがない場合は何もしません。 削除の確認ダイアログ (`QMessageBox`) を表示し、ユーザーが「はい」を選択した場合、 選択されたアイテムに対応するデータ辞書を`data_list` (例: `self.datasets` や `self.references`) から削除します。 同時に、`QListWidget` からも対応するアイテムを削除します。 削除後、`update_plot()` を呼び出してグラフを再描画します。 :param list_widget: 選択アイテムが存在するQListWidget。 :type list_widget: QListWidget :param data_list: データソースとなるリスト (`self.datasets` または `self.references`)。 :type data_list: list[dict] :param item_type_name: アイテムの種類を示す文字列 (例: "データセット", "リファレンス")。確認メッセージに使用。 :type item_type_name: str :returns: なし """ 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 apply_item_linewidth(self, list_widget: QListWidget, data_list: list[dict], spinbox: QDoubleSpinBox): """ スピンボックスの値を選択されているアイテムの線幅に適用する汎用メソッドです。 概要: データリストまたはリファレンスリストで選択されている1つまたは複数のアイテムの グラフ表示線幅を調整します。 詳細説明: `_get_selected_items` を使用して現在選択されているアイテムのリストを取得します。 `spinbox` から新しい線幅値を取得し、選択されたすべてのアイテムの`"linewidth"`キーを更新します。 線幅が実際に変更された場合のみ、`update_plot()` を呼び出してグラフを再描画します。 :param list_widget: 選択アイテムが存在するQListWidget。 :type list_widget: QListWidget :param data_list: データソースとなるリスト (`self.datasets` または `self.references`)。 :type data_list: list[dict] :param spinbox: 線幅値を入力するQDoubleSpinBox。 :type spinbox: QDoubleSpinBox :returns: なし """ 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 apply_colorscale_to_selected_items(self, list_widget: QListWidget, data_list: list[dict], item_type_name: str): """ 選択されたアイテムにカラースケールを適用する汎用メソッドです。 概要: データリストまたはリファレンスリストで選択されている複数のアイテムに対して、 指定されたカラーマップと範囲に基づいてグラデーションカラーを割り当てます。 詳細説明: `_get_selected_items` を使用して現在選択されているアイテムのリストを取得します。 `data_panel.combo_colormap` から選択されたカラーマップ名と、 `data_panel.spinbox_cmap_min`、`data_panel.spinbox_cmap_max` から範囲値を取得します。 範囲値が不正な場合、警告メッセージを表示します。 選択されたアイテムの数に基づいて各アイテムに正規化された位置を計算し、 それをカラーマップの範囲にマッピングして色を割り当てます。 割り当てられた色は、アイテムのデータ辞書の`"color"`キーに`QColor`オブジェクトとして保存されます。 色変更をUIに反映するため、`_update_all_list_items_visuals()` を呼び出して リストアイテムの見た目を更新し、`update_plot()` を呼び出してグラフを再描画します。 :param list_widget: 選択アイテムが存在するQListWidget。 :type list_widget: QListWidget :param data_list: データソースとなるリスト (`self.datasets` または `self.references`)。 :type data_list: list[dict] :param item_type_name: アイテムの種類を示す文字列 (例: "データセット", "リファレンス")。メッセージに使用。 :type item_type_name: str :returns: なし """ 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.data_panel.combo_colormap.currentText() cmap_min_val = self.data_panel.spinbox_cmap_min.value() cmap_max_val = self.data_panel.spinbox_cmap_max.value() if cmap_min_val >= cmap_max_val: QMessageBox.warning(self, "入力エラー", "カラースケール範囲の始点は終点より小さい必要があります。") return try: cmap = matplotlib.colormaps.get_cmap(selected_cmap_name) except Exception as e: QMessageBox.critical(self, "エラー", f"カラースケール取得エラー:\n{e}") 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): """ データリストとリファレンスリストの両方の見た目を現在のデータ状態に合わせて更新します。 概要: 内部データ (`self.datasets`, `self.references`) の状態(可視性、色)を、 対応するQListWidgetItemの表示(打ち消し線、前景色)に同期させます。 詳細説明: このメソッドは、`data_panel.list_widget_data` と `ref_panel.list_widget_references` の両方の すべてのアイテムをループ処理します。 各アイテムの`UserRole`からデータ辞書を取得し、その名前を使用してメインのデータリストから 最新のデータ辞書 (`latest_data_info` または `latest_ref_info`) を検索します。 そのデータ辞書の`"visible"`状態に基づいて、`QListWidgetItem`のフォントに打ち消し線を追加または削除します。 また、データの`"color"`に基づいてアイテムの前景色を設定し、 アイテムが非表示の場合は文字色をグレーに設定します。 :returns: なし """ # 1. データリスト (self.data_panel.list_widget_data) の更新 print("デバッグ: データリストの見た目を更新中...") for i in range(self.data_panel.list_widget_data.count()): item = self.data_panel.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.ref_panel.list_widget_references) の更新 print("デバッグ: リファレンスリストの見た目を更新中...") for i in range(self.ref_panel.list_widget_references.count()): item = self.ref_panel.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 _sync_data_order_from_widget(self, list_widget: QListWidget, data_list: list[dict], item_type_name: str): """ QListWidgetのアイテム順序変更を内部データリストに同期する汎用メソッドです。 概要: ユーザーがドラッグ&ドロップなどでQListWidget内のアイテムの順序を変更した場合、 アプリケーションの内部データリスト (`self.datasets` または `self.references`) の 順序もそれに合わせて更新します。 詳細説明: 指定された`list_widget`の現在のアイテム順序に基づいて、新しい順序のデータリストを作成します。 各`QListWidgetItem`の`UserRole`に格納されているデータ辞書プロキシから名前を抽出し、 元のマスターリスト (`data_list`) からその名前を持つ最新の辞書オブジェクトを検索して、 新しい順序のリストに格納します。 最後に、元の`data_list`をクリアし、新しい順序のデータで再構築します。 順序変更はグラフの表示(特にオフセットなど)に影響を与える可能性があるため、 `update_plot()` を呼び出してグラフを再描画します。 :param list_widget: 順序が変更されたQListWidget。 :type list_widget: QListWidget :param data_list: 同期対象の内部データリスト (`self.datasets` または `self.references`)。 :type data_list: list[dict] :param item_type_name: アイテムの種類を示す文字列 (例: "データ", "リファレンス")。デバッグ出力に使用。 :type item_type_name: str :returns: なし """ 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 _update_selection_dependent_ui(self, list_widget: QListWidget, data_list: list[dict], offset_spinbox: QDoubleSpinBox, offset_label: QLabel, linewidth_spinbox: QDoubleSpinBox, rename_button: QPushButton, set_color_button: QPushButton, apply_colorscale_button: QPushButton, remove_button: QPushButton, item_type_name_for_offset_label: str): """ リストウィジェットの選択状態に応じて関連UIを更新する汎用ヘルパーメソッドです。 概要: データリストやリファレンスリストの選択状態に基づいて、ボタンの有効/無効、 スピンボックスの表示値と範囲、およびラベルテキストを動的に調整します。 詳細説明: `_get_selected_items` を使用して現在選択されているアイテムのリストを取得し、 選択の有無 (`has_selection`) と単一選択かどうか (`single_selection`) を判断します。 これに基づいて、色設定、カラースケール適用、削除、名前変更ボタンの有効/無効状態を設定します。 Y軸オフセットスピンボックスと線幅スピンボックスも、選択状態に応じて有効/無効が切り替わります。 Y軸オフセット関連のUI (`offset_label`, `offset_spinbox`) は、 現在のY軸スケール(線形、対数)に応じてラベルテキストとスピンボックスの範囲/ステップ値を動的に変更します。 選択されているアイテムが存在する場合、そのオフセット値と線幅値をスピンボックスに表示します。 :param list_widget: 選択アイテムが存在するQListWidget。 :type list_widget: QListWidget :param data_list: データソースとなるリスト (`self.datasets` または `self.references`)。 :type data_list: list[dict] :param offset_spinbox: Y軸オフセット値を入力するQDoubleSpinBox。 :type offset_spinbox: QDoubleSpinBox :param offset_label: Y軸オフセットスピンボックスのラベル。 :type offset_label: QLabel :param linewidth_spinbox: 線幅値を入力するQDoubleSpinBox。 :type linewidth_spinbox: QDoubleSpinBox :param rename_button: 名前変更ボタン。 :type rename_button: QPushButton :param set_color_button: 色設定ボタン。 :type set_color_button: QPushButton :param apply_colorscale_button: カラースケール適用ボタン。 :type apply_colorscale_button: QPushButton :param remove_button: 削除ボタン。 :type remove_button: QPushButton :param item_type_name_for_offset_label: オフセットラベルに使用するアイテムの種類名。 :type item_type_name_for_offset_label: str :returns: なし """ selected_data, _ = 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) if offset_spinbox: offset_spinbox.setEnabled(single_selection) if linewidth_spinbox: linewidth_spinbox.setEnabled(has_selection) # 現在のY軸スケールに応じて、オフセットUIのラベルと範囲を動的に変更 scale_text = self.settings_panel.combo_data_y_scale.currentText() if offset_label and offset_spinbox: if scale_text == "対数": offset_label.setText("Yオフセット (10^X):") offset_spinbox.setRange(-50.0, 50.0) offset_spinbox.setSingleStep(0.5) offset_spinbox.setDecimals(1) else: # 線形 or 平方根 offset_label.setText("Yオフセット (加算):") offset_spinbox.setRange(-1e18, 1e18) offset_spinbox.setSingleStep(10.0) offset_spinbox.setDecimals(2) # オフセットと線幅の現在の値をUIに表示 if single_selection and offset_spinbox: scale_text = self.settings_panel.combo_data_y_scale.currentText() if scale_text == "対数": offset_val = selected_data[0].get("y_offset_log_exponent", 0.0) else: # 線形 or 平方根 offset_val = selected_data[0].get("y_offset_linear", 0.0) offset_spinbox.blockSignals(True) offset_spinbox.setValue(offset_val) offset_spinbox.blockSignals(False) elif offset_spinbox: offset_spinbox.setValue(0.0) if has_selection and linewidth_spinbox: linewidth = selected_data[0].get("linewidth", self.default_linewidth) linewidth_spinbox.blockSignals(True) linewidth_spinbox.setValue(linewidth) linewidth_spinbox.blockSignals(False) elif linewidth_spinbox: linewidth_spinbox.setValue(self.default_linewidth)
[ドキュメント] def handle_exception(exc_type, exc_value, exc_traceback): """ キャッチされなかった例外を処理し、エラーダイアログを表示するグローバルハンドラです。 概要: Pythonアプリケーションで未処理の例外が発生した場合に呼び出され、 エラーの詳細をユーザーに分かりやすく提示します。 詳細説明: 発生した例外のタイプ、値、トレースバック情報を取得し、 標準出力(コンソール)にトレースバック情報を整形して出力します。 その後、`QMessageBox` を使用して、ユーザーフレンドリーなエラーメッセージと 詳細なトレースバック情報を表示するダイアログを生成します。 このダイアログは、ユーザーが「OK」ボタンをクリックするまでブロックされます。 この関数は `sys.excepthook` に設定され、アプリケーション全体の例外処理を担当します。 :param exc_type: 例外のタイプ (例: `TypeError`, `ValueError`)。 :type exc_type: type :param exc_value: 例外のインスタンス。 :type exc_value: Exception :param exc_traceback: 例外の発生元を指すトレースバックオブジェクト。 :type exc_traceback: traceback :returns: なし """ # トレースバック情報を整形して、コンソールにも出力(デバッグ用) 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(): """ アプリケーションのエントリポイントです。 概要: XRD_GUIアプリケーションを初期化し、実行します。 詳細説明: グローバルな例外ハンドラ (`handle_exception`) を `sys.excepthook` に設定することで、 未処理の例外が発生した場合にカスタムエラーダイアログが表示されるようにします。 `QApplication` インスタンスを作成し、スタイルを"Fusion"に設定します。 `GenericGraphApp` のメインウィンドウインスタンスを作成し、表示します。 最後に `app.exec()` を呼び出してQtイベントループを開始し、 アプリケーションがユーザーからの入力やシステムイベントに応答できるようにします。 """ 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()