# -*- 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.2.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
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
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
"""インポートここまで"""
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のデフォルトスタイル設定 (論文風を目指す) ---
# ユーザー提供のrcParamsを参考に設定
matplotlib.rcParams['xtick.direction'] = 'in'
matplotlib.rcParams['ytick.direction'] = 'in'
matplotlib.rcParams["xtick.minor.visible"] = True
matplotlib.rcParams["ytick.minor.visible"] = True
matplotlib.rcParams['xtick.top'] = True
matplotlib.rcParams['ytick.right'] = True
# 軸の太さや目盛りのスタイル (デフォルトより少し強調)
matplotlib.rcParams["axes.linewidth"] = 0.8 # 軸の枠線の太さ
matplotlib.rcParams["xtick.major.width"] = 0.8
matplotlib.rcParams["ytick.major.width"] = 0.8
matplotlib.rcParams["xtick.minor.width"] = 0.6
matplotlib.rcParams["ytick.minor.width"] = 0.6
matplotlib.rcParams["xtick.major.size"] = 6.0 # 目盛りの長さ
matplotlib.rcParams["ytick.major.size"] = 6.0
matplotlib.rcParams["xtick.minor.size"] = 3.0
matplotlib.rcParams["ytick.minor.size"] = 3.0
# 凡例のスタイル
matplotlib.rcParams["legend.fancybox"] = False # 角丸OFF
matplotlib.rcParams["legend.framealpha"] = 0.8 # 背景の透明度 (1で不透明、0で透明)
matplotlib.rcParams["legend.edgecolor"] = 'black' # 枠線の色
# グリッドはデフォルトではオフにする (論文スタイルでは使わないか、薄くすることが多い)
matplotlib.rcParams['axes.grid'] = False
# --- スタイル設定ここまで ---
self.setWindowTitle("汎用グラフ表示ソフト")
self.setGeometry(100, 100, 1300, 800)
self.datasets = []
self.references = []
# フォントは apply_font_to_all_widgets でrcParamsにも反映させる
self.ui_font = QFont("sans-serif", 12) # UI用のフォント (サイズ10で固定)
self.graph_font = QFont("Times New Roman", 12) if sys.platform == "win32" else QFont("sans-serif", 12)
# グラフのベースフォントサイズは self.graph_font から取得
self.base_font_size = self.graph_font.pointSize()
self.color_cycle_index = 0
self.current_y_offset_increment = 0.0 # 現在の自動オフセットの累積値を保持
self.auto_offset_step = 0 # 自動オフセットの基本ステップ量 (後で調整)
self.default_linear_offset_step = 10.0 # 線形スケール時の自動オフセットのデフォルトステップ量
self.default_log_offset_exponent_step = 1.0 # 対数スケール時の自動オフセットのデフォルト指数ステップ (10倍ずつ)
self.default_linewidth = 0.5 # デフォルトの線幅
self.reference_max_peak_display_height = 120.0 # 線形/sqrt/symlog用 (ユーザー指定の値に更新)
self.reference_log_display_decades = 1.0 # 対数スケール時、最大ピークが見た目上何桁分の高さになるか
self.x_axis_is_locked = False # X軸が手動でロックされているか
self.locked_x_range = None # ロックされたX軸の範囲 (min, max)
self.x_tick_interval = None # X軸の主目盛間隔 (Noneは自動設定)
self.x_label_text = "2θ/θ (deg.)" # X軸ラベルの初期値を設定
self.y_tick_labels_visible = True # Y軸の数値ラベルの表示状態
self.legend_visible = True # 凡例の表示状態
self.ref_y_label_visible = True # Ref Y軸ラベルの表示状態
self.ref_y_tick_labels_visible = True
self.data_y_label_text = "Intensity $I$ (arb.unit)"
self.is_initial_plot = True # 最初の描画かを判定するフラグ
self.previous_data_y_scale_text = "線形" # データ用スケールの前の値を保持
self.is_single_plot_mode = False # 1画面モードかどうかの状態
self.is_labeling_mode = False # ピーク付けモードの状態
# UIに表示するシンボルと、Matplotlibが実際に使うマーカーコードを定義
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 = None # 注釈オブジェクトを保持する変数
self.currently_annotated_peak_info = None # 現在どのピークに注釈を付けているか ({"pos": (float, float), "hkl_text": str})
# 縦横比の選択肢 (ラベルと (幅比, 高さ比) または figsizeタプル)
self.aspect_ratios = {
"カスタム": None,
"1:1 (正方形)": (1, 1),
"4:3 (標準)": (4, 3),
"16:9 (ワイド)": (16, 9),
"3:4 (縦長)": (3, 4),
"黄金比 (1:1.618)": (1, 1.618),
"白銀比 (1:1.414)": (1, 1.414),
}
self.fixed_paper_sizes = {
"論文デフォルト (幅8cm)": (8/2.54, 6/2.54), # ← この行を追加
"幅8cm x 高さ6cm (4:3)": (8/2.54, 6/2.54),
"幅8cm x 高さ8cm (1:1)": (8/2.54, 8/2.54),
"幅12cm x 高さ9cm (4:3)": (12/2.54, 9/2.54),
}
self.default_custom_size_cm = (15.0, 10.0) # デフォルトのカスタムサイズ (幅, 高さ) in cm
# 利用可能なカラースケール名のリストを取得
if hasattr(matplotlib, 'colormaps'):
self.available_colormaps = sorted(list(matplotlib.colormaps.keys()))
elif hasattr(matplotlib.cm, 'cmap_d'):
self.available_colormaps = sorted(list(matplotlib.cm.cmap_d.keys()))
else:
self.available_colormaps = [] # Fallback if no colormaps found
# 1.利用可能なファイルフィルターを取得
if XRD_GUI_lib is not None and hasattr(XRD_GUI_lib, 'get_supported_file_filters'):
print("デバッグ: 外部ライブラリからファイルフィルターを動的に取得します。")
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)"
# 2. 変数が準備できた後で、UIを作成する
self.init_ui()
# 3. UI作成後に、UI要素に依存する変数を初期化する
self.previous_data_y_scale_text = self.combo_data_y_scale.currentText()
# 4. 最後にフォントを適用し、初期描画を行う
self._apply_font_to_qt_widgets(self.ui_font)
self.apply_font_to_graph(self.graph_font, update_plot=False)
self.update_plot()
def init_ui(self):
"""UIを作成する(実行順序を修正)"""
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(400 + 20)
# --- 右側のグラフエリアを作成 ---
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)
# --- 各UIグループを作成し、コントロールパネルに追加 ---
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()
graph_settings_group = self._create_graph_settings_group()
app_settings_group = self._create_app_settings_group()
self.control_panel_layout.addStretch(1)
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)
# 全てのUI部品が作成された後で、サブプロットの初期作成とイベント接続を行う
self._setup_subplots()
# --- Figure Canvas全体のイベント接続 ---
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(150)
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)
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 _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.update_plot)
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)
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 = 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, "visible": True, "color": self.get_next_color(),
"linewidth": self.default_linewidth, "offset_y_linear": 0.0,
"offset_y_log_exponent": -1.0
}
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(),
"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"]}
serializable_ref["positions"] = self._to_list(ref.get("positions", []))
serializable_ref["intensities"] = self._to_list(ref.get("intensities", []))
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) # 後方互換性
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.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 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())
if scale_text == "対数":
offset = data.get("y_offset_log_exponent", 0.0)
y_to_plot = y_original * (10**offset)
elif scale_text == "平方根":
offset = data.get("y_offset_linear", 0.0)
y_to_plot = np.sqrt(np.maximum(0, y_original)) + offset
else: # 線形
offset = data.get("y_offset_linear", 0.0)
y_to_plot = y_original + offset
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 _draw_references(self, ax):
"""指定されたAxesにリファレンスデータを描画する(対数スケール改善版)"""
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')
for i, ref_data in enumerate(self.references):
if not ref_data.get("visible", True): continue
ref_plotted = True
positions = ref_data.get("positions", np.array([]))
intensities = ref_data.get("intensities", np.array([]))
if len(positions) == 0: continue
q_color = ref_data.get("color", QColor("gray"))
mpl_color = q_color.name()
# 強度を正規化(最大値を1にする)
i_max = np.max(intensities); i_max = 1.0 if i_max <= 0 else i_max
normalized_i = intensities / i_max
if scale_text == "対数":
# 各プロットを10倍ずつ離して配置
spacing_multiplier = 10**(-i)
baseline_y = spacing_multiplier
# 高さを (10^スケール係数) の範囲で表現
heights = 10**(normalized_i * scale_factor)
y_tops = baseline_y * heights
else: # 線形 or 平方根
offset = -i * 1.0
baseline_y = offset
processed_i = np.sqrt(normalized_i) if scale_text == "平方根" else normalized_i
heights = processed_i * scale_factor
y_tops = offset + heights
ref_data["y_tops_last_plot"] = y_tops
# 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(
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')
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')
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):
"""XRDピークリファレンスファイル(TXT形式)を解析し、リファレンス名、2θ位置、強度を抽出する"""
"""リファレンスデータファイル(TXT形式)を解析し、サンプル名、角度、強度を抽出する"""
# --- XRD_GUI_lib が利用可能であれば、そちらのパーサーを使用 ---
# ユーザー指定の関数名 `parse_referenced` を使用
if XRD_GUI_lib is not None and hasattr(XRD_GUI_lib, "parse_reference"):
try:
print("デバッグ: 外部ライブラリの XRD_GUI_lib.parse_reference() を使用します。")
# 外部ライブラリの関数を呼び出し、結果をそのまま返す
return XRD_GUI_lib.parse_reference(filepath)
except Exception as e:
print(f"デバッグ: XRD_GUI_lib.parse_reference() でエラーが発生しました: {e}")
# エラー時は、この後の内蔵パーサーに処理を継続させる
pass
reference_name = os.path.splitext(os.path.basename(filepath))[0]
positions = [] # 2theta
intensities = [] # I
hkls = [] # ★★★ hklを保存するリストを追加 ★★★
header_skipped = 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
if not header_skipped:
parts_check = line.split()
try:
float(parts_check[0]) # 最初の要素が数値に変換できるか試す
if len(parts_check) > 9 and any(char.isalpha() for char in parts_check[7]): # 8列目に文字が含まれていればヘッダーとみなす
print(f"デバッグ: リファレンスファイルのヘッダー行と判断 (行 {line_number+1}): {line}")
header_skipped = True
continue
except (ValueError, IndexError):
print(f"デバッグ: リファレンスファイルのヘッダー行の可能性 (行 {line_number+1}): {line}")
header_skipped = True
continue
# データ行の処理
parts = line.split()
if len(parts) >= 9:
try:
# ★★★ h, k, l の値を読み取る ★★★
h = int(parts[0])
k = int(parts[1])
l = int(parts[2])
pos_2theta = float(parts[7]) # 8列目 (0-indexed)
intensity = float(parts[8]) # 9列目 (0-indexed)
positions.append(pos_2theta)
intensities.append(intensity)
hkls.append(f"({h} {k} {l})") # ★★★ 文字列としてhklを保存 ★★★
except ValueError:
print(f"デバッグ: リファレンスファイルのデータ行で数値変換エラー (行 {line_number+1}): {line}")
continue
else:
print(f"デバッグ: リファレンスファイルのデータ行の列数が不足 (行 {line_number+1}): {line}")
if not positions or not intensities:
print(f"警告: ファイル '{filepath}' から有効なリファレンスデータを抽出できませんでした。")
return None, None, None, None # ★★★ 戻り値を追加 ★★★
return reference_name, np.array(positions), np.array(intensities), hkls # ★★★ 戻り値を追加 ★★★
except FileNotFoundError:
QMessageBox.critical(self, "エラー", f"ファイルが見つかりません:\n{filepath}")
return None, None, None, None # ★★★ 戻り値を追加 ★★★
except Exception as e:
QMessageBox.critical(self, "エラー", f"リファレンスファイルの読み込み中にエラーが発生しました:\n{e}\nファイル: {filepath}")
return 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 = self.parse_reference_file(filepath)
if ref_name is not None and positions is not None:
unique_ref_name = self._generate_unique_name(ref_name)
new_reference = {
"name": unique_ref_name, "positions": positions, "intensities": intensities,
"hkls": hkls, "visible": True, "color": self.get_next_color(),
"linewidth": self.default_linewidth, "offset_y_linear": 0.0,
"offset_y_log_exponent": -1.0
}
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)
# ★★★ 常に最新のupdate_plotを呼ぶように統一 ★★★
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):
"""グラフ上でマウスが動いたときに呼び出され、ピーク注釈とインジケーター線を表示する(左右切り替え版)"""
# 必要なオブジェクトが初期化されていなければ何もしない
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_visible = self.peak_annotation_right.get_visible()
is_left_visible = self.peak_annotation_left.get_visible()
is_indicator_visible = self.indicator_line.get_visible()
# カーソルが下のプロット(axes2)の外に出た場合
if event.inaxes != self.canvas.axes2:
if is_right_visible or is_left_visible or is_indicator_visible:
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
# マウスに最も近いピークを探す
min_dist_pixels = float('inf')
closest_peak_info = None
for ref_data in self.references:
if ref_data.get("visible", True) and "y_tops_last_plot" in ref_data:
positions, y_tops, hkls = ref_data.get("positions", []), ref_data.get("y_tops_last_plot", []), ref_data.get("hkls", [])
for i in range(len(positions)):
px, py = self.canvas.axes2.transData.transform((positions[i], y_tops[i]))
dist = np.sqrt((event.x - px)**2 + (event.y - py)**2)
if dist < min_dist_pixels:
min_dist_pixels = dist
closest_peak_info = {
"pos": (positions[i], y_tops[i]),
"hkl": hkls[i] if i < len(hkls) else "N/A",
"color": ref_data.get("color") # マーカー色用に色情報も保持
}
# ピークの近くにカーソルがある場合
if closest_peak_info and min_dist_pixels < 15:
# 位置に応じて、使用する注釈オブジェクトを決定
xlim = self.canvas.axes2.get_xlim()
x_range = xlim[1] - xlim[0]
peak_x = closest_peak_info["pos"][0]
if (peak_x - xlim[0]) / x_range > 0.85:
active_annotation = self.peak_annotation_left
inactive_annotation = self.peak_annotation_right
else:
active_annotation = self.peak_annotation_right
inactive_annotation = self.peak_annotation_left
# 有効な方の注釈を更新して表示
active_annotation.set_text(closest_peak_info["hkl"])
active_annotation.xy = closest_peak_info["pos"]
active_annotation.set_visible(True)
# 使わない方の注釈は必ず非表示にする
inactive_annotation.set_visible(False)
# インジケーター線を更新して表示
y_lim_data_plot = self.canvas.axes.get_ylim()
self.indicator_line.set_data([peak_x, peak_x], y_lim_data_plot)
self.indicator_line.set_visible(True)
self.canvas.draw_idle()
# ホバー情報を保存
self.hovered_peak_info = closest_peak_info
self.canvas.draw_idle()
# ピークからカーソルが離れた場合
else:
if is_right_visible or is_left_visible or is_indicator_visible:
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 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()