# -*- 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.3.0" # アプリケーションのバージョン import importlib import sys import os def check_dependencies(): """ 実行に最低限必要なライブラリがインストールされているかを確認し、 不足している場合は、どのライブラリが必要かをまとめてメッセージ表示してプログラムを終了する。 """ # (チェックするライブラリ名, pipでインストールする際のパッケージ名) required_libraries = [ ('numpy', 'numpy'), ('matplotlib', 'matplotlib'), ('PyQt6', 'PyQt6'), ('pandas', 'pandas'), ] 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 logging import json import re from datetime import datetime import traceback import pandas as pd import numpy as np import matplotlib import matplotlib.pyplot as plt from matplotlib.gridspec import GridSpec from matplotlib.ticker import LogLocator, LogFormatterMathtext, NullFormatter matplotlib.use('QtAgg') # PyQt6とMatplotlibを連携 from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qtagg import NavigationToolbar2QT as NavigationToolbar from matplotlib.figure import Figure # Matplotlibのフォント関連のログ出力を抑制 font_logger = logging.getLogger('matplotlib.font_manager') font_logger.setLevel(logging.ERROR) # ERRORレベル以上のログのみ表示するよう設定 (findfont: Font family not found を抑制) from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QLineEdit, QComboBox, QFileDialog, QColorDialog, QGroupBox, QListWidget, QListWidgetItem, QFontDialog, QMessageBox, QDoubleSpinBox, QInputDialog, QScrollArea, QCheckBox, QFrame,QGridLayout ) from PyQt6.QtCore import Qt from PyQt6.QtGui import QFont, QColor # --- XRD_GUI_libの条件付きインポート --- script_dir = os.path.dirname(os.path.abspath(__file__)) module_path = os.path.join(script_dir, "XRD_GUI_lib.py") if os.path.isfile(module_path): import XRD_GUI_lib else: XRD_GUI_lib = None import ast import operator import math """インポートここまで""" 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) 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)" self.init_ui() self.previous_data_y_scale_text = self.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() # --- UI生成関連 --- def init_ui(self): main_widget = QWidget(self) self.setCentralWidget(main_widget) main_layout = QHBoxLayout(main_widget) control_panel_contents_widget = QWidget() self.control_panel_layout = QVBoxLayout(control_panel_contents_widget) control_panel_contents_widget.setFixedWidth(400) scroll_area = QScrollArea() scroll_area.setWidget(control_panel_contents_widget) scroll_area.setWidgetResizable(True) scroll_area.setFixedWidth(420) # スクロールバー分少し広げる 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) main_layout.addWidget(scroll_area) main_layout.addWidget(graph_widget, 1) # グループ作成 file_group = self._create_file_group() data_list_group = self._create_data_list_group() marker_group = self._create_marker_group() ref_list_group = self._create_ref_list_group() # ここでフィルタUIも作成 graph_settings_group = self._create_graph_settings_group() app_settings_group = self._create_app_settings_group() self.control_panel_layout.addWidget(file_group) self.control_panel_layout.addWidget(data_list_group) self.control_panel_layout.addWidget(marker_group) self.control_panel_layout.addWidget(ref_list_group) self.control_panel_layout.addWidget(graph_settings_group) self.control_panel_layout.addWidget(app_settings_group) self.control_panel_layout.addStretch(1) self._make_group_collapsible(file_group) self._make_group_collapsible(data_list_group) self._make_group_collapsible(marker_group) self._make_group_collapsible(ref_list_group) self._make_group_collapsible(graph_settings_group) self._make_group_collapsible(app_settings_group) 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) 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 _create_file_group(self): """ファイル操作関連のUIグループを作成する""" group = QGroupBox("ファイル操作") layout = QVBoxLayout() self.btn_load_xrd_data = QPushButton(self.load_data_button_text) self.btn_load_xrd_data.clicked.connect(self.load_xrd_data_dialog) layout.addWidget(self.btn_load_xrd_data) self.btn_load_reference = QPushButton(self.load_ref_button_text) self.btn_load_reference.clicked.connect(self.load_reference_dialog) layout.addWidget(self.btn_load_reference) self.btn_save_graph = QPushButton("グラフ保存") self.btn_save_graph.clicked.connect(self.save_graph_dialog) layout.addWidget(self.btn_save_graph) session_layout = QHBoxLayout() self.btn_save_session = QPushButton("セッション保存 (.json)") self.btn_save_session.clicked.connect(self.save_session) session_layout.addWidget(self.btn_save_session) self.btn_load_session = QPushButton("セッション読み込み (.json)") self.btn_load_session.clicked.connect(self.load_session) session_layout.addWidget(self.btn_load_session) layout.addLayout(session_layout) group.setLayout(layout) return group def _create_data_list_group(self): """データリストと関連操作のUIグループを作成する""" group = QGroupBox("データリストと操作") layout = QVBoxLayout() layout.addWidget(QLabel("データリスト(ダブルクリックで表示切替):")) self.list_widget_data = QListWidget() self.list_widget_data.setMinimumHeight(150) self.list_widget_data.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) self.list_widget_data.setDragDropMode(QListWidget.DragDropMode.InternalMove) self.list_widget_data.itemSelectionChanged.connect(self.on_data_selection_changed) # ダブルクリック機能の接続 self.list_widget_data.itemDoubleClicked.connect( lambda item: self.toggle_item_visibility(item, self.datasets, "データセット") ) self.list_widget_data.model().rowsMoved.connect( lambda: self._sync_data_order_from_widget(self.list_widget_data, self.datasets, "データセット") ) layout.addWidget(self.list_widget_data) # データ用手動オフセットUI offset_layout = QHBoxLayout() self.manual_offset_label = QLabel("Yオフセット調整:") offset_layout.addWidget(self.manual_offset_label) self.spinbox_data_offset = QDoubleSpinBox() self.spinbox_data_offset.setRange(-1000000.0, 1000000.0) self.spinbox_data_offset.setSingleStep(100.0) self.spinbox_data_offset.setEnabled(False) self.spinbox_data_offset.editingFinished.connect( lambda: self.apply_manual_offset_from_spinbox(self.list_widget_data, self.datasets, self.spinbox_data_offset) ) offset_layout.addWidget(self.spinbox_data_offset) layout.addLayout(offset_layout) # 線幅UI linewidth_layout = QHBoxLayout() linewidth_layout.addWidget(QLabel("線の太さ:")) self.spinbox_data_linewidth = QDoubleSpinBox() self.spinbox_data_linewidth.setRange(0.1, 10.0); self.spinbox_data_linewidth.setSingleStep(0.1) self.spinbox_data_linewidth.setEnabled(False) self.spinbox_data_linewidth.editingFinished.connect( lambda: self.apply_item_linewidth(self.list_widget_data, self.datasets, self.spinbox_data_linewidth) ) linewidth_layout.addWidget(self.spinbox_data_linewidth) layout.addLayout(linewidth_layout) # 操作ボタン op_layout_1 = QHBoxLayout() self.btn_set_color = QPushButton("色変更") self.btn_set_color.clicked.connect(lambda: self.set_selected_item_color(self.list_widget_data, self.datasets, "データセット")) op_layout_1.addWidget(self.btn_set_color) self.btn_rename_dataset = QPushButton("名前を変更") self.btn_rename_dataset.clicked.connect(lambda: self.rename_selected_item(self.list_widget_data, self.datasets, "データセット")) self.btn_rename_dataset.setEnabled(False) op_layout_1.addWidget(self.btn_rename_dataset) layout.addLayout(op_layout_1) # --- カラースケール --- cmap_layout = QHBoxLayout() cmap_layout.addWidget(QLabel("カラースケール:")) self.combo_colormap = QComboBox() if hasattr(self, 'available_colormaps'): self.combo_colormap.addItems(self.available_colormaps) if 'viridis' in self.available_colormaps: self.combo_colormap.setCurrentText('viridis') cmap_layout.addWidget(self.combo_colormap, 1) layout.addLayout(cmap_layout) # --- 復活させたUI --- cmap_range_layout = QHBoxLayout() cmap_range_layout.addWidget(QLabel("範囲(0-1):")) self.spinbox_cmap_min = QDoubleSpinBox() self.spinbox_cmap_min.setRange(0.0, 1.0); self.spinbox_cmap_min.setSingleStep(0.05); self.spinbox_cmap_min.setValue(0.0) cmap_range_layout.addWidget(self.spinbox_cmap_min) cmap_range_layout.addWidget(QLabel("–")) # 区切り文字 self.spinbox_cmap_max = QDoubleSpinBox() self.spinbox_cmap_max.setRange(0.0, 1.0); self.spinbox_cmap_max.setSingleStep(0.05); self.spinbox_cmap_max.setValue(1.0) cmap_range_layout.addWidget(self.spinbox_cmap_max) layout.addLayout(cmap_range_layout) self.btn_apply_colorscale = QPushButton("データにカラースケール適用") self.btn_apply_colorscale.clicked.connect(lambda: self.apply_colorscale_to_selected_items(self.list_widget_data, self.datasets, "データセット")) layout.addWidget(self.btn_apply_colorscale) self.btn_remove_data = QPushButton("選択データを削除") self.btn_remove_data.clicked.connect(lambda: self.remove_selected_items(self.list_widget_data, self.datasets, "データセット")) layout.addWidget(self.btn_remove_data) group.setLayout(layout) return group def _create_ref_list_group(self): """リファレンスリストUI:フィルタリング機能を追加""" group = QGroupBox("リファレンスリストと操作") layout = QVBoxLayout() layout.addWidget(QLabel("リファレンスリスト (ドラッグで順序変更):")) self.list_widget_references = QListWidget() self.list_widget_references.setMinimumHeight(120) self.list_widget_references.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) self.list_widget_references.setDragDropMode(QListWidget.DragDropMode.InternalMove) self.list_widget_references.itemSelectionChanged.connect(self.on_reference_selection_changed) self.list_widget_references.itemDoubleClicked.connect( lambda item: self.toggle_item_visibility(item, self.references, "リファレンス") ) self.list_widget_references.model().rowsMoved.connect( lambda: self._sync_data_order_from_widget(self.list_widget_references, self.references, "リファレンス") ) layout.addWidget(self.list_widget_references) # --- 新機能: フィルタリングUI --- filter_box = QGroupBox("ピークフィルタ (変数: h, k, l, i)") filter_layout = QVBoxLayout() filter_hint = QLabel("例: l==0 (h00配向), (h+k)%2==0\n条件を満たすピークのみ表示し再正規化します。\n演算子:比較/四則/%[剰余] /and/or/not/括弧/abs/min/max/int/round") filter_hint.setStyleSheet("color: gray; font-size: 10px;") filter_layout.addWidget(filter_hint) filter_input_layout = QHBoxLayout() self.edit_ref_filter = QLineEdit() self.edit_ref_filter.setPlaceholderText("例: k==0 and l==0") self.edit_ref_filter.setToolTip("Pythonの式として評価されます。") self.edit_ref_filter.setEnabled(True) self.edit_ref_filter.setStyleSheet("background-color: white; color: black;") self.edit_ref_filter.returnPressed.connect(self.apply_reference_filter) # Enterキーでも適用 filter_input_layout.addWidget(self.edit_ref_filter) self.btn_apply_filter = QPushButton("適用") self.btn_apply_filter.clicked.connect(self.apply_reference_filter) filter_input_layout.addWidget(self.btn_apply_filter) filter_layout.addLayout(filter_input_layout) filter_box.setLayout(filter_layout) # レイアウトをセット layout.addWidget(filter_box) # ----------------------------- linewidth_layout = QHBoxLayout() linewidth_layout.addWidget(QLabel("線の太さ (Ref):")) self.spinbox_ref_linewidth = QDoubleSpinBox() self.spinbox_ref_linewidth.setRange(0.1, 10.0); self.spinbox_ref_linewidth.setSingleStep(0.1) self.spinbox_ref_linewidth.setEnabled(False) self.spinbox_ref_linewidth.editingFinished.connect( lambda: self.apply_item_linewidth(self.list_widget_references, self.references, self.spinbox_ref_linewidth) ) linewidth_layout.addWidget(self.spinbox_ref_linewidth) layout.addLayout(linewidth_layout) op_layout = QHBoxLayout() self.btn_set_reference_color = QPushButton("色変更 (Ref)") self.btn_set_reference_color.clicked.connect(lambda: self.set_selected_item_color(self.list_widget_references, self.references, "リファレンス")) self.btn_set_reference_color.setEnabled(False) op_layout.addWidget(self.btn_set_reference_color) self.btn_rename_reference = QPushButton("名前変更 (Ref)") self.btn_rename_reference.clicked.connect(lambda: self.rename_selected_item(self.list_widget_references, self.references, "リファレンス")) self.btn_rename_reference.setEnabled(False) op_layout.addWidget(self.btn_rename_reference) layout.addLayout(op_layout) self.btn_apply_colorscale_ref = QPushButton("カラースケール適用 (Ref)") self.btn_apply_colorscale_ref.setEnabled(False) self.btn_apply_colorscale_ref.clicked.connect(lambda: self.apply_colorscale_to_selected_items(self.list_widget_references, self.references, "リファレンス")) layout.addWidget(self.btn_apply_colorscale_ref) self.btn_remove_reference = QPushButton("選択リファレンスを削除") self.btn_remove_reference.setEnabled(False) self.btn_remove_reference.clicked.connect(lambda: self.remove_selected_items(self.list_widget_references, self.references, "リファレンス")) layout.addWidget(self.btn_remove_reference) group.setLayout(layout) return group def apply_reference_filter(self): """リファレンスのフィルタ条件を適用して再描画""" condition = self.edit_ref_filter.text().strip() self.current_ref_filter_condition = condition print(f"デバッグ: フィルタ条件 '{condition}' を適用します。") self.update_plot() def _create_graph_settings_group(self): """グラフの見た目に関する設定UIグループを作成する""" group = QGroupBox("グラフ設定") layout = QVBoxLayout() # --- Y軸スケール & 全体スケール係数 --- scale_layout = QGridLayout() scale_layout.addWidget(QLabel("データY軸スケール:"), 0, 0) self.combo_data_y_scale = QComboBox() self.combo_data_y_scale.addItems(["線形", "平方根", "対数"]) self.combo_data_y_scale.currentTextChanged.connect(self.on_data_scale_changed) scale_layout.addWidget(self.combo_data_y_scale, 0, 1) scale_layout.addWidget(QLabel("リファレンスYスケール:"), 1, 0) self.combo_ref_y_scale = QComboBox() self.combo_ref_y_scale.addItems(["線形", "平方根", "対数"]) self.combo_ref_y_scale.currentTextChanged.connect(self.update_plot) scale_layout.addWidget(self.combo_ref_y_scale, 1, 1) scale_layout.addWidget(QLabel("プロット高さ係数:"), 2, 0) self.spin_scaling_factor = QDoubleSpinBox() self.spin_scaling_factor.setRange(0.1, 1.0) self.spin_scaling_factor.setSingleStep(0.05) self.spin_scaling_factor.setValue(0.8) self.spin_scaling_factor.editingFinished.connect(self.update_plot) scale_layout.addWidget(self.spin_scaling_factor, 2, 1) layout.addLayout(scale_layout) # --- 表示要素 --- visibility_layout = QHBoxLayout() self.check_y_ticks_visible = QCheckBox("Y軸数値") self.check_y_ticks_visible.setChecked(self.y_tick_labels_visible) self.check_y_ticks_visible.stateChanged.connect(self.on_y_tick_visibility_changed) visibility_layout.addWidget(self.check_y_ticks_visible) self.check_ref_y_ticks_visible = QCheckBox("Ref Y数値") self.check_ref_y_ticks_visible.setChecked(self.ref_y_tick_labels_visible) self.check_ref_y_ticks_visible.stateChanged.connect(self.on_ref_y_tick_visibility_changed) visibility_layout.addWidget(self.check_ref_y_ticks_visible) self.check_ref_y_label_visible = QCheckBox("Ref Yラベル") self.check_ref_y_label_visible.setChecked(self.ref_y_label_visible) self.check_ref_y_label_visible.stateChanged.connect(self.on_ref_y_label_visibility_changed) visibility_layout.addWidget(self.check_ref_y_label_visible) self.check_legend_visible = QCheckBox("凡例") self.check_legend_visible.setChecked(self.legend_visible) self.check_legend_visible.stateChanged.connect(self.on_legend_visibility_changed) visibility_layout.addWidget(self.check_legend_visible) self.check_replace_non_positive = QCheckBox("0以下の値を1に置換 (対数用)") self.check_replace_non_positive.setChecked(self.replace_non_positive_with_one) self.check_replace_non_positive.stateChanged.connect(self.on_replace_non_positive_changed) # 2行目の0列目に配置し、2列にまたがるように設定 visibility_layout.addWidget(self.check_replace_non_positive, 1) layout.addLayout(visibility_layout) # --- 区切り線 --- line1 = QFrame() line1.setFrameShape(QFrame.Shape.HLine) line1.setFrameShadow(QFrame.Shadow.Sunken) layout.addWidget(line1) # --- レイアウト関連 --- layout.addWidget(QLabel("レイアウト")) # 小見出し self.check_single_plot_mode = QCheckBox("1画面モード (リファレンス非表示)") self.check_single_plot_mode.setChecked(self.is_single_plot_mode) self.check_single_plot_mode.stateChanged.connect(self.on_single_plot_mode_changed) layout.addWidget(self.check_single_plot_mode) # ---プロット比率 --- plot_ratio_layout = QHBoxLayout() plot_ratio_layout.addWidget(QLabel("プロット比率 (上):")) self.spin_plot_ratio = QDoubleSpinBox() self.spin_plot_ratio.setRange(0, 100.0) self.spin_plot_ratio.setSingleStep(1.0) self.spin_plot_ratio.setValue(80.0) # デフォルトは 80% : 20% self.spin_plot_ratio.setSuffix("%") self.spin_plot_ratio.editingFinished.connect(self.apply_plot_ratio) plot_ratio_layout.addWidget(self.spin_plot_ratio) layout.addLayout(plot_ratio_layout) aspect_ratio_layout = QHBoxLayout() aspect_ratio_layout.addWidget(QLabel("グラフ縦横比:")) self.combo_aspect_ratio = QComboBox() self.combo_aspect_ratio.addItems(list(self.aspect_ratios.keys()) + list(self.fixed_paper_sizes.keys())) self.combo_aspect_ratio.currentTextChanged.connect(self.on_aspect_ratio_changed) aspect_ratio_layout.addWidget(self.combo_aspect_ratio) layout.addLayout(aspect_ratio_layout) size_cm_layout = QHBoxLayout() size_cm_layout.addWidget(QLabel("サイズ指定(cm):")) self.spin_width_cm = QDoubleSpinBox(); self.spin_width_cm.setRange(1.0, 50.0); self.spin_width_cm.setValue(self.default_custom_size_cm[0]) size_cm_layout.addWidget(self.spin_width_cm) self.spin_height_cm = QDoubleSpinBox(); self.spin_height_cm.setRange(1.0, 50.0); self.spin_height_cm.setValue(self.default_custom_size_cm[1]) size_cm_layout.addWidget(self.spin_height_cm) self.btn_apply_size_cm = QPushButton("サイズ適用") self.btn_apply_size_cm.clicked.connect(self.apply_custom_size_cm) size_cm_layout.addWidget(self.btn_apply_size_cm) layout.addLayout(size_cm_layout) # --- 区切り線 --- line2 = QFrame() line2.setFrameShape(QFrame.Shape.HLine) line2.setFrameShadow(QFrame.Shadow.Sunken) layout.addWidget(line2) # --- 軸範囲関連 --- layout.addWidget(QLabel("軸範囲")) # 小見出し x_range_layout = QHBoxLayout() x_range_layout.addWidget(QLabel("X範囲:")) self.edit_x_min = QLineEdit(); self.edit_x_max = QLineEdit() x_range_layout.addWidget(self.edit_x_min); x_range_layout.addWidget(QLabel("–")); x_range_layout.addWidget(self.edit_x_max) self.btn_apply_x_zoom = QPushButton("適用") self.btn_apply_x_zoom.clicked.connect(self.apply_manual_x_zoom) x_range_layout.addWidget(self.btn_apply_x_zoom) layout.addLayout(x_range_layout) x_tick_layout = QHBoxLayout() x_tick_layout.addWidget(QLabel("X主目盛間隔:")) self.edit_x_tick_interval = QLineEdit() x_tick_layout.addWidget(self.edit_x_tick_interval) self.btn_apply_x_tick = QPushButton("適用") self.btn_apply_x_tick.clicked.connect(self.apply_x_tick_interval) x_tick_layout.addWidget(self.btn_apply_x_tick) layout.addLayout(x_tick_layout) x_label_layout = QHBoxLayout() x_label_layout.addWidget(QLabel("X軸ラベル:")) self.edit_x_label = QLineEdit() self.edit_x_label.setText(self.x_label_text) # 初期値をセット self.edit_x_label.editingFinished.connect(self.apply_x_label) # 編集完了で適用 x_label_layout.addWidget(self.edit_x_label) layout.addLayout(x_label_layout) data_y_label_layout = QHBoxLayout() data_y_label_layout.addWidget(QLabel("データY軸ラベル:")) self.edit_data_y_label = QLineEdit() self.edit_data_y_label.setText(self.data_y_label_text) self.edit_data_y_label.editingFinished.connect(self.apply_data_y_label) data_y_label_layout.addWidget(self.edit_data_y_label) layout.addLayout(data_y_label_layout) y_range_layout = QHBoxLayout() y_range_layout.addWidget(QLabel("Y範囲:")) self.edit_y_min = QLineEdit(); self.edit_y_max = QLineEdit() y_range_layout.addWidget(self.edit_y_min); y_range_layout.addWidget(QLabel("–")); y_range_layout.addWidget(self.edit_y_max) self.btn_apply_y_zoom = QPushButton("適用") self.btn_apply_y_zoom.clicked.connect(self.apply_manual_y_zoom) y_range_layout.addWidget(self.btn_apply_y_zoom) layout.addLayout(y_range_layout) ref_y_range_layout = QHBoxLayout() ref_y_range_layout.addWidget(QLabel("Ref Y範囲:")) self.edit_ref_y_min = QLineEdit(); self.edit_ref_y_max = QLineEdit() ref_y_range_layout.addWidget(self.edit_ref_y_min); ref_y_range_layout.addWidget(QLabel("–")); ref_y_range_layout.addWidget(self.edit_ref_y_max) self.btn_apply_ref_y_zoom = QPushButton("適用") self.btn_apply_ref_y_zoom.clicked.connect(self.apply_manual_ref_y_zoom) ref_y_range_layout.addWidget(self.btn_apply_ref_y_zoom) layout.addLayout(ref_y_range_layout) self.btn_reset_zoom = QPushButton("ズームリセット") self.btn_reset_zoom.clicked.connect(self.reset_zoom) layout.addWidget(self.btn_reset_zoom) group.setLayout(layout) return group 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() mode_layout = QHBoxLayout() self.btn_labeling_mode = QPushButton("ピーク付けモード開始") self.btn_labeling_mode.setCheckable(True) self.btn_labeling_mode.toggled.connect(self.toggle_labeling_mode) mode_layout.addWidget(self.btn_labeling_mode) self.btn_add_manual_marker = QPushButton("手動で追加") self.btn_add_manual_marker.clicked.connect(self.add_manual_marker) mode_layout.addWidget(self.btn_add_manual_marker) layout.addLayout(mode_layout) layout.addWidget(QLabel("マーカーリスト (選択データ):")) self.list_widget_markers = QListWidget() self.list_widget_markers.setFixedHeight(100) self.list_widget_markers.itemSelectionChanged.connect(self.on_marker_selection_changed) layout.addWidget(self.list_widget_markers) 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(5.0, 20.0) self.spin_marker_offset.setSingleStep(0.5) self.spin_marker_offset.setValue(self.default_marker_offset_percent) self.spin_marker_offset.setSuffix(" %") self.spin_marker_offset.editingFinished.connect(self.apply_marker_offset) controls_layout.addWidget(self.spin_marker_offset, 1, 1, 1, 3) # 3列にまたがって配置 layout.addLayout(controls_layout) # 初期状態では無効化 self.combo_marker_symbol.setEnabled(False) self.btn_marker_color.setEnabled(False) self.btn_remove_marker.setEnabled(False) group.setLayout(layout) return group def _setup_subplots(self): """現在のモードに応じて、グラフのサブプロットを再生成する""" # Figureから既存のAxesをすべて削除 for ax in self.canvas.fig.get_axes(): ax.remove() if self.is_single_plot_mode: # --- 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.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 on_aspect_ratio_changed(self, selected_text): """グラフの縦横比が変更されたときに呼び出される""" print(f"デバッグ: 縦横比変更 -> {selected_text}") # 固定サイズ指定の場合 if selected_text in self.fixed_paper_sizes: width_inch, height_inch = self.fixed_paper_sizes[selected_text] print(f"デバッグ: 固定サイズ指定: width={width_inch:.2f} inch, height={height_inch:.2f} inch") # Figureのサイズを直接インチで設定 self.canvas.fig.set_size_inches(width_inch, height_inch, forward=True) # 必要に応じてウィンドウ自体のサイズも調整検討 (今回はFigureのみ) # 比率指定の場合 elif selected_text in self.aspect_ratios and self.aspect_ratios[selected_text] is not None: ratio_w, ratio_h = self.aspect_ratios[selected_text] print(f"デバッグ: 比率指定: {ratio_w}:{ratio_h}") base_fig_width_inch = 6.0 new_fig_width_inch = base_fig_width_inch new_fig_height_inch = base_fig_width_inch * (ratio_h / ratio_w) print(f"デバッグ: 計算サイズ: width={new_fig_width_inch:.2f} inch, height={new_fig_height_inch:.2f} inch") self.canvas.fig.set_size_inches(new_fig_width_inch, new_fig_height_inch, forward=True) self.update_plot() # スタイルなどを再適用して再描画 def _apply_font_to_qt_widgets(self, font): """指定されたフォントをQtウィジェット (アプリケーション全体、メインウィンドウ、コントロールパネル内) に適用する""" QApplication.setFont(font) self.setFont(font) # コントロールパネル内のウィジェットにフォントを再帰的に適用 if hasattr(self, 'control_panel_layout'): def apply_font_recursive_to_children(parent_widget_or_layout_item): widget = None if isinstance(parent_widget_or_layout_item, QWidget): widget = parent_widget_or_layout_item elif hasattr(parent_widget_or_layout_item, 'widget') and parent_widget_or_layout_item.widget(): widget = parent_widget_or_layout_item.widget() if widget: widget.setFont(font) if isinstance(widget, QGroupBox): # QGroupBoxなら中の子ウィジェットも for child_widget in widget.findChildren(QWidget): child_widget.setFont(font) if hasattr(parent_widget_or_layout_item, 'layout') and parent_widget_or_layout_item.layout(): layout = parent_widget_or_layout_item.layout() for i in range(layout.count()): apply_font_recursive_to_children(layout.itemAt(i)) for i in range(self.control_panel_layout.count()): apply_font_recursive_to_children(self.control_panel_layout.itemAt(i)) if hasattr(self, 'list_widget_data'): self.list_widget_data.setFont(font) if hasattr(self, 'list_widget_references'): self.list_widget_references.setFont(font) def apply_font_to_graph(self, font, update_plot=True): """グラフのフォント(ファミリー、サイズ)を設定し、再描画する""" print(f"デバッグ: グラフのフォントを '{font.family()}' ({font.pointSizeF()}pt) に設定します。") # MatplotlibのrcParamsにフォント設定を反映する selected_font_family = font.family() # 日本語表示のためのフォント候補リスト (優先順位順) # ユーザー選択フォント、Windows標準、macOS標準、Linux標準的なもの、最後の手段 jp_font_candidates = [ selected_font_family, # ユーザーが選択したフォントを最優先 'Yu Gothic', 'MS Gothic', 'Meiryo', # Windowsで一般的な日本語フォント 'IPAexGothic', # クロスプラットフォームで使える可能性のある日本語フォント 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', # macOSで一般的な日本語フォント 'Noto Sans CJK JP', 'TakaoPGothic', # Linuxなどで利用可能な日本語フォント 'sans-serif' # Matplotlibの汎用フォールバック (日本語が出るとは限らない) ] matplotlib.rcParams['font.family'] = jp_font_candidates 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() # スタイルが変更されたので、再描画 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 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 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.list_widget_data.geometry().contains(self.list_widget_data.mapFromGlobal(self.mapToGlobal(pos))): target_list_type = "data" elif self.list_widget_references.geometry().contains(self.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 _load_data_file(self, filepath): """単一のデータファイルを読み込む内部メソッド""" sample_name, x_data, y_data = self.parse_xrd_file(filepath) if sample_name is not None: self.add_dataset(name=sample_name, x_data=x_data, y_data=y_data) def _load_reference_file(self, filepath): ref_name, positions, intensities, hkls, raw_indices = self.parse_reference_file(filepath) if ref_name is None: return unique_ref_name = self._generate_unique_name(ref_name) new_reference = { "name": unique_ref_name, "positions": np.array(positions), "intensities": np.array(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.list_widget_references.addItem(item) def add_dataset(self, name, x_data, y_data, color=None): """新しいデータセットをリストに追加し、リストウィジェットにも表示する (自動オフセット調整)""" # ユニークな名前を生成する処理 unique_name = self._generate_unique_name(name) if color is None: color = self.get_next_color() y_offset_for_new_dataset = 0.0 # オフセット値を格納するキーを現在のY軸スケールに応じて決定 ref_scale_text = self.combo_data_y_scale.currentText() # 現在のY軸スケールを取得 offset_key_to_use = "y_offset_linear" # デフォルトは線形オフセット if ref_scale_text == "対数 (log10)" or ref_scale_text == "symlog": if self.datasets: # 最初のデータセットではない場合 num_log_offset_datasets = 0 # 既に存在する対数オフセットされたデータ数を数える (簡易的な方法) for ds_item_check in self.datasets: if ds_item_check.get("y_offset_log_exponent", 0.0) != 0.0: num_log_offset_datasets +=1 # 新しい対数オフセット指数を計算 (例: 0, -1, -2 ...) # self.default_log_offset_exponent_step は __init__ で 1.0 と定義済み y_offset_log_exponent_for_new_dataset = -num_log_offset_datasets * self.default_log_offset_exponent_step # y_offset_for_new_dataset はここでは使わず、後で dataset 辞書作成時に直接キーを指定する offset_key_to_use = "y_offset_log_exponent" # ↓ y_offset_for_new_dataset に指数そのものを入れる y_offset_for_new_dataset = y_offset_log_exponent_for_new_dataset print(f"デバッグ: 新規データ '{unique_name}' (対数/symlog系) のための自動オフセット指数: {y_offset_for_new_dataset:.1f}") else: # 最初のデータセットの場合 y_offset_for_new_dataset = 0.0 # 指数も0 elif self.datasets: # 線形または平方根スケールで、かつ最初のデータセットではない場合 min_existing_offset = 0 for ds_item in self.datasets: min_existing_offset = min(min_existing_offset, ds_item.get("y_offset_linear", 0.0)) current_data_y_range = 0 if len(y_data) > 0: y_min_val, y_max_val = np.min(y_data), np.max(y_data) current_data_y_range = y_max_val - y_min_val offset_step_for_this_data = current_data_y_range * 1.0 if offset_step_for_this_data < 1e-6 : if np.max(np.abs(y_data)) > 1e-6: offset_step_for_this_data = np.max(np.abs(y_data)) * 0.5 else: offset_step_for_this_data = self.default_linear_offset_step # __init__で定義したデフォルトステップ print(f"デバッグ: 新規データ '{unique_name}' (線形/sqrt系) のための自動オフセットステップ量: {offset_step_for_this_data:.2f}") y_offset_for_new_dataset = min_existing_offset - offset_step_for_this_data # dataset辞書の作成 dataset = { "name": unique_name, "x": np.asarray(x_data), "original_y": np.asarray(y_data), "processed_y": np.copy(np.asarray(y_data)), # 値を変更しない_process_y_dataを想定 "color": color, "group": None, "visible": True, "line_object": None, "linewidth": self.default_linewidth, "y_offset_linear": 0.0, # 線形/平方根/symlog用オフセットの初期値 "y_offset_log_exponent": 0.0, # 対数(log10)用オフセット指数(10^0=1倍)の初期値 "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.list_widget_data.addItem(item) return dataset def parse_xrd_file(self, filepath): """XRDデータファイル(TXT形式)を解析し、サンプル名、角度、強度を抽出する""" # --- XRD_GUI_lib が利用可能であれば、そちらのパーサーを使用 --- # ユーザー指定の関数名 `parse_xrd` を使用 if XRD_GUI_lib is not None and hasattr(XRD_GUI_lib, "parse_xrd"): try: print("デバッグ: 外部ライブラリの XRD_GUI_lib.parse_xrd() を使用します。") # 外部ライブラリの関数を呼び出し、結果をそのまま返す return XRD_GUI_lib.parse_xrd(filepath) except Exception as e: # 外部ライブラリでのエラーは致命的ではないため、警告として出力し、内蔵パーサーにフォールバック QMessageBox.warning(self, "外部パーサーエラー", f"外部ライブラリ (XRD_GUI_lib.py) でのXRDデータ解析中にエラーが発生しました。\n内蔵パーサーを試行します。\nエラー詳細: {e}") # エラー時は、この後の内蔵パーサーに処理を継続させる pass sample_name = os.path.splitext(os.path.basename(filepath))[0] # ファイル名をデフォルト名に angles = [] intensities = [] # データ読み取りを開始するキーワード(このキーワードを含む行の次からデータを読み取る) start_reading_keywords = ["Step", "ScanSpeed"] reading_data = False # 数値データ部分を読み取り中かどうかのフラグ try: with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: for line_number, line in enumerate(f): line = line.strip() if not line: # 空行はスキップ continue # reading_data フラグが False の間はヘッダーとして処理 if not reading_data: if line.startswith("Sample"): parts = line.split('\t', 1) if len(parts) > 1: sample_name = parts[1].strip() # ヘッダーの終わりを示すキーワードを探す if any(keyword in line for keyword in start_reading_keywords): print(f"デバッグ: データ読み取り開始トリガーを検出 (行 {line_number+1}): {line}") reading_data = True # 次の行からデータ読み取り開始 continue # ヘッダー行はここで処理終了 # reading_data フラグが True になったら、データ行として処理を試みる try: parts = line.split() # スペースやタブで分割 if len(parts) >= 2: # 少なくとも2列あるか angle = float(parts[0]) intensity = float(parts[1]) angles.append(angle) intensities.append(intensity) else: # データ部分の途中で列数が不足する行があれば、そこで終了する print(f"デバッグ: データ行の列数不足、読み取り終了 (行 {line_number+1}): {line}") break except (ValueError, IndexError): # データ部分の途中で数値に変換できない行があれば、そこで終了する print(f"デバッグ: 数値変換エラー、データ読み取り終了 (行 {line_number+1}): {line}") break if not angles or not intensities: QMessageBox.warning(self, "パースエラー", f"ファイル '{os.path.basename(filepath)}' から有効な数値データを抽出できませんでした。\nファイルの形式を確認してください。") return None, None, None print(f"デバッグ: '{sample_name}' のパース成功。{len(angles)}点のデータを抽出。") return sample_name, np.array(angles), np.array(intensities) except FileNotFoundError: QMessageBox.critical(self, "エラー", f"ファイルが見つかりません:\n{filepath}") return None, None, None except Exception as e: QMessageBox.critical(self, "エラー", f"ファイルの読み込み中に予期せぬエラーが発生しました:\n{e}\nファイル: {filepath}") return None, None, None def _generate_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 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.combo_data_y_scale.currentText() if offset_label and offset_spinbox: if scale_text == "対数": offset_label.setText("Yオフセット (10^X):") offset_spinbox.setRange(-10.0, 10.0) offset_spinbox.setSingleStep(0.5) offset_spinbox.setDecimals(1) else: # 線形 or 平方根 offset_label.setText("Yオフセット (加算):") offset_spinbox.setRange(-1e7, 1e7) offset_spinbox.setSingleStep(10.0) offset_spinbox.setDecimals(2) # オフセットと線幅の現在の値をUIに表示 if single_selection and offset_spinbox: scale_text = self.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 on_data_selection_changed(self): """データリストの選択が変更されたときに呼び出される""" self._update_selection_dependent_ui( list_widget=self.list_widget_data, data_list=self.datasets, offset_spinbox=self.spinbox_data_offset, offset_label=self.manual_offset_label, linewidth_spinbox=self.spinbox_data_linewidth, rename_button=self.btn_rename_dataset, set_color_button=self.btn_set_color, apply_colorscale_button=self.btn_apply_colorscale, remove_button=self.btn_remove_data, item_type_name_for_offset_label="データ" ) self.spinbox_data_offset.setEnabled(len(self.list_widget_data.selectedItems()) == 1) self._update_marker_panel_ui() # マーカーリストを更新 def _update_marker_panel_ui(self): """マーカーパネル全体のUIを更新するヘルパー関数""" self.list_widget_markers.clear() selected_datasets, _ = self._get_selected_items(self.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を更新""" is_selected = bool(self.list_widget_markers.selectedItems()) self.combo_marker_symbol.setEnabled(is_selected) self.btn_marker_color.setEnabled(is_selected) self.btn_remove_marker.setEnabled(is_selected) if is_selected: item = self.list_widget_markers.selectedItems()[0] marker_index = self.list_widget_markers.row(item) selected_datasets, _ = self._get_selected_items(self.list_widget_data, self.datasets) if not selected_datasets: return dataset = selected_datasets[0] if "markers" in dataset and marker_index < len(dataset["markers"]): marker = dataset["markers"][marker_index] self.combo_marker_symbol.blockSignals(True) self.combo_marker_symbol.setCurrentText(marker.get("symbol", self.marker_symbols_internal[0])) self.combo_marker_symbol.blockSignals(False) 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.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 toggle_item_visibility(self, item, data_list, item_type_name): """ 指定されたリストウィジェットのアイテムの表示/非表示を切り替える汎用メソッド item: ダブルクリックされた QListWidgetItem data_list: self.datasets または self.references item_type_name: "データセット" または "リファレンス" (デバッグ用) """ item_info = item.data(Qt.ItemDataRole.UserRole) if not (item_info and "name" in item_info): return # data_list から該当するマスターデータを名前で検索して更新 target_item_data = None for d in data_list: if d.get("name") == item_info.get("name"): target_item_data = d break if target_item_data: # "visible" キーの値を反転 (True -> False, False -> True) target_item_data["visible"] = not target_item_data.get("visible", True) print(f"デバッグ: {item_type_name} '{target_item_data['name']}' の表示状態を {target_item_data['visible']} に変更。") # リストの見た目とグラフを更新 self._update_all_list_items_visuals() self.update_plot() else: # この警告は、リストとデータの同期が取れていない場合に表示される可能性があります print(f"デバッグ: 警告 - toggle_item_visibility で '{item_info.get('name')}' が {item_type_name} リストに見つかりません。") def save_graph_dialog(self): "グラフを保存するダイアログを表示する" if not self.datasets: QMessageBox.information(self, "情報", "保存するグラフデータがありません。") return file_path, selected_filter = QFileDialog.getSaveFileName( self, "グラフを名前を付けて保存", "", # 初期ディレクトリ "PNG (*.png);;JPEG (*.jpg *.jpeg);;SVG (*.svg);;PDF (*.pdf);;All Files (*)" ) if file_path: try: 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 session_data = { "app_version": __version__, "saved_at": datetime.now().isoformat(), "ui_settings": { "data_y_scale": self.combo_data_y_scale.currentText(), "ref_y_scale": self.combo_ref_y_scale.currentText(), "scaling_factor": self.spin_scaling_factor.value(), "plot_ratio": self.spin_plot_ratio.value(), "filter_condition": self.edit_ref_filter.text(), "aspect_ratio": self.combo_aspect_ratio.currentText(), "custom_width_cm": self.spin_width_cm.value(), "custom_height_cm": self.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.y_tick_labels_visible, "ref_y_ticks_visible": self.ref_y_tick_labels_visible, "ref_y_label_visible": self.ref_y_label_visible, "legend_visible": self.legend_visible, "graph_font_family": self.graph_font.family(), "graph_font_size": self.graph_font.pointSizeF(), "x_label_text": self.x_label_text, "data_y_label_text": self.data_y_label_text, "colormap_settings": { "current_colormap": self.combo_colormap.currentText(), "cmap_min_val": self.spinbox_cmap_min.value(), "cmap_max_val": self.spinbox_cmap_max.value() }, }, "datasets": [], "references": [] } # データセット情報の保存 for ds in self.datasets: serializable_ds = {k: v for k, v in ds.items() if k not in ["line_object", "processed_y"]} serializable_ds["x"] = self._to_list(ds.get("x", [])) serializable_ds["original_y"] = self._to_list(ds.get("original_y", [])) if isinstance(serializable_ds.get("color"), QColor): serializable_ds["color"] = serializable_ds["color"].name() # マーカーリスト内の色(QColor)も文字列に変換 if "markers" in serializable_ds: serializable_markers = [] for marker in serializable_ds["markers"]: s_marker = marker.copy() if isinstance(s_marker.get("color"), QColor): s_marker["color"] = s_marker["color"].name() serializable_markers.append(s_marker) serializable_ds["markers"] = serializable_markers session_data["datasets"].append(serializable_ds) # リファレンス情報の保存 for ref in self.references: serializable_ref = {k: v for k, v in ref.items() if k not in ["line_object", "y_tops_last_plot", "display_data"]} serializable_ref["positions"] = self._to_list(ref.get("positions", [])) serializable_ref["intensities"] = self._to_list(ref.get("intensities", [])) # raw_indices は辞書リストなのでそのままJSON化可能 if isinstance(serializable_ref.get("color"), QColor): serializable_ref["color"] = serializable_ref["color"].name() session_data["references"].append(serializable_ref) filepath, _ = QFileDialog.getSaveFileName(self, "セッションを保存", "", "JSON Files (*.json)") if not filepath: return False try: with open(filepath, 'w', encoding='utf-8') as f: json.dump(session_data, f, indent=4, ensure_ascii=False) QMessageBox.information(self, "成功", f"セッションを正常に保存しました:\n{filepath}") return True except Exception as e: QMessageBox.critical(self, "エラー", f"セッションの保存中にエラーが発生しました:\n{e}") return False def load_session(self): """JSONファイルからセッションを読み込み、状態を復元する(後方互換性対応版)""" # 現在データがある場合、上書きする前に保存を確認 if self.datasets or self.references: reply = QMessageBox.question(self, '確認', "現在のセッションを破棄して新しいセッションを読み込みますか?\n\n変更を保存しますか?", QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.Cancel) if reply == QMessageBox.StandardButton.Save: # 「保存」が押されたら、保存処理を試みる saved_successfully = self.save_session() if not saved_successfully: return # ユーザーが保存をキャンセルした場合、読み込みも中止 elif reply == QMessageBox.StandardButton.Cancel: # 「キャンセル」が押されたら、読み込みを中止 return # 「破棄(Discard)」が押された場合は、何もせずそのまま次に進む filepath, _ = QFileDialog.getOpenFileName(self, "セッションを読み込み", "", "JSON Files (*.json)") if not filepath: return try: with open(filepath, 'r', encoding='utf-8') as f: session_data = json.load(f) # --- データをクリアして再読み込み --- self.datasets.clear(); self.references.clear() self.list_widget_data.clear(); self.list_widget_references.clear() # データセットの復元(y_offsetがない場合は0を適用) 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")) ds_data["y_offset"] = ds_data.get("y_offset", 0.0) # 後方互換性 # マーカーリストが存在する場合、その中の色も文字列からQColorオブジェクトに復元する if "markers" in ds_data: for marker in ds_data["markers"]: if isinstance(marker.get("color"), str): marker["color"] = QColor(marker["color"]) self.datasets.append(ds_data) item = QListWidgetItem(ds_data["name"]) item.setData(Qt.ItemDataRole.UserRole, ds_data) self.list_widget_data.addItem(item) # リファレンスの復元 for ref_data 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")) self.references.append(ref_data) item = QListWidgetItem(ref_data["name"]) item.setData(Qt.ItemDataRole.UserRole, ref_data) self.list_widget_references.addItem(item) # --- UI設定の復元(存在しないキーはデフォルト値を使用) --- ui_settings = session_data.get("ui_settings", {}) # .get(key, default_value) を使うことで、キーが存在しなくてもエラーにならない self.edit_ref_filter.setText(ui_settings.get("filter_condition", "")) # ★フィルタ条件復元 self.current_ref_filter_condition = ui_settings.get("filter_condition", "") self.combo_data_y_scale.setCurrentText(ui_settings.get("data_y_scale", "線形")) self.combo_ref_y_scale.setCurrentText(ui_settings.get("ref_y_scale", "線形")) self.spin_scaling_factor.setValue(ui_settings.get("scaling_factor", 0.8)) self.spin_plot_ratio.setValue(ui_settings.get("plot_ratio", 80.0)) self.combo_aspect_ratio.setCurrentText(ui_settings.get("aspect_ratio", "カスタム")) self.spin_width_cm.setValue(ui_settings.get("custom_width_cm", 15.0)) self.spin_height_cm.setValue(ui_settings.get("custom_height_cm", 10.0)) 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) self.edit_x_tick_interval.setText(str(self.x_tick_interval) if self.x_tick_interval is not None else "") self.check_y_ticks_visible.setChecked(ui_settings.get("data_y_ticks_visible", True)) self.check_ref_y_ticks_visible.setChecked(ui_settings.get("ref_y_ticks_visible", True)) self.check_ref_y_label_visible.setChecked(ui_settings.get("ref_y_label_visible", True)) self.check_legend_visible.setChecked(ui_settings.get("legend_visible", True)) # カラースケール設定の復元(古いファイルになくてもエラーにならない) cmap_settings = ui_settings.get("colormap_settings", {}) self.combo_colormap.setCurrentText(cmap_settings.get("current_colormap", "viridis")) self.spinbox_cmap_min.setValue(cmap_settings.get("cmap_min_val", 0.0)) self.spinbox_cmap_max.setValue(cmap_settings.get("cmap_max_val", 1.0)) self.x_label_text = ui_settings.get("x_label_text", "2θ/ω (deg.)") self.edit_x_label.setText(self.x_label_text) self.data_y_label_text = ui_settings.get("data_y_label_text", "Intensity / (arb.unit)") font_family = ui_settings.get("graph_font_family", "sans-serif") font_size = ui_settings.get("graph_font_size", 12) self.graph_font = QFont(font_family, int(font_size)) self.apply_font_to_graph(self.graph_font, update_plot=False) self.edit_data_y_label.setText(self.data_y_label_text) # --- 最後に、復元した全設定をグラフに適用 --- self._update_all_list_items_visuals() self.apply_plot_ratio() # プロット比率を適用 self.update_plot() # グラフ全体を更新 QMessageBox.information(self, "成功", "セッションを正常に読み込みました。") except Exception as e: QMessageBox.critical(self, "エラー", f"セッションの読み込み中にエラーが発生しました:\n{e}\n{traceback.format_exc()}") def _to_list(self, variable): """NumPy配列やpandasオブジェクトを安全にPythonリストに変換する""" if isinstance(variable, np.ndarray): return variable.tolist() elif isinstance(variable, pd.DataFrame): return variable.values.tolist() elif isinstance(variable, pd.Series): return variable.tolist() elif isinstance(variable, list): return variable else: # その他のイテラブルなオブジェクトもリストに変換を試みる return list(variable) def apply_manual_x_zoom(self): """X軸の範囲を手動で設定し、その範囲をロックする""" print("DEBUG_ZOOM --- apply_manual_x_zoom called ---") try: x_min_str = self.edit_x_min.text() x_max_str = self.edit_x_max.text() # 入力がない場合は何もしない(ロックも変更しない) if not x_min_str and not x_max_str: return current_x_lim = self.canvas.axes.get_xlim() x_min = 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.edit_y_min.text() y_max_str = self.edit_y_max.text() if not y_min_str and not y_max_str: return current_y_lim = self.canvas.axes.get_ylim() y_min = 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.edit_ref_y_min.text() y_max_str = self.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 on_ref_y_tick_visibility_changed(self, state): """リファレンスプロットのY軸数値の表示/非表示チェックボックスが変更されたときに呼び出される""" self.ref_y_tick_labels_visible = (state == Qt.CheckState.Checked.value) 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 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_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.combo_aspect_ratio.blockSignals(True) self.combo_aspect_ratio.setCurrentText("カスタム") self.combo_aspect_ratio.blockSignals(False) self.update_plot() # 再描画してレイアウトを調整 except Exception as e: QMessageBox.critical(self, "エラー", f"サイズ適用中にエラーが発生しました:\n{e}") def on_axes_limits_changed(self, axes): """Matplotlibの軸範囲が変更されたときに呼び出され、入力欄を更新する""" ax_name = "TOP(data)" if axes == self.canvas.axes else "BOTTOM(ref)" #print(f"!!!!!!!! XLIM_CHANGED_EVENT on '{ax_name}' ax. New xlim: {axes.get_xlim()} !!!!!!!!") # 変更されたのがどちらのAxesか判定する if axes == self.canvas.axes: # データプロット xlim = axes.get_xlim() ylim = axes.get_ylim() self.edit_x_min.setText(f"{xlim[0]:.3g}") self.edit_x_max.setText(f"{xlim[1]:.3g}") self.edit_y_min.setText(f"{ylim[0]:.3g}") self.edit_y_max.setText(f"{ylim[1]:.3g}") elif axes == self.canvas.axes2: # リファレンスプロット ylim = axes.get_ylim() self.edit_ref_y_min.setText(f"{ylim[0]:.3g}") self.edit_ref_y_max.setText(f"{ylim[1]:.3g}") def _process_y_data(self, y_original, scale_type): # scale_type引数は残しても良いが、現在は使わない """Yデータをそのまま返す (Y軸スケールはset_yscaleで処理するため)""" # 以前の log10 や sqrt の計算はここでは行わない return np.copy(y_original) # 元のデータを変更しないようにコピーを返す def _get_selected_items(self, list_widget, data_list): """選択されているアイテムのデータ辞書とQListWidgetItemを返す汎用ヘルパー""" selected_items = list_widget.selectedItems() if not selected_items: return [], [] # UserRoleから辞書を取得し、その名前のセットを作成 selected_data_proxies = [item.data(Qt.ItemDataRole.UserRole) for item in selected_items] selected_names = {d.get("name") for d in selected_data_proxies if d} # マスターリスト(data_list)から、選択された名前と一致する最新の辞書オブジェクトを抽出 # これにより、UserRoleのデータが古くなっている可能性を排除できる selected_data_dicts = [d for d in data_list if d.get("name") in selected_names] return selected_data_dicts, selected_items def remove_selected_items(self, list_widget, data_list, item_type_name): """選択されたアイテムを削除する汎用メソッド""" selected_data, selected_items = self._get_selected_items(list_widget, data_list) if not selected_data: return reply = QMessageBox.question(self, "確認", f"{len(selected_data)}個の{item_type_name}を削除しますか?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No) if reply == QMessageBox.StandardButton.Yes: names_to_remove = {d["name"] for d in selected_data} # 元のリストを直接変更(in-place modification) # こうすることで self.datasets や self.references 自体が更新される items_to_keep = [d for d in data_list if d.get("name") not in names_to_remove] data_list.clear() data_list.extend(items_to_keep) # QListWidgetからアイテムを削除 for item in selected_items: list_widget.takeItem(list_widget.row(item)) print(f"デバッグ: {item_type_name}を削除しました。残りの要素数: {len(data_list)}") self.update_plot() # on_..._selection_changed は選択がクリアされると自動で呼ばれる def set_selected_item_color(self, list_widget, data_list, item_type_name): """選択されたアイテムの色を変更する汎用メソッド""" selected_data, _ = self._get_selected_items(list_widget, data_list) if not selected_data: return initial_color = selected_data[0].get("color", QColor("black")) color = QColorDialog.getColor(initial_color, self, f"{item_type_name}の色を選択") if color and color.isValid(): for d in selected_data: d["color"] = color self._update_all_list_items_visuals() # 両方のリストを更新するヘルパーを呼ぶ self.update_plot() def rename_selected_item(self, list_widget, data_list, item_type_name): """選択された単一アイテムの名前を変更する汎用メソッド (重複チェック付き)""" selected_data, selected_items = self._get_selected_items(list_widget, data_list) if len(selected_data) != 1: return item = selected_items[0] original_data = selected_data[0] current_name = original_data.get("name", "") text, ok = QInputDialog.getText(self, f"{item_type_name}名の変更", f"新しい名前を入力 ('{current_name}' から):", QLineEdit.EchoMode.Normal, current_name) if ok and text and text.strip(): new_name = text.strip() if new_name == current_name: return # 名前が変わっていない場合は何もしない # 重複チェックのロジック # チェック対象は、自分自身の現在の名前を除く、すべての名前 all_other_names = ({d.get("name") for d in self.datasets} | {r.get("name") for r in self.references}) - {current_name} if new_name in all_other_names: QMessageBox.warning(self, "名前の重複", f"名前 '{new_name}' はすでに存在します。\n別の名前を入力してください。") return # 重複している場合は処理を中断 # 重複がなければ、名前を更新 original_data["name"] = new_name item.setText(new_name) item.setData(Qt.ItemDataRole.UserRole, original_data) # ★★★ 更新された辞書全体をUserRoleに設定 ★★★ self.update_plot() def apply_colorscale_to_selected_items(self, list_widget, data_list, item_type_name): """選択されたアイテムにカラースケールを適用する汎用メソッド""" selected_data, _ = self._get_selected_items(list_widget, data_list) num_selected = len(selected_data) if num_selected == 0: return # (この部分はデータセットもリファレンスも同じUIを共有するので、self から直接取得) selected_cmap_name = self.combo_colormap.currentText() cmap_min_val = self.spinbox_cmap_min.value() cmap_max_val = self.spinbox_cmap_max.value() if cmap_min_val >= cmap_max_val: QMessageBox.warning(self, "入力エラー", "カラースケール範囲の始点は終点より小さい必要があります。") return 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.list_widget_data) の更新 print("デバッグ: データリストの見た目を更新中...") for i in range(self.list_widget_data.count()): item = self.list_widget_data.item(i) # UserRoleには辞書そのものが格納されている想定 data_info = item.data(Qt.ItemDataRole.UserRole) if data_info and "name" in data_info: # self.datasets から最新の情報を取得 latest_data_info = None for ds in self.datasets: if ds.get("name") == data_info.get("name"): latest_data_info = ds break if latest_data_info: # 色を更新 item.setForeground(latest_data_info.get("color", QColor("black"))) # 表示/非表示状態に応じて打ち消し線を設定 font = item.font() is_visible = latest_data_info.get("visible", True) font.setStrikeOut(not is_visible) item.setFont(font) # 非表示の場合は文字色をグレーにする if not is_visible: item.setForeground(QColor("gray")) # 2. リファレンスリスト (self.list_widget_references) の更新 print("デバッグ: リファレンスリストの見た目を更新中...") for i in range(self.list_widget_references.count()): item = self.list_widget_references.item(i) # UserRoleには辞書そのものが格納されている想定 ref_info_from_item = item.data(Qt.ItemDataRole.UserRole) # UserRoleから辞書を取得 if ref_info_from_item and "name" in ref_info_from_item: # self.references から最新の情報を取得 latest_ref_info = None for ref_in_main_list in self.references: if ref_in_main_list.get("name") == ref_info_from_item.get("name"): latest_ref_info = ref_in_main_list break if latest_ref_info: is_visible = latest_ref_info.get("visible", True) # フォントに打ち消し線を設定 font = item.font() # QListWidgetItemから現在のフォントを取得 font.setStrikeOut(not is_visible) item.setFont(font) # 表示状態に応じて文字色を設定 if is_visible: item.setForeground(latest_ref_info.get("color", QColor("black"))) else: item.setForeground(QColor("gray")) # 非表示の場合はグレー else: print(f"デバッグ: リストアイテム {i} のUserRoleに有効なリファレンス情報がありません。") def apply_item_linewidth(self, list_widget, data_list, spinbox): """スピンボックスの値を、選択されているアイテムの線幅に適用する""" selected_data, _ = self._get_selected_items(list_widget, data_list) if not selected_data: return new_linewidth = spinbox.value() data_updated = False for d in selected_data: if abs(d.get("linewidth", self.default_linewidth) - new_linewidth) > 1e-9: d["linewidth"] = new_linewidth data_updated = True if data_updated: print(f"デバッグ: {len(selected_data)}個のアイテムの線幅を {new_linewidth:.1f} に変更しました。") self.update_plot() def _sync_data_order_from_widget(self, list_widget, data_list, item_type_name): """QListWidgetのアイテム順序変更を内部データリストに同期する""" print(f"デバッグ: {item_type_name}リストの順序が変更されたため、内部データを同期します。") new_ordered_data = [] for i in range(list_widget.count()): item = list_widget.item(i) item_data_proxy = item.data(Qt.ItemDataRole.UserRole) # UserRoleには辞書が格納されている想定 if item_data_proxy and "name" in item_data_proxy: # マスターデータリスト(data_list)から、同じ名前を持つ最新の辞書オブジェクトを検索 # これにより、UserRoleのデータが古い場合でも最新の情報を参照できる found_master_data = next((d for d in data_list if d.get("name") == item_data_proxy.get("name")), None) if found_master_data: new_ordered_data.append(found_master_data) else: # 通常は発生しないはずだが、念のため警告 print(f"デバッグ警告: {item_type_name} '{item_data_proxy.get('name')}' がマスターリストに見つかりません。") new_ordered_data.append(item_data_proxy) # フォールバックとしてプロキシデータを追加 data_list.clear() data_list.extend(new_ordered_data) self.update_plot() # 順序変更がプロットに影響する可能性があるので再描画 def on_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 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: y_margin = (y_max - y_min) * 0.05 if (y_max - y_min) > 1e-9 else 0.5 self.canvas.axes.set_ylim(y_min - y_margin, y_max + y_margin) else: self.canvas.axes.set_ylim(0, 1) if self.canvas.axes2 is not None and ref_plotted: y_margin = (ref_y_max - ref_y_min) * 0.05 if (ref_y_max - ref_y_min) > 1e-9 else 0.5 self.canvas.axes2.set_ylim(ref_y_min - y_margin, ref_y_max + y_margin) elif self.canvas.axes2 is not None: self.canvas.axes2.set_ylim(-1, 1) # --- 6. 描画の後処理 --- if self.canvas.axes2 is not None: for baseline in baselines_to_draw: self.canvas.axes2.hlines(baseline["y"], final_xlim[0], final_xlim[1], color=baseline["color"], linestyle=baseline["style"]) self._initialize_peak_annotation(self.canvas.axes2) self.indicator_line, = self.canvas.axes.plot([], [], color='red', linestyle='--', linewidth=0.8, zorder=10, visible=False) # モードに応じて、適切な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 apply_plot_ratio(self): """スピンボックスの値に基づいて、上下プロットの高さの比率を変更する""" top_ratio = self.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 _draw_datasets(self, ax, process_data=True): """指定されたAxesにデータセットを描画する(マーカー描画ロジック修正版)""" scale_text = self.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", False): 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) if self.replace_non_positive_with_one: # チェックがオンの場合、0以下の値を1に置き換える y_processed[y_processed <= 0] = 1 if scale_text == "対数": offset = data.get("y_offset_log_exponent", 0.0) y_to_plot = y_processed * (10**offset) # ← y_original から変更 elif scale_text == "平方根": offset = data.get("y_offset_linear", 0.0) y_to_plot = np.sqrt(np.maximum(0, y_processed)) + offset # ← y_original から変更 else: # 線形 offset = data.get("y_offset_linear", 0.0) y_to_plot = y_processed + offset # ← y_original から変更 ax.plot(x_values, y_to_plot, label=data.get("name"), color=mpl_color, linewidth=data.get("linewidth", self.default_linewidth)) data_plotted = True if len(y_to_plot) > 0: current_min = np.min(y_to_plot); current_max = np.max(y_to_plot) actual_y_min = min(actual_y_min, current_min) actual_y_max = max(actual_y_max, current_max) # --- ここからがマーカー描画処理 --- if data.get("visible", True) and "markers" in data and data["markers"]: for marker in data["markers"]: x_pos = marker.get("x") if x_pos is None: continue # 1. データセット固有のオフセットパーセンテージを取得 offset_percent = data.get("marker_offset_percent", self.default_marker_offset_percent) / 100.0 # 2. マーカー位置に最も近いデータ点のインデックスを探す closest_index = np.argmin(np.abs(x_values - x_pos)) # 3. その点を中心に、前後2点(合計5点)のウィンドウを定義(配列の端を考慮) window_radius = 2 start_index = max(0, closest_index - window_radius) end_index = min(len(y_to_plot), closest_index + window_radius + 1) # 4. ウィンドウ内のデータ点の最大値(max)をマーカーの基準高さとする if end_index > start_index: y_base = np.max(y_to_plot[start_index:end_index]) else: y_base = np.interp(x_pos, x_values, y_to_plot) # フォールバック # 5. スケールに応じてマーカーの最終的なY座標を計算 if scale_text == "対数": # 対数スケールでは掛け算でオフセットを適用 y_marker = y_base * (1 + offset_percent * 5) # 5-20% -> 1.25-2.0倍 else: # 線形とルート # 線形軸では、全体の描画範囲に対する割合でオフセットを足し算 y_range = np.max(y_to_plot) - np.min(y_to_plot) offset_val = y_range * offset_percent if y_range > 0 else np.max(y_to_plot) * offset_percent y_marker = y_base + offset_val q_color = marker.get("color", data["color"]) m_color = (q_color.redF(), q_color.greenF(), q_color.blueF(), q_color.alphaF()) display_symbol = marker.get("symbol", self.marker_symbols_internal[0]) marker_code = self.marker_symbol_map.get(display_symbol, 'v') ax.scatter(x_pos, y_marker, marker=marker_code, color=m_color, s=60, zorder=10) # --- マーカー描画ここまで --- if not data_plotted: actual_y_min, actual_y_max = 0, 1 return data_plotted, actual_y_min, actual_y_max def apply_marker_offset(self): """マーカー高さオフセットの値をデータセットに適用するメソッド""" selected_datasets, _ = self._get_selected_items(self.list_widget_data, self.datasets) if not selected_datasets: return new_offset = self.spin_marker_offset.value() # 選択されているすべてのデータセットに適用 for dataset in selected_datasets: dataset["marker_offset_percent"] = new_offset self.update_plot() def _safe_evaluate_filter(self, expr, context): """ AST(抽象構文木)を使用してリファレンス表示に使用する数式を安全に評価する。 eval()を使用せず、許可された演算子と関数のみを実行するため、 任意コード実行やDoS攻撃(巨大な指数計算など)を防ぐことができる。 """ # 1. DoS対策: 文字列長制限 if len(expr) > 300: return False # 許可する演算子と関数の定義 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 } allowed_names = {"h", "k", "l", "i"} def _eval_node(node): # 数値リテラル if isinstance(node, ast.Constant): if isinstance(node.value, (int, float, bool)): return node.value raise ValueError("Unsupported constant type") # 変数 (h, k, l, i) if isinstance(node, ast.Name): if node.id in allowed_names: return float(context.get(node.id, 0)) # 数値として扱う raise ValueError(f"Unauthorized variable: {node.id}") # 二項演算 (+, -, *, /, %, **) 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) # DoS対策: 巨大な指数計算のブロック if op_type == ast.Pow: if abs(right) > 100: # 指数を100以下に制限 raise ValueError("Exponent too large") return allowed_operators[op_type](left, right) # 単項演算 (-, not) 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)) # 比較演算 (==, <, > etc) 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 # 論理演算 (and, or) if isinstance(node, ast.BoolOp): op_type = type(node.op) values = [_eval_node(v) for v in node.values] if op_type == ast.And: return all(values) elif op_type == ast.Or: return any(values) raise ValueError("Unauthorized bool operator") # 関数呼び出し (abs, int, pow など) 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"Unauthorized function: {func_name}") args = [_eval_node(arg) for arg in node.args] # DoS対策: pow関数の指数制限 if func_name == "pow": if len(args) == 2 and abs(args[1]) > 100: raise ValueError("Exponent too large in pow()") return allowed_funcs[func_name](*args) 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: # 構文エラーや許可されていない操作があった場合はFalseとして扱う # デバッグ用にコンソール出力しても良い print(f"Filter Error: {e}") return False def _draw_references(self, ax): """リファレンス描画処理(セキュリティ対策済みフィルタ + ログ表示修正版)""" scale_text = self.combo_ref_y_scale.currentText() scale_factor = self.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: 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 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) 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_ref_plot_styles(self, ax, has_data): """【下側】リファレンスプロットのスタイルを適用する(Xラベル設定を削除)""" scale_text = self.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)") 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()) def _apply_data_plot_styles(self, ax, data_plotted): """【上側】データプロットのスタイルを適用する(軸範囲設定を削除)""" scale_text = self.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()) 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)) def load_xrd_data_dialog(self): """ファイルダイアログを開き、選択されたXRDデータを読み込んでプロットする""" filepath, _ = QFileDialog.getOpenFileName( self, "XRDデータファイルを開く", "", self.data_file_filter ) if filepath: sample_name, x_data, y_data = self.parse_xrd_file(filepath) if sample_name is not None: self.add_dataset(name=sample_name, x_data=x_data, y_data=y_data) # ★★★ 常に最新のupdate_plotを呼ぶように統一 ★★★ self.update_plot() print(f"データ '{sample_name}' を読み込みました。") ###リファレンスの処理 def parse_reference_file(self, filepath): if XRD_GUI_lib is not None and hasattr(XRD_GUI_lib, "parse_reference"): try: name, pos, inten, hkls, raw = XRD_GUI_lib.parse_reference(filepath, xmin=0.0, xmax=120.0) # ★空なら内蔵へ if pos is not None and len(pos) > 0: return name, np.array(pos), np.array(inten), hkls, raw except Exception as e: print(f"外部パーサー parse_reference 失敗: {e}") return self._parse_reference_plaintext(filepath) def _format_hkl(self, hkl): # hkl が [h,k,l] か [h,k,i,l] を想定 try: if len(hkl) == 3: return f"({hkl[0]} {hkl[1]} {hkl[2]})" if len(hkl) == 4: return f"({hkl[0]} {hkl[1]} {hkl[2]} {hkl[3]})" except: pass return str(hkl) def _build_raw_indices_from_hkls(self, hkls): raw = [] for s in hkls or []: clean = s.replace("(", "").replace(")", "") vals = [int(x) for x in clean.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 raw.append({"h": h, "k": k, "l": l, "i": i_val}) return raw def _parse_reference_plaintext(self, filepath): reference_name = os.path.splitext(os.path.basename(filepath))[0] positions = [] intensities = [] hkls = [] raw_indices = [] reading_data = False idx_2theta = None idx_I = None bad_rows = 0 total_rows = 0 def is_unit_token(tok: str) -> bool: tok = tok.strip() return tok.startswith("(") and tok.endswith(")") def to_float_safe(s: str) -> float: # 変な文字が混ざっても数値だけ抜いてfloat化 s2 = re.sub(r"[^0-9eE\+\-\.]", "", s) if s2 in ("", ".", "-", "+"): raise ValueError return float(s2) try: with open(filepath, "r", encoding="utf-8", errors="ignore") as f: for line in f: line = line.strip() if not line: continue parts = line.split() if len(parts) < 3: continue # --- ヘッダ検出 --- if (not reading_data) and ("h" in [p.lower() for p in parts]) and ("k" in [p.lower() for p in parts]) and ("l" in [p.lower() for p in parts]): header_clean = [p for p in parts if not is_unit_token(p)] header_lower = [p.lower() for p in header_clean] idx_I = None for i, p in enumerate(header_lower): if p == "i" or p == "intensity": idx_I = i break idx_2theta = None for i, p in enumerate(header_clean): pl = p.lower() if ("2θ" in p) or ("2theta" in pl) or ("theta" in pl) or ("2th" in pl): idx_2theta = i break # 2θが取れない場合は I の左を採用(あなたの形式に強い) if idx_2theta is None and idx_I is not None and idx_I - 1 >= 0: idx_2theta = idx_I - 1 # I が取れない場合は 2θ の右 if idx_I is None and idx_2theta is not None and idx_2theta + 1 < len(header_clean): idx_I = idx_2theta + 1 print(f"[Header] idx_2theta={idx_2theta}, idx_I={idx_I}, header_clean={header_clean}") reading_data = True continue if not reading_data: continue # --- データ行 --- try: h = int(parts[0]); k = int(parts[1]); l = int(parts[2]) except Exception: continue if idx_2theta is None or idx_I is None: continue # 列数不足はスキップ(breakしない) if len(parts) <= max(idx_2theta, idx_I): bad_rows += 1 continue total_rows += 1 try: pos_val = to_float_safe(parts[idx_2theta]) int_val = to_float_safe(parts[idx_I]) except Exception: bad_rows += 1 continue positions.append(pos_val) intensities.append(int_val) hkls.append(f"({h} {k} {l})") raw_indices.append({"h": h, "k": k, "l": l, "i": -(h + k)}) if not positions: print(f"[WARN] no peaks parsed: total_rows={total_rows}, bad_rows={bad_rows}") return None, None, None, None, None positions = np.array(positions, dtype=float) intensities = np.array(intensities, dtype=float) # ref表示用に正規化 maxI = float(np.max(intensities)) if intensities.size else 1.0 intensities = intensities / maxI if maxI > 0 else np.zeros_like(intensities) print(f"[Parsed] N={len(positions)} 2theta(min,max)=({positions.min():.2f},{positions.max():.2f}) bad_rows={bad_rows}") return reference_name, positions, intensities, hkls, raw_indices except Exception as e: print(f"Parse error (_parse_reference_plaintext): {e}") return None, None, None, None, None def load_reference_dialog(self): filepath, _ = QFileDialog.getOpenFileName(self, "XRDリファレンスを開く", "", self.reference_file_filter) if filepath: ref_name, positions, intensities, hkls, raw_indices = self.parse_reference_file(filepath) 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.list_widget_references.addItem(item) self.update_plot() print(f"リファレンス '{unique_ref_name}' を読み込みました。") def on_reference_selection_changed(self): """リファレンスリストの選択が変更されたときに呼び出される (UI整理版)""" selected_refs, _ = self._get_selected_items(self.list_widget_references, self.references) has_selection = bool(selected_refs) single_selection = len(selected_refs) == 1 # --- 現在存在するUI要素の有効/無効を切り替える --- self.btn_set_reference_color.setEnabled(has_selection) self.btn_rename_reference.setEnabled(single_selection) self.btn_remove_reference.setEnabled(has_selection) self.spinbox_ref_linewidth.setEnabled(has_selection) if hasattr(self, 'btn_apply_colorscale_ref'): # 念のため存在確認 self.btn_apply_colorscale_ref.setEnabled(has_selection) # --- 選択されたアイテムの線幅をスピンボックスに表示する --- if has_selection: linewidth = selected_refs[0].get("linewidth", self.default_linewidth) self.spinbox_ref_linewidth.blockSignals(True) self.spinbox_ref_linewidth.setValue(linewidth) self.spinbox_ref_linewidth.blockSignals(False) else: # 選択がない場合はデフォルト値に戻す self.spinbox_ref_linewidth.blockSignals(True) self.spinbox_ref_linewidth.setValue(self.default_linewidth) self.spinbox_ref_linewidth.blockSignals(False) def 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_label_visibility_changed(self, state): """Ref Y軸ラベルの表示/非表示チェックボックスが変更されたときに呼び出される""" self.ref_y_label_visible = (state == Qt.CheckState.Checked.value) self.update_plot() def apply_data_y_label(self): """QLineEditのテキストをデータY軸ラベルに適用し、グラフを更新する""" new_label = self.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_legend_visibility_changed(self, state): """凡例の表示/非表示チェックボックスが変更されたときに呼び出される""" self.legend_visible = (state == Qt.CheckState.Checked.value) print(f"デバッグ: 凡例の表示状態を {self.legend_visible} に変更しました。") self.update_plot() # グラフを再描画して変更を適用 def apply_x_label(self): """QLineEditのテキストをX軸ラベルに適用し、グラフを更新する""" new_label = self.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 on_single_plot_mode_changed(self, state): """「1画面モード」チェックボックスが変更されたときに呼び出される""" self.is_single_plot_mode = (state == Qt.CheckState.Checked.value) # 1画面モードの場合、リファレンスのプロット比率調整は無意味なので無効化 self.spin_plot_ratio.setEnabled(not self.is_single_plot_mode) self.update_plot() 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_mouse_move_on_graph(self, event): """マウスオーバー処理:マウスのY座標(ベースラインからの高さ)で対象リファレンスを厳密にフィルタリング""" if not all(hasattr(self, attr) for attr in ['canvas', 'peak_annotation_right', 'peak_annotation_left', 'indicator_line']): return if self.canvas.axes2 is None: self.hovered_peak_info = None return is_right = self.peak_annotation_right.get_visible() is_left = self.peak_annotation_left.get_visible() is_line = self.indicator_line.get_visible() if event.inaxes != self.canvas.axes2: 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) self.canvas.draw_idle() self.hovered_peak_info = None return # 現在のスケール設定を取得(高さ計算用) scale_text = self.combo_ref_y_scale.currentText() scale_factor = self.spin_scaling_factor.value() # 1. 候補となる全ピークを収集(ただし、マウスがY軸方向の「帯」に入っているリファレンスのみ) # 1) まず「どのリファレンス帯にいるか」を決める(display_dataの実値で帯を作る) BAND_MARGIN_IN = 6 # 帯の内側判定マージン(px) BAND_PICK_LIMIT = 30 # 帯からこれ以上離れてたら無視(px) 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) if positions is None or y_tops is None or len(positions) == 0 or len(y_tops) == 0: continue # 帯のY範囲を「実際に描いたy_topsとbaseline」から決める(推定しない) y_low = min(float(np.min(y_tops)), float(baseline)) y_high = max(float(np.max(y_tops)), float(baseline)) # ピクセルへ変換 low_px = self.canvas.axes2.transData.transform((0, y_low))[1] high_px = self.canvas.axes2.transData.transform((0, y_high))[1] lower_px, upper_px = (low_px, high_px) if low_px <= high_px else (high_px, low_px) # マウスyが帯の中なら距離0、外なら最近傍距離 if (lower_px - BAND_MARGIN_IN) <= event.y <= (upper_px + BAND_MARGIN_IN): dy = 0.0 else: dy = min(abs(event.y - lower_px), abs(event.y - upper_px)) band_candidates.append((dy, ref_data, lower_px, upper_px)) # 帯が何も無いなら終了 if not band_candidates: self.peak_annotation_right.set_visible(False) self.peak_annotation_left.set_visible(False) self.indicator_line.set_visible(False) self.canvas.draw_idle() self.hovered_peak_info = None return # 2) 一番近い帯(dy最小)を選ぶ dy, active_ref, lower_px, upper_px = min(band_candidates, key=lambda t: t[0]) # 帯から遠すぎるなら何も表示しない(境界で別帯が出るのを防ぐ) if dy > BAND_PICK_LIMIT: self.peak_annotation_right.set_visible(False) self.peak_annotation_left.set_visible(False) self.indicator_line.set_visible(False) self.canvas.draw_idle() self.hovered_peak_info = None 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) 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) self.canvas.draw_idle() self.hovered_peak_info = None def on_replace_non_positive_changed(self, state): """「0以下の値を1に置換」チェックボックスの状態が変更されたときに呼び出される""" self.replace_non_positive_with_one = (state == Qt.CheckState.Checked.value) self.update_plot() def toggle_labeling_mode(self, checked): """「ピーク付けモード」のON/OFFを切り替える""" selected_datasets, _ = self._get_selected_items(self.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.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.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 edit_selected_marker_symbol(self, symbol): """選択されたマーカーのシンボルを変更""" selected_datasets, _ = self._get_selected_items(self.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] marker_index = self.list_widget_markers.row(selected_marker_items[0]) if "markers" in dataset and 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.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] marker_index = self.list_widget_markers.row(selected_marker_items[0]) if "markers" in dataset and marker_index < len(dataset["markers"]): initial_color = dataset["markers"][marker_index].get("color", dataset["color"]) color = QColorDialog.getColor(initial_color, self, "マーカーの色を選択") if color.isValid(): dataset["markers"][marker_index]["color"] = color self.update_plot() def remove_selected_marker(self): """選択されたマーカーを削除""" selected_datasets, _ = self._get_selected_items(self.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] marker_index = self.list_widget_markers.row(selected_marker_items[0]) if "markers" in dataset and marker_index < len(dataset["markers"]): reply = QMessageBox.question(self, "確認", "選択したマーカーを削除しますか?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No) if reply == QMessageBox.StandardButton.Yes: del dataset["markers"][marker_index] self._update_marker_panel_ui() 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() class XRD_GUI(GenericGraphApp): def __init__(self): super().__init__() self.setWindowTitle("XRD分析グラフツール") 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()