# -*- 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 __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(): """ 実行に最低限必要なライブラリがインストールされているかを確認し、 不足している場合は、どのライブラリが必要かをまとめてメッセージ表示してプログラムを終了する。 """ # (チェックするライブラリ名, 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") 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 # バックエンド設定とログ抑制 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のグラフを描画するためのキャンバスクラス""" 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): # アプリケーションのデフォルトX軸範囲をクラス変数として定義 DEFAULT_X_RANGE = (0, 100) # ================================================================ # ### 1.基幹・初期化セクション/アプリケーションの起動と基本的な設定 ### # ================================================================ def __init__(self): 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): """デフォルトの色を順番に提供する""" # 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, filter_string): """ファイルフィルター文字列から拡張子を抽出し、ボタンのテキストを生成する""" 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): """ 指定された名前が self.datasets と self.references の中でユニークかチェックし、 重複している場合は " (2)", " (3)" ... を付けてユニークな名前を生成して返す。 """ all_existing_names = {d.get("name") for d in self.datasets} | {r.get("name") for r in self.references} if desired_name not in all_existing_names: # そもそも重複していなければ、そのままの名前を返す return desired_name # 重複している場合は、新しい名前を試す counter = 2 while True: new_name = f"{desired_name} ({counter})" if new_name not in all_existing_names: print(f"デバッグ: 名前 '{desired_name}' が重複しているため、'{new_name}' を生成しました。") return new_name counter += 1 # ============================================================ # ### 2. UI構築・デザイン関連/ウィジェットの生成やフォント設定 ### # ============================================================ def init_ui(self): """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): """折りたたみ機能のためのヘルパーメソッド""" 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): """指定されたフォントをQtウィジェット (アプリケーション全体、メインウィンドウ、コントロールパネル内) に適用する""" QApplication.setFont(font) self.setFont(font) # コントロールパネル内のウィジェットにフォントを再帰的に適用 if hasattr(self, 'control_panel_layout'): def apply_font_recursive_to_children(parent_widget_or_layout_item): widget = None if isinstance(parent_widget_or_layout_item, QWidget): widget = parent_widget_or_layout_item elif hasattr(parent_widget_or_layout_item, 'widget') and parent_widget_or_layout_item.widget(): widget = parent_widget_or_layout_item.widget() if widget: widget.setFont(font) if isinstance(widget, QGroupBox): # QGroupBoxなら中の子ウィジェットも for child_widget in widget.findChildren(QWidget): child_widget.setFont(font) if hasattr(parent_widget_or_layout_item, 'layout') and parent_widget_or_layout_item.layout(): layout = parent_widget_or_layout_item.layout() for i in range(layout.count()): apply_font_recursive_to_children(layout.itemAt(i)) for i in range(self.control_panel_layout.count()): apply_font_recursive_to_children(self.control_panel_layout.itemAt(i)) if hasattr(self, '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): """フォントなどアプリケーション全体の設定UIグループを作成する""" 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): """マーカー操作用のUIグループ (整理済み)""" 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): """グラフのフォント選択ダイアログを開く""" 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, update_plot=True): """グラフのフォント(ファミリー、サイズ)を設定し、再描画する""" print(f"デバッグ: グラフのフォントを '{font.family()}' ({font.pointSizeF()}pt) に設定します。") # MatplotlibのrcParamsにフォント設定を反映する selected_font_family = font.family() # 日本語表示のためのフォント候補リスト (優先順位順) # ユーザー選択フォント、Windows標準、macOS標準、Linux標準的なもの、最後の手段 jp_font_candidates = [ selected_font_family, # ユーザーが選択したフォントを最優先 'Yu Gothic', 'MS Gothic', 'Meiryo', # Windowsで一般的な日本語フォント 'IPAexGothic', # クロスプラットフォームで使える可能性のある日本語フォント 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', # macOSで一般的な日本語フォント 'Noto Sans CJK JP', 'TakaoPGothic', # Linuxなどで利用可能な日本語フォント 'sans-serif' # Matplotlibの汎用フォールバック (日本語が出るとは限らない) ] matplotlib.rcParams['font.family'] = jp_font_candidates 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=True): """グラフを再描画する(1画面/2画面モード対応)""" # --- 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): """現在のモードに応じて、グラフのサブプロットを再生成する""" # 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, process_data=True): """指定されたAxesにデータセットを描画する(マーカー描画ロジック修正版)""" 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): """リファレンス描画処理(セキュリティ対策済みフィルタ + ログ表示修正版)""" 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, data_plotted): """【上側】データプロットのスタイルを適用する(軸範囲設定を削除)""" 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, has_data): """【下側】リファレンスプロットのスタイルを適用する""" 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): """スピンボックスの値に基づいて、上下プロットの高さの比率を変更する""" 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): """グラフの縦横比が変更されたときに呼び出される""" 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単位のサイズをグラフに適用する""" 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データを読み込んでプロットする""" 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): 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): "グラフを保存するダイアログを表示する" 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): """現在のセッションをJSONファイルに保存する(マーカーの色情報も正しく処理する修正版)""" 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 def load_session(self): """JSONファイルからセッションを読み込み、状態を復元する""" 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): """ファイルがドラッグされたときにカーソル形状を変更する""" if event.mimeData().hasUrls(): event.acceptProposedAction() else: event.ignore() def dropEvent(self, event): """ファイルがドロップされたときに読み込み処理を実行する""" 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): """ウィンドウが閉じられるときに呼び出される""" # データセットかリファレンスが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): """単一のデータファイルを読み込む内部メソッド""" 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, x_data, y_data, color=None): """新しいデータセットをリストに追加し、リストウィジェットにも表示する (自動オフセット調整)""" # ユニークな名前を生成する処理 unique_name = self._generate_unique_name(name) if color is None: color = self.get_next_color() y_offset_for_new_dataset = 0.0 # オフセット値を格納するキーを現在のY軸スケールに応じて決定 ref_scale_text = self.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): """データリストの選択が変更されたときに呼び出される""" 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): self._recompute_dataset_offsets_for_scale(new_scale) self.update_plot() def _recompute_dataset_offsets_for_scale(self, scale_text: str): """ スケール変更時に、積み上げの見かけ距離が崩れないように オフセットを“そのスケール用”に作り直す。 """ # 表示順(リスト順)で上から0,1,2...とする visible_datasets = [d for d in self.datasets if d.get("visible", True)] n = len(visible_datasets) if n <= 1: 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, data_list, spinbox): """スピンボックスの値を、選択されているデータアイテムのオフセットに適用する""" 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): """「0以下の値を1に置換」チェックボックスの状態が変更されたときに呼び出される""" self.replace_non_positive_with_one = (state == Qt.CheckState.Checked.value) self.update_plot() # ============================================================================ # ### 6. リファレンス関連/リファレンス(文献値)のフィルタリングや格子情報の処理 ### # ============================================================================ def _load_reference_file(self, filepath): 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整理版)""" 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): """リファレンスのフィルタ条件を適用して再描画""" 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, context, raise_error=False): """ AST(抽象構文木)を使用してリファレンス表示に使用する数式を安全に評価する。 eval()を使用せず、許可された演算子と関数のみを実行するため、 任意コード実行やDoS攻撃(巨大な指数計算など)を防ぐことができる。 """ # 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): 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を更新するヘルパー関数""" 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を制御""" 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): """「ピーク付けモード」のON/OFFを切り替える""" 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): """手動でマーカーを追加するダイアログを開く""" 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): """グラフがクリックされたときの処理(ピークスナップ機能付き)""" 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): """オフセット適用:個別選択時はそのマーカーのみ、未選択時はデータセット全体""" 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): """マーカーの太さを適用する""" 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): """選択されたすべてのマーカーのシンボルを一括変更""" 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): """選択されたすべてのマーカーの色を一括変更""" 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): """選択されたすべてのマーカーを一括削除""" 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 run_auto_peak_search(self): """選択されたデータセットに対して自動ピーク検索を実行する""" selected_ds, _ = self._get_selected_items(self.data_panel.list_widget_data, self.datasets) if not selected_ds: QMessageBox.warning(self, "注意", "対象のデータセットを選択してください。") return ds = selected_ds[0] # パラメータの取得 ns = int(self.data_panel.spin_smooth_window.value()) th = self.spin_peak_search_threshold.value() # 自動検索実行 found_peaks = calculations.perform_peak_search( ds["x"], ds["original_y"], nsmooth=ns, threshold=th ) if not found_peaks: QMessageBox.information(self, "結果", "指定された条件でピークは見つかりませんでした。") return # 見つかったピークをマーカーとして追加 if "markers" not in ds: ds["markers"] = [] for p in found_peaks: ds["markers"].append({ "x": p["x"], "symbol": self.marker_symbols_internal[0], "color": ds["color"], "label": f"Auto:{p['x']:.2f}" }) self._update_marker_panel_ui() self.update_plot() QMessageBox.information(self, "完了", f"{len(found_peaks)} 個のピークを検出しました。") def on_marker_scroll(self, event): """マウスホイール操作:個別選択時はそのマーカーのみターゲットにする""" 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): """マウスオーバー処理:ベースラインが無くてもピークを検出しやすく修正""" 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): """マウスオーバー時のピーク情報注釈を初期化する(左右2パターン作成)""" # --- 右側に表示するデフォルトの注釈 --- 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): """Matplotlibの軸範囲が変更されたときに呼び出され、設定パネルの入力欄を更新する""" 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軸の範囲を手動で設定し、その範囲をロックする""" 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軸の範囲を手動で設定する""" 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軸の範囲を手動で設定する""" 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軸のロックと目盛間隔を解除する(デバッグ版)""" 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): """入力された間隔をインスタンス変数に保存し、グラフを更新する""" 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軸ラベルに適用し、グラフを更新する""" 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軸ラベルに適用し、グラフを更新する""" 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): """Y軸の数値ラベルの表示/非表示チェックボックスが変更されたときに呼び出される""" 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): """リファレンスプロットのY軸数値の表示/非表示チェックボックスが変更されたときに呼び出される""" self.ref_y_tick_labels_visible = (state == Qt.CheckState.Checked.value) self.update_plot() def on_ref_y_label_visibility_changed(self, state): """Ref Y軸ラベルの表示/非表示チェックボックスが変更されたときに呼び出される""" self.ref_y_label_visible = (state == Qt.CheckState.Checked.value) self.update_plot() def on_legend_visibility_changed(self, state): """凡例の表示/非表示チェックボックスが変更されたときに呼び出される""" self.legend_visible = (state == Qt.CheckState.Checked.value) print(f"デバッグ: 凡例の表示状態を {self.legend_visible} に変更しました。") self.update_plot() # グラフを再描画して変更を適用 def on_single_plot_mode_changed(self, state): """「1画面モード」チェックボックスが変更されたときに呼び出される""" 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, data_list): """選択されているアイテムのデータ辞書とQListWidgetItemを返す汎用ヘルパー""" selected_items = list_widget.selectedItems() if not selected_items: return [], [] # UserRoleから辞書を取得し、その名前のセットを作成 selected_data_proxies = [item.data(Qt.ItemDataRole.UserRole) for item in selected_items] selected_names = {d.get("name") for d in selected_data_proxies if d} # マスターリスト(data_list)から、選択された名前と一致する最新の辞書オブジェクトを抽出 # これにより、UserRoleのデータが古くなっている可能性を排除できる selected_data_dicts = [d for d in data_list if d.get("name") in selected_names] return selected_data_dicts, selected_items def toggle_item_visibility(self, item, data_list, item_type_name): """ 指定されたリストウィジェットのアイテムの表示/非表示を切り替える汎用メソッド item: ダブルクリックされた QListWidgetItem data_list: self.datasets または self.references item_type_name: "データセット" または "リファレンス" (デバッグ用) """ item_info = item.data(Qt.ItemDataRole.UserRole) if not (item_info and "name" in item_info): return # data_list から該当するマスターデータを名前で検索して更新 target_item_data = None for d in data_list: if d.get("name") == item_info.get("name"): target_item_data = d break if target_item_data: # "visible" キーの値を反転 (True -> False, False -> True) target_item_data["visible"] = not target_item_data.get("visible", True) print(f"デバッグ: {item_type_name} '{target_item_data['name']}' の表示状態を {target_item_data['visible']} に変更。") # リストの見た目とグラフを更新 self._update_all_list_items_visuals() self.update_plot() else: # この警告は、リストとデータの同期が取れていない場合に表示される可能性があります print(f"デバッグ: 警告 - toggle_item_visibility で '{item_info.get('name')}' が {item_type_name} リストに見つかりません。") def set_selected_item_color(self, list_widget, data_list, item_type_name): """選択されたアイテムの色を変更する汎用メソッド""" selected_data, _ = self._get_selected_items(list_widget, data_list) if not selected_data: return initial_color = selected_data[0].get("color", QColor("black")) color = QColorDialog.getColor(initial_color, self, f"{item_type_name}の色を選択") if color and color.isValid(): for d in selected_data: d["color"] = color self._update_all_list_items_visuals() # 両方のリストを更新するヘルパーを呼ぶ self.update_plot() def rename_selected_item(self, list_widget, data_list, item_type_name): """選択された単一アイテムの名前を変更する汎用メソッド (重複チェック付き)""" selected_data, selected_items = self._get_selected_items(list_widget, data_list) if len(selected_data) != 1: return item = selected_items[0] original_data = selected_data[0] current_name = original_data.get("name", "") text, ok = QInputDialog.getText(self, f"{item_type_name}名の変更", f"新しい名前を入力 ('{current_name}' から):", QLineEdit.EchoMode.Normal, current_name) if ok and text and text.strip(): new_name = text.strip() if new_name == current_name: return # 名前が変わっていない場合は何もしない # 重複チェックのロジック # チェック対象は、自分自身の現在の名前を除く、すべての名前 all_other_names = ({d.get("name") for d in self.datasets} | {r.get("name") for r in self.references}) - {current_name} if new_name in all_other_names: QMessageBox.warning(self, "名前の重複", f"名前 '{new_name}' はすでに存在します。\n別の名前を入力してください。") return # 重複している場合は処理を中断 # 重複がなければ、名前を更新 original_data["name"] = new_name item.setText(new_name) item.setData(Qt.ItemDataRole.UserRole, original_data) # ★★★ 更新された辞書全体をUserRoleに設定 ★★★ self.update_plot() def remove_selected_items(self, list_widget, data_list, item_type_name): """選択されたアイテムを削除する汎用メソッド""" selected_data, selected_items = self._get_selected_items(list_widget, data_list) if not selected_data: return reply = QMessageBox.question(self, "確認", f"{len(selected_data)}個の{item_type_name}を削除しますか?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No) if reply == QMessageBox.StandardButton.Yes: names_to_remove = {d["name"] for d in selected_data} # 元のリストを直接変更(in-place modification) # こうすることで self.datasets や self.references 自体が更新される items_to_keep = [d for d in data_list if d.get("name") not in names_to_remove] data_list.clear() data_list.extend(items_to_keep) # QListWidgetからアイテムを削除 for item in selected_items: list_widget.takeItem(list_widget.row(item)) print(f"デバッグ: {item_type_name}を削除しました。残りの要素数: {len(data_list)}") self.update_plot() # on_..._selection_changed は選択がクリアされると自動で呼ばれる def apply_item_linewidth(self, list_widget, data_list, spinbox): """スピンボックスの値を、選択されているアイテムの線幅に適用する""" selected_data, _ = self._get_selected_items(list_widget, data_list) if not selected_data: return new_linewidth = spinbox.value() data_updated = False for d in selected_data: if abs(d.get("linewidth", self.default_linewidth) - new_linewidth) > 1e-9: d["linewidth"] = new_linewidth data_updated = True if data_updated: print(f"デバッグ: {len(selected_data)}個のアイテムの線幅を {new_linewidth:.1f} に変更しました。") self.update_plot() def apply_colorscale_to_selected_items(self, list_widget, data_list, item_type_name): """選択されたアイテムにカラースケールを適用する汎用メソッド""" selected_data, _ = self._get_selected_items(list_widget, data_list) num_selected = len(selected_data) if num_selected == 0: return # (この部分はデータセットもリファレンスも同じUIを共有するので、self から直接取得) selected_cmap_name = self.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): """データリストとリファレンスリストの両方の見た目を現在のデータ状態に合わせて更新する""" # 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, data_list, item_type_name): """QListWidgetのアイテム順序変更を内部データリストに同期する""" print(f"デバッグ: {item_type_name}リストの順序が変更されたため、内部データを同期します。") new_ordered_data = [] for i in range(list_widget.count()): item = list_widget.item(i) item_data_proxy = item.data(Qt.ItemDataRole.UserRole) # UserRoleには辞書が格納されている想定 if item_data_proxy and "name" in item_data_proxy: # マスターデータリスト(data_list)から、同じ名前を持つ最新の辞書オブジェクトを検索 # これにより、UserRoleのデータが古い場合でも最新の情報を参照できる found_master_data = next((d for d in data_list if d.get("name") == item_data_proxy.get("name")), None) if found_master_data: new_ordered_data.append(found_master_data) else: # 通常は発生しないはずだが、念のため警告 print(f"デバッグ警告: {item_type_name} '{item_data_proxy.get('name')}' がマスターリストに見つかりません。") new_ordered_data.append(item_data_proxy) # フォールバックとしてプロキシデータを追加 data_list.clear() data_list.extend(new_ordered_data) self.update_plot() # 順序変更がプロットに影響する可能性があるので再描画 def _update_selection_dependent_ui(self, list_widget, data_list, offset_spinbox, offset_label, linewidth_spinbox, rename_button, set_color_button, apply_colorscale_button, remove_button, item_type_name_for_offset_label): """リストウィジェットの選択状態に応じて関連UIを更新する(動的ラベル版)""" selected_data, _ = 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): """ キャッチされなかった例外を処理し、エラーダイアログを表示するグローバルハンドラ """ # トレースバック情報を整形して、コンソールにも出力(デバッグ用) tb_info = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback)) print(tb_info) # ユーザーに表示するエラーメッセージを作成 error_message = f"致命的なエラーが発生しました。処理を中断します。\n\nエラー内容: {exc_value}" # エラーダイアログの作成 msg_box = QMessageBox() msg_box.setIcon(QMessageBox.Icon.Critical) msg_box.setWindowTitle("致命的なエラー") msg_box.setText(error_message) # 詳細なトレースバック情報は「詳細の表示」ボタンで確認できるようにする msg_box.setDetailedText(tb_info) msg_box.setStandardButtons(QMessageBox.StandardButton.Ok) msg_box.exec() def main(): sys.excepthook = handle_exception app = QApplication(sys.argv) app.setStyle("Fusion") main_win = GenericGraphApp() main_win.show() sys.exit(app.exec()) if __name__ == '__main__': main()