未分類

# ==========================================
# 気象GPV 局地分析ツール ビューワー (App B)
# VERSION INFO: 134.0_EXE (ANALスライダー・メタグラム拡張版)
# ==========================================
import sys, os, glob, re, gc, time, logging

# --- EXE化対策: ライブラリの読み込み順序を強制 ---
import pyproj
import shapely
import cartopy

if getattr(sys, 'frozen', False):
    exe_dir = os.path.dirname(sys.executable)
    os.environ["CARTOPY_DATA_DIR"] = os.path.join(exe_dir, "cartopy_data")

import numpy as np
import textwrap
from datetime import datetime, timedelta
import scipy.ndimage as ndimage

from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, 
                             QHBoxLayout, QSlider, QPushButton, QLabel, 
                             QTabWidget, QListWidget, QTextEdit, QComboBox, QFileDialog, QDialog, 
                             QMessageBox, QLineEdit, QTableWidget, QTableWidgetItem, QHeaderView)
from PyQt6.QtCore import Qt, QTimer, QSettings
from PyQt6.QtGui import QColor, QFont, QCloseEvent

os.environ['QT_API'] = 'pyqt6'
import matplotlib
matplotlib.use('qtagg')
import matplotlib.colors as mcolors
import matplotlib as mpl
import matplotlib.path as mpath
import matplotlib.pyplot as plt

mpl.rcParams['font.family'] = 'MS Gothic'
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
import cartopy.crs as ccrs
import cartopy.feature as cfeature
import cartopy.io.shapereader as shpreader
import matplotlib.patheffects as pe

STATIONS = [
            ("札幌", 43.060, 141.328), ("稚内", 45.415, 141.677), ("旭川", 43.762, 142.358),
            ("網走", 44.018, 144.279), ("釧路", 42.984, 144.381), ("帯広", 42.923, 143.219),
            ("函館", 41.817, 140.753), ("青森", 40.824, 140.740), ("秋田", 39.718, 140.102),
            ("盛岡", 39.698, 141.155), ("仙台", 38.261, 140.896), ("山形", 38.255, 140.339),
            ("福島", 37.750, 140.467), ("新潟", 37.940, 139.043), ("富山", 36.710, 137.202),
            ("金沢", 36.588, 136.625), ("福井", 36.055, 136.224), ("宇都宮", 36.555, 139.870),
            ("前橋", 36.396, 139.060), ("熊谷", 36.147, 139.388), ("水戸", 36.381, 140.452),
            ("東京", 35.691, 139.756), ("銚子", 35.738, 140.828), ("横浜", 35.438, 139.654),
            ("長野", 36.662, 138.196), ("甲府", 35.670, 138.568), ("静岡", 34.978, 138.403),
            ("名古屋", 35.166, 136.965), ("岐阜", 35.400, 136.758), ("津", 34.733, 136.510),
            ("大津", 35.004, 135.859), ("京都", 35.011, 135.733), ("大阪", 34.682, 135.522),
            ("神戸", 34.696, 135.211), ("奈良", 34.692, 135.827), ("和歌山", 34.226, 135.167),
            ("鳥取", 35.485, 134.237), ("松江", 35.467, 133.064), ("岡山", 34.661, 133.919),
            ("広島", 34.396, 132.459), ("山口", 34.155, 131.472), ("徳島", 34.066, 134.554),
            ("高松", 34.316, 134.053), ("松山", 33.843, 132.766), ("高知", 33.566, 133.546),
            ("福岡", 33.581, 130.375), ("佐賀", 33.262, 130.301), ("長崎", 32.733, 129.873),
            ("熊本", 32.813, 130.704), ("大分", 33.237, 131.612), ("宮崎", 31.942, 131.411),
            ("鹿児島", 31.554, 130.549), ("名瀬", 28.378, 129.497), ("那覇", 26.206, 127.686),
            ("南大東", 25.845, 131.229), ("宮古島", 24.783, 125.276), ("石垣島", 24.336, 124.164)
]

MEPS_STAT_MODELS = ["MEPS_GUID_MAX", "MEPS_GUID_MIN", "MEPS_GUID_PROB1", "MEPS_GUID_PROB10", "MEPS_GUID_PROB20", "MEPS_GUID_PROB30"]
MEPS_STAT_TAB_TITLES = {
    "MEPS_GUID_MAX":    "MEPS_GUID(上位90%)",
    "MEPS_GUID_MIN":    "MEPS_GUID(下位10%)",
    "MEPS_GUID_PROB1":  "MEPS_GUID(確率≥1)",
    "MEPS_GUID_PROB10": "MEPS_GUID(確率≥10)",
    "MEPS_GUID_PROB20": "MEPS_GUID(確率≥20)",
    "MEPS_GUID_PROB30": "MEPS_GUID(確率≥30)",
}

CACHED_SHAPES = {'other_cities': None, 'sapporo_districts': None, 'sapporo_city': None, 'kamikawa': None}
SHAPEFILE_FOUND = False

def search_shapefile():
    base_dir = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.getcwd()
    paths = glob.glob(os.path.join(base_dir, '**', 'N03*.shp'), recursive=True)
    return paths[0] if paths else None

def get_simplified_geometries():
    global CACHED_SHAPES, SHAPEFILE_FOUND
    if CACHED_SHAPES['other_cities'] is not None: return CACHED_SHAPES['other_cities'], CACHED_SHAPES['sapporo_districts'], CACHED_SHAPES['sapporo_city'], CACHED_SHAPES['kamikawa']
    shp_path = search_shapefile()
    if not shp_path or not os.path.exists(shp_path): return [], [], [], []
    SHAPEFILE_FOUND = True
    other_cities_g = []; sapporo_districts_g = {}; sapporo_city_g = []; kamikawa_g = []
    sapporo_dist_names = ['中央区', '北区', '東区', '白石区', '厚別区', '豊平区', '清田区', '南区', '西区', '手稲区']
    for d_name in sapporo_dist_names: sapporo_districts_g[d_name] = []
    try:
        reader = shpreader.Reader(shp_path, encoding='cp932')
        for r in reader.records():
            geom = r.geometry
            try: geom = geom.simplify(0.002, preserve_topology=False)
            except: pass
            pref_name = str(r.attributes.get('N03_001', '')); subpref_name = str(r.attributes.get('N03_002', ''))
            city_name = str(r.attributes.get('N03_004', '')); cit_name_spec = str(r.attributes.get('N03_003', ''))
            is_sap_dist = False
            if '札幌市' in cit_name_spec:
                for d_name in sapporo_dist_names:
                    if d_name == city_name: is_sap_dist = True; sapporo_districts_g[d_name].append(geom); sapporo_city_g.append(geom); break
            if '上川' in subpref_name: kamikawa_g.append(geom)
            if not is_sap_dist and '北海道' in pref_name:
                try: 
                    if geom.area > 0.0005: other_cities_g.append(geom)
                except: pass
        CACHED_SHAPES['other_cities'] = other_cities_g; CACHED_SHAPES['sapporo_districts'] = sapporo_districts_g; 
        CACHED_SHAPES['sapporo_city'] = sapporo_city_g; CACHED_SHAPES['kamikawa'] = kamikawa_g
        return other_cities_g, sapporo_districts_g, sapporo_city_g, kamikawa_g
    except Exception as e:
        logging.error(f"Shapefile processing error: {e}")
        return [], [], [], []

def calc_t_td(t, rh):
    rh_s = np.clip(rh, 1.0, 100.0)
    e = 6.112 * np.exp((17.67 * t) / (t + 243.5)) * (rh_s / 100.0)
    e = np.clip(e, 0.001, 1000)
    td = (243.5 * np.log(e / 6.112)) / (17.67 - np.log(e / 6.112))
    return t - td

def rh_from_t_td(t, t_td):
    td = t - t_td
    e = 6.112 * np.exp((17.67 * td) / (td + 243.5))
    es = 6.112 * np.exp((17.67 * t) / (t + 243.5))
    rh = (e / es) * 100.0
    return np.clip(rh, 0.0, 100.0)

def calc_ept(t, rh, p):
    t_k = t + 273.15
    rh_s = np.clip(rh, 1.0, 100.0)
    e = 6.112 * np.exp((17.67 * t) / (t + 243.5)) * (rh_s / 100.0)
    e = np.clip(e, 0.001, p - 0.1)
    q = 0.622 * e / (p - e)
    tlcl = 2840.0 / (3.5 * np.log(t_k) - np.log(e) - 4.805) + 55.0
    theta = t_k * (1000.0 / p) ** 0.2854
    ept = theta * np.exp(((3376.0 / tlcl) - 2.54) * q * (1.0 + 0.81 * q))
    return ept

def get_t_from_ept(target_ept, p):
    t_min, t_max = -80.0, 50.0
    for _ in range(15):
        t_mid = (t_min + t_max) / 2.0
        ept_mid = calc_ept(t_mid, 100.0, p)
        if ept_mid < target_ept: t_min = t_mid
        else: t_max = t_mid
    return (t_min + t_max) / 2.0

class SingleImageWindow(QDialog):
    def __init__(self, parent, title, model_name, stat_name, init_z_str, canvas):
        super().__init__(parent)
        self.model_name = model_name; self.stat_name = stat_name; self.init_z_str = init_z_str
        self.setWindowTitle(title)
        self.resize(1100, 950)
        self.setStyleSheet("QDialog { background-color: #0A192F; color: #E0E0E0; }")
        
        layout = QVBoxLayout(self)
        layout.setContentsMargins(10, 10, 10, 10)
        layout.addWidget(canvas, stretch=1)
        
        btn_layout = QHBoxLayout()
        btn_layout.setContentsMargins(10, 10, 10, 10)
        
        save_btn = QPushButton("📷 画像として保存")
        save_btn.setStyleSheet("background-color: #27AE60; color: white; font-size: 14pt; font-weight: bold; padding: 12px; border-radius: 5px;")
        save_btn.clicked.connect(self.save_image)
        
        close_btn = QPushButton("閉じる")
        close_btn.setStyleSheet("background-color: #C0392B; color: white; font-size: 14pt; font-weight: bold; padding: 12px; border-radius: 5px;")
        close_btn.clicked.connect(self.accept)
        
        btn_layout.addStretch()
        btn_layout.addWidget(save_btn)
        btn_layout.addWidget(close_btn)
        btn_layout.addStretch()
        layout.addLayout(btn_layout)

    def save_image(self):
        desktop = os.path.expanduser("~/Desktop")
        filename = f"{self.model_name}_{self.stat_name}_{self.init_z_str}.png"
        path, _ = QFileDialog.getSaveFileName(self, "画像を保存", os.path.join(desktop, filename), "Images (*.png)")
        if path:
            canvas_widget = self.layout().itemAt(0).widget()
            pixmap = canvas_widget.grab() if hasattr(canvas_widget, 'grab') else None
            if pixmap: pixmap.save(path)
            else: self.layout().itemAt(0).widget().figure.savefig(path, dpi=150, bbox_inches='tight', facecolor='white')
            QMessageBox.information(self, "保存完了", f"保存しました:\n{os.path.basename(path)}")

class SplashWindow(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint)
        self.setFixedSize(480, 250)
        self.setStyleSheet("background-color: #0A192F; border: 3px solid #64FFDA; border-radius: 12px;")
        layout = QVBoxLayout(self)
        
        title_lbl = QLabel("気象GPV 局地分析ツール\n(Ver 134.0 統合版)")
        title_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
        title_lbl.setStyleSheet("font-size: 18pt; font-weight: bold; color: #E0E0E0; border: none; margin-top: 15px;")
        layout.addWidget(title_lbl)
        
        msg_lbl = QLabel("最新の解析データをロードしています...\n(約5秒で自動起動します)")
        msg_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
        msg_lbl.setStyleSheet("font-size: 14pt; color: #64FFDA; border: none;")
        layout.addWidget(msg_lbl)

class WeatherApp(QMainWindow):
    def __init__(self):
        super().__init__()
        self.settings = QSettings('WeatherApp', 'Viewer')
        self.cache_dir = self.settings.value('cache_dir', os.path.join(os.getcwd(), "gpv_cache_npz"))
        self.setup_logging()

        self.setWindowTitle("気象GPV 局地分析ツール (Ver 134.0 - ANALスライダー対応・メタグラム拡張版)")
        self.resize(1850, 1050)
        self.model_init_times = {"MSM": None, "GSM_JP": None, "MSM_GUID": None, "GSM_GUID": None, "ANAL": None}
        for _m in MEPS_STAT_MODELS: self.model_init_times[_m] = None
        self.tab_ui = {}
        
        self.monitor_timer = QTimer(self); self.monitor_timer.timeout.connect(self.scan_cache_dir)
        self.anim_timer = QTimer(self); self.anim_timer.timeout.connect(self.anim_step)
        self.current_anim_model = None
        self.setStyleSheet("""
            * { font-size: 13pt; font-family: "MS Gothic", sans-serif; color: #E0E0E0; }
            QMainWindow, QDialog, QMessageBox { background-color: #0A192F; color: #E0E0E0; }
            QMessageBox QLabel { color: #E0E0E0; }
            QMessageBox QPushButton { background-color: #1D3557; color: white; padding: 5px 15px; border-radius: 3px; font-weight: bold; }
            QMessageBox QPushButton:hover { background-color: #457B9D; }
            QTabWidget::pane { border: 1px solid #444; background: #112240; }
            QTabBar::tab { background: #1B2B4A; color: #8892B0; padding: 6px 12px; font-size: 11pt; border: 1px solid #333; border-bottom: none; margin-right: 2px; }
            QTabBar::tab:selected { background: #112240; color: #64FFDA; font-weight: bold; font-size: 11pt; border-top: 3px solid #64FFDA; }

            QSlider::groove:vertical { border: 1px solid #444; background: #0A192F; width: 14px; border-radius: 7px; }
            QSlider::handle:vertical { background: #64FFDA; border: 2px solid #000; height: 32px; margin: 0 -10px; border-radius: 16px; }
            QPushButton { background-color: #1D3557; border: 1px solid #457B9D; padding: 8px; border-radius: 4px; color: #F1FAEE; }
            QPushButton:hover { background-color: #457B9D; }
            QPushButton::checked { background-color: #E63946; color: white; font-weight: bold; }
            QListWidget { background: #112240; border: 1px solid #444; color: #E0E0E0; }
            QListWidget::item { padding: 3px 5px; margin: 1px 5px; background: #1D3557; border: 1px solid #333; border-radius: 6px; }
            QListWidget::item:selected { background: #E63946; color: white; font-weight: bold; }
            QListWidget::item:hover:!selected { background: #457B9D; }
            QComboBox { background: #112240; border: 1px solid #8892B0; color: white; padding: 5px; }
            QComboBox QAbstractItemView { background: #112240; color: white; }
            QLabel { background: transparent; }
            QTextEdit { background-color: #112240; color: #64FFDA; border: 1px solid #444; }
            .NavButton { background-color: #2980B9; color: white; font-weight: bold; padding: 5px; border-radius: 5px; }
            .PlayButton { background-color: #27AE60; color: white; font-weight: bold; padding: 5px 15px; border-radius: 5px; }
            .StopButton { background-color: #C0392B; color: white; font-weight: bold; padding: 5px 15px; border-radius: 5px; }
            .SyncButton { background-color: #F39C12; color: white; font-weight: bold; border: 2px solid #D68910; border-radius: 5px; padding: 5px 15px;}
            .SaveButton { background-color: #8E44AD; color: white; font-weight: bold; border-radius: 5px; padding: 4px 10px; font-size: 10pt;}
            .MetaButton { background-color: #16A085; color: white; font-weight: bold; border: 2px solid #0E6655; border-radius: 5px; padding: 5px 15px; }
            .MetaButton:checked { background-color: #0E6655; border: 2px solid #1ABC9C; }
            .AdminButton { background-color: #34495E; color: white; font-weight: bold; border-radius: 5px; padding: 4px 10px; font-size: 10pt;}
        """)

        main_widget = QWidget(); self.setCentralWidget(main_widget); main_layout = QVBoxLayout(main_widget)
        
        self.tabs = QTabWidget()
        corner_widget = QWidget()
        corner_layout = QHBoxLayout(corner_widget)
        corner_layout.setContentsMargins(0, 0, 5, 0)
        
        self.status_banner = QLabel("待機中...")
        self.status_banner.setStyleSheet("color: white; font-weight: bold; background-color: #E63946; padding: 4px 10px; border-radius: 4px; font-size: 10pt;")

        self.save_img_btn = QPushButton("📷 画面保存")
        self.save_img_btn.setProperty("class", "SaveButton")
        self.save_img_btn.clicked.connect(self.save_current_image)
        
        self.change_dir_btn = QPushButton("📁 読込先変更")
        self.change_dir_btn.setProperty("class", "AdminButton")
        self.change_dir_btn.clicked.connect(self.change_dir)

        self.sync_btn = QPushButton("🔄 画面同期")
        self.sync_btn.setProperty("class", "SyncButton")
        self.sync_btn.clicked.connect(lambda: self.scan_cache_dir(force_draw=True))

        self.exit_app_btn = QPushButton("🛑 完全停止")
        self.exit_app_btn.setProperty("class", "StopButton")
        self.exit_app_btn.clicked.connect(self.close)
        
        corner_layout.addWidget(self.status_banner)
        corner_layout.addWidget(self.save_img_btn)
        corner_layout.addWidget(self.change_dir_btn)
        corner_layout.addWidget(self.sync_btn)
        corner_layout.addWidget(self.exit_app_btn)
        
        self.tabs.setCornerWidget(corner_widget, Qt.Corner.TopRightCorner)
        
        self.setup_tab("MSM", "MSM GPV", is_guidance=False, is_anal=False)
        self.setup_tab("GSM_JP", "GSM_JP_GPV", is_guidance=False, is_anal=False)
        self.setup_tab("ANAL", "毎時大気解析", is_guidance=False, is_anal=True)
        self.setup_tab("MSM_GUID", "MSM_GUID", is_guidance=True, is_anal=False)
        self.setup_tab("GSM_GUID", "GSM_GUID", is_guidance=True, is_anal=False)
        
        for _m in MEPS_STAT_MODELS:
            self.setup_tab(_m, MEPS_STAT_TAB_TITLES[_m], is_guidance=True, is_anal=False)
            
        self.setup_history_tab()
        
        main_layout.addWidget(self.tabs)
        get_simplified_geometries()
        
        self.splash = SplashWindow()
        self.splash.show()
        QTimer.singleShot(5000, self.finish_startup_loading)

    def closeEvent(self, event: QCloseEvent):
        reply = QMessageBox.question(self, '終了の確認', 'システムを完全に停止してよろしいですか?',
                                     QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No)
        if reply == QMessageBox.StandardButton.Yes:
            event.accept()
        else:
            event.ignore()

    def setup_logging(self):
        self.log_file = os.path.join(self.cache_dir, "system_log.txt")
        os.makedirs(self.cache_dir, exist_ok=True)
        logging.basicConfig(filename=self.log_file, level=logging.INFO, 
                            format='%(asctime)s - %(levelname)s - %(message)s', encoding='utf-8')
        
    def change_dir(self):
        directory = QFileDialog.getExistingDirectory(self, "GPVデータ(npz)が保存されるフォルダを選択", self.cache_dir)
        if directory:
            self.cache_dir = directory
            self.settings.setValue('cache_dir', directory)
            self.setup_logging()
            QMessageBox.information(self, "設定完了", f"読込先フォルダを更新しました:\n{directory}")
            self.scan_cache_dir(force_draw=True)

    def finish_startup_loading(self):
        self.splash.close()
        self.scan_cache_dir(quiet=True)
        self.monitor_timer.start(5000)
        
        for i in range(self.tabs.count()):
            if "MSM" in self.tabs.tabText(i) and "GUID" not in self.tabs.tabText(i):
                self.tabs.setCurrentIndex(i); break
        if "MSM" in self.tab_ui:
            items = self.tab_ui["MSM"]['list'].findItems("地上気圧", Qt.MatchFlag.MatchExactly)
            if items: self.tab_ui["MSM"]['list'].setCurrentItem(items[0])
        self.show()

    def save_current_image(self):
        current_tab = self.tabs.tabText(self.tabs.currentIndex())
        _meps_hit = next((m for m in MEPS_STAT_MODELS if MEPS_STAT_TAB_TITLES[m] == current_tab), None)
        if _meps_hit: model_name = _meps_hit
        elif "MSM_GUID" in current_tab: model_name = "MSM_GUID"
        elif "GSM_GUID" in current_tab: model_name = "GSM_GUID"
        elif "毎時大気" in current_tab: model_name = "ANAL"
        elif "MSM" in current_tab: model_name = "MSM"
        elif "GSM_JP" in current_tab: model_name = "GSM_JP"
        else: return
        
        if model_name not in self.tab_ui or self.tab_ui[model_name]['ax'] is None: return
        ui = self.tab_ui[model_name]
        val = ui['slider'].value(); elem = ui['list'].currentItem().text()
        it_str = self.model_init_times.get(model_name)
        
        if model_name == "ANAL":
            val_idx = int(val)
            combo = ui['init_combo']
            if val_idx < combo.count():
                it_str = combo.itemData(val_idx)
            
        if not it_str: return
            
        dt_utc = datetime.strptime(it_str, "%Y%m%d%H%M%S")
        init_z = dt_utc.strftime("%m%d%H") + "Z"
        filename = f"{model_name}_{elem}_{init_z}_FT{val:02d}.png"
        desktop = os.path.expanduser("~/Desktop")
        
        path, _ = QFileDialog.getSaveFileName(self, "画像を保存", os.path.join(desktop, filename), "Images (*.png *.jpg)")
        if path:
            ui['fig'].savefig(path, dpi=150, bbox_inches='tight', facecolor='white')
            self.status_banner.setText(f"保存完了: {os.path.basename(path)}")
    
    def init_map(self, fig, area_idx, model):
        fig.clear()
        if area_idx == 4:
            gs = fig.add_gridspec(1, 2)
            ax1 = fig.add_subplot(gs[0, 0], projection=ccrs.PlateCarree()) 
            ax2 = fig.add_subplot(gs[0, 1], projection=ccrs.PlateCarree()) 
            ax1.set_extent([141.20, 141.70, 43.00, 43.65], crs=ccrs.PlateCarree()) 
            ax2.set_extent([141.30, 141.80, 42.50, 43.15], crs=ccrs.PlateCarree()) 
            self.draw_base_map(ax1, 3)
            self.draw_base_map(ax2, 3)
            
            # --- 追加: マップを上部に詰めて余白を消す ---
            ax1.set_anchor('N')
            ax2.set_anchor('N')
            
            fig.subplots_adjust(left=0.03, right=0.92, top=0.93, bottom=0.05, wspace=0.1)
            return [ax1, ax2]
        else:
            ax = fig.add_subplot(1, 1, 1, projection=ccrs.PlateCarree())
            ax.set_extent(self.get_extent(area_idx, model), crs=ccrs.PlateCarree())
            self.draw_base_map(ax, area_idx)
            
            # --- 追加: マップを上部に詰めて余白を消す ---
            ax.set_anchor('N')
            
            fig.subplots_adjust(left=0.05, right=0.9, top=0.92, bottom=0.05)
            return ax

    def get_extent(self, area_index, model_name):
        if area_index == 0: return [120, 150, 27.5, 48] if "MSM" in model_name or "ANAL" in model_name else [120, 150, 20, 50]
        elif area_index == 1: return [138.5, 146.5, 40.5, 46.5]  
        elif area_index == 2: return [141.6, 143.6, 42.8, 45.0]  
        elif area_index == 3: return [141.05, 141.65, 42.85, 43.27] 
        elif area_index == 4: return [141.35, 141.80, 42.50, 43.25] 
        elif area_index == 5: return [141.10, 141.45, 43.15, 43.70] 
        else: return [141.05, 141.65, 42.85, 43.27]
    
    def crop_data(self, lon, lat, data, extent):
        if lon is None or lat is None or data is None or data.size == 0:
            return np.array([]), np.array([]), np.array([])
        buffer = 1.0; lon_min, lon_max = extent[0] - buffer, extent[1] + buffer; lat_min, lat_max = extent[2] - buffer, extent[3] + buffer
        if lon.ndim == 1:
            m_lon = (lon >= lon_min) & (lon <= lon_max); m_lat = (lat >= lat_min) & (lat <= lat_max)
            if not np.any(m_lon) or not np.any(m_lat): return np.array([]), np.array([]), np.array([])
            return lon[m_lon], lat[m_lat], data[np.ix_(m_lat, m_lon)]
        else:
            mask = (lon >= lon_min) & (lon <= lon_max) & (lat >= lat_min) & (lat <= lat_max)
            j, i = np.where(mask)
            if len(j) == 0: return np.array([]), np.array([]), np.array([])
            return lon[j.min():j.max()+1, i.min():i.max()+1], lat[j.min():j.max()+1, i.min():i.max()+1], data[j.min():j.max()+1, i.min():i.max()+1]

    def draw_base_map(self, ax, area_index):
        SEA_COLOR = '#A4C2E4'; LAND_COLOR = '#F5F5F0'; COASTLINE_COLOR = '#333333'
        ax.set_facecolor(SEA_COLOR); ax.add_feature(cfeature.LAND.with_scale('10m'), facecolor=LAND_COLOR, zorder=0)
        ax.add_feature(cfeature.OCEAN.with_scale('10m'), facecolor=SEA_COLOR, zorder=0)
        ax.add_feature(cfeature.LAKES.with_scale('10m'), edgecolor='#4682B4', facecolor=SEA_COLOR, zorder=1)
        ax.add_feature(cfeature.RIVERS.with_scale('10m'), linewidth=0.8, edgecolor='#4682B4', zorder=1)
        
        try:
            ax.add_feature(cfeature.COASTLINE.with_scale('10m'), linewidth=0.6, edgecolor=COASTLINE_COLOR, zorder=2)
        except Exception as e:
            logging.error(f"Cartopy COASTLINE error: {e}")

        other_cities_g, sapporo_districts_g, sapporo_city_g, kamikawa_g = get_simplified_geometries()
        
        if area_index >= 1 and other_cities_g: 
            ax.add_geometries(other_cities_g, ccrs.PlateCarree(), edgecolor='black', facecolor='none', linewidth=0.8, linestyle='-', zorder=3)
        
        if area_index == 3 and sapporo_districts_g:
            for d_name, geoms in sapporo_districts_g.items():
                if geoms: ax.add_geometries(geoms, ccrs.PlateCarree(), edgecolor='#FF0000', facecolor='none', linewidth=1.5, zorder=10)
        
        if sapporo_city_g and area_index == 3: ax.add_geometries(sapporo_city_g, ccrs.PlateCarree(), edgecolor='#FF0000', facecolor='#FF8C00', alpha=0.3, linewidth=2.0, linestyle='-', zorder=4)
        elif sapporo_city_g and area_index >= 1: ax.add_geometries(sapporo_city_g, ccrs.PlateCarree(), edgecolor='#FF0000', facecolor='#FF8C00', alpha=0.2, linewidth=1.0, linestyle='-', zorder=4)
        
        if area_index >= 1 and kamikawa_g: ax.add_geometries(kamikawa_g, ccrs.PlateCarree(), edgecolor='#FF0000', facecolor='#FF8C00', alpha=0.25 if area_index == 2 else 0.15, linewidth=1.5, linestyle='-', zorder=5)
        
        try:
            pref_lines = cfeature.NaturalEarthFeature('cultural', 'admin_1_states_provinces_lines', '10m', edgecolor='#444444', facecolor='none')
            ax.add_feature(pref_lines, linewidth=0.8, linestyle='-.', zorder=7)
        except: pass

        gl = ax.gridlines(crs=ccrs.PlateCarree(), draw_labels=True, linewidth=0.5, color='gray', alpha=0.5, linestyle='--', zorder=6)
        gl.top_labels = False; gl.right_labels = False 
        gl.xlabel_style = {'size': 11, 'color': 'black', 'fontname': 'MS Gothic'}; gl.ylabel_style = {'size': 11, 'color': 'black', 'fontname': 'MS Gothic'}
        
        if area_index == 4 or area_index == 5: gl.xlocator = mpl.ticker.MultipleLocator(0.1); gl.ylocator = mpl.ticker.MultipleLocator(0.1) 
        elif area_index == 3: gl.xlocator = mpl.ticker.MultipleLocator(0.05); gl.ylocator = mpl.ticker.MultipleLocator(0.05) 
        elif area_index == 2: gl.xlocator = mpl.ticker.MultipleLocator(0.5); gl.ylocator = mpl.ticker.MultipleLocator(0.5) 
        elif area_index == 1: gl.xlocator = mpl.ticker.MultipleLocator(1.0); gl.ylocator = mpl.ticker.MultipleLocator(1.0) 
        else: gl.xlocator = mpl.ticker.MultipleLocator(5.0) if area_index == 0 else mpl.ticker.MultipleLocator(1.0); gl.ylocator = mpl.ticker.MultipleLocator(5.0) if area_index == 0 else mpl.ticker.MultipleLocator(1.0)
    
    def get_local_info(self, elem_name, lon, lat, data, u=None, v=None, area_index=0, model_name=""):
        if data is None or lon is None or lat is None or data.size == 0: return ""
        target_lon, target_lat, loc_name = (142.368, 43.760, "旭川") if area_index == 2 else (141.35, 43.06, "札幌")
        if lon.ndim == 1: lon_2d, lat_2d = np.meshgrid(lon, lat)
        else: lon_2d, lat_2d = lon, lat
        dist = (lon_2d - target_lon)**2 + (lat_2d - target_lat)**2
        sy, sx = np.unravel_index(np.argmin(dist), dist.shape)
        if sy >= data.shape[0] or sx >= data.shape[1]: return ""
        s_val = data[sy, sx]
        if np.isnan(s_val): return ""
        
        is_prob = "PROB" in model_name
        thresh = model_name.replace("MEPS_GUID_PROB", "") if is_prob else ""
        unit = "mm" if "降水" in elem_name else "cm"
        
        if is_prob and ("降水" in elem_name or "降雪" in elem_name):
            info = f"[{loc_name}現況]\n{elem_name}(≥{thresh}{unit})確率: {s_val:.0f} %"
        elif "降水量" in elem_name or "降水" in elem_name: info = f"[{loc_name}現況]\n降水量: {s_val:.1f} mm"
        elif "降雪量" in elem_name or "降雪" in elem_name: info = f"[{loc_name}現況]\n降雪量: {s_val:.1f} cm"
        else: info = f"[{loc_name}現況]\n" 
        
        if "降水" not in elem_name and "降雪" not in elem_name:
            if "気圧" in elem_name: info += f"気圧: {s_val:.1f} hPa"
            elif "確率" in elem_name and "発雷" not in elem_name: info += f"{elem_name}: {s_val:.0f} %"
            elif "発雷確率" in elem_name: info += f"発雷確率: {s_val:.0f} %"
            elif "天気" in elem_name: 
                wx_map = {1: "晴", 2: "曇", 3: "雨", 4: "雪"}
                info += f"卓越天気: {wx_map.get(int(s_val), '不明')}"
            elif "雲量" in elem_name: info += f"{elem_name}: {s_val:.1f} %"
            elif "高度" in elem_name: info += f"{elem_name[:6]}高度: {s_val:.0f} m"
            elif "渦度" in elem_name: info += f"正渦度(10^-5/s): {s_val:.1f}"
            elif "気温" in elem_name: info += f"{elem_name[:6]}気温: {s_val:.1f} ℃"
            elif "鉛直流" in elem_name: info += f"鉛直流: {s_val:.1f} hPa/h"
            elif "湿数" in elem_name: info += f"{elem_name[:6]}湿数: {s_val:.1f} ℃"
            elif "相当温位" in elem_name: info += f"{elem_name[:6]}相当温位: {s_val:.1f} K"
            else: info += f"値: {s_val:.1f}"
        
        if u is not None and v is not None and sy < u.shape[0] and sx < u.shape[1]: info += f"\n風速: {np.hypot(u[sy, sx], v[sy, sx]):.1f} m/s"
        return info

    def on_area_btn_clicked(self, model_name, idx):
        ui = self.tab_ui[model_name]
        for i, btn in enumerate(ui['area_btns']): btn.setChecked(i == idx)
        is_meta_btn = ("メタグラム" in ui['area_btns'][-1].text() and idx == len(ui['area_btns'])-1)
        if is_meta_btn:
            ui['canvas'].hide()
            if 'info_panel' in ui: ui['info_panel'].hide()
            if 'meta_ctrl' in ui: ui['meta_ctrl'].show()
        else:
            if 'meta_ctrl' in ui: ui['meta_ctrl'].hide()
            ui['canvas'].show()
            self.draw_frame(model_name, ui['slider'].value())

    def on_init_changed(self, model_name, idx):
        ui = self.tab_ui[model_name]
        if idx < 0: return
        selected_time_str = ui['init_combo'].itemData(idx)
        if selected_time_str:
            self.model_init_times[model_name] = selected_time_str
            self.update_slider_max(model_name, selected_time_str)
            if len(ui['area_btns']) > 4 and ui['area_btns'][-1].isChecked(): return
            
            elem_name = ui['list'].currentItem().text()
            val = ui['slider'].value()
            if "降水" in elem_name and val == 0 and "確率" not in elem_name and "時間降水" not in elem_name and model_name != "ANAL":
                val = ui['step']
                ui['slider'].blockSignals(True)
                ui['slider'].setValue(val)
                ui['slider'].blockSignals(False)

            self.draw_frame(model_name, val)

    def on_element_changed(self, model_name):
        ui = self.tab_ui[model_name]
        elem_name = ui['list'].currentItem().text()
        
        if "GUID" in model_name:
            min_ft = 0
            if "6時間" in elem_name: min_ft = 6
            elif "12時間" in elem_name: min_ft = 12
            elif "24時間" in elem_name: min_ft = 24
            elif "3時間" in elem_name: min_ft = 3
            
            ui['slider'].setMinimum(min_ft)
            if ui['slider'].value() < min_ft:
                ui['slider'].blockSignals(True)
                ui['slider'].setValue(min_ft)
                ui['slider'].blockSignals(False)
        else:
            ui['slider'].setMinimum(0)
            
        self.on_slider_changed(model_name, ui['slider'].value())

    def on_slider_changed(self, model_name, val):
        ui = self.tab_ui[model_name]
        elem_name = ui['list'].currentItem().text()

        if model_name == "GSM_JP":
            ground_cloud_elems = ["地上気圧", "地上風向風速", "地上降水", "全雲量", "上層雲量", "中層雲量", "下層雲量", "地上3種重ね"]
            threshold = 132 if elem_name in ground_cloud_elems else 84

            if val > threshold and val % 6 != 0:
                last_val = ui.get('last_val', 0)
                direction = 1 if val >= last_val else -1
                if direction == 1: new_val = val + (6 - (val % 6))
                else: new_val = val - (val % 6)

                if new_val > ui['slider'].maximum(): new_val = ui['slider'].maximum()

                ui['slider'].blockSignals(True)
                ui['slider'].setValue(new_val)
                ui['slider'].blockSignals(False)
                val = new_val

        ui['last_val'] = val  
        self.draw_frame(model_name, val)
    
    def update_slider_max(self, ui_key, init_time_str):
        ui = self.tab_ui.get(ui_key)
        if not ui: return
        
        if ui_key == "ANAL":
            count = ui['init_combo'].count()
            final_max = max(0, count - 1)
            step = 1; tick_step = 1
        else:
            files = glob.glob(os.path.join(self.cache_dir, f"{ui_key}_{init_time_str}_FT*.npz"))
            max_ft_found = max([0] + [int(re.search(r'_FT(\d+)\.npz', f).group(1)) for f in files if re.search(r'_FT(\d+)\.npz', f)])
            hh = init_time_str[8:10] if len(init_time_str) >= 10 else '00'
            
            if ui_key == "MSM": default_max = 78 if hh in ['00', '12'] else 39; step = 1; tick_step = 6
            elif ui_key == "GSM_JP": default_max = 264 if hh in ['00', '12'] else 132; step = 3; tick_step = 12
            elif ui_key == "MSM_GUID": default_max = 84; step = 3; tick_step = 6
            elif ui_key == "GSM_GUID": default_max = 84; step = 3; tick_step = 12
            elif ui_key in MEPS_STAT_MODELS: default_max = 39; step = 3; tick_step = 12
            else: default_max = 132; step = 6; tick_step = 24
                
            final_max = max(default_max, max_ft_found); final_max = (final_max // step) * step
            
        ui['slider'].setMaximum(final_max)
        ui['slider'].setTickInterval(step); ui['slider'].setSingleStep(step); ui['slider'].setPageStep(step)
        
        ticks_layout = ui['ticks_layout']
        for i in reversed(range(ticks_layout.count())):
            w = ticks_layout.itemAt(i).widget()
            if w: w.setParent(None)
        
        if ui_key == "ANAL":
            for t in range(0, final_max + 1, tick_step):
                time_offset = t * 30
                lbl = QLabel(f"-{time_offset}分"); lbl.setStyleSheet("font-size: 8pt; color: #8892B0;"); lbl.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter); ticks_layout.addWidget(lbl)
        else:
            for t in range(0, final_max + 1, tick_step):
                lbl = QLabel(f"{t}h"); lbl.setStyleSheet("font-size: 8pt; color: #8892B0;"); lbl.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter); ticks_layout.addWidget(lbl)

    def create_compressed_left_panel(self, model_name):
        left_panel = QVBoxLayout(); left_panel.setContentsMargins(5, 5, 5, 5)
        step = 1 if model_name in ["MSM", "ANAL"] else 3

        left_panel.addWidget(QLabel(f"📡 {model_name} 初期時刻(UTC):", styleSheet="font-weight: bold; font-size: 11pt; color: #E0E0E0;"))
        init_combo = QComboBox(); init_combo.addItem("(データ待機中...)", None); left_panel.addWidget(init_combo)
        interval_label = QLabel(f"【{step}時間間隔表示】", styleSheet="color: #64FFDA; font-weight: bold; font-size: 11pt;"); left_panel.addWidget(interval_label)

        mid_hbox = QHBoxLayout(); elem_vbox = QVBoxLayout(); elem_vbox.addWidget(QLabel("■ 要素選択"))
        element_list = QListWidget(); element_list.setFixedWidth(200)
        
        
        if model_name in MEPS_STAT_MODELS:
            elems = ["3時間降水量", "6時間降水量", "12時間降水量", "1時間最大降水量", "3時間最大降水量", "24時間最大降水量", "3時間降雪量", "6時間降雪量", "12時間降雪量", "24時間降雪量", "発雷確率"]
        elif "GUID" in model_name:
            elems = ["卓越天気", "3時間降水量", "6時間降水量", "12時間降水量", "24時間降水量", "3時間降雪量", "6時間降雪量", "12時間降雪量", "24時間降雪量", "発雷確率"]
        elif model_name == "ANAL":
            elems = ["地上気温", "地上風向風速", "300hPa風向・風速", "500hPa風向・風速", "500hPa気温", "700hPa気温・風", "850hPa気温・風", "925hPa気温・風", "975hPa気温・風"]
        else:
            elems = ["地上3種重ね", "地上気圧","地上風向風速", "地上降水", "全雲量", "上層雲量", "中層雲量", "下層雲量", "300hPa高度", "300hPa風向・風速", "500hPa高度", "500hPa風向・風速", "500hPa渦度", "500hPa気温", "700hPa湿数・風", "700hPa鉛直流", "850hPa気温・風", "850hPa相当温位", "850hPa湿数・風", "925hPa相当温位", "925hPa湿数・風", "975hPa相当温位", "975hPa湿数・風"]
            
        element_list.addItems(elems); element_list.setCurrentRow(0); elem_vbox.addWidget(element_list); mid_hbox.addLayout(elem_vbox)

        slider_vbox = QVBoxLayout()
        time_label = QLabel("FT=0h\n(--/--)"); time_label.setStyleSheet("font-weight: bold; color: #64FFDA; font-size: 11pt; background: #1D3557; padding: 5px; border: 2px solid #457B9D; border-radius: 5px;"); time_label.setAlignment(Qt.AlignmentFlag.AlignCenter); slider_vbox.addWidget(time_label)

        slider_control_hbox = QHBoxLayout()
        btn_prev = QPushButton("▲"); btn_prev.setProperty("class", "NavButton"); btn_prev.setFixedWidth(30)
        btn_next = QPushButton("▼"); btn_next.setProperty("class", "NavButton"); btn_next.setFixedWidth(30)
        slider = QSlider(Qt.Orientation.Vertical); slider.setRange(0, 39); slider.setTickPosition(QSlider.TickPosition.TicksLeft); slider.setTickInterval(step); slider.setSingleStep(step); slider.setPageStep(step); slider.setInvertedAppearance(True)
        
        btn_vbox = QVBoxLayout(); btn_vbox.addWidget(slider, stretch=1); btn_vbox.setAlignment(Qt.AlignmentFlag.AlignHCenter)
        nav_hbox = QHBoxLayout(); nav_hbox.addWidget(btn_prev); nav_hbox.addWidget(btn_next)
        inner_slider_vbox = QVBoxLayout(); inner_slider_vbox.addLayout(nav_hbox); inner_slider_vbox.addLayout(btn_vbox); slider_control_hbox.addLayout(inner_slider_vbox)

        ticks_vbox = QVBoxLayout(); ticks_vbox.setContentsMargins(0, 30, 0, 10)
        slider_control_hbox.addLayout(ticks_vbox); slider_vbox.addLayout(slider_control_hbox, stretch=1)
        
        play_hbox = QHBoxLayout()
        btn_play = QPushButton("▶"); btn_play.setProperty("class", "PlayButton"); btn_play.setFixedWidth(40)
        btn_stop = QPushButton("■"); btn_stop.setProperty("class", "StopButton"); btn_stop.setFixedWidth(40)
        play_hbox.addWidget(btn_play); play_hbox.addWidget(btn_stop); slider_vbox.addLayout(play_hbox)
        
        combo_speed = QComboBox(); combo_speed.addItems(["速1", "速2", "速3", "速4", "速5"]); combo_speed.setCurrentIndex(2); slider_vbox.addWidget(combo_speed)
        mid_hbox.addLayout(slider_vbox); left_panel.addLayout(mid_hbox, stretch=1)

        return left_panel, init_combo, interval_label, element_list, time_label, btn_prev, btn_next, slider, btn_play, btn_stop, combo_speed, ticks_vbox
    
    def setup_tab(self, model_name, tab_title, is_guidance=False, is_anal=False):
        tab = QWidget(); main_hbox = QHBoxLayout(tab)
        left_panel, init_combo, interval_label, element_list, time_label, btn_prev, btn_next, slider, btn_play, btn_stop, combo_speed, ticks_layout = self.create_compressed_left_panel(model_name)
        main_hbox.addLayout(left_panel, stretch=2)

        right_panel = QVBoxLayout()
        area_layout = QHBoxLayout(); area_layout.addWidget(QLabel("表示領域:"))
        
        area_btns = [QPushButton("日本周辺"), QPushButton("北海道全域"), QPushButton("上川地方"), QPushButton("札幌近郊"), QPushButton("恵庭・石狩"), QPushButton("📊 メタグラム")]
        for i, btn in enumerate(area_btns):
            btn.setCheckable(True); area_layout.addWidget(btn); btn.clicked.connect(lambda checked, idx=i, m=model_name: self.on_area_btn_clicked(m, idx))
        area_btns[0].setChecked(True)
        
        btn_prev.clicked.connect(lambda: step_slider(-1)); btn_next.clicked.connect(lambda: step_slider(1))
        slider.valueChanged.connect(lambda val, m=model_name: self.on_slider_changed(m, val))
        
        if not is_anal:
            btn_play.clicked.connect(lambda: self.start_animation(model_name)); btn_stop.clicked.connect(self.stop_animation)
            
        element_list.itemSelectionChanged.connect(lambda m=model_name: self.on_element_changed(m))
        init_combo.currentIndexChanged.connect(lambda idx, m=model_name: self.on_init_changed(m, idx)) 
        self.tabs.addTab(tab, tab_title)
        
        area_layout.addStretch()
        right_panel.addLayout(area_layout)

        # ノートPC等の画面向けにベースサイズをややコンパクトにし、レスポンシブ性を向上
        fig = Figure(figsize=(12, 10)); fig.patch.set_facecolor('white'); canvas = FigureCanvas(fig)

        meta_ctrl_widget = QWidget(); meta_ctrl_layout = QVBoxLayout(meta_ctrl_widget)
        h_ctrl = QHBoxLayout()
        h_ctrl.addWidget(QLabel("地点選択:")); station_combo = QComboBox()
        for name, lat, lon in STATIONS: station_combo.addItem(name, (lat, lon))
        h_ctrl.addWidget(station_combo)
        
        if is_anal:
            btn_generate_meta = QPushButton("表示 (解析要素時系列グラフ生成)")
            btn_generate_meta.setProperty("class", "SyncButton")
            btn_generate_meta.clicked.connect(lambda: self.draw_anal_visual_metagram(station_combo))
            h_ctrl.addWidget(btn_generate_meta)
        else:
            btn_text = "表示 (A4横サイズ テーブル画像生成)" if is_guidance else "表示 (図解メタグラム生成)"
            btn_generate_meta = QPushButton(btn_text)
            btn_generate_meta.setProperty("class", "SyncButton")
            if is_guidance or model_name in MEPS_STAT_MODELS: btn_generate_meta.clicked.connect(lambda: self.draw_guidance_metagram(station_combo, model_name))
            elif model_name == "GSM_JP": btn_generate_meta.clicked.connect(lambda: self.draw_gsm_visual_metagram(station_combo))
            elif model_name == "MSM": btn_generate_meta.clicked.connect(lambda: self.draw_msm_visual_metagram(station_combo))
            h_ctrl.addWidget(btn_generate_meta)
            
        h_ctrl.addStretch()
        meta_ctrl_layout.addLayout(h_ctrl); meta_ctrl_layout.addStretch(); meta_ctrl_widget.hide()

        right_panel.addWidget(canvas, stretch=10)
        right_panel.addWidget(meta_ctrl_widget, stretch=10)
        main_hbox.addLayout(right_panel, stretch=8)

        info_panel = QLabel(canvas)
        info_panel.setStyleSheet("background-color: rgba(17, 34, 64, 0.85); border: 2px solid #64FFDA; border-radius: 5px; padding: 5px; font-weight: bold; font-size: 12pt; color: #E0E0E0;"); info_panel.hide()

        step = 1 if model_name in ["MSM", "ANAL"] else 3

        self.tab_ui[model_name] = {
            'list': element_list, 'area_btns': area_btns, 'slider': slider, 'time_label': time_label, 'init_combo': init_combo, 
            'interval_label': interval_label, 'fig': fig, 'canvas': canvas, 'meta_ctrl': meta_ctrl_widget, 'ax': None, 'current_area_index': -1, 
            'dynamic_artists': [], 'colorbars': [], 'info_panel': info_panel, 'combo_speed': combo_speed, 'ticks_layout': ticks_layout,
            'step': step,'last_val':0
        }
        
        if is_anal:
            btn_play.hide(); btn_stop.hide(); combo_speed.hide()
            interval_label.setText("【過去履歴閲覧 (30分間隔)】")
        
        def step_slider(val_change, m=model_name):
            ui = self.tab_ui[m]
            current_val = ui['slider'].value()
            elem_name = ui['list'].currentItem().text()
            
            s_step = ui['step']
            if m == "GSM_JP":
                ground_cloud_elems = ["地上気圧", "地上風向風速", "地上降水", "全雲量", "上層雲量", "中層雲量", "下層雲量", "地上3種重ね"]
                threshold = 132 if elem_name in ground_cloud_elems else 84
                if val_change > 0: s_step = 6 if current_val >= threshold else 3
                else: s_step = 6 if current_val > threshold else 3
            elif m == "ANAL":
                s_step = 1
                    
            new_val = current_val + (s_step * val_change)
            if new_val > ui['slider'].maximum(): new_val = ui['slider'].minimum()
            if new_val < ui['slider'].minimum(): new_val = ui['slider'].maximum()
            ui['slider'].setValue(new_val)

    def get_exact_key(self, data_obj, exact_candidates):
        for c in exact_candidates:
            if c in data_obj.files: return c
        for k in data_obj.files:
            if k.lower() in [c.lower() for c in exact_candidates]: return k
        return None

    def get_precip_key(self, data):
        return self.get_exact_key(data, ['precip', 'pr', 'tp', 'apcp', 'prate', 'rain', 'rn', 'pop', 'var_0_1_8', 'var_0_1_52'])

    def get_w_key(self, data):
        return self.get_exact_key(data, ['w', 'w700', 'vvel', 'vvel700', 'dzdt', 'dzdt700'])

    def get_coords_for_data(self, data_obj, data_array):
        if data_array is None: return None, None
        ny = data_array.shape[0]; nx = data_array.shape[1] if data_array.ndim > 1 else len(data_array)
        for k in data_obj.files:
            if 'lon' in k.lower():
                if (data_obj[k].ndim == 1 and data_obj[k].shape[0] == nx) or (data_obj[k].ndim == 2 and data_obj[k].shape == data_array.shape):
                    lat_k = k.replace('lon', 'lat').replace('Lon', 'Lat')
                    if lat_k in data_obj.files: return data_obj[k], data_obj[lat_k]
                    for lk in data_obj.files:
                        if 'lat' in lk.lower() and ((data_obj[lk].ndim == 1 and data_obj[lk].shape[0] == ny) or data_obj[lk].shape == data_array.shape):
                            return data_obj[k], data_obj[lk]
        return None, None

    def extract_val_with_key(self, data, key, stat_lon, stat_lat):
        if key not in data.files: return np.nan
        arr = data[key]
        lon_arr, lat_arr = self.get_coords_for_data(data, arr)
        
        if lon_arr is None or lat_arr is None:
            lon_k = next((k for k in data.files if 'lon' in k.lower()), None)
            lat_k = next((k for k in data.files if 'lat' in k.lower()), None)
            if not lon_k or not lat_k: return np.nan
            lon_arr, lat_arr = data[lon_k], data[lat_k]

        if lon_arr.ndim == 1 and arr.shape == (len(lon_arr), len(lat_arr)): arr = arr.T
        if lon_arr.ndim == 1 and arr.shape != (len(lat_arr), len(lon_arr)):
            if arr.shape[1] > arr.shape[0] and len(lat_arr) > len(lon_arr): arr = arr.T
            lon_arr = np.linspace(lon_arr.min(), lon_arr.max(), arr.shape[1])
            lat_arr = np.linspace(lat_arr.min(), lat_arr.max(), arr.shape[0])
            
        if lon_arr.ndim == 1: lon_2d, lat_2d = np.meshgrid(lon_arr, lat_arr)
        else: lon_2d, lat_2d = lon_arr, lat_arr
            
        dist = (lon_2d - stat_lon)**2 + (lat_2d - stat_lat)**2
        sy, sx = np.unravel_index(np.argmin(dist), dist.shape)
        
        if sy < arr.shape[0] and sx < arr.shape[1]:
            v = arr[sy, sx]
            if np.isnan(v) or v > 200000 or v < -10000: return np.nan
            return float(v)
        return np.nan
    
    def extract_val_robust(self, data, keys, stat_lon, stat_lat):
        k = self.get_exact_key(data, keys)
        if not k:
            for dk in data.files:
                if any(x in dk.lower() for x in keys): k = dk; break
        if not k: return np.nan
        return self.extract_val_with_key(data, k, stat_lon, stat_lat)

    def draw_msm_visual_metagram(self, station_combo):
        it_str = self.model_init_times.get("MSM")
        if not it_str:
            QMessageBox.warning(self, "エラー", "データが存在しません。"); return
            
        stat_name = station_combo.currentText(); stat_lat, stat_lon = station_combo.currentData()
        dt_utc = datetime.strptime(it_str, "%Y%m%d%H%M%S")
        dt_jst = dt_utc + timedelta(hours=9)
        init_z_str = f"{dt_utc.strftime('%m%d%H')}Z" 
        title_time_str = f"{dt_utc.strftime('%m月%d日%H')}Z / {dt_jst.strftime('%H')}JST" 
        
        max_ft = self.tab_ui['MSM']['slider'].maximum()
        fts_surf = list(range(0, max_ft + 1))
        fts_prof = [ft for ft in fts_surf if ft % 3 == 0]
        
        plevs = [1000, 975, 950, 925, 850, 700, 600, 500, 300]; y_coords = [0.0, 0.05, 0.1, 0.15, 0.25, 0.5, 0.625, 0.75, 1.0]
        T_prof = np.full((len(plevs), len(fts_prof)), np.nan); RH_prof = np.full((len(plevs), len(fts_prof)), np.nan)
        U_prof = np.full((len(plevs), len(fts_prof)), np.nan); V_prof = np.full((len(plevs), len(fts_prof)), np.nan)
        
        SSI_850_series = np.full(len(fts_prof), np.nan)
        SSI_700_series = np.full(len(fts_prof), np.nan)
        SSI_925_series = np.full(len(fts_prof), np.nan)
        P_surf_series = np.full(len(fts_surf), np.nan); T_surf_series = np.full(len(fts_surf), np.nan); Pr_surf_series = np.full(len(fts_surf), np.nan)
        
        Vort_500_series = np.full(len(fts_prof), np.nan) 
        W_700_series = np.full(len(fts_prof), np.nan)

        for c_idx, ft in enumerate(fts_surf):
            gpv_file = os.path.join(self.cache_dir, f"MSM_{it_str}_FT{ft:02d}.npz")
            if not os.path.exists(gpv_file): continue
            try:
                d = np.load(gpv_file)
                P_surf_series[c_idx] = self.extract_val_robust(d, ['prmsl', 'mslet', 'msl', 'slp'], stat_lon, stat_lat)
                if not np.isnan(P_surf_series[c_idx]) and P_surf_series[c_idx] > 2000: P_surf_series[c_idx] /= 100.0
                T_surf_series[c_idx] = self.extract_val_robust(d, ['t2m', 'tmp2m', 'temp2m'], stat_lon, stat_lat)
                if not np.isnan(T_surf_series[c_idx]) and T_surf_series[c_idx] > 100: T_surf_series[c_idx] -= 273.15
                
                p_key = self.get_precip_key(d)
                if p_key:
                    pr = self.extract_val_with_key(d, p_key, stat_lon, stat_lat)
                    Pr_surf_series[c_idx] = pr if not np.isnan(pr) else 0.0
                else: Pr_surf_series[c_idx] = 0.0
                d.close()
            except Exception as e: logging.error(f"Error reading {gpv_file}: {e}")

        for c_idx, ft in enumerate(fts_prof):
            gpv_file = os.path.join(self.cache_dir, f"MSM_{it_str}_FT{ft:02d}.npz")
            if not os.path.exists(gpv_file): continue
            try:
                d = np.load(gpv_file)
                t2m = self.extract_val_robust(d, ['t2m', 'tmp2m', 'temp2m'], stat_lon, stat_lat)
                if not np.isnan(t2m) and t2m > 100: t2m -= 273.15
                T_prof[0, c_idx] = t2m
                
                RH_prof[0, c_idx] = self.extract_val_robust(d, ['rh2m', 'r2m'], stat_lon, stat_lat)
                U_prof[0, c_idx] = self.extract_val_robust(d, ['u10', '10u', 'u_10m'], stat_lon, stat_lat)
                V_prof[0, c_idx] = self.extract_val_robust(d, ['v10', '10v', 'v_10m'], stat_lon, stat_lat)
                
                h_map = {975:1, 950:2, 925:3, 850:4, 700:5, 600:6, 500:7, 300:8}
                for p_lvl, idx in h_map.items():
                    t_val = self.extract_val_robust(d, [f't{p_lvl}', f'tmp{p_lvl}', f't_{p_lvl}', f'tmp_{p_lvl}'], stat_lon, stat_lat)
                    if not np.isnan(t_val) and t_val > 100: t_val -= 273.15
                    rh_val = self.extract_val_robust(d, [f'rh{p_lvl}', f'r{p_lvl}', f'rh_{p_lvl}', f'r_{p_lvl}'], stat_lon, stat_lat)
                    if np.isnan(rh_val) and not np.isnan(t_val):
                        t_td = self.extract_val_robust(d, [f't_td{p_lvl}', f'dew{p_lvl}'], stat_lon, stat_lat)
                        if not np.isnan(t_td): rh_val = rh_from_t_td(t_val, t_td)
                    
                    T_prof[idx, c_idx] = t_val
                    RH_prof[idx, c_idx] = rh_val if not np.isnan(rh_val) else 0.0
                    U_prof[idx, c_idx] = self.extract_val_robust(d, [f'u{p_lvl}', f'u_{p_lvl}'], stat_lon, stat_lat)
                    V_prof[idx, c_idx] = self.extract_val_robust(d, [f'v{p_lvl}', f'v_{p_lvl}'], stat_lon, stat_lat)

                t925 = T_prof[3, c_idx]; rh925 = RH_prof[3, c_idx]
                t850 = T_prof[4, c_idx]; rh850 = RH_prof[4, c_idx]
                t700 = T_prof[5, c_idx]; rh700 = RH_prof[5, c_idx]
                t500 = T_prof[7, c_idx]
                
                vort = self.extract_val_robust(d, ['vort500', 'vort', 'abs_vort'], stat_lon, stat_lat)
                if not np.isnan(vort):
                    Vort_500_series[c_idx] = vort * 100000.0 if abs(vort) < 1.0 else vort

                w_val = self.extract_val_robust(d, ['w', 'w700', 'vvel', 'vvel700', 'dzdt', 'dzdt700'], stat_lon, stat_lat)
                if not np.isnan(w_val):
                    W_700_series[c_idx] = w_val * 36.0 if abs(w_val) < 15.0 else w_val
                
                if not np.isnan(t850) and not np.isnan(rh850):
                    if not np.isnan(t500):
                        SSI_850_series[c_idx] = t500 - get_t_from_ept(calc_ept(t850, rh850, 850), 500)
                    if not np.isnan(t700):
                        SSI_700_series[c_idx] = t700 - get_t_from_ept(calc_ept(t850, rh850, 850), 700) 
                if not np.isnan(t700) and not np.isnan(t925) and not np.isnan(rh925):
                    SSI_925_series[c_idx] = t700 - get_t_from_ept(calc_ept(t925, rh925, 925), 700)
                d.close()
                
            except: pass

        fig = Figure(figsize=(16, 10.6)); fig.patch.set_facecolor('white') 
        fig.subplots_adjust(left=0.06, right=0.96, top=0.88, bottom=0.08, hspace=0.65)
        fig.suptitle(f"MSM 図解メタグラム - {stat_name} (初期時: {title_time_str})", fontsize=15, weight='bold', y=0.96)

        gs = fig.add_gridspec(14, 1)
        ax_cross = fig.add_subplot(gs[0:5, 0])
        ax_t2m = fig.add_subplot(gs[5:7, 0], sharex=ax_cross) 
        ax_slp = ax_t2m.twinx()                               
        ax_dyn = fig.add_subplot(gs[7:9, 0], sharex=ax_cross) 
        ax_pr = fig.add_subplot(gs[9:11, 0], sharex=ax_cross)
        ax_ssi = fig.add_subplot(gs[11:14, 0], sharex=ax_cross)
        
        X_prof, Y_prof = np.meshgrid(fts_prof, y_coords)
        cmap = mcolors.ListedColormap(['#E8F8F5', '#A3E4D7', '#48C9B0', '#117A65'])
        bounds = [70, 80, 90, 95, 100]; norm = mcolors.BoundaryNorm(bounds, cmap.N)
        cf = ax_cross.contourf(X_prof, Y_prof, RH_prof, levels=bounds, cmap=cmap, norm=norm, extend='max', alpha=0.6)
        
        cs = ax_cross.contour(X_prof, Y_prof, T_prof, levels=np.arange(-60, 40, 6), colors='red', linewidths=1.0, alpha=0.7)
        ax_cross.clabel(cs, inline=True, fontsize=9, fmt='%1.0f')
        cs_zero = ax_cross.contour(X_prof, Y_prof, T_prof, levels=[0], colors='blue', linewidths=2.0)
        ax_cross.clabel(cs_zero, inline=True, fontsize=10, fmt='0℃')
        ax_cross.barbs(X_prof[:, ::1], Y_prof[:, ::1], U_prof[:, ::1], V_prof[:, ::1], length=4.5, color='black', linewidth=0.6)
        
        ax_cross.set_yticks(y_coords); ax_cross.set_yticklabels([str(p) for p in plevs])
        ax_cross.set_ylabel("気\n圧\n\n[hPa]", weight='bold', rotation=0, labelpad=10, va='center')
        ax_cross.set_title("湿域・気温・風 断面図 (3時間毎)", loc='left', weight='bold')

        ax_t2m.plot(fts_surf, T_surf_series, color='#E74C3C', marker='o', markersize=3, linestyle='-', linewidth=2, label="地上気温")
        ax_t2m.set_ylabel("地\n上\n気\n温\n\n[℃]", color='#E74C3C', weight='bold', rotation=0, labelpad=10, va='center')
        
        ax_slp.plot(fts_surf, P_surf_series, color='#2980B9', marker='s', markersize=3, linestyle='-', linewidth=2, label="海面気圧")
        ax_slp.set_ylabel("海\n面\n気\n圧\n\n[hPa]", color='#2980B9', weight='bold', rotation=0, labelpad=15, va='center')
        
        ax_t2m.legend(loc='upper left')
        ax_slp.legend(loc='upper right')

        ax_top_jst = ax_cross.twiny()
        ax_top_jst.set_xlim(ax_cross.get_xlim())
        ax_top_jst.set_xticks(range(0, fts_surf[-1]+1, 6))
        jsts = [(dt_jst + timedelta(hours=x)).strftime("%d日%H時") for x in range(0, fts_surf[-1]+1, 6)]
        ax_top_jst.set_xticklabels(jsts, fontsize=9, color='#333333', weight='bold')

        color_vort = '#8E44AD'
        ax_dyn.plot(fts_prof, Vort_500_series, color=color_vort, marker='^', markersize=4, linestyle='-', linewidth=2, label="500hPa 正渦度")
        ax_dyn.set_ylabel("渦\n度\n\n[10⁻⁵/s]", color=color_vort, weight='bold', rotation=0, labelpad=15, va='center')
        ax_dyn.tick_params(axis='y', labelcolor=color_vort)
        ax_dyn.axhline(0, color='gray', linestyle='--', linewidth=1)

        ax_updraft = ax_dyn.twinx()
        color_updraft = '#D35400'
        ax_updraft.plot(fts_prof, W_700_series, color=color_updraft, marker='v', markersize=4, linestyle='-', linewidth=2, label="700hPa 鉛直速度")
        ax_updraft.set_ylabel("鉛\n直\n速\n度\n\n[hPa/h]", color=color_updraft, weight='bold', rotation=0, labelpad=25, va='center')
        ax_updraft.tick_params(axis='y', labelcolor=color_updraft)
        ax_updraft.invert_yaxis() 
        
        ax_dyn.legend(loc='upper left')
        ax_updraft.legend(loc='upper right')

        ax_pr.bar(fts_surf, Pr_surf_series, width=0.6, color='#3498DB', alpha=0.9, label="期間降水量 (1時間積算)")
        ax_pr.set_ylabel("降\n水\n量\n\n[mm]", color='#3498DB', weight='bold', rotation=0, labelpad=10, va='center')
        ax_pr.legend(loc='upper left')
        ax_pr.axhline(0, color='gray', linewidth=1.0)

        ax_ssi.plot(fts_prof, SSI_850_series, color='#C0392B', marker='o', markersize=4, linestyle='-', linewidth=2, label="SSI (850-500)")
        ax_ssi.plot(fts_prof, SSI_700_series, color='#E67E22', marker='^', markersize=4, linestyle='-', linewidth=2, label="SSI (850-700)")
        ax_ssi.plot(fts_prof, SSI_925_series, color='#8E44AD', marker='s', markersize=4, linestyle='-', linewidth=2, label="SSI (925-700)")
        ax_ssi.axhline(0, color='gray', linestyle=':', linewidth=1)
        ax_ssi.set_ylabel("S\nS\nI\n\n[℃]", weight='bold', rotation=0, labelpad=10, va='center')
        ax_ssi.set_xlabel("予報時間 (FT)", weight='bold', fontsize=12)
        ax_ssi.legend(loc='lower left')

        for ax in [ax_cross, ax_t2m, ax_dyn, ax_pr, ax_ssi]:
            ax.set_xlim(fts_surf[0]-1, fts_surf[-1]+1)
            ax.set_xticks(range(0, fts_surf[-1]+1, 6))
            ax.grid(True, axis='x', color='gray', linestyle='--', alpha=0.5)
            ax.grid(True, axis='y', color='gray', linestyle='--', alpha=0.3)
            if ax != ax_ssi: plt.setp(ax.get_xticklabels(), visible=False)
        
        plt.setp(ax_ssi.get_xticklabels(), rotation=45, ha='right', fontsize=11)
        fig.align_ylabels([ax_cross, ax_t2m, ax_dyn, ax_pr, ax_ssi]) 
        canvas = FigureCanvas(fig)
        win = SingleImageWindow(self, f"MSM 図解メタグラム - {stat_name}", "MSM", stat_name, init_z_str, canvas)
        win.exec()

    def draw_gsm_visual_metagram(self, station_combo):
        it_str = self.model_init_times.get("GSM_JP")
        if not it_str:
            QMessageBox.warning(self, "エラー", "データが存在しません。"); return
            
        stat_name = station_combo.currentText(); stat_lat, stat_lon = station_combo.currentData()
        dt_utc = datetime.strptime(it_str, "%Y%m%d%H%M%S")
        dt_jst = dt_utc + timedelta(hours=9)
        init_z_str = f"{dt_utc.strftime('%m%d%H')}Z" 
        title_time_str = f"{dt_utc.strftime('%m月%d日%H')}Z / {dt_jst.strftime('%H')}JST" 
        
        max_ft = self.tab_ui['GSM_JP']['slider'].maximum()
        fts = list(range(0, max_ft + 1, 3))
        
        plevs = [1000, 975, 950, 925, 850, 700, 600, 500, 300]; y_coords = [0.0, 0.05, 0.1, 0.15, 0.25, 0.5, 0.625, 0.75, 1.0]
        T_prof = np.full((len(plevs), len(fts)), np.nan); RH_prof = np.full((len(plevs), len(fts)), np.nan)
        U_prof = np.full((len(plevs), len(fts)), np.nan); V_prof = np.full((len(plevs), len(fts)), np.nan)
        
        SSI_850_series = np.full(len(fts), np.nan)
        SSI_700_series = np.full(len(fts), np.nan) 
        SSI_925_series = np.full(len(fts), np.nan)
        P_surf_series = np.full(len(fts), np.nan); T_surf_series = np.full(len(fts), np.nan); Pr_surf_series = np.full(len(fts), np.nan)
        
        Vort_500_series = np.full(len(fts), np.nan)
        W_700_series = np.full(len(fts), np.nan)

        for c_idx, ft in enumerate(fts):
            disp_ft = ft if ft <= 132 or ft % 6 == 0 else ft - (ft % 6)
            gpv_file = os.path.join(self.cache_dir, f"GSM_JP_{it_str}_FT{disp_ft:02d}.npz")
            
            if os.path.exists(gpv_file):
                try:
                    d = np.load(gpv_file)
                    P_surf_series[c_idx] = self.extract_val_robust(d, ['prmsl', 'mslet', 'msl', 'slp'], stat_lon, stat_lat)
                    if not np.isnan(P_surf_series[c_idx]) and P_surf_series[c_idx] > 2000: P_surf_series[c_idx] /= 100.0
                    T_surf_series[c_idx] = self.extract_val_robust(d, ['t2m', 'tmp2m', 'temp2m'], stat_lon, stat_lat)
                    if not np.isnan(T_surf_series[c_idx]) and T_surf_series[c_idx] > 100: T_surf_series[c_idx] -= 273.15
                    
                    p_key = self.get_precip_key(d)
                    if p_key:
                        pr = self.extract_val_with_key(d, p_key, stat_lon, stat_lat)
                        if disp_ft >= 3:
                            offset = 6 if disp_ft > 132 else 3
                            prev_file = os.path.join(self.cache_dir, f"GSM_JP_{it_str}_FT{disp_ft-offset:02d}.npz")
                            if os.path.exists(prev_file):
                                try:
                                    with np.load(prev_file) as d_prev:
                                        prev_p_key = self.get_precip_key(d_prev)
                                        if prev_p_key:
                                            prev_pr = self.extract_val_with_key(d_prev, prev_p_key, stat_lon, stat_lat)
                                            if not np.isnan(prev_pr): pr = max(0, pr - prev_pr)
                                except: pass
                        Pr_surf_series[c_idx] = pr if not np.isnan(pr) else 0.0
                    else: Pr_surf_series[c_idx] = 0.0

                    T_prof[0, c_idx] = T_surf_series[c_idx] if not np.isnan(T_surf_series[c_idx]) else self.extract_val_robust(d, ['t2m', 'tmp2m', 'temp2m'], stat_lon, stat_lat)
                    RH_prof[0, c_idx] = self.extract_val_robust(d, ['rh2m', 'r2m'], stat_lon, stat_lat)
                    U_prof[0, c_idx] = self.extract_val_robust(d, ['u10', '10u', 'u_10m'], stat_lon, stat_lat)
                    V_prof[0, c_idx] = self.extract_val_robust(d, ['v10', '10v', 'v_10m'], stat_lon, stat_lat)
                    
                    h_map = {975:1, 950:2, 925:3, 850:4, 700:5, 600:6, 500:7, 300:8}
                    for p_lvl, idx in h_map.items():
                        t_val = self.extract_val_robust(d, [f't{p_lvl}', f'tmp{p_lvl}', f't_{p_lvl}'], stat_lon, stat_lat)
                        if not np.isnan(t_val) and t_val > 100: t_val -= 273.15
                        rh_val = self.extract_val_robust(d, [f'rh{p_lvl}', f'r{p_lvl}', f'rh_{p_lvl}'], stat_lon, stat_lat)
                        if np.isnan(rh_val) and not np.isnan(t_val):
                            t_td = self.extract_val_robust(d, [f't_td{p_lvl}', f'dew{p_lvl}'], stat_lon, stat_lat)
                            if not np.isnan(t_td): rh_val = rh_from_t_td(t_val, t_td)
                        
                        T_prof[idx, c_idx] = t_val
                        RH_prof[idx, c_idx] = rh_val if not np.isnan(rh_val) else 0.0
                        U_prof[idx, c_idx] = self.extract_val_robust(d, [f'u{p_lvl}', f'u_{p_lvl}'], stat_lon, stat_lat)
                        V_prof[idx, c_idx] = self.extract_val_robust(d, [f'v{p_lvl}', f'v_{p_lvl}'], stat_lon, stat_lat)

                    t925 = T_prof[3, c_idx]; rh925 = RH_prof[3, c_idx]
                    t850 = T_prof[4, c_idx]; rh850 = RH_prof[4, c_idx]
                    t700 = T_prof[5, c_idx]; rh700 = RH_prof[5, c_idx]
                    t500 = T_prof[7, c_idx]
                    
                    vort = self.extract_val_robust(d, ['vort500', 'vort', 'abs_vort'], stat_lon, stat_lat)
                    if not np.isnan(vort):
                        Vort_500_series[c_idx] = vort * 100000.0 if abs(vort) < 1.0 else vort

                    w_val = self.extract_val_robust(d, ['w', 'w700', 'vvel', 'vvel700', 'dzdt', 'dzdt700'], stat_lon, stat_lat)
                    if not np.isnan(w_val):
                        W_700_series[c_idx] = w_val * 36.0 if abs(w_val) < 15.0 else w_val
                    
                    if not np.isnan(t850) and not np.isnan(rh850):
                        if not np.isnan(t500):
                            SSI_850_series[c_idx] = t500 - get_t_from_ept(calc_ept(t850, rh850, 850), 500)
                        if not np.isnan(t700):
                            SSI_700_series[c_idx] = t700 - get_t_from_ept(calc_ept(t850, rh850, 850), 700) 
                    if not np.isnan(t700) and not np.isnan(t925) and not np.isnan(rh925):
                        SSI_925_series[c_idx] = t700 - get_t_from_ept(calc_ept(t925, rh925, 925), 700)
                    d.close()
                except: pass

        fig = Figure(figsize=(16, 10.6)); fig.patch.set_facecolor('white') 
        fig.subplots_adjust(left=0.06, right=0.96, top=0.88, bottom=0.08, hspace=0.65) 
        fig.suptitle(f"GSM_JP 図解メタグラム - {stat_name} (初期時: {title_time_str})", fontsize=15, weight='bold', y=0.96)

        gs = fig.add_gridspec(14, 1) 
        ax_cross = fig.add_subplot(gs[0:5, 0])
        ax_t2m = fig.add_subplot(gs[5:7, 0], sharex=ax_cross) 
        ax_slp = ax_t2m.twinx()                               
        ax_dyn = fig.add_subplot(gs[7:9, 0], sharex=ax_cross) 
        ax_pr = fig.add_subplot(gs[9:11, 0], sharex=ax_cross)
        ax_ssi = fig.add_subplot(gs[11:14, 0], sharex=ax_cross)
        
        X, Y = np.meshgrid(fts, y_coords)
        cmap = mcolors.ListedColormap(['#E8F8F5', '#A3E4D7', '#48C9B0', '#117A65'])
        bounds = [70, 80, 90, 95, 100]; norm = mcolors.BoundaryNorm(bounds, cmap.N)
        cf = ax_cross.contourf(X, Y, RH_prof, levels=bounds, cmap=cmap, norm=norm, extend='max', alpha=0.6)
        
        cs = ax_cross.contour(X, Y, T_prof, levels=np.arange(-60, 40, 6), colors='red', linewidths=1.0, alpha=0.7)
        ax_cross.clabel(cs, inline=True, fontsize=9, fmt='%1.0f')
        cs_zero = ax_cross.contour(X, Y, T_prof, levels=[0], colors='blue', linewidths=2.0)
        ax_cross.clabel(cs_zero, inline=True, fontsize=10, fmt='0℃')
        ax_cross.barbs(X[:, ::1], Y[:, ::1], U_prof[:, ::1], V_prof[:, ::1], length=4.5, color='black', linewidth=0.6)
        
        ax_cross.set_yticks(y_coords); ax_cross.set_yticklabels([str(p) for p in plevs])
        ax_cross.set_ylabel("気\n圧\n\n[hPa]", weight='bold', rotation=0, labelpad=10, va='center')
        ax_cross.set_title("湿域・気温・風 断面図 (※132h以降は全要素6時間間隔で補間展開)", loc='left', weight='bold')

        ax_t2m.plot(fts, T_surf_series, color='#E74C3C', marker='o', markersize=4, linestyle='-', linewidth=2, label="地上気温")
        ax_t2m.set_ylabel("地\n上\n気\n温\n\n[℃]", color='#E74C3C', weight='bold', rotation=0, labelpad=10, va='center')
        
        ax_slp.plot(fts, P_surf_series, color='#2980B9', marker='s', markersize=4, linestyle='-', linewidth=2, label="海面気圧")
        ax_slp.set_ylabel("海\n面\n気\n圧\n\n[hPa]", color='#2980B9', weight='bold', rotation=0, labelpad=15, va='center')
        
        ax_t2m.legend(loc='upper left')
        ax_slp.legend(loc='upper right')

        ax_top_jst = ax_cross.twiny()
        ax_top_jst.set_xlim(ax_cross.get_xlim())
        ax_top_jst.set_xticks(range(0, fts[-1]+1, 12))
        jsts = [(dt_jst + timedelta(hours=x)).strftime("%d日%H時") for x in range(0, fts[-1]+1, 12)]
        ax_top_jst.set_xticklabels(jsts, fontsize=9, color='#333333', weight='bold')

        color_vort = '#8E44AD'
        ax_dyn.plot(fts, Vort_500_series, color=color_vort, marker='^', markersize=4, linestyle='-', linewidth=2, label="500hPa 正渦度")
        ax_dyn.set_ylabel("渦\n度\n\n[10⁻⁵/s]", color=color_vort, weight='bold', rotation=0, labelpad=15, va='center')
        ax_dyn.tick_params(axis='y', labelcolor=color_vort)
        ax_dyn.axhline(0, color='gray', linestyle='--', linewidth=1)

        ax_updraft = ax_dyn.twinx()
        color_updraft = '#D35400'
        ax_updraft.plot(fts, W_700_series, color=color_updraft, marker='v', markersize=4, linestyle='-', linewidth=2, label="700hPa 鉛直速度")
        ax_updraft.set_ylabel("鉛\n直\n速\n度\n\n[hPa/h]", color=color_updraft, weight='bold', rotation=0, labelpad=25, va='center')
        ax_updraft.tick_params(axis='y', labelcolor=color_updraft)
        ax_updraft.invert_yaxis() 
        
        ax_dyn.legend(loc='upper left')
        ax_updraft.legend(loc='upper right')

        ax_pr.bar(fts, Pr_surf_series, width=1.8, color='#3498DB', alpha=0.9, label="期間降水量 (差分)")
        ax_pr.set_ylabel("降\n水\n量\n\n[mm]", color='#3498DB', weight='bold', rotation=0, labelpad=10, va='center')
        ax_pr.legend(loc='upper left')
        ax_pr.axhline(0, color='gray', linewidth=1.0)

        ax_ssi.plot(fts, SSI_850_series, color='#C0392B', marker='o', markersize=4, linestyle='-', linewidth=2, label="SSI (850-500)")
        ax_ssi.plot(fts, SSI_700_series, color='#E67E22', marker='^', markersize=4, linestyle='-', linewidth=2, label="SSI (850-700)")
        ax_ssi.plot(fts, SSI_925_series, color='#8E44AD', marker='s', markersize=4, linestyle='-', linewidth=2, label="SSI (925-700)")
        ax_ssi.axhline(0, color='gray', linestyle=':', linewidth=1)
        ax_ssi.set_ylabel("S\nS\nI\n\n[℃]", weight='bold', rotation=0, labelpad=10, va='center')
        ax_ssi.set_xlabel("予報時間 (FT)", weight='bold', fontsize=12)
        ax_ssi.legend(loc='lower left')

        for ax in [ax_cross, ax_t2m, ax_dyn, ax_pr, ax_ssi]:
            ax.set_xlim(fts[0]-1, fts[-1]+1)
            ax.set_xticks(range(0, fts[-1]+1, 12))
            ax.grid(True, axis='x', color='gray', linestyle='--', alpha=0.5)
            ax.grid(True, axis='y', color='gray', linestyle='--', alpha=0.3)
            if ax != ax_ssi: plt.setp(ax.get_xticklabels(), visible=False)
        
        plt.setp(ax_ssi.get_xticklabels(), rotation=45, ha='right', fontsize=11)
        fig.align_ylabels([ax_cross, ax_t2m, ax_dyn, ax_pr, ax_ssi]) 
        canvas = FigureCanvas(fig)
        win = SingleImageWindow(self, f"GSM_JP 図解メタグラム - {stat_name}", "GSM_JP", stat_name, init_z_str, canvas)
        win.exec()

    def draw_anal_visual_metagram(self, station_combo):
        model_name = "ANAL"
        ui = self.tab_ui[model_name]
        elem_name = ui['list'].currentItem().text()
        
        stat_name = station_combo.currentText()
        stat_lat, stat_lon = station_combo.currentData()
        
        combo = ui['init_combo']
        count = min(6, combo.count())
        if count == 0:
            QMessageBox.warning(self, "エラー", "データが存在しません。")
            return
            
        times = []
        vals = []
        u_vals = []
        v_vals = []
        
        for i in range(count - 1, -1, -1):
            it_str = combo.itemData(i)
            dt_utc = datetime.strptime(it_str, "%Y%m%d%H%M%S")
            dt_jst = dt_utc + timedelta(hours=9)
            times.append(dt_jst)
            
            target_file = os.path.join(self.cache_dir, f"ANAL_{it_str}_FT00.npz")
            v, u, v_w = np.nan, np.nan, np.nan
            if os.path.exists(target_file):
                try:
                    with np.load(target_file) as d:
                        if elem_name == "地上気温":
                            v = self.extract_val_robust(d, ['t2m', 'tmp2m', 'temp2m'], stat_lon, stat_lat)
                            if not np.isnan(v) and v > 100: v -= 273.15
                        elif elem_name == "地上風向風速":
                            u = self.extract_val_robust(d, ['u10', '10u', 'u_10m'], stat_lon, stat_lat)
                            v_w = self.extract_val_robust(d, ['v10', '10v', 'v_10m'], stat_lon, stat_lat)
                            if not np.isnan(u) and not np.isnan(v_w): v = np.hypot(u, v_w)
                        elif "気温" in elem_name or "風" in elem_name:
                            lvl_match = re.search(r'(\d+)hpa', elem_name.lower())
                            if lvl_match:
                                lvl = lvl_match.group(1)
                                if "気温" in elem_name:
                                    v = self.extract_val_robust(d, [f't{lvl}', f'tmp{lvl}', f't_{lvl}'], stat_lon, stat_lat)
                                    if not np.isnan(v) and v > 100: v -= 273.15
                                if "風" in elem_name:
                                    u = self.extract_val_robust(d, [f'u{lvl}', f'u_{lvl}'], stat_lon, stat_lat)
                                    v_w = self.extract_val_robust(d, [f'v{lvl}', f'v_{lvl}'], stat_lon, stat_lat)
                                    if "風向・風速" in elem_name and not np.isnan(u) and not np.isnan(v_w):
                                        v = np.hypot(u, v_w)
                except Exception as e:
                    logging.error(f"ANAL meta error: {e}")
            
            vals.append(v)
            u_vals.append(u)
            v_vals.append(v_w)
            
        fig = Figure(figsize=(10, 6)); fig.patch.set_facecolor('white')
        fig.subplots_adjust(left=0.1, right=0.9, top=0.85, bottom=0.15)
        ax = fig.add_subplot(111)
        
        x_pos = np.arange(len(times))
        x_labels = [t.strftime("%H:%M") for t in times]
        
        valid_vals = [val for val in vals if not np.isnan(val)]
        if valid_vals:
            ax.plot(x_pos, vals, marker='o', linestyle='-', color='#C0392B', linewidth=2)
            
            ylabel = "値"
            if "気温" in elem_name: ylabel = "気温 [℃]"
            elif "風" in elem_name: ylabel = "風速 [m/s]"
            ax.set_ylabel(ylabel, weight='bold', fontsize=12)
            
            if any(not np.isnan(u) for u in u_vals):
                for i in range(len(x_pos)):
                    if not np.isnan(u_vals[i]) and not np.isnan(v_vals[i]) and not np.isnan(vals[i]):
                        ax.barbs(x_pos[i], vals[i], u_vals[i], v_vals[i], length=6, color='blue')
                        
        else:
            ax.text(0.5, 0.5, "有効なデータがありません", ha='center', va='center', transform=ax.transAxes)
            
        ax.set_xticks(x_pos)
        ax.set_xticklabels(x_labels)
        ax.grid(True, linestyle='--', alpha=0.7)
        title_str = f"毎時大気解析({elem_name}) メタグラム - {stat_name}\n"
        title_str += f"(最新: {times[-1].strftime('%m/%d %H:%M')} JST)" if times else ""
        ax.set_title(title_str, weight='bold', fontsize=14)
        
        canvas = FigureCanvas(fig)
        win = SingleImageWindow(self, f"ANALメタグラム - {stat_name}", "ANAL", stat_name, times[-1].strftime("%m%d%H") if times else "none", canvas)
        win.exec()

    def draw_guidance_metagram(self, station_combo, model_name):
        it_str = self.model_init_times.get(model_name)
        if not it_str:
            QMessageBox.warning(self, "エラー", "データが存在しません。"); return
            
        stat_name = station_combo.currentText(); stat_lat, stat_lon = station_combo.currentData()
        dt_utc = datetime.strptime(it_str, "%Y%m%d%H%M%S")
        dt_jst = dt_utc + timedelta(hours=9)
        
        is_meps = model_name in MEPS_STAT_MODELS
        
        if is_meps:
            elements = [
                ("3時間降水量", "precip", 1),
                ("6時間降水量", "precip6", 2),
                ("12時間降水量", "precip12", 4),
                ("1時間最大\n降水量", "precip1max", 1),
                ("3時間最大\n降水量", "precip3max", 1),
                ("3時間降雪量", "snow3", 1),
                ("6時間降雪量", "snow6", 2),
                ("12時間降雪量", "snow12", 4),
                ("24時間降雪量", "snow24", 8),
                ("発雷確率", "thund", 1)
            ]
                    
            stats = [
                ("上位90%", "MEPS_GUID_MAX"),
                ("確率≥1", "MEPS_GUID_PROB1"),
                ("確率≥10", "MEPS_GUID_PROB10"),
                ("確率≥20", "MEPS_GUID_PROB20"),
                ("確率≥30", "MEPS_GUID_PROB30"),
                ("下位10%", "MEPS_GUID_MIN")
            ]
            
            max_ft = 39 
            fts = list(range(3, max_ft + 1, 3)) 
            
            data_dict = {elem[1]: {s[0]: {} for s in stats} for elem in elements}
            
            for s_name, s_model in stats:
                s_it = self.model_init_times.get(s_model, it_str)
                for ft in fts:
                    guid_file = os.path.join(self.cache_dir, f"{s_model}_{s_it}_FT{ft:02d}.npz")
                    if os.path.exists(guid_file):
                        try:
                            with np.load(guid_file) as d:
                                for e_name, e_key, span in elements:
                                    search_keys = [e_key]
                                    span_h = span * 3 if span < 8 else 24
                                    
                                    if "降水量" in e_name:
                                        if "最大" in e_name:
                                            span_h = 1 if "1時間" in e_name else (3 if "3時間" in e_name else 24)
                                            search_keys.extend([k for k in d.files if k.startswith("tp_") and k.endswith(f"_{max(0, ft-span_h)}_{ft}")])
                                        else:
                                            search_keys.extend(['tp', 'precip'] + [k for k in d.files if k.startswith("tp_") and k.endswith(f"_{max(0, ft-3)}_{ft}")])
                                    elif "降雪" in e_name:
                                        search_keys.extend(['asnow'] + [k for k in d.files if k.startswith("asnow_") and k.endswith(f"_{max(0, ft-span_h)}_{ft}")])
                                    elif "発雷" in e_name:
                                        search_keys.extend(['tstm', 'thund'] + [k for k in d.files if k.startswith("tstm_") and k.endswith(f"_{max(0, ft-3)}_{ft}")])
                                    
                                    val = self.extract_val_robust(d, search_keys, stat_lon, stat_lat)
                                    data_dict[e_key][s_name][ft] = val
                        except: pass

            fig = Figure(figsize=(16, 12)); fig.patch.set_facecolor('white')
            fig.subplots_adjust(left=0.01, right=0.99, top=0.94, bottom=0.01)
            ax = fig.add_subplot(111); ax.axis('off')
            
            n_rows = len(elements) * len(stats) + 3 
            n_cols = 2 + len(fts) 
            
            ax.set_xlim(0, n_cols)
            ax.set_ylim(0, n_rows)
            
            # fontsize引数を追加してフォントサイズを動的調整可能に
            def draw_cell(x, y, w, h, text, bg_color='white', font_weight='normal', text_color='black', fontsize=10):
                rect = plt.Rectangle((x, y), w, h, facecolor=bg_color, edgecolor='black', linewidth=0.5)
                ax.add_patch(rect)
                ax.text(x + w/2.0, y + h/2.0, text, color=text_color, ha='center', va='center', weight=font_weight, fontsize=fontsize)
            
            current_y = n_rows - 1
            # ヘッダーマスを 2x3 サイズに変更
            draw_cell(0, current_y - 2, 2, 3, "要素 / 日時(JST)", bg_color='#1D3557', font_weight='bold', text_color='white', fontsize=11)
            
            for i, ft in enumerate(fts):
                target_jst = dt_jst + timedelta(hours=ft)
                # 日付(高さ1)と、時間・FT(高さ2)に分割
                draw_cell(2 + i, current_y, 1, 1, target_jst.strftime("%m/%d"), bg_color='#1D3557', font_weight='bold', text_color='white', fontsize=9)
                draw_cell(2 + i, current_y - 2, 1, 2, f"{target_jst.strftime('%H')}時\n(FT{ft:02d})", bg_color='#457B9D', font_weight='bold', text_color='white', fontsize=9)
                
            current_y -= 3
            
            for e_name, e_key, span in elements:
                draw_cell(0, current_y - len(stats) + 1, 1, len(stats), e_name, bg_color='#F1FAEE', font_weight='bold')
                
                for s_idx, (s_name, _) in enumerate(stats):
                    row_y = current_y - s_idx
                    draw_cell(1, row_y, 1, 1, s_name, bg_color='#E0E0E0' if s_idx%2==0 else 'white')
                    
                    col_idx = 0
                    while col_idx < len(fts):
                        ft = fts[col_idx]
                        target_ft = ft + (span - 1) * 3
                        
                        # --- 修正: 単純な累積降水量の合算処理 ---
                        if e_key in ["precip6", "precip12"]:
                            val = 0
                            v_count = 0
                            for step in range(span):
                                v = data_dict[e_key][s_name].get(ft + step * 3, np.nan)
                                if not np.isnan(v):
                                    val += v
                                    v_count += 1
                            if v_count == 0: val = np.nan
                        else:
                            val = data_dict[e_key][s_name].get(target_ft, np.nan)
                        
                        is_prob_stat = "確率" in s_name
                        
                        val_str = f"{val:.1f}" if not np.isnan(val) else "-"
                        if ("確率" in e_name or is_prob_stat) and not np.isnan(val): 
                            val_str = f"{val:.0f}"
                        if val_str == "0.0" or val_str == "0": 
                            val_str = "0"
                        if is_prob_stat and val_str != "-" and val_str != "0":
                            val_str += "%"
                        
                        w = min(span, len(fts) - col_idx)
                        bg_c = 'white' if s_idx%2!=0 else '#F8F9FA'
                        
                        if not np.isnan(val) and val > 0:
                            if is_prob_stat or "確率" in e_name:
                                if val >= 50: bg_c = '#FFCDD2'
                                elif val >= 20: bg_c = '#FFF9C4'
                            elif "降水" in e_name or "降雪" in e_name:
                                if val >= 20: bg_c = '#FFCDD2'
                                elif val >= 10: bg_c = '#FFF9C4'
                                elif val >= 1: bg_c = '#E3F2FD'

                        draw_cell(2 + col_idx, row_y, w, 1, val_str, bg_color=bg_c)
                        col_idx += span

                current_y -= len(stats)
                
            fig.suptitle(f"{stat_name} MEPS 統合メタグラム (初期時: {dt_utc.strftime('%m/%d %H:00Z')})", fontsize=20, weight='bold', y=0.99)
            canvas = FigureCanvas(fig)
            win = SingleImageWindow(self, f"GUID_{stat_name}", model_name, stat_name, dt_utc.strftime("%m%d%H") + "Z", canvas)
            win.exec()
            
        else:
            fig = Figure(figsize=(11.69, 8.27)); fig.patch.set_facecolor('white')
            ax = fig.add_subplot(111); ax.axis('off')
            
            table_data = []
            headers = ["FT", "日時(JST)", "卓越天気", "降水量", "降雪量", "発雷確率", "気温(℃)", "風向", "風速(m/s)"]
            
            max_ft = self.tab_ui[model_name]['slider'].maximum()
            fts = list(range(0, max_ft + 1, 3))
            
            gpv_model = "GSM_JP" if model_name == "GSM_GUID" else "MSM"
            gpv_it_str = self.model_init_times.get(gpv_model)
            
            for ft in fts:
                target_jst = dt_utc + timedelta(hours=9+ft)
                row = [f"FT={ft:02d}", target_jst.strftime("%m/%d %H:00"), "-", "-", "-", "-", "-", "-", "-"]
                
                guid_file = os.path.join(self.cache_dir, f"{model_name}_{it_str}_FT{ft:02d}.npz")
                if os.path.exists(guid_file):
                    try:
                        with np.load(guid_file) as d:
                            pop = self.extract_val_robust(d, ['precip', 'tp', 'apcp', 'pop', 'var_0_1_8', 'var_0_1_52'], stat_lon, stat_lat)
                            pos = self.extract_val_robust(d, ['snow', 'weasd', 'snod', 'var_0_1_11', 'var_0_1_13', 'var_0_1_29'], stat_lon, stat_lat)
                            pol = self.extract_val_robust(d, ['thund', 'pol', 'lig', 'thunder', 'tstm', 'var_0_19_193'], stat_lon, stat_lat)
                            wx = self.extract_val_robust(d, ['wea', 'weather', 'wx', 'var_-1_-1_-1', 'var_0_19_192'], stat_lon, stat_lat)                   
                            
                            if not np.isnan(wx): row[2] = {1:"晴", 2:"曇", 3:"雨", 4:"雪"}.get(int(wx), "不明")
                            if not np.isnan(pop): row[3] = f"{pop:.1f} mm"
                            if not np.isnan(pos): row[4] = f"{pos:.1f} cm"
                            if not np.isnan(pol): row[5] = f"{pol:.0f} %"
                    except: pass
                    
                gpv_ft = ft
                if gpv_model == "GSM_JP" and ft > 132: gpv_ft = ft - (ft % 6) if ft % 6 != 0 else ft
                
                gpv_file = os.path.join(self.cache_dir, f"{gpv_model}_{gpv_it_str}_FT{gpv_ft:02d}.npz")
                if not os.path.exists(gpv_file): gpv_file = os.path.join(self.cache_dir, f"{gpv_model}_{it_str}_FT{gpv_ft:02d}.npz")
                if not os.path.exists(gpv_file):
                    p_files = glob.glob(os.path.join(self.cache_dir, f"{gpv_model}_*_FT{gpv_ft:02d}.npz"))
                    if p_files: gpv_file = sorted(p_files, key=lambda x: abs(int(re.search(r'_(\d{14})_', x).group(1)) - int(it_str)))[0]
                    
                if os.path.exists(gpv_file):
                    try:
                        with np.load(gpv_file) as d_gpv:
                            t = self.extract_val_robust(d_gpv, ['t2m', 'tmp2m', 'temp2m'], stat_lon, stat_lat)
                            if not np.isnan(t): row[6] = f"{(t - 273.15 if t > 100 else t):.1f}"
                            u = self.extract_val_robust(d_gpv, ['u10', '10u', 'u_10m'], stat_lon, stat_lat)
                            v = self.extract_val_robust(d_gpv, ['v10', '10v', 'v_10m'], stat_lon, stat_lat)
                            if not np.isnan(u) and not np.isnan(v):
                                row[8] = f"{np.hypot(u, v):.1f}"
                                deg = (np.degrees(np.arctan2(u, v)) + 180) % 360
                                dirs = ["北", "北北東", "北東", "東北東", "東", "東南東", "南東", "南南東", "南", "南南西", "南西", "西南西", "西", "西北西", "北西", "北北西", "北"]
                                row[7] = dirs[int(round(deg / 22.5)) % 16]
                    except: pass
                        
                table_data.append(row)
                
            table = ax.table(cellText=table_data, colLabels=headers, loc='center', cellLoc='center')
            table.auto_set_font_size(False); table.set_fontsize(10); table.scale(1, 1.4)
            
            for j in range(len(headers)):
                cell = table[0, j]; cell.set_facecolor('#1D3557'); cell.get_text().set_color('white'); cell.get_text().set_weight('bold')
                
            fig.suptitle(f"{stat_name} 統合ガイダンス時系列表 (初期時: {dt_utc.strftime('%m/%d %H:00Z')})", fontsize=15, weight='bold', y=0.96)
            canvas = FigureCanvas(fig)
            win = SingleImageWindow(self, f"GUID_{stat_name}", model_name, stat_name, dt_utc.strftime("%m%d%H") + "Z", canvas)
            win.exec()

    def draw_guidance(self, ax, ui, data, elem_name, val, extent, area_index, model_name, it_str):
        drawn_flag = False; info_text = ""
        is_meps = model_name in MEPS_STAT_MODELS
        is_prob = "PROB" in model_name

        def _cl(arr, minv, maxv, fill=np.nan): 
            if arr is None: return None
            a = np.array(arr, dtype=float)
            if not np.isnan(fill): a = np.nan_to_num(a, nan=fill, posinf=fill, neginf=fill)
            else: a[np.isinf(a)] = np.nan
            return np.clip(a, minv, maxv)
        
        def add_vertical_colorbar(mappable, label_text):
            cax = ui['fig'].add_axes([0.93, 0.15, 0.018, 0.7]) 
            cb = ui['fig'].colorbar(mappable, cax=cax, orientation='vertical')
            cb.set_label(label_text, fontsize=12, weight='bold'); ui['colorbars'].append(cb)
        
        if "降水量" in elem_name or "降雪量" in elem_name:
            accum_val = None; valid_data = False; lon_c = None; lat_c = None

            if "1時間" in elem_name: accum_hours = 1
            elif "3時間" in elem_name: accum_hours = 3
            elif "6時間" in elem_name: accum_hours = 6
            elif "12時間" in elem_name: accum_hours = 12
            elif "24時間" in elem_name: accum_hours = 24
            else: accum_hours = 3

            is_meps = model_name in MEPS_STAT_MODELS
            
            # npzに事前計算されている要素(最大降水量、降雪量、通常の3時間降水)か判定
            is_meps_precalculated = False
            if is_meps:
                if "最大" in elem_name or "降雪" in elem_name or accum_hours == 3:
                    is_meps_precalculated = True

            if is_meps_precalculated:
                fixed_key = None
                if "降水量" in elem_name:
                    if "1時間最大" in elem_name: fixed_key = "precip1max"
                    elif "3時間最大" in elem_name: fixed_key = "precip3max"
                    elif "24時間最大" in elem_name: fixed_key = "precip24max"
                    else: fixed_key = "precip" 
                else: 
                    fixed_key = f"snow{accum_hours}"

                meps_key = None
                if fixed_key and fixed_key in data.files:
                    meps_key = fixed_key
                else:
                    vc = "tp_" if "降水量" in elem_name else "asnow_"
                    tr = f"_{max(0, val-accum_hours)}_{val}"
                    for k in data.files:
                        if k.startswith(vc) and k.endswith(tr) and not ("lon" in k or "lat" in k):
                            meps_key = k; break
                    if not meps_key:
                        for k in data.files:
                            if k.startswith(vc) and k.endswith(f"_{val}") and not ("lon" in k or "lat" in k):
                                meps_key = k; break

                if meps_key and meps_key in data.files:
                    accum_val = _cl(data[meps_key], 0, 1000, 0.0)
                    lon_c, lat_c = self.get_coords_for_data(data, accum_val)
                    valid_data = True

            else:
                # 事前計算されていない要素(MEPSの6・12・24時間降水量、非MEPSモデル)は遡って足し算する
                for hr_offset in range(0, accum_hours, 3):
                    target_ft = val - hr_offset
                    if target_ft <= 0: continue
                    
                    d_curr = None
                    if target_ft == val: d_curr = data
                    else:
                        target_file = os.path.join(self.cache_dir, f"{model_name}_{it_str}_FT{target_ft:02d}.npz")
                        if os.path.exists(target_file):
                            try: d_curr = np.load(target_file)
                            except: pass
                    
                    if d_curr is not None:
                        if "降水量" in elem_name: key = self.get_exact_key(d_curr, ['precip', 'tp', 'apcp', 'pop', 'var_0_1_8', 'var_0_1_52'])
                        else: key = self.get_exact_key(d_curr, ['snow', 'weasd', 'snod', 'var_0_1_11', 'var_0_1_13', 'var_0_1_29', 'asnow'])   
                        if key:
                            v = _cl(d_curr[key], 0, 1000, 0.0)
                            if accum_val is None:
                                accum_val = v.copy() 
                                lon_c, lat_c = self.get_coords_for_data(d_curr, v)
                                valid_data = True
                            else:
                                if accum_val.shape == v.shape:
                                    accum_val += v
                                
                        if target_ft != val: d_curr.close()
                
            if valid_data and accum_val is not None:
                accum_val_masked = np.ma.masked_less(accum_val, 1.0 if is_prob else 0.1)
                
                if is_prob:
                    thresh = model_name.replace("MEPS_GUID_PROB", "")
                    unit = "mm" if "降水" in elem_name else "cm"
                    label_text = f'{elem_name} (≥{thresh}{unit}) 超過確率 [%]'
                    levels = [1, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
                    cmap = mcolors.ListedColormap(['#F7DC6F', '#F4D03F', '#F5B041', '#EB984E', '#DC7633', '#E74C3C', '#C0392B', '#9B59B6', '#8E44AD', '#1F618D'])
                    cmap.set_under('none'); cmap.set_over('#0B5345')
                else:
                    levels = [0.1, 1, 5, 10, 20, 30, 50, 80]
                    
                    if "降水量" in elem_name:
                        cmap = mcolors.ListedColormap(['#A0E8A0', '#D4F05A', '#FFFF00', '#FFA500', '#FF5500', '#FF0000', '#FF00FF'])
                        cmap.set_under('none'); cmap.set_over('#800080')
                        label_text = f'降水量 ({accum_hours}時間{"最大" if is_meps and "最大" in elem_name else "積算"}) [mm]'
                    else:
                        cmap = mcolors.ListedColormap(['#29B6F6', '#0288D1', '#01579B', '#1A237E', '#311B92', '#4A148C', '#880E4F'])
                        cmap.set_under('none'); cmap.set_over('#4A0024')
                        label_text = f'降雪量 ({accum_hours}時間積算) [cm等]'
                
                norm = mcolors.BoundaryNorm(levels, cmap.N)
                is_snow = "降雪" in elem_name
                pm_alpha = 0.55 if is_prob else (0.8 if is_snow else 0.5)
                pm = self.plot_blocky(ax, lon_c, lat_c, accum_val_masked, ui, cmap, extent, norm=norm, alpha=pm_alpha)
                
                if pm:
                    add_vertical_colorbar(pm, label_text)
                    drawn_flag = True
                                        
                    lc, lac, dc = self.crop_data(lon_c, lat_c, accum_val, extent)
                    if lc.size > 0:
                        l2, la2 = np.meshgrid(lc, lac) if lc.ndim == 1 else (lc, lac)
                        min_lon, max_lon, min_lat, max_lat = extent 
                        
                        if area_index in [3, 4]:
                            try:
                                zoom_factor = 8 if "GSM" in model_name else 2
                                dc_z = ndimage.zoom(dc, zoom_factor, order=1)
                                if lc.ndim == 1:
                                    lc_z = ndimage.zoom(lc, zoom_factor, order=1)
                                    lac_z = ndimage.zoom(lac, zoom_factor, order=1)
                                    l2_z, la2_z = np.meshgrid(lc_z, lac_z)
                                else:
                                    l2_z = ndimage.zoom(l2, zoom_factor, order=1)
                                    la2_z = ndimage.zoom(la2, zoom_factor, order=1)
                                
                                for r in range(dc_z.shape[0]):
                                    for c in range(dc_z.shape[1]):
                                        if r % zoom_factor == 0 and c % zoom_factor == 0: continue
                                        lon_v, lat_v, val_p = l2_z[r, c], la2_z[r, c], dc_z[r, c]
                                        limit = 1.0 if is_prob else 0.1
                                        if val_p >= limit and min_lon <= lon_v <= max_lon and min_lat <= lat_v <= max_lat:
                                            txt_fmt = f"{val_p:.0f}" if is_prob else f"{val_p:.1f}"
                                            txt = ax.text(lon_v, lat_v, txt_fmt, color='#1f77b4', fontsize=10, ha='center', va='center', transform=ccrs.PlateCarree(), zorder=12, weight='bold', clip_on=True, path_effects=[pe.withStroke(linewidth=2, foreground='white')])
                                            ui['dynamic_artists'].append(txt)
                                            
                                for r in range(dc.shape[0]):
                                    for c in range(dc.shape[1]):
                                        lon_v, lat_v, val_p = l2[r, c], la2[r, c], dc[r, c]
                                        limit = 1.0 if is_prob else 0.1
                                        if val_p >= limit and min_lon <= lon_v <= max_lon and min_lat <= lat_v <= max_lat:
                                            txt_fmt = f"{val_p:.0f}" if is_prob else f"{val_p:.1f}"
                                            txt = ax.text(lon_v, lat_v, txt_fmt, color='black', fontsize=11, ha='center', va='center', transform=ccrs.PlateCarree(), zorder=13, weight='bold', bbox=dict(facecolor='white', edgecolor='black', boxstyle='square,pad=0.2', alpha=0.9), clip_on=True)
                                            ui['dynamic_artists'].append(txt)
                            except Exception as e:
                                print(f"札幌近郊 内挿・格子点描画エラー: {e}")
                        
                        elif area_index == 2:
                            try:
                                zoom_factor = 4 if "GSM" in model_name else 1
                                dc_z = ndimage.zoom(dc, zoom_factor, order=1)
                                l2_z = ndimage.zoom(l2, zoom_factor, order=1)
                                la2_z = ndimage.zoom(la2, zoom_factor, order=1)
                                for r in range(dc_z.shape[0]):
                                    for c in range(dc_z.shape[1]):
                                        if r % zoom_factor == 0 and c % zoom_factor == 0:
                                            if (r // max(1, zoom_factor) + c // max(1, zoom_factor)) % 3 == 0:
                                                continue
                                                
                                            lon_v, lat_v, val_p = l2_z[r, c], la2_z[r, c], dc_z[r, c]
                                            limit = 1.0 if is_prob else 0.1
                                            if val_p >= limit and min_lon <= lon_v <= max_lon and min_lat <= lat_v <= max_lat:
                                                txt_fmt = f"{val_p:.0f}" if is_prob else f"{val_p:.1f}"
                                                txt_color = '#0288D1' if "降雪" in elem_name else '#1f77b4'
                                                if is_prob: txt_color = '#D35400'
                                                
                                                txt = ax.text(lon_v, lat_v, txt_fmt, color=txt_color, fontsize=6, ha='center', va='center', transform=ccrs.PlateCarree(), zorder=13, weight='bold', path_effects=[pe.withStroke(linewidth=1.5, foreground='white')], clip_on=True)
                                                ui['dynamic_artists'].append(txt)
                            except Exception as e:
                                print(f"上川地方 透過文字描画エラー: {e}")
        
        elif "発雷" in elem_name:
            key = None
            if is_meps:
                if "thund" in data.files:
                    key = "thund"
                else:
                    tr = f"_{max(0, val-3)}_{val}"
                    for k in data.files:
                        if k.startswith("tstm_") and k.endswith(tr) and not ("lon" in k or "lat" in k):
                            key = k; break
                    if not key:
                        for k in data.files:
                            if k.startswith("tstm_") and k.endswith(f"_{val}") and not ("lon" in k or "lat" in k):
                                key = k; break
            else:
                for k in data.files:
                    if any(x in k.lower() for x in ['thund', 'pol', 'lig', 'ts', 'tstm', 'var_0_19_193']) and not ("lon" in k or "lat" in k):
                        key = k; break

            if key:
                try:
                    val_data = _cl(data[key], 0, 100, np.nan) 
                    lon_c, lat_c = self.get_coords_for_data(data, val_data)
                    
                    if lon_c is None or lat_c is None:
                        lon_k = next((k for k in data.files if 'lon' in k.lower()), None)
                        lat_k = next((k for k in data.files if 'lat' in k.lower()), None)
                        if lon_k and lat_k: lon_c, lat_c = data[lon_k], data[lat_k]

                    if lon_c is not None and lat_c is not None:
                        if lon_c.ndim == 1 and val_data.shape == (len(lon_c), len(lat_c)): val_data = val_data.T
                        if lon_c.ndim == 1 and val_data.shape != (len(lat_c), len(lon_c)):
                            if val_data.shape[1] > val_data.shape[0] and len(lat_c) > len(lon_c): val_data = val_data.T
                            lon_c = np.linspace(lon_c.min(), lon_c.max(), val_data.shape[1])
                            lat_c = np.linspace(lat_c.min(), lat_c.max(), val_data.shape[0])

                    val_masked = np.ma.masked_less(val_data, 1.0)
                    levels = [1, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
                    cmap = mcolors.ListedColormap(['#F7DC6F', '#F4D03F', '#F5B041', '#EB984E', '#DC7633', '#E74C3C', '#C0392B', '#9B59B6', '#8E44AD', '#1F618D'])
                    cmap.set_under('none'); cmap.set_bad('none') 
                    norm = mcolors.BoundaryNorm(levels, cmap.N)
                    
                    pm = self.plot_blocky(ax, lon_c, lat_c, val_masked, ui, cmap, extent, norm=norm, alpha=0.6)
                    if pm: 
                        add_vertical_colorbar(pm, '発雷確率凡例 (1%~100%) [%]')
                        info_text = self.get_local_info(elem_name, lon_c, lat_c, val_data, area_index=area_index, model_name=model_name)
                        drawn_flag = True
                        
                        lc, lac, dc = self.crop_data(lon_c, lat_c, val_data, extent)
                        if lc.size > 0 and area_index != 0:
                            l2, la2 = np.meshgrid(lc, lac) if lc.ndim == 1 else (lc, lac)
                            min_lon, max_lon, min_lat, max_lat = extent
                            for r in range(dc.shape[0]):
                                for c in range(dc.shape[1]):
                                    lon_v, lat_v, val_p = l2[r, c], la2[r, c], dc[r, c]
                                    if val_p >= 1.0 and min_lon <= lon_v <= max_lon and min_lat <= lat_v <= max_lat:
                                        txt = ax.text(lon_v, lat_v, f"{val_p:.0f}", color='black', fontsize=9, ha='center', va='center', transform=ccrs.PlateCarree(), zorder=13, weight='bold')
                                        ui['dynamic_artists'].append(txt)
                except Exception as e: print(f"Lightning graphic render exception: {e}")

        elif "天気" in elem_name:
            key = None
            for k in data.files:
                if any(x in k.lower() for x in ['wea', 'wx', 'var_-1_-1_-1', 'var_0_19_192']): key = k; break
                
            if key:
                val_data = _cl(data[key], 0, 10, np.nan) 
                lon_c, lat_c = self.get_coords_for_data(data, val_data)
                
                if lon_c is None or lat_c is None:
                    lon_k = next((k for k in data.files if 'lon' in k.lower()), None)
                    lat_k = next((k for k in data.files if 'lat' in k.lower()), None)
                    if lon_k and lat_k: lon_c, lat_c = data[lon_k], data[lat_k]

                if lon_c is not None and lat_c is not None:
                    if lon_c.ndim == 1 and val_data.shape == (len(lon_c), len(lat_c)):
                        val_data = val_data.T

                val_masked = np.ma.masked_outside(val_data, 0.5, 4.5)
                levels = [0.5, 1.5, 2.5, 3.5, 4.5] 
                colors = ['#FF9800', '#B0C4DE', '#1E90FF', '#FFFFFF'] 
                cmap_wx = mcolors.ListedColormap(colors)
                cmap_wx.set_under('none')
                cmap_wx.set_over('none')
                cmap_wx.set_bad('none') 
                norm_wx = mcolors.BoundaryNorm(levels, cmap_wx.N)
                
                pm = self.plot_blocky(ax, lon_c, lat_c, val_masked, ui, cmap_wx, extent, norm=norm_wx, alpha=0.6)
                if pm: 
                    add_vertical_colorbar(pm, '卓越天気 (1:晴 2:曇 3:雨 4:雪)')
                    info_text = self.get_local_info(elem_name, lon_c, lat_c, val_data, area_index=area_index, model_name=model_name)
                    drawn_flag = True            
        
        return drawn_flag, info_text


    def plot_contour(self, ax, lon, lat, data, ui, levels, color, extent, linewidths=1.5):
        lon_c, lat_c, data_c = self.crop_data(lon, lat, data, extent)
        if lon_c.size == 0 or lat_c.size == 0 or data_c.size == 0: return None
        cs = ax.contour(lon_c, lat_c, data_c, levels=levels, colors=color, linewidths=linewidths, transform=ccrs.PlateCarree(), zorder=7)
        ui['dynamic_artists'].append(cs); ax.clabel(cs, inline=True, fontsize=11, fmt='%1.0f'); return cs

    def plot_contourf(self, ax, lon, lat, data, ui, levels, cmap_or_colors, extent, extend='both', alpha=0.6, norm=None):
        lon_c, lat_c, data_c = self.crop_data(lon, lat, data, extent)
        if lon_c.size == 0 or lat_c.size == 0 or data_c.size == 0: return None
        kwargs = {'levels': levels, 'extend': extend, 'transform': ccrs.PlateCarree(), 'alpha': alpha, 'zorder': 6}
        if isinstance(cmap_or_colors, str) or isinstance(cmap_or_colors, mcolors.Colormap): kwargs['cmap'] = cmap_or_colors
        else: kwargs['colors'] = cmap_or_colors
        if norm is not None: kwargs['norm'] = norm
        cf = ax.contourf(lon_c, lat_c, data_c, **kwargs)
        ui['dynamic_artists'].append(cf); return cf

    def plot_blocky(self, ax, lon, lat, data, ui, cmap_or_colors, extent, norm=None, alpha=0.85):
        lon_c, lat_c, data_c = self.crop_data(lon, lat, data, extent)
        if lon_c.size == 0 or lat_c.size == 0 or data_c.size == 0: return None
        if lon_c.ndim == 1: lon_c, lat_c = np.meshgrid(lon_c, lat_c)
        kwargs = {'transform': ccrs.PlateCarree(), 'alpha': alpha, 'zorder': 6, 'shading': 'nearest'}
        if isinstance(cmap_or_colors, str) or isinstance(cmap_or_colors, mcolors.Colormap): kwargs['cmap'] = cmap_or_colors
        else: kwargs['cmap'] = mcolors.ListedColormap(cmap_or_colors)
        if norm is not None: kwargs['norm'] = norm
        pm = ax.pcolormesh(lon_c, lat_c, data_c, **kwargs)
        ui['dynamic_artists'].append(pm); return pm

    def plot_barbs(self, ax, lon, lat, u, v, ui, area_index, extent, model_name=""):
        lon_c, lat_c, u_c = self.crop_data(lon, lat, u, extent)
        _, _, v_c = self.crop_data(lon, lat, v, extent)
        if lon_c.size == 0 or lat_c.size == 0 or u_c.size == 0 or v_c.size == 0: return None
        u_c = np.nan_to_num(u_c, nan=0.0); v_c = np.nan_to_num(v_c, nan=0.0)
        if lon_c.ndim == 1: lon_2d, lat_2d = np.meshgrid(lon_c, lat_c)
        else: lon_2d, lat_2d = lon_c, lat_c
        if lon_2d.shape != u_c.shape: return None
        
        # 間引き率の最適化(1/4, 1/2)
        if area_index == 0: skip = 40
        elif area_index == 1: 
            if "MSM" in model_name or "ANAL" in model_name: skip = 8
            else: skip = 6
        elif area_index == 2: skip = 2 
        else: skip = 1
            
        q = ax.barbs(lon_2d[::skip,::skip], lat_2d[::skip,::skip], u_c[::skip,::skip], v_c[::skip,::skip], length=5.5, color='black', transform=ccrs.PlateCarree(), zorder=10)
        ui['dynamic_artists'].append(q)

    def show_error_msg(self, ax, ui, msg):
        wrapped_msg = "\n".join(textwrap.wrap(msg, width=50))
        txt = ax.text(0.5, 0.5, wrapped_msg, transform=ax.transAxes, color='red', fontsize=12, ha='center', va='center', weight='bold', bbox=dict(facecolor='white', alpha=0.9, edgecolor='red'))
        ui['dynamic_artists'].append(txt)
    
    def extract_2d_array(self, data, exact_keys, target_level=None, fuzzy_keys=None):
        for k in exact_keys:
            if k in data.files:
                arr = np.squeeze(data[k])
                if arr.ndim == 2: return arr
        
        if fuzzy_keys:
            for k in data.files:
                kl = k.lower()
                if any(fk in kl for fk in fuzzy_keys) and not any(ex in kl for ex in ['lon', 'lat', 'time', 'valid']):
                    arr = np.squeeze(data[k])
                    if arr.ndim == 2: return arr
                    if arr.ndim == 3 and target_level is not None:
                        levels_16 = [1000, 925, 850, 700, 500, 400, 300, 250, 200, 150, 100, 70, 50, 30, 20, 10]
                        levels_msm = [1000, 975, 950, 925, 900, 850, 800, 700, 600, 500, 400, 300]
                        axis = 0 if arr.shape[0] < arr.shape[1] else 2
                        sz = arr.shape[axis]
                        if sz == len(levels_16) and target_level in levels_16:
                            return arr[levels_16.index(target_level),:,:] if axis == 0 else arr[:,:,levels_16.index(target_level)]
                        elif sz == len(levels_msm) and target_level in levels_msm:
                            return arr[levels_msm.index(target_level),:,:] if axis == 0 else arr[:,:,levels_msm.index(target_level)]
        return None

    def draw_frame(self, model_name, val):
        ui = self.tab_ui[model_name]; it_str = self.model_init_times.get(model_name)
        
        area_index = next(i for i, btn in enumerate(ui['area_btns']) if btn.isChecked())
        if ui['ax'] is None or ui['current_area_index'] != area_index:
            ui['ax'] = self.init_map(ui['fig'], area_index, model_name)
            ui['current_area_index'] = area_index

        axes_list = ui['ax'] if isinstance(ui['ax'], list) else [ui['ax']]
        
        elem_name = ui['list'].currentItem().text()
        display_val = val; fallback_msg = ""
        
        if model_name == "ANAL":
            val_idx = int(val)
            combo = ui['init_combo']
            if val_idx < combo.count():
                it_str = combo.itemData(val_idx)
            else:
                it_str = None
            display_val = 0
            
            if it_str:
                dt_utc = datetime.strptime(it_str, "%Y%m%d%H%M%S")
                dt_jst = dt_utc + timedelta(hours=9)
                target_time = dt_jst
            else:
                target_time = datetime.now()
            
            ui['time_label'].setText(f"過去 {val*30}分\n({target_time.strftime('%m/%d %H:%M')})")
        else:
            if model_name == "GSM_JP":
                ground_cloud_elems = ["地上気圧", "地上風向風速", "地上降水", "全雲量", "上層雲量", "中層雲量", "下層雲量", "地上3種重ね"]
                threshold = 132 if elem_name in ground_cloud_elems else 84
                if val > threshold and val % 6 != 0:
                    display_val = val - (val % 6); fallback_msg = f" (※FT={display_val}h 代替表示)"
            elif model_name == "MSM" and "hpa" in elem_name.lower() and val % 3 != 0:
                display_val = (val // 3) * 3; fallback_msg = f" (※FT={display_val}h 代替表示)"
            if "GUID" in model_name and "気温" not in elem_name and "風" not in elem_name and val % 3 != 0:
                display_val = (val // 3) * 3; fallback_msg += f" (※FT={display_val}h 代替表示)"

            if it_str:
                dt_utc = datetime.strptime(it_str, "%Y%m%d%H%M%S")
                dt_jst = dt_utc + timedelta(hours=9)
                target_time = dt_jst + timedelta(hours=val)
                ui['time_label'].setText(f"FT={val:02d}h\n({target_time.strftime('%m/%d %H:%M')})")

        if not it_str: 
            self.show_error_msg(axes_list[0], ui, f"{model_name} のデータがありません。")
            ui['canvas'].draw_idle(); return
        
        while ui['dynamic_artists']:
            art = ui['dynamic_artists'].pop(0)
            try:
                if hasattr(art, 'collections'):
                    for c in art.collections:
                        try: c.remove()
                        except: pass
                else: art.remove()
            except: pass
        
        for cb in ui['colorbars']:
            try: 
                cb.remove()
                # 追加: カラーバー用の透明な軸枠(Axes)が残らないよう完全に削除(メモリリーク対策)
                if hasattr(cb, 'ax') and cb.ax in ui['fig'].axes:
                    cb.ax.remove()
            except: pass
        ui['colorbars'].clear(); ui['info_panel'].hide()

        target_file = os.path.join(self.cache_dir, f"{model_name}_{it_str}_FT{display_val:02d}.npz")
        
        for ax_idx, ax in enumerate(axes_list):
            if model_name == "ANAL":
                ax.title.set_text(f"[{model_name}] {elem_name} / 基準初期:{dt_utc.strftime('%m/%d %H:00Z')}\n過去{val*30}分 ({target_time.strftime('%m/%d %H:%M')} JST)")
            else:
                ax.title.set_text(f"[{model_name}] {elem_name} / 初期:{dt_utc.strftime('%m/%d %H:00Z')}\nFT={val}h ({target_time.strftime('%m/%d %H:%M')} JST){fallback_msg}")
            
            if not os.path.exists(target_file):
                if ax_idx == 0: self.show_error_msg(ax, ui, f"データ未収録 (FT={display_val}h)")
                continue

            try:
                data = np.load(target_file)
                lon_key = self.get_exact_key(data, ['lon_surf', 'lon', 'lon_p', 'lon_pall'])
                lat_key = self.get_exact_key(data, ['lat_surf', 'lat', 'lat_p', 'lat_pall'])
                
                if not lon_key or not lat_key:
                    if ax_idx == 0: self.show_error_msg(ax, ui, "座標軸欠損エラー")
                    data.close(); continue

                extent = ax.get_extent(crs=ccrs.PlateCarree())
                drawn_flag = False; info_text = ""

                def add_vertical_colorbar(mappable, label_text):
                    cax = ui['fig'].add_axes([0.92, 0.15, 0.015, 0.7]) 
                    cb = ui['fig'].colorbar(mappable, cax=cax, orientation='vertical')
                    cb.set_label(label_text, fontsize=11, weight='bold'); ui['colorbars'].append(cb)

                def _cl(arr, minv, maxv, fill=0.0):
                    if arr is None: return None
                    a = np.array(arr, dtype=float)
                    return np.clip(np.nan_to_num(a, nan=fill), minv, maxv)

                if elem_name == "地上3種重ね":
                    p_key = self.get_precip_key(data)
                    if p_key:
                        pval = _cl(data[p_key], 0, 1000, 0.0)
                        if model_name == "GSM_JP" and display_val >= 3:
                            offset = 6 if display_val > 132 else 3
                            prev_file = os.path.join(self.cache_dir, f"{model_name}_{it_str}_FT{display_val-offset:02d}.npz")
                            if os.path.exists(prev_file):
                                try:
                                    with np.load(prev_file) as d_prev:
                                        prev_p_key = self.get_precip_key(d_prev)
                                        if prev_p_key:
                                            prev_pval = _cl(d_prev[prev_p_key], 0, 1000, 0.0)
                                            if prev_pval.shape == pval.shape: pval = np.clip(pval - prev_pval, 0, None)
                                except: pass
                        lon_c, lat_c = self.get_coords_for_data(data, pval)
                        pval_smooth = ndimage.gaussian_filter(pval, sigma=0.2)
                        pval_smooth = np.ma.masked_less(pval_smooth, 0.1) 
                        levels = [0.1, 1, 5, 10, 20, 30, 50, 80]
                        cmap_precip = mcolors.ListedColormap(['#A0E8A0', '#D4F05A', '#FFFF00', '#FFA500', '#FF5500', '#FF0000', '#FF00FF'])
                        cmap_precip.set_under('none'); cmap_precip.set_over('#800080')
                        norm_precip = mcolors.BoundaryNorm(levels, cmap_precip.N)
                        cf = self.plot_contourf(ax, lon_c, lat_c, pval_smooth, ui, levels, cmap_precip, extent, extend='max', alpha=0.55, norm=norm_precip)
                        if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, '降水量重ね表示 [mm]')
                    
                    slp = self.extract_2d_array(data, ['prmsl', 'mslet', 'msl', 'slp'], fuzzy_keys=['msl', 'prmsl'])
                    if slp is not None:
                        if np.nanmax(slp) > 2000: slp = slp / 100.0
                        slp_hpa = _cl(slp, 800, 1150, 1013.0)
                        lon_c, lat_c = self.get_coords_for_data(data, slp_hpa)
                        slp_smooth = ndimage.gaussian_filter(slp_hpa, sigma=1.0)
                        self.plot_contour(ax, lon_c, lat_c, slp_smooth, ui, np.arange(920, 1080, 4), '#1F618D', extent, linewidths=1.2)
                    
                    u_val = self.extract_2d_array(data, ['u10', '10u', 'u_10m'], fuzzy_keys=['u10', 'u_wind'])
                    v_val = self.extract_2d_array(data, ['v10', '10v', 'v_10m'], fuzzy_keys=['v10', 'v_wind'])
                    if u_val is not None and v_val is not None:
                        u_val = _cl(u_val, -200, 200, 0.0); v_val = _cl(v_val, -200, 200, 0.0)
                        lon_c, lat_c = self.get_coords_for_data(data, u_val)
                        self.plot_barbs(ax, lon_c, lat_c, u_val, v_val, ui, area_index, extent, model_name)
                    drawn_flag = True

                elif "GUID" in model_name:
                    drawn_flag, info_text = self.draw_guidance(ax, ui, data, elem_name, val, extent, area_index, model_name, it_str)

                elif elem_name == "地上気温":
                    t_val = self.extract_2d_array(data, ['t2m', 'tmp2m', 'temp2m'], fuzzy_keys=['tmp', 'temp', 't'])
                    if t_val is not None:
                        if np.nanmax(t_val) > 100: t_val = t_val - 273.15
                        t_val = _cl(t_val, -50, 40, 0.0); lon_c, lat_c = self.get_coords_for_data(data, t_val)
                        cf = self.plot_contourf(ax, lon_c, lat_c, t_val, ui, np.arange(-30, 40, 3), 'jet', extent, extend='both', alpha=0.4)
                        self.plot_contour(ax, lon_c, lat_c, t_val, ui, np.arange(-30, 40, 3), 'black', extent, linewidths=0.5)
                        if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, '地上気温 [℃]')
                        info_text = self.get_local_info(elem_name, lon_c, lat_c, t_val, area_index=area_index, model_name=model_name); drawn_flag = True

                elif elem_name == "地上気圧":
                    slp = self.extract_2d_array(data, ['prmsl', 'mslet', 'msl', 'slp'], fuzzy_keys=['msl', 'prmsl'])
                    if slp is not None:
                        if np.nanmax(slp) > 2000: slp = slp / 100.0
                        slp_hpa = _cl(slp, 800, 1150, 1013.0); lon_c, lat_c = self.get_coords_for_data(data, slp_hpa)
                        cs = self.plot_contour(ax, lon_c, lat_c, ndimage.gaussian_filter(slp_hpa, sigma=1.0), ui, np.arange(940, 1060, 4), '#2980B9', extent)
                        if cs: info_text += self.get_local_info("気圧", lon_c, lat_c, slp, area_index=area_index, model_name=model_name); drawn_flag = True

                elif elem_name == "地上風向風速":
                    u_val = self.extract_2d_array(data, ['u10', '10u', 'u_10m'], fuzzy_keys=['u10', 'u_wind'])
                    v_val = self.extract_2d_array(data, ['v10', '10v', 'v_10m'], fuzzy_keys=['v10', 'v_wind'])
                    if u_val is not None and v_val is not None:
                        u_val = _cl(u_val, -200, 200, 0.0); v_val = _cl(v_val, -200, 200, 0.0)
                        spd = np.hypot(u_val, v_val); lon_c, lat_c = self.get_coords_for_data(data, spd)
                        cf = self.plot_contourf(ax, lon_c, lat_c, spd, ui, [2, 4, 6, 8, 10, 15, 20, 25], ['#E0F7FA', '#B2EBF2', '#4DD0E1', '#00BCD4', '#00838F', '#9C27B0', '#4A148C'], extent, extend='max', alpha=0.5)
                        if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, '地上風速 [m/s]')
                        self.plot_barbs(ax, lon_c, lat_c, u_val, v_val, ui, area_index, extent, model_name)
                        info_text = self.get_local_info("風向風速", lon_c, lat_c, spd, u_val, v_val, area_index=area_index, model_name=model_name); drawn_flag = True
                
                elif "地上降水" in elem_name:
                    p_key = self.get_precip_key(data)
                    if p_key:
                        pval = _cl(data[p_key], 0, 1000, 0.0)
                        if model_name == "GSM_JP" and display_val >= 3:
                            offset = 6 if display_val > 132 else 3
                            prev_file = os.path.join(self.cache_dir, f"{model_name}_{it_str}_FT{display_val-offset:02d}.npz")
                            if os.path.exists(prev_file):
                                try:
                                    with np.load(prev_file) as d_prev:
                                        prev_p_key = self.get_precip_key(d_prev)
                                        if prev_p_key:
                                            prev_pval = _cl(d_prev[prev_p_key], 0, 1000, 0.0)
                                            if prev_pval.shape == pval.shape: pval = np.clip(pval - prev_pval, 0, None)
                                except: pass
                        lon_c, lat_c = self.get_coords_for_data(data, pval)
                        
                        if np.nanmax(pval) < 0.1:
                            txt = ax.text(0.5, 0.5, "降水域なし", transform=ax.transAxes, color='blue', fontsize=16, ha='center', va='center', weight='bold', bbox=dict(facecolor='white', alpha=0.8, edgecolor='blue'))
                            ui['dynamic_artists'].append(txt)
                            info_text = self.get_local_info("降水", lon_c, lat_c, pval, area_index=area_index, model_name=model_name)
                            drawn_flag = True
                        else:
                            pval_smooth = np.ma.masked_less(ndimage.gaussian_filter(pval, sigma=0.2), 0.1)
                            levels = [0.1, 1, 5, 10, 20, 30, 50, 80]
                            cmap_precip = mcolors.ListedColormap(['#A0E8A0', '#D4F05A', '#FFFF00', '#FFA500', '#FF5500', '#FF0000', '#FF00FF'])
                            cmap_precip.set_under('none'); cmap_precip.set_over('#800080')
                            norm_precip = mcolors.BoundaryNorm(levels, cmap_precip.N)
                            cf = self.plot_contourf(ax, lon_c, lat_c, pval_smooth, ui, levels, cmap_precip, extent, extend='max', alpha=0.85, norm=norm_precip)
                            if cf:
                                self.plot_contour(ax, lon_c, lat_c, pval_smooth, ui, levels, '#444444', extent, linewidths=0.5)
                                if ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, f'降水量 [mm]')
                                info_text = self.get_local_info("降水", lon_c, lat_c, pval, area_index=area_index, model_name=model_name)
                                drawn_flag = True

                elif "雲量" in elem_name:
                    c_val = None
                    if elem_name == "全雲量": 
                        c_val = self.extract_2d_array(data, ['tcc', 'tcdc', 'cloud', 'tcdc_surf', 'var_0_6_1', 'var_0_6_192'], fuzzy_keys=['tcc', 'tcdc', 'cloud', 'cld'])
                        if c_val is None:
                            hcc = self.extract_2d_array(data, ['hcc', 'hcdc', 'high', 'var_0_6_3'], fuzzy_keys=['hcc', 'high'])
                            mcc = self.extract_2d_array(data, ['mcc', 'mcdc', 'mid', 'var_0_6_4'], fuzzy_keys=['mcc', 'mid'])
                            lcc = self.extract_2d_array(data, ['lcc', 'lcdc', 'low', 'var_0_6_5'], fuzzy_keys=['lcc', 'low'])
                            if hcc is not None or mcc is not None or lcc is not None:
                                hcc_v = hcc if hcc is not None else 0
                                mcc_v = mcc if mcc is not None else 0
                                lcc_v = lcc if lcc is not None else 0
                                c_val = np.maximum(np.maximum(hcc_v, mcc_v), lcc_v)

                    elif elem_name == "上層雲量":
                        c_val = self.extract_2d_array(data, ['hcc', 'hcdc', 'high', 'var_0_6_3'], fuzzy_keys=['hcc', 'high'])
                    elif elem_name == "中層雲量":
                        c_val = self.extract_2d_array(data, ['mcc', 'mcdc', 'mid', 'var_0_6_4'], fuzzy_keys=['mcc', 'mid'])
                    elif elem_name == "下層雲量":
                        c_val = self.extract_2d_array(data, ['lcc', 'lcdc', 'low', 'var_0_6_5'], fuzzy_keys=['lcc', 'low'])

                    if c_val is not None:
                        c_val = _cl(c_val, 0, 150, 0.0)
                        lon_c, lat_c = self.get_coords_for_data(data, c_val)
                    
                        if lon_c is None or lat_c is None:
                            lon_k = self.get_exact_key(data, ['lon_surf', 'lon', 'lon_p', 'lon_pall'])
                            lat_k = self.get_exact_key(data, ['lat_surf', 'lat', 'lat_p', 'lat_pall'])
                            if lon_k and lat_k: lon_c, lat_c = data[lon_k], data[lat_k]

                        if lon_c is not None and lat_c is not None:
                            if np.nanmax(c_val) <= 1.0 and np.nanmax(c_val) > 0: c_val = c_val * 100.0
                        
                            if np.nanmax(c_val) < 0.1:
                                txt = ax.text(0.5, 0.5, "雲域なし", transform=ax.transAxes, color='gray', fontsize=14, ha='center', va='center', weight='bold', bbox=dict(facecolor='white', alpha=0.8, edgecolor='gray'))
                                ui['dynamic_artists'].append(txt)
                                info_text = self.get_local_info(elem_name, lon_c, lat_c, c_val, area_index=area_index, model_name=model_name)
                                drawn_flag = True
                            else:
                                levels = [20, 30, 40, 50, 60, 70, 80, 90, 100]
                                cmap_cloud = mcolors.ListedColormap(['#D9D9D9', '#BDBDBD', '#969696', '#737373', '#525252', '#252525', '#0F0F0F', '#000000'])
                                cmap_cloud.set_under('none') 
                                cmap_cloud.set_over('#000000')
                                norm_cloud = mcolors.BoundaryNorm(levels, cmap_cloud.N)
                            
                                cf = self.plot_contourf(ax, lon_c, lat_c, c_val, ui, levels, cmap_cloud, extent, extend='max', alpha=0.7, norm=norm_cloud)
                            
                                if cf:
                                    if len(ui['colorbars']) == 0:
                                        add_vertical_colorbar(cf, f'{elem_name} [%]')
                            
                                info_text = self.get_local_info(elem_name, lon_c, lat_c, c_val, area_index=area_index, model_name=model_name)
                                drawn_flag = True
                
                elif "300hpa高度" in elem_name.lower():
                    h_val = self.extract_2d_array(data, ['gh300', 'hgt300', 'hgt_300'], 300, ['hgt', 'gh', 'z'])
                    if h_val is not None:
                        h_val = _cl(h_val, -500, 20000, 0.0); lon_p, lat_p = self.get_coords_for_data(data, h_val)
                        cf = self.plot_contourf(ax, lon_p, lat_p, h_val, ui, np.arange(8400, 10800, 60), 'PuBu', extent, alpha=0.6)
                        self.plot_contour(ax, lon_p, lat_p, h_val, ui, np.arange(8400, 10800, 60), 'darkblue', extent)
                        if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, '300hPa 高度 [m]'); info_text = self.get_local_info(elem_name, lon_p, lat_p, h_val, area_index=area_index, model_name=model_name); drawn_flag = True

                elif "300hpa風向・風速" in elem_name.lower():
                    u_val = self.extract_2d_array(data, ['u300', 'u_300'], 300, ['u', 'ugrd'])
                    v_val = self.extract_2d_array(data, ['v300', 'v_300'], 300, ['v', 'vgrd'])
                    if u_val is not None and v_val is not None:
                        u_val = _cl(u_val, -300, 300, 0.0); v_val = _cl(v_val, -300, 300, 0.0)
                        spd = np.hypot(u_val, v_val); lon_p, lat_p = self.get_coords_for_data(data, spd)
                        cf = self.plot_contourf(ax, lon_p, lat_p, spd, ui, [20, 30, 40, 50, 60, 80, 100], 'YlOrRd', extent, extend='max', alpha=0.35)
                        self.plot_barbs(ax, lon_p, lat_p, u_val, v_val, ui, area_index, extent, model_name)
                        if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, '300hPa 風速 [m/s]'); info_text = self.get_local_info(elem_name, lon_p, lat_p, spd, u_val, v_val, area_index=area_index, model_name=model_name); drawn_flag = True

                elif "500hpa高度" in elem_name.lower():
                    h_val = self.extract_2d_array(data, ['gh500', 'hgt500', 'hgt_500'], 500, ['hgt', 'gh', 'z'])
                    if h_val is not None:
                        h_val = _cl(h_val, -500, 20000, 0.0); lon_p, lat_p = self.get_coords_for_data(data, h_val)
                        cf = self.plot_contourf(ax, lon_p, lat_p, h_val, ui, np.arange(4800, 6000, 60), 'Greens', extent, alpha=0.6)
                        self.plot_contour(ax, lon_p, lat_p, h_val, ui, np.arange(4800, 6000, 60), 'darkgreen', extent)
                        if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, '500hPa 高度 [m]'); info_text = self.get_local_info(elem_name, lon_p, lat_p, h_val, area_index=area_index, model_name=model_name); drawn_flag = True

                elif "500hpa風向・風速" in elem_name.lower():
                    u_val = self.extract_2d_array(data, ['u500', 'u_500'], 500, ['u', 'ugrd'])
                    v_val = self.extract_2d_array(data, ['v500', 'v_500'], 500, ['v', 'vgrd'])
                    if u_val is not None and v_val is not None: 
                        u_val = _cl(u_val, -300, 300, 0.0); v_val = _cl(v_val, -300, 300, 0.0)
                        spd = np.hypot(u_val, v_val); lon_p, lat_p = self.get_coords_for_data(data, spd)
                        cf = self.plot_contourf(ax, lon_p, lat_p, spd, ui, [10, 20, 30, 40, 50, 60, 80], 'YlOrRd', extent, extend='max', alpha=0.35)
                        self.plot_barbs(ax, lon_p, lat_p, u_val, v_val, ui, area_index, extent, model_name)
                        if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, '500hPa 風速 [m/s]'); info_text = self.get_local_info(elem_name, lon_p, lat_p, spd, u_val, v_val, area_index=area_index, model_name=model_name); drawn_flag = True
                elif "500hpa渦度" in elem_name.lower():
                    vort = self.extract_2d_array(data, ['vort500', 'vort', 'abs_vort'], 500, ['vort', 'abs_vort'])
                    if vort is not None:
                        if np.nanmax(np.abs(vort)) < 1.0: vort = vort * 100000.0
                        vort = _cl(vort, -1000, 1000, 0.0); lon_p, lat_p = self.get_coords_for_data(data, vort)
                        
                        # --- 修正: 滑らかにして正渦度のみ色付け、グラデーションカラーマップを適用 ---
                        vort_smooth = ndimage.gaussian_filter(vort, sigma=1.0)
                        vort_masked = np.ma.masked_less(vort_smooth, 10) 
                        levels_v = [10, 20, 30, 40, 60, 80, 100, 120, 150, 200]
                        cmap_v = mcolors.ListedColormap(['#FFF5E6', '#FDEBD0', '#FAD7A1', '#F8C471', '#F39C12', '#D35400', '#C0392B', '#922B21', '#641E16'])
                        cmap_v.set_under('none')
                        cmap_v.set_over('#4A235A')
                        norm_v = mcolors.BoundaryNorm(levels_v, cmap_v.N)
                        
                        cf = self.plot_contourf(ax, lon_p, lat_p, vort_masked, ui, levels_v, cmap_v, extent, extend='max', alpha=0.6, norm=norm_v)
                        # 等値線は見やすさのため正負で色を分けて描画
                        self.plot_contour(ax, lon_p, lat_p, vort_smooth, ui, np.arange(20, 300, 20), '#4A235A', extent, linewidths=0.8)
                        self.plot_contour(ax, lon_p, lat_p, vort_smooth, ui, np.arange(-200, 0, 20), '#154360', extent, linewidths=0.5)
                        
                        if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, '500hPa 正渦度 [10^-5/s]'); info_text = self.get_local_info(elem_name, lon_p, lat_p, vort, area_index=area_index, model_name=model_name); drawn_flag = True

                elif "hpa気温" in elem_name.lower() or "hpa気温・風" in elem_name.lower():
                    w = re.search(r'(\d+)hpa', elem_name.lower())
                    if w:
                        lvl = int(w.group(1))
                        t_val = self.extract_2d_array(data, [f't{lvl}', f'tmp{lvl}', f't_{lvl}', f'tmp_{lvl}'], lvl, ['tmp', 'temp', 't'])
                        if t_val is not None:
                            if np.nanmax(t_val) > 100: t_val = t_val - 273.15
                            t_val = _cl(t_val, -100, 60, 0.0); lon_p, lat_p = self.get_coords_for_data(data, t_val)
                            cf = self.plot_contourf(ax, lon_p, lat_p, t_val, ui, np.arange(-54, 40, 3), 'jet', extent, extend='both', alpha=0.3)
                            self.plot_contour(ax, lon_p, lat_p, t_val, ui, np.arange(-54, 40, 3), 'blue', extent, linewidths=0.5)
                            if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, f'{lvl}hPa 気温 [℃]')
                            
                            lon_c, lat_c, t_c = self.crop_data(lon_p, lat_p, t_val, extent)
                            if lon_c.size > 0:
                                cs = ax.contour(lon_c, lat_c, t_c, levels=[-36, -6, 0, 15, 30], colors='red', linewidths=2.0, transform=ccrs.PlateCarree(), zorder=8)
                                ui['dynamic_artists'].append(cs); ax.clabel(cs, inline=True, fontsize=12, fmt='%1.0f')
                            
                            u_val = self.extract_2d_array(data, [f'u{lvl}', f'u_{lvl}'], lvl, ['u', 'ugrd'])
                            v_val = self.extract_2d_array(data, [f'v{lvl}', f'v_{lvl}'], lvl, ['v', 'vgrd'])
                            if u_val is not None: u_val = _cl(u_val, -200, 200, 0.0)
                            if v_val is not None: v_val = _cl(v_val, -200, 200, 0.0)
                            info_text = self.get_local_info(elem_name, lon_p, lat_p, t_val, u_val, v_val, area_index=area_index, model_name=model_name)
                            drawn_flag = True
                            if u_val is not None and v_val is not None: self.plot_barbs(ax, lon_p, lat_p, u_val, v_val, ui, area_index, extent, model_name)

                elif "700hpa湿数" in elem_name.lower():
                    t = self.extract_2d_array(data, ['t700', 'tmp700', 't_700'], 700, ['tmp', 'temp', 't'])
                    rh_raw = self.extract_2d_array(data, ['rh700', 'r700', 'rh_700'], 700, ['rh', 'hum', 'r'])
                    if t is not None and rh_raw is not None:
                        if np.nanmax(t) > 100: t = t - 273.15
                        rh = _cl(rh_raw, 0, 150, 0.0)
                        if np.nanmax(rh) <= 1.0 and np.nanmax(rh) > 0: rh = rh * 100.0
                        lon_p, lat_p = self.get_coords_for_data(data, t); t_td = calc_t_td(t, rh)
                        cf = self.plot_contourf(ax, lon_p, lat_p, t_td, ui, [0, 3, 6, 9, 12, 15], ['#27AE60', '#82E0AA', '#D5F5E3', '#F9E79F', '#F5CBA7', '#E74C3C'], extent, extend='max', alpha=0.35) 
                        u_val = self.extract_2d_array(data, ['u700', 'u_700'], 700, ['u', 'ugrd'])
                        v_val = self.extract_2d_array(data, ['v700', 'v_700'], 700, ['v', 'vgrd'])
                        if u_val is not None: u_val = _cl(u_val, -200, 200, 0.0)
                        if v_val is not None: v_val = _cl(v_val, -200, 200, 0.0)
                        if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, '700hPa 湿数 [℃]')
                        info_text = self.get_local_info("湿数", lon_p, lat_p, t_td, u_val, v_val, area_index=area_index, model_name=model_name); drawn_flag = True
                        if u_val is not None and v_val is not None: self.plot_barbs(ax, lon_p, lat_p, u_val, v_val, ui, area_index, extent, model_name)

                elif "700hpa鉛直流" in elem_name.lower():
                    w_val = self.extract_2d_array(data, ['w', 'w700', 'vvel', 'vvel700', 'dzdt', 'dzdt700'], 700, ['w', 'vvel', 'dzdt'])
                    if w_val is not None:
                        # --- 修正: 最大値ではなく、全体の平均値から確実な単位判定を行う ---
                        if np.nanmean(np.abs(w_val)) < 3.0: 
                            w_val = w_val * 36.0 
                        
                        w_val = _cl(w_val, -200, 200, 0.0); lon_p, lat_p = self.get_coords_for_data(data, w_val)
                        
                        # --- 修正: 弱いノイズをカットし、透過度(alpha)を 0.85 -> 0.5 に下げて下絵を見せる ---
                        w_val_masked = np.ma.masked_inside(ndimage.gaussian_filter(w_val, sigma=0.8), -3.0, 3.0)
                        levels_w = [-80, -50, -30, -10, -3, 3, 10, 30, 50, 80]
                        
                        cf = self.plot_contourf(ax, lon_p, lat_p, w_val_masked, ui, levels_w, 'PiYG', extent, extend='both', alpha=0.5)
                        
                        if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, '700hPa 鉛直流 [hPa/h]'); info_text = self.get_local_info(elem_name, lon_p, lat_p, w_val, area_index=area_index, model_name=model_name); drawn_flag = True
                        
                elif "850hpa相当温位" in elem_name.lower():
                    t = self.extract_2d_array(data, ['t850', 'tmp850', 't_850'], 850, ['tmp', 'temp', 't'])
                    rh_raw = self.extract_2d_array(data, ['rh850', 'r850', 'rh_850'], 850, ['rh', 'hum', 'r'])
                    if t is not None and rh_raw is not None:
                        if np.nanmax(t) > 100: t = t - 273.15
                        rh = _cl(rh_raw, 0, 150, 0.0)
                        if np.nanmax(rh) <= 1.0 and np.nanmax(rh) > 0: rh = rh * 100.0
                        lon_p, lat_p = self.get_coords_for_data(data, t); ept = calc_ept(t, rh, 850); levels = np.arange(270, 360, 6)
                        cf = self.plot_contourf(ax, lon_p, lat_p, ept, ui, levels, 'rainbow', extent, extend='both', alpha=0.6) 
                        self.plot_contour(ax, lon_p, lat_p, ept, ui, levels, 'black', extent)
                        if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, '850hPa 相当温位 [K]'); info_text = self.get_local_info(elem_name, lon_p, lat_p, ept, area_index=area_index, model_name=model_name); drawn_flag = True

                elif "850hpa湿数" in elem_name.lower():
                    t = self.extract_2d_array(data, ['t850', 'tmp850', 't_850'], 850, ['tmp', 'temp', 't'])
                    rh_raw = self.extract_2d_array(data, ['rh850', 'r850', 'rh_850'], 850, ['rh', 'hum', 'r'])
                    if t is not None and rh_raw is not None:
                        if np.nanmax(t) > 100: t = t - 273.15
                        rh = _cl(rh_raw, 0, 150, 0.0)
                        if np.nanmax(rh) <= 1.0 and np.nanmax(rh) > 0: rh = rh * 100.0
                        lon_p, lat_p = self.get_coords_for_data(data, t); t_td = calc_t_td(t, rh)
                        cf = self.plot_contourf(ax, lon_p, lat_p, t_td, ui, [0, 3, 6, 9, 12, 15], ['#27AE60', '#82E0AA', '#D5F5E3', '#F9E79F', '#F5CBA7', '#E74C3C'], extent, extend='max', alpha=0.35)
                        u_val = self.extract_2d_array(data, ['u850', 'u_850'], 850, ['u', 'ugrd'])
                        v_val = self.extract_2d_array(data, ['v850', 'v_850'], 850, ['v', 'vgrd'])
                        if u_val is not None: u_val = _cl(u_val, -200, 200, 0.0)
                        if v_val is not None: v_val = _cl(v_val, -200, 200, 0.0)
                        if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, '850hPa 湿数 [℃]')
                        info_text = self.get_local_info("湿数", lon_p, lat_p, t_td, u_val, v_val, area_index=area_index, model_name=model_name); drawn_flag = True
                        if u_val is not None and v_val is not None: self.plot_barbs(ax, lon_p, lat_p, u_val, v_val, ui, area_index, extent, model_name)

                elif "925hpa" in elem_name.lower() or "975hpa" in elem_name.lower():
                    lvl = 925 if "925" in elem_name else 975
                    t = self.extract_2d_array(data, [f't{lvl}', f'tmp{lvl}', f't_{lvl}'], lvl, ['tmp', 'temp', 't'])
                    rh_raw = self.extract_2d_array(data, [f'rh{lvl}', f'r{lvl}', f'rh_{lvl}'], lvl, ['rh', 'hum', 'r'])
                    if t is not None and rh_raw is not None:
                        if np.nanmax(t) > 100: t = t - 273.15
                        rh = _cl(rh_raw, 0, 150, 0.0)
                        if np.nanmax(rh) <= 1.0 and np.nanmax(rh) > 0: rh = rh * 100.0
                        lon_p, lat_p = self.get_coords_for_data(data, t)
                        
                        if "相当温位" in elem_name:
                            ept = calc_ept(t, rh, lvl); levels = np.arange(270, 360, 6)
                            cf = self.plot_contourf(ax, lon_p, lat_p, ept, ui, levels, 'rainbow', extent, extend='both', alpha=0.6)
                            self.plot_contour(ax, lon_p, lat_p, ept, ui, levels, 'black', extent)
                            if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, f'{lvl}hPa 相当温位 [K]'); info_text = self.get_local_info(elem_name, lon_p, lat_p, ept, area_index=area_index, model_name=model_name); drawn_flag = True
                        else:
                            t_td = calc_t_td(t, rh); levels = [0, 3, 6, 9, 12, 15]
                            cf = self.plot_contourf(ax, lon_p, lat_p, t_td, ui, levels, ['#27AE60', '#82E0AA', '#D5F5E3', '#F9E79F', '#F5CBA7', '#E74C3C'], extent, extend='max', alpha=0.35)
                            u_val = self.extract_2d_array(data, [f'u{lvl}', f'u_{lvl}'], lvl, ['u', 'ugrd'])
                            v_val = self.extract_2d_array(data, [f'v{lvl}', f'v_{lvl}'], lvl, ['v', 'vgrd'])
                            if u_val is not None: u_val = _cl(u_val, -200, 200, 0.0)
                            if v_val is not None: v_val = _cl(v_val, -200, 200, 0.0)
                            if cf and ax_idx == len(axes_list)-1: add_vertical_colorbar(cf, f'{lvl}hPa 湿数 [℃]')
                            info_text = self.get_local_info("湿数", lon_p, lat_p, t_td, u_val, v_val, area_index=area_index, model_name=model_name); drawn_flag = True
                            if u_val is not None and v_val is not None: self.plot_barbs(ax, lon_p, lat_p, u_val, v_val, ui, area_index, extent, model_name)

                data.close(); del data 
                if info_text and ax_idx == 0:
                    ui['info_panel'].setText(info_text); ui['info_panel'].adjustSize(); ui['info_panel'].move(10, 10); ui['info_panel'].show()
                if not drawn_flag and ax_idx == 0:
                    self.show_error_msg(ax, ui, f"[!] {elem_name} 描画エラー")
                
            except Exception as e: logging.error(f"Draw Error for {elem_name} at FT={display_val}: {e}")

        ui['canvas'].draw_idle(); gc.collect()

    def setup_history_tab(self):
        tab = QWidget(); layout = QVBoxLayout(tab); history_text = QTextEdit(); history_text.setReadOnly(True)
        history_text.setStyleSheet("background-color: #112240; color: #E0E0E0; font-family: 'MS Gothic'; font-size: 12pt; padding: 10px; border: none;")
        html_content = """
        <h2 style='color: #64FFDA;'>気象GPV 局地分析ツール 更新履歴</h2><hr style='border-color: #457B9D;'><ul>
        <li><b>Ver 134.0 (ANALスライダー・メタグラム拡張版)</b><br>
        1. <b>大気解析(ANAL)履歴ブラウザ化</b>: 過去のANALデータをスライダーで遡って閲覧できるように改善。<br>
        2. <b>風矢羽根の最適化表示</b>: 日本周辺・北海道全域で1/4、上川地方で1/2に間引き、視認性を大幅に向上。<br>
        3. <b>大気解析専用メタグラム搭載</b>: ANALの過去6枠分(3時間分)の解析要素時系列グラフを描画する機能を追加。<br>
        4. <b>MEPSガイダンスメタグラム改修</b>: テーブル内の文字サイズを縮小し、一覧表示を最適化。<br>
        </ul>"""
        history_text.setHtml(html_content); layout.addWidget(history_text); self.tabs.addTab(tab, "更新履歴")

    def scan_cache_dir(self, quiet=False, force_draw=False):
        if not os.path.exists(self.cache_dir): return
        files = glob.glob(os.path.join(self.cache_dir, "*.npz"))
        updated = False
        
        for m in ["MSM", "GSM_JP", "ANAL", "MSM_GUID", "GSM_GUID"] + MEPS_STAT_MODELS:
            m_files = [f for f in files if os.path.basename(f).startswith(f"{m}_")]
            times = sorted(list(set(re.search(r'_(\d{14})_', f).group(1) for f in m_files if re.search(r'_(\d{14})_', f))), reverse=True)
            if times:
                combo = self.tab_ui.get(m, {}).get('init_combo')
                if combo:
                    current_items = [combo.itemData(i) for i in range(combo.count())]
                    if current_items != times:
                        combo.blockSignals(True); combo.clear()
                        for t in times:
                            dt_utc = datetime.strptime(t, "%Y%m%d%H%M%S")
                            combo.addItem(dt_utc.strftime('%m/%d %H:%M Z') + (" (最新)", "")[t != times[0]], t)
                        combo.blockSignals(False)
                        if self.model_init_times.get(m) not in times:
                            self.model_init_times[m] = times[0]; 
                            self.update_slider_max(m, times[0])
                            self.tab_ui[m]['slider'].setValue(0); combo.setCurrentIndex(0); updated = True
                    current_it = self.model_init_times.get(m)
                    if current_it:
                        self.update_slider_max(m, current_it)
        if (updated or force_draw) and not quiet:
            self.status_banner.setText("最新データを画面に同期しました")
            current_tab_name = self.tabs.tabText(self.tabs.currentIndex())
            _meps_hit = next((m for m in MEPS_STAT_MODELS if MEPS_STAT_TAB_TITLES[m] == current_tab_name), None)
            
            if _meps_hit: current_m = _meps_hit
            elif "MSM_GUID" in current_tab_name: current_m = "MSM_GUID"
            elif "GSM_GUID" in current_tab_name: current_m = "GSM_GUID"
            elif "毎時大気" in current_tab_name: current_m = "ANAL"
            elif "MSM" in current_tab_name: current_m = "MSM"
            elif "GSM_JP" in current_tab_name: current_m = "GSM_JP"
            else: current_m = None
            
            if current_m in self.tab_ui: 
                if len(self.tab_ui[current_m]['area_btns']) > 5 and self.tab_ui[current_m]['area_btns'][-1].isChecked(): pass
                else: 
                    self.draw_frame(current_m, self.tab_ui[current_m]['slider'].value())

    def start_animation(self, model_name):
        self.current_anim_model = model_name
        speeds = [2000, 1000, 500, 200, 50]
        self.anim_timer.start(speeds[self.tab_ui[model_name]['combo_speed'].currentIndex()])

    def remove_vertical_colorbar(self):
        pass

    def stop_animation(self):
        self.anim_timer.stop()

    def anim_step(self):
        if not self.current_anim_model: return
        ui = self.tab_ui[self.current_anim_model]
        current_val = ui['slider'].value()
        elem_name = ui['list'].currentItem().text()
        
        step_val = ui['step']
        if self.current_anim_model == "GSM_JP":
            ground_cloud_elems = ["地上気圧", "地上風向風速", "地上降水", "全雲量", "上層雲量", "中層雲量", "下層雲量", "地上3種重ね"]
            threshold = 132 if elem_name in ground_cloud_elems else 84
            if current_val >= threshold: step_val = 6
                
        new_val = current_val + step_val
        ui['slider'].setValue(new_val if new_val <= ui['slider'].maximum() else ui['slider'].minimum())

if __name__ == '__main__':
    import multiprocessing
    multiprocessing.freeze_support()
    import traceback
    try:
        app = QApplication(sys.argv)
        window = WeatherApp()
        sys.exit(app.exec())
    except Exception as e:
        print("起動時にエラーが発生しました:")
        traceback.print_exc()
        input("Enterキーを押すと終了します...")