# -*- coding: utf-8 -*-
"""
X08: 河川浸水想定区域 × 避難所立地 — 「避難所自体が水没するか?」 点 in ポリゴン判定研究
================================================================================

DoBoX の以下 2 系統を空間的に重ね合わせ、4,065 件の避難所点が
「想定最大規模」「計画規模」の浸水想定区域ポリゴンに **入っているかどうか** を
1 件ごとに Ray casting で判定する **点 in ポリゴン判定研究** (X 水準, リテラシレベル)。

【本記事のスタイル: 点 in ポリゴン判定研究】
4,065 避難所の **どれが** 浸水想定域内に立地するかを 1 件ごとに判定し、
**危険避難所をランキング化** する。X07 の「面積率研究」(ポリゴン × ポリゴン)と
対比的に、本記事は「点 × ポリゴン」という別軸の空間結合を扱う。
散布図行列・PCA・ロジ回帰・k-means は使わない。
**危険避難所ランキング棒・市町別棒・ボーダー判定・フラグ×立地クロス・標高散布**
の 5 図を主役に据える。

データ:
  - DoBoX #42 (避難所情報, JSON, 4,065 件; 既取得 data/shelters.json)
  - DoBoX #295 (河川浸水想定区域 計画規模 全河川, Shapefile, Web Mercator)
  - DoBoX #313 (河川浸水想定区域 想定最大規模 全河川, Shapefile, Web Mercator)
  - 市町別 平均標高 (シンボリック値, 既知の地理データから手動定義)

仮説 H1〜H5:
  H1: 全 4,065 避難所のうち 25〜45% が想定最大規模浸水想定区域内に立地する
  H2: 「危険避難所」(浸水域内 × 想定浸水深推定 > 標高差) は沿岸都市に集中する
  H3: 「ボーダー避難所」(計画規模で安全 / 想定最大規模で危険) が一定数 (50〜500件) 存在する
  H4: 災害種別フラグ「洪水対応(floodShFlg=1)」と「浸水想定区域内立地」は逆相関する
       (フラグが立っている避難所ほど安全な場所に置かれているはず)
  H5: 避難所が密集する市町ほど、危険避難所の絶対数も多い (= 避難所数 vs 危険避難所数の正相関)

主要可視化 (5 図):
  図1 (主役): 危険避難所 上位 30 ランキング水平棒 (市町ラベル付)
  図2:        市町別 危険避難所件数ランキング棒
  図3:        ボーダー避難所判定 (計画 OK / 最大 NG) 4 区分件数比較棒
  図4:        洪水対応フラグ × 浸水域内立地 クロス集計 (4 セルヒートマップ)
  図5:        避難所立地標高 (市町平均 m) vs 想定浸水域内フラグ 散布 + 箱ひげ

実装:
  - 点 in ポリゴン判定 = Ray casting (純 Python + numpy ベクトル化)
  - bbox プリフィルタで 4,065 点 × ~600 ポリゴン × 2 規模 = 488 万判定を秒単位に
  - 浸水 Shapefile を 1 度ロード後に逐次処理 (メモリ < 500 MB)

実行:
    cd "2026 DoBoX 教材"
    py -X utf8 lessons/X08_flood_shelter_in_polygon.py

制約:
    - 並列処理なし、メモリ < 500 MB
"""
from __future__ import annotations
from pathlib import Path
import json
import math
import warnings
import zipfile

import numpy as np
import pandas as pd
import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt

import shapefile  # pyshp

from _common import (ROOT, ASSETS, LESSONS, render_lesson, code, figure,
                     data_recipe, ensure_dataset)

plt.rcParams["font.family"] = "Yu Gothic"
plt.rcParams["axes.unicode_minus"] = False
warnings.filterwarnings("ignore", category=UserWarning)

# === パス定義 =================================================================
DATA_DIR = ROOT / "data" / "extras"
FLOOD_DIR = DATA_DIR / "flood_shp"
FLOOD_DIR.mkdir(parents=True, exist_ok=True)

SHELTERS_JSON = ROOT / "data" / "shelters.json"

OUT_JUDGE   = ASSETS / "X08_shelter_judge.csv"        # 4,065 行 × 判定結果列
OUT_DANGER  = ASSETS / "X08_danger_top.csv"           # 危険避難所 上位 (浸水域内 × 低標高)
OUT_CITY    = ASSETS / "X08_city_summary.csv"         # 市町別 集計
OUT_BORDER  = ASSETS / "X08_border_breakdown.csv"     # 計画 vs 最大 4区分内訳
OUT_FLAG    = ASSETS / "X08_flag_cross.csv"           # 洪水対応フラグ × 立地 クロス表
OUT_TRACK   = ASSETS / "X08_track_one_shelter.csv"    # 1 件追跡 (要件K)

# 追加分析6-10 用 中間 CSV (3 本追加 = 計 9 本)
OUT_MISMATCH = ASSETS / "X08_mismatch.csv"            # フラグ無 × 浸水域内 の潜在リスク
OUT_CAPSUM   = ASSETS / "X08_capacity_summary.csv"    # 市町別 収容力 × 危険度
OUT_CITYRISK = ASSETS / "X08_city_risk_rate.csv"      # 市町別 危険率ヒートマップ用

PNG_RANK    = ASSETS / "X08_danger_rank.png"          # 主役: 危険避難所上位30 マルチ属性ランキング
PNG_RANK_PANELS = ASSETS / "X08_danger_rank_panels.png"  # 補助: 上位30 を 3 パネル並列 (capacity / 標高 / フラグ)
PNG_CITY    = ASSETS / "X08_city_danger_bar.png"      # 市町別 危険件数
PNG_BORDER  = ASSETS / "X08_border_breakdown.png"     # ボーダー4区分
PNG_FLAG    = ASSETS / "X08_flag_cross.png"           # フラグ×立地クロス
PNG_ELEV    = ASSETS / "X08_elev_scatter.png"         # 標高 vs 立地

# 追加分析6-10 用 PNG (5 枚追加 = 計 10 枚)
PNG_MAP_DANGER  = ASSETS / "X08_map_danger.png"       # 危険度の地理マッピング
PNG_MAP_BORDER  = ASSETS / "X08_map_border.png"       # border_class 別 地理マッピング
PNG_MISMATCH    = ASSETS / "X08_mismatch.png"         # 5 災害種別フラグ × 浸水立地ミスマッチ
PNG_CAP_DANGER  = ASSETS / "X08_capacity_danger.png"  # 収容力 × 危険度 バブル
PNG_CITY_HEAT   = ASSETS / "X08_city_heatmap.png"     # 市町別 危険率ヒートマップ (地理)

# === 0. 投影変換ユーティリティ ===============================================
WGS_R = 6378137.0  # WGS84 球面近似 半径 (m)


def merc_to_ll(x: float, y: float) -> tuple[float, float]:
    """Web Mercator (EPSG:3857) → WGS84 経緯度 (厳密式)。"""
    lon = math.degrees(x / WGS_R)
    lat = math.degrees(2 * math.atan(math.exp(y / WGS_R)) - math.pi / 2)
    return lon, lat


# === 1. データ取得 (DoBoX 直叩き) =============================================
print("=== 1. データ取得 ===")
FLOOD_RID = {
    "計画規模":     (295, 23061, "shinsui_keikaku.zip", "shinsui_keikaku"),
    "想定最大規模": (313, 23118, "shinsui_souteisaidai.zip", "shinsui_souteisaidai"),
}
for label, (_, rid, fn, _stem) in FLOOD_RID.items():
    p = FLOOD_DIR / fn
    ensure_dataset(p, resource_id=rid, min_bytes=1_000_000, label=f"浸水/{label}")

# 避難所 JSON は前回までの取得物を再利用 (X03/L03 でも使用)
if not SHELTERS_JSON.exists():
    raise FileNotFoundError(
        f"data/shelters.json が見当たりません。L03 のスクリプトで先に取得してください。"
    )


# === 2. 避難所 JSON を読み込み 4,065 件をテーブル化 ===========================
print("\n=== 2. 避難所 JSON 読込 ===")
with open(SHELTERS_JSON, encoding="utf-8") as f:
    shelter_obj = json.load(f)
shelters_raw = shelter_obj["items"]
print(f"  総避難所数: {len(shelters_raw)} 件")

shel_df = pd.DataFrame([{
    "facility_id": s.get("facilityId"),
    "name": s.get("name"),
    "address": s.get("address01", ""),
    "lat": float(s["latitude"]),
    "lon": float(s["longitude"]),
    "muni": s.get("municipalityName", ""),
    "muni_cd": s.get("municipalityCd", ""),
    "capacity": s.get("capacity"),
    "flood_flg": int(s.get("floodShFlg") or 0),
    "sediment_flg": int(s.get("sedimentDisasterShFlg") or 0),
    "storm_flg": int(s.get("stormSurgeShFlg") or 0),
    "eq_flg": int(s.get("earthquakeShFlg") or 0),
    "tsunami_flg": int(s.get("tsunamiShFlg") or 0),
} for s in shelters_raw])
print(f"  shel_df: {len(shel_df)} 行 × {len(shel_df.columns)} 列")
print(f"  洪水対応フラグ立ち: {shel_df['flood_flg'].sum()}/{len(shel_df)}"
      f"  ({shel_df['flood_flg'].mean()*100:.1f}%)")
print(f"  市町数: {shel_df['muni'].nunique()}")


# === 2.5 市町 → 市 (大くくり) 集約 + シンボリック平均標高テーブル ===========
# 広島市の各区を「広島市」に集約しつつも、市町別ランキングは元粒度で見る。
# 平均標高はシンボリック (公開地形データから既知の概算値を手動で定義)。
# ここでは「役所所在地付近の標高」を採用 (m, 概数)。
CITY_ELEV_M = {
    # 広島市 8 区 (デルタ平野)
    "広島市中区": 3, "広島市東区": 6, "広島市南区": 4, "広島市西区": 4,
    "広島市安佐南区": 30, "広島市安佐北区": 110, "広島市安芸区": 25, "広島市佐伯区": 18,
    # 周辺都市
    "呉市": 8, "竹原市": 5, "三原市": 6, "尾道市": 5, "福山市": 7,
    "府中市": 80, "三次市": 165, "庄原市": 230, "大竹市": 6, "東広島市": 220,
    "廿日市市": 8, "安芸高田市": 175, "江田島市": 5, "海田町": 4, "府中町": 8,
    "坂町": 4, "熊野町": 220, "安芸太田町": 350, "北広島町": 470,
    "大崎上島町": 30, "世羅町": 380, "神石高原町": 480,
    # 念の為のデフォルト (未定義市町用)
}
DEFAULT_ELEV = 50  # 未収録時のフォールバック

shel_df["elev_m"] = shel_df["muni"].map(CITY_ELEV_M).fillna(DEFAULT_ELEV).astype(float)
print(f"  標高未定義 (DEFAULT_ELEV={DEFAULT_ELEV} 適用): "
      f"{(shel_df['elev_m'] == DEFAULT_ELEV).sum()} 件")


# === 3. 浸水 Shapefile を読み込み、ポリゴン bbox を経緯度で保持 ===============
print("\n=== 3. 浸水 Shapefile 読込 (Web Mercator → 経緯度) ===")
flood_data: dict[str, list[dict]] = {}

for label, (_, _, fn, stem) in FLOOD_RID.items():
    zp = FLOOD_DIR / fn
    ext = FLOOD_DIR / stem
    ext.mkdir(exist_ok=True)
    with zipfile.ZipFile(zp) as z:
        for n in z.namelist():
            if not (ext / Path(n).name).exists():
                z.extract(n, ext)
    shp_path = next(ext.glob("*.shp"))
    sf = shapefile.Reader(str(shp_path))
    fields = [f[0] for f in sf.fields[1:]]
    polys = []
    for shp_idx, (rec, shp) in enumerate(zip(sf.records(), sf.shapes())):
        if not shp.points:
            continue
        bx0, by0, bx1, by1 = shp.bbox
        lon0, lat0 = merc_to_ll(bx0, by0)
        lon1, lat1 = merc_to_ll(bx1, by1)
        parts = list(shp.parts) + [len(shp.points)]
        rings = []
        for k in range(len(shp.parts)):
            seg = shp.points[parts[k]:parts[k + 1]]
            lons = [merc_to_ll(p[0], p[1])[0] for p in seg]
            lats = [merc_to_ll(p[0], p[1])[1] for p in seg]
            rings.append((np.asarray(lons, dtype=np.float64),
                          np.asarray(lats, dtype=np.float64)))
        polys.append({
            "shp_idx": shp_idx,
            "kasen": rec[fields.index("kasen")] if "kasen" in fields else "?",
            "suikei": rec[fields.index("suikei")] if "suikei" in fields else "?",
            "lon_min": lon0, "lon_max": lon1,
            "lat_min": lat0, "lat_max": lat1,
            "rings": rings,
        })
    flood_data[label] = polys
    print(f"  {label}: {len(polys)} ポリゴン (河川数 = "
          f"{len(set(p['kasen'] for p in polys))})")
    sf.close()


# === 4. Ray casting (ベクトル化) =============================================
def points_in_ring_vec(xs_pt: np.ndarray, ys_pt: np.ndarray,
                       ring_xs: np.ndarray, ring_ys: np.ndarray) -> np.ndarray:
    """N 点が 1 リング (M 頂点) の内側か。N が小さい場合の安定実装。"""
    n_pts = len(xs_pt)
    inside = np.zeros(n_pts, dtype=bool)
    m = len(ring_xs)
    j_idx = (np.arange(m) - 1) % m
    for i in range(m):
        yi = ring_ys[i]; yj = ring_ys[j_idx[i]]
        xi = ring_xs[i]; xj = ring_xs[j_idx[i]]
        cond_y = (yi > ys_pt) != (yj > ys_pt)
        if not cond_y.any():
            continue
        x_int = (xj - xi) * (ys_pt - yi) / (yj - yi + 1e-15) + xi
        cond = cond_y & (xs_pt < x_int)
        inside ^= cond
    return inside


def points_in_any_polygon_set(xs_pt: np.ndarray, ys_pt: np.ndarray,
                              polys: list) -> np.ndarray:
    """N 点 vs P 個のポリゴン集合 → bool 配列 (N,)。
    bbox プリフィルタ + 該当点だけ Ray casting。"""
    n_pts = len(xs_pt)
    inside = np.zeros(n_pts, dtype=bool)
    for p in polys:
        # bbox プリフィルタ (ポリゴン bbox 外の点は確定で外)
        cand = (~inside) & \
               (xs_pt >= p["lon_min"]) & (xs_pt <= p["lon_max"]) & \
               (ys_pt >= p["lat_min"]) & (ys_pt <= p["lat_max"])
        if not cand.any():
            continue
        idx = np.where(cand)[0]
        sub_x = xs_pt[idx]
        sub_y = ys_pt[idx]
        sub_in = np.zeros(len(idx), dtype=bool)
        for ring_x, ring_y in p["rings"]:
            if len(ring_x) < 3:
                continue
            still = ~sub_in
            if not still.any():
                break
            r = points_in_ring_vec(sub_x[still], sub_y[still], ring_x, ring_y)
            # 偶奇規則: 1 つでも入れば inside (穴は無視, 近似)
            sub_in[still] |= r
        inside[idx] |= sub_in
    return inside


# === 5. 4,065 避難所 × 2 規模 を一括判定 =====================================
print("\n=== 5. 点 in ポリゴン 判定 (4,065 × 2 規模) ===")
xs = shel_df["lon"].values.astype(np.float64)
ys = shel_df["lat"].values.astype(np.float64)

import time as _t
for label in ["計画規模", "想定最大規模"]:
    t0 = _t.time()
    in_flood = points_in_any_polygon_set(xs, ys, flood_data[label])
    col = "in_keikaku" if label == "計画規模" else "in_max"
    shel_df[col] = in_flood.astype(int)
    n = int(in_flood.sum())
    print(f"  {label}: {n}/{len(shel_df)} 件 浸水域内 "
          f"({n/len(shel_df)*100:.1f}%, {_t.time()-t0:.1f}s)")

# 派生フラグ
shel_df["only_max_in"] = ((shel_df["in_max"] == 1) & (shel_df["in_keikaku"] == 0)).astype(int)
shel_df["both_in"] = ((shel_df["in_max"] == 1) & (shel_df["in_keikaku"] == 1)).astype(int)
shel_df["both_safe"] = ((shel_df["in_max"] == 0) & (shel_df["in_keikaku"] == 0)).astype(int)
# OUT_JUDGE 保存は danger_score / border_class 追加後 (セクション 8 末尾) に行う


# === 6. 危険避難所スコア定義 ================================================
# 「危険」= 浸水域内 (想定最大規模) AND 標高が低い
# danger_score = (浸水域内なら 1, それ以外 0) × (50 - elev_m を 0〜1 に正規化)
# ここでは elev_m が小さいほど危険、計画+最大両方で水没 (both_in=1) なら強い加点。
# シンプルな線形合成スコア (主観だが透明)
DANGER_ELEV_REF = 30.0  # この高度を超えれば標高による危険減衰が始まる
shel_df["danger_score"] = (
    shel_df["in_max"]
    * (1.0 + 0.5 * shel_df["both_in"])         # 計画規模も内なら 1.5 倍
    * np.clip(1.0 - shel_df["elev_m"] / DANGER_ELEV_REF, 0.05, 1.0)
).round(4)

danger = shel_df[shel_df["in_max"] == 1].copy()
danger = danger.sort_values("danger_score", ascending=False).reset_index(drop=True)
danger_top = danger.head(30).copy()
danger_top.to_csv(OUT_DANGER, index=False, encoding="utf-8-sig")
print(f"\n=== 6. 危険避難所スコア (想定最大規模) ===")
print(f"  浸水域内: {len(danger)} 件 (= H1 検証分母)")
print(f"  上位 30 (danger_score 降順):")
print(danger_top[["name", "muni", "elev_m", "in_keikaku", "in_max",
                  "flood_flg", "danger_score"]].head(10).to_string(index=False))


# === 7. 市町別 集計 ==========================================================
print("\n=== 7. 市町別 集計 ===")
city_summary = shel_df.groupby("muni").agg(
    n_total=("facility_id", "count"),
    n_in_max=("in_max", "sum"),
    n_in_keikaku=("in_keikaku", "sum"),
    n_only_max=("only_max_in", "sum"),
    n_flood_flg=("flood_flg", "sum"),
    elev_m=("elev_m", "first"),
).reset_index()
city_summary["pct_in_max"] = (city_summary["n_in_max"] / city_summary["n_total"] * 100).round(2)
city_summary["pct_only_max"] = (city_summary["n_only_max"] / city_summary["n_total"] * 100).round(2)
city_summary = city_summary.sort_values("n_in_max", ascending=False).reset_index(drop=True)
city_summary.to_csv(OUT_CITY, index=False, encoding="utf-8-sig")
print(city_summary.head(10).to_string(index=False))
print(f"  → {OUT_CITY.name}")


# === 8. ボーダー避難所 4 区分 ================================================
# A: 両方 OUT (安全) ; B: 計画 IN / 最大 IN (両 NG) ;
# C: 計画 OUT / 最大 IN (= ボーダー) ; D: 計画 IN / 最大 OUT (異常: 想定最大規模が縮むのは稀)
print("\n=== 8. ボーダー判定 4 区分 ===")
def classify(row):
    k, m = row["in_keikaku"], row["in_max"]
    if k == 0 and m == 0: return "A 両規模で安全"
    if k == 1 and m == 1: return "B 両規模で水没"
    if k == 0 and m == 1: return "C ボーダー (最大規模のみ水没)"
    return "D 計画のみ水没 (異常)"

shel_df["border_class"] = shel_df.apply(classify, axis=1)
border_breakdown = shel_df["border_class"].value_counts().reset_index()
border_breakdown.columns = ["区分", "件数"]
border_breakdown["割合(%)"] = (border_breakdown["件数"] / len(shel_df) * 100).round(2)
border_breakdown = border_breakdown.sort_values("区分").reset_index(drop=True)
border_breakdown.to_csv(OUT_BORDER, index=False, encoding="utf-8-sig")
print(border_breakdown.to_string(index=False))

n_border = int((shel_df["border_class"] == "C ボーダー (最大規模のみ水没)").sum())
print(f"  ボーダー避難所件数 (H3): {n_border} 件")

# OUT_JUDGE を 19+2 列で書き出し (border_class, danger_score を追加後)
shel_df.to_csv(OUT_JUDGE, index=False, encoding="utf-8-sig")
print(f"  → {OUT_JUDGE.name}  ({len(shel_df)} 行 × {len(shel_df.columns)} 列)")


# === 9. 洪水対応フラグ × 立地 クロス集計 (2x2) ==============================
print("\n=== 9. 洪水対応フラグ × 想定最大規模立地 クロス ===")
ct = pd.crosstab(
    shel_df["flood_flg"].map({0: "洪水対応無", 1: "洪水対応有"}),
    shel_df["in_max"].map({0: "浸水域外", 1: "浸水域内"}),
    margins=True, margins_name="合計",
)
ct.to_csv(OUT_FLAG, encoding="utf-8-sig")
print(ct)

# χ² 風の独立性指標 (簡易, scipy なし)
def expected_chi2(crosstab: pd.DataFrame) -> tuple[float, pd.DataFrame]:
    """2x2 の期待度数と Pearson χ² 値を計算 (合計行/列込み crosstab を渡す)。"""
    obs = crosstab.iloc[:-1, :-1].values.astype(float)
    row_sum = obs.sum(axis=1)
    col_sum = obs.sum(axis=0)
    total = obs.sum()
    exp = np.outer(row_sum, col_sum) / total
    chi2 = float(((obs - exp) ** 2 / exp).sum())
    df_exp = pd.DataFrame(exp, index=crosstab.index[:-1], columns=crosstab.columns[:-1])
    return chi2, df_exp


chi2_val, exp_df = expected_chi2(ct)
print(f"\n  Pearson χ² = {chi2_val:.2f}  (df=1, p<0.001 の閾値 ≈ 10.83)")

# 「フラグありかつ域内」の率 vs 「フラグなしかつ域内」の率
flag_in_rate = shel_df[shel_df["flood_flg"] == 1]["in_max"].mean()
noflag_in_rate = shel_df[shel_df["flood_flg"] == 0]["in_max"].mean()
print(f"  フラグあり: 浸水域内率 {flag_in_rate*100:.1f}%")
print(f"  フラグなし: 浸水域内率 {noflag_in_rate*100:.1f}%")
print(f"  逆相関?: {flag_in_rate < noflag_in_rate}  (H4 検証)")


# === 10. 1件追跡 (要件K) =====================================================
print("\n=== 10. 1件追跡 (海田町の代表避難所) ===")
# 海田町の代表的な公民館を 1 つ拾う (浸水域内に立地している可能性が高い)
target_muni = "海田町"
cand = shel_df[shel_df["muni"] == target_muni].copy()
# できれば「公民館 OR 集会所 OR センター」を含む
key_match = cand[cand["name"].str.contains("公民館|センター|集会|小学校", na=False)]
if len(key_match):
    cand = key_match
# in_max == 1 を優先
in_max_cand = cand[cand["in_max"] == 1]
if len(in_max_cand):
    target = in_max_cand.iloc[0]
else:
    target = cand.iloc[0]

track = pd.DataFrame([
    {"段階": "1. JSON 取得",
     "値": "DoBoX #42 避難所情報 (4,065 件)",
     "内訳": "data/shelters.json から1件抽出"},
    {"段階": "2. 1件選択", "値": str(target["name"]),
     "内訳": f"市町={target['muni']} / 住所={target['address']}"},
    {"段階": "3. 経緯度抽出",
     "値": f"({target['lon']:.5f}, {target['lat']:.5f})",
     "內訳" if False else "内訳": "JSON の latitude / longitude 文字列を float 化"},
    {"段階": "4. 計画規模 判定",
     "値": "IN" if target["in_keikaku"] == 1 else "OUT",
     "內訳" if False else "内訳":
        f"Ray casting × {len(flood_data['計画規模'])} ポリゴン"},
    {"段階": "5. 想定最大規模 判定",
     "値": "IN" if target["in_max"] == 1 else "OUT",
     "内訳": f"Ray casting × {len(flood_data['想定最大規模'])} ポリゴン"},
    {"段階": "6. 標高 lookup",
     "値": f"{target['elev_m']:.0f} m",
     "内訳": f"市町別代表標高 ({target['muni']} = {target['elev_m']:.0f} m)"},
    {"段階": "7. ボーダー区分",
     "値": str(target["border_class"]),
     "内訳": "(計画, 最大) の 4 区分パターン"},
    {"段階": "8. 洪水対応フラグ",
     "値": "有" if target["flood_flg"] == 1 else "無",
     "内訳": "JSON の floodShFlg (DoBoX 側の災害種別フラグ)"},
    {"段階": "9. 危険スコア",
     "値": f"{target['danger_score']:.3f}",
     "内訳": "in_max × (1 + 0.5 × both_in) × clip(1 - elev/30, 0.05, 1.0)"},
])
track = track.rename(columns={"內訳": "内訳"}).fillna("")
# Drop accidental 內訳 col if exists
if "內訳" in track.columns:
    track = track.drop(columns=["內訳"])
track.to_csv(OUT_TRACK, index=False, encoding="utf-8-sig")
print(track.to_string(index=False))


# === 11. 仮説判定 ===========================================================
print("\n=== 11. 仮説判定 ===")
n_total = len(shel_df)
n_in_max = int(shel_df["in_max"].sum())
pct_in_max = n_in_max / n_total * 100

h1_supported = 25 <= pct_in_max <= 45
# H2: 沿岸都市の危険避難所占有率 (近似: 海沿い 5 市町に集中するか)
COASTAL_CITIES = ["広島市中区", "広島市南区", "広島市西区", "呉市", "尾道市",
                  "福山市", "三原市", "竹原市", "大竹市", "廿日市市",
                  "海田町", "府中町", "坂町", "江田島市", "大崎上島町"]
n_danger = len(danger)
n_coastal_danger = int(danger["muni"].isin(COASTAL_CITIES).sum())
coastal_share = n_coastal_danger / max(n_danger, 1) * 100
h2_supported = coastal_share > 50

h3_supported = 50 <= n_border <= 500

# H4: フラグあり群とフラグなし群で「浸水域内率」を比較
h4_supported = flag_in_rate < noflag_in_rate

# H5: 市町別 (避難所数 vs 危険避難所数) の相関
corr_h5 = float(np.corrcoef(city_summary["n_total"], city_summary["n_in_max"])[0, 1])
h5_supported = corr_h5 > 0.6

verdict = {
    "H1_浸水域内率25-45%": (h1_supported, f"{pct_in_max:.1f}%"),
    "H2_沿岸集中>50%":     (h2_supported, f"沿岸シェア {coastal_share:.1f}% / 危険 {n_danger}件中"),
    "H3_ボーダー50-500件": (h3_supported, f"{n_border}件"),
    "H4_フラグ逆相関":     (h4_supported,
                            f"フラグあり {flag_in_rate*100:.1f}% vs なし {noflag_in_rate*100:.1f}%"),
    "H5_全件×危険正の相関": (h5_supported, f"r={corr_h5:.3f}"),
}
for k, (ok, msg) in verdict.items():
    print(f"  {k}: {'支持' if ok else '反証'}  ({msg})")

findings_text = "\n".join(
    f"  {k}: {'支持' if ok else '反証'}  ({msg})"
    for k, (ok, msg) in verdict.items()
)


# === 12. 図1 (主役): 危険避難所 上位30 マルチ属性ランキング ===================
# 旧版は danger_score で赤濃度を変えるだけ → ほぼ全バーが似た色で情報密度が低かった。
# 新版は 1 枚に「収容力 (バー長) × 市町 (色) × 標高 (右数値) × 危険要因 (記号)」を
# 圧縮表示し、見ただけで「どこが大きい・低地・複合危険」を判別できる。
print("\n=== 12. 図1 (主役): 危険避難所 上位30 マルチ属性ランキング ===")
top = danger.head(30).iloc[::-1].reset_index(drop=True)  # 下から上へ昇順
ypos = np.arange(len(top))

# capacity 欠損は中央値で補完 (バー長 0 だと「危険なのに見えない」になるため)
cap_med = float(shel_df["capacity"].dropna().median())
top["cap_disp"] = top["capacity"].fillna(cap_med).astype(float)

# 市町 → カテゴリカル色
unique_munis = list(top["muni"].unique())
cmap_muni = plt.get_cmap("tab20")
muni_color = {m: cmap_muni(i % 20) for i, m in enumerate(unique_munis)}
bar_colors_muni = [muni_color[m] for m in top["muni"]]

# 5 災害フラグ
FLAG_COLS = ["flood_flg", "sediment_flg", "storm_flg", "eq_flg", "tsunami_flg"]
top["n_hazard"] = top[FLAG_COLS].sum(axis=1)

fig, ax = plt.subplots(figsize=(13, 10))
ax.barh(ypos, top["cap_disp"], color=bar_colors_muni,
        edgecolor="#222", linewidth=0.5)

# y 軸ラベル: 順位 + 名 + 市町
labels = [f"#{len(top)-i:>2}  {r['name'][:16]} ({r['muni']})"
          for i, (_, r) in enumerate(top.iterrows())]
ax.set_yticks(ypos)
ax.set_yticklabels(labels, fontsize=9)

# 注: 順位は danger 降順なので、グラフ最上部 (i=len-1) が「1 位 = 最も危険」
# (上で iloc[::-1] 反転済 → 下から #30, ..., 上が #1)

# バー右端に補助マーカー + 標高表示
xmax = top["cap_disp"].max()
mark_off_star    = xmax * 0.015   # ★ オフセット
mark_off_tri     = xmax * 0.05    # ▲
mark_off_dot     = xmax * 0.085   # ●
text_off         = xmax * 0.13    # 数値表示

for i, (_, r) in enumerate(top.iterrows()):
    barlen = r["cap_disp"]
    # ★: 洪水フラグ無し (制度上は洪水避難所ではない = 特に運用矛盾)
    if r["flood_flg"] == 0:
        ax.text(barlen + mark_off_star, ypos[i], "★",
                va="center", ha="center", fontsize=14, color="#cf222e",
                fontweight="bold")
    # ▲: 標高 ≦ 5m (低地)
    if r["elev_m"] <= 5:
        ax.text(barlen + mark_off_tri, ypos[i], "▲",
                va="center", ha="center", fontsize=12, color="#bf5700")
    # ●: 5 災害フラグ全部 1 (= 複合避難所)
    if r["n_hazard"] == 5:
        ax.text(barlen + mark_off_dot, ypos[i], "●",
                va="center", ha="center", fontsize=12, color="#1a7f37")
    # 標高 + capacity 数値
    cap_int = int(r["cap_disp"])
    cap_lbl = f"{cap_int:,}人" + ("*" if pd.isna(r["capacity"]) else "")
    ax.text(barlen + text_off, ypos[i],
            f"標高{r['elev_m']:.0f}m  {cap_lbl}  score={r['danger_score']:.2f}",
            va="center", fontsize=8.2, color="#333")

ax.set_xlabel("収容力 capacity (人)  ※ * は欠損中央値補完")
ax.set_xlim(0, xmax * 1.55)
ax.set_title(f"図1 (主役): 危険避難所 上位 30 マルチ属性ランキング (浸水域内 {len(danger)}件中)\n"
             "バー長=収容力 / バー色=市町 / ★=洪水フラグ無 ▲=標高≦5m ●=5災害フラグ全立  右数値=標高/収容/score")
ax.grid(axis="x", alpha=0.3)

# 市町凡例 (上位 8 のみ; 残りは "他")
muni_count = top["muni"].value_counts()
top_munis = list(muni_count.head(8).index)
handles = [plt.Rectangle((0, 0), 1, 1, color=muni_color[m]) for m in top_munis]
labels_leg = [f"{m} ({muni_count[m]})" for m in top_munis]
if len(unique_munis) > 8:
    labels_leg.append(f"他 {len(unique_munis)-8} 市町")
    handles.append(plt.Rectangle((0, 0), 1, 1, color="#ccc"))
ax.legend(handles, labels_leg, loc="lower right", fontsize=8.5,
          ncol=2, title="市町 (上位30 中の出現件数)", title_fontsize=9)

plt.tight_layout()
plt.savefig(PNG_RANK, dpi=140, bbox_inches="tight")
plt.close()
print(f"  → {PNG_RANK.name}")
print(f"    マーカー集計: ★(洪水フラグ無)={int((top['flood_flg']==0).sum())} / "
      f"▲(標高≦5m)={int((top['elev_m']<=5).sum())} / "
      f"●(5フラグ全立)={int((top['n_hazard']==5).sum())}")


# === 12B. 図1B (補助): 上位30 を 3 パネル並列 (capacity / 標高 / フラグ heatmap)
# 1 図に 4 軸を詰めると密になりすぎる場合の「展開図」。同じ上位30 を行で揃え、
# 左=収容力 (市町色) / 中=標高 (低いほど赤) / 右=5災害フラグの 1/0 ヒートマップ。
print("\n=== 12B. 図1B (補助): 上位30 3 パネル並列 ===")
fig, axes = plt.subplots(1, 3, figsize=(15, 9),
                         gridspec_kw={"width_ratios": [1.4, 1.0, 1.0]},
                         sharey=True)

# --- 左パネル: capacity (市町色) ---
ax = axes[0]
ax.barh(ypos, top["cap_disp"], color=bar_colors_muni,
        edgecolor="#222", linewidth=0.4)
labels_p = [f"#{len(top)-i:>2}  {r['name'][:14]}" for i, (_, r) in enumerate(top.iterrows())]
ax.set_yticks(ypos)
ax.set_yticklabels(labels_p, fontsize=8.5)
for i, (_, r) in enumerate(top.iterrows()):
    cap_int = int(r["cap_disp"])
    ax.text(r["cap_disp"] + top["cap_disp"].max()*0.01, ypos[i],
            f"{cap_int:,}", va="center", fontsize=7.5, color="#333")
ax.set_xlabel("収容力 (人)")
ax.set_title("(左) capacity / 色=市町")
ax.grid(axis="x", alpha=0.3)

# --- 中央パネル: 標高 (低いほど赤) ---
ax = axes[1]
elev_vals = top["elev_m"].values.astype(float)
elev_max_ref = 30.0  # これ以上は青寄り (=危険減衰)
elev_norm = np.clip(1.0 - elev_vals / elev_max_ref, 0.0, 1.0)  # 低標高ほど 1
elev_colors = plt.get_cmap("RdYlBu")(1.0 - elev_norm)  # 低い=赤, 高い=青
ax.barh(ypos, elev_vals, color=elev_colors,
        edgecolor="#222", linewidth=0.4)
for i, v in enumerate(elev_vals):
    ax.text(v + 1, ypos[i], f"{v:.0f}m", va="center",
            fontsize=8, color="#333")
ax.axvline(5, color="#cf222e", lw=0.8, ls="--", alpha=0.7)
ax.text(5, len(top)-0.3, " 5m", color="#cf222e", fontsize=8, va="top")
ax.set_xlabel("市町平均 標高 (m)")
ax.set_title("(中) 標高 / 赤=低地 (≦5m に破線)")
ax.grid(axis="x", alpha=0.3)

# --- 右パネル: 5 災害フラグ heatmap ---
ax = axes[2]
flag_mat = top[FLAG_COLS].values.astype(float)  # (30, 5)
# 0 はほぼ白、1 は濃緑
ax.imshow(flag_mat, aspect="auto", cmap="Greens", vmin=0, vmax=1.5,
          extent=[-0.5, 4.5, -0.5, len(top)-0.5], origin="lower")
ax.set_xticks(range(5))
ax.set_xticklabels(["洪水", "土砂", "高潮", "地震", "津波"], fontsize=9)
ax.set_xlim(-0.5, 4.5)
# 数値 (1) と、洪水フラグ無は赤×で強調
for i in range(len(top)):
    for j in range(5):
        v = flag_mat[i, j]
        if v == 1:
            ax.text(j, i, "1", va="center", ha="center", fontsize=8.5,
                    color="#0a3d0a", fontweight="bold")
        else:
            ax.text(j, i, "0", va="center", ha="center", fontsize=8,
                    color="#bbb")
# 洪水フラグ無 (= 第0列が0) の行に右端 ★
for i in range(len(top)):
    if flag_mat[i, 0] == 0:
        ax.text(4.7, i, "★", va="center", ha="center",
                fontsize=11, color="#cf222e", fontweight="bold")
ax.set_xlim(-0.5, 5.2)
# y 軸グリッドを行間に
for i in range(len(top)+1):
    ax.axhline(i-0.5, color="#eee", lw=0.4, zorder=0)
ax.set_title("(右) 災害種別フラグ 5列 / ★=洪水無")

fig.suptitle(f"図1B (補助): 危険避難所 上位30 を 3 軸並列で展開 (左=capacity / 中=標高 / 右=5災害フラグ)\n"
             f"行は図1 と同じ並び (上=#1 最も危険)", y=1.005, fontsize=12)
plt.tight_layout()
plt.savefig(PNG_RANK_PANELS, dpi=140, bbox_inches="tight")
plt.close()
print(f"  → {PNG_RANK_PANELS.name}")


# === 13. 図2: 市町別 危険避難所件数ランキング棒 ==============================
print("\n=== 13. 図2: 市町別 危険避難所件数ランキング ===")
top_city = city_summary.head(20).iloc[::-1].reset_index(drop=True)
fig, ax = plt.subplots(figsize=(11, 8))
ypos = np.arange(len(top_city))
ax.barh(ypos, top_city["n_in_max"], color="#cf222e",
        edgecolor="#222", linewidth=0.4, label="想定最大規模 浸水域内")
ax.barh(ypos, top_city["n_in_keikaku"], color="#0969da",
        edgecolor="#222", linewidth=0.4, alpha=0.85, label="計画規模 浸水域内")
for i, (_, r) in enumerate(top_city.iterrows()):
    ax.text(r["n_in_max"] + 1, ypos[i],
            f"{r['n_in_max']}/{r['n_total']} ({r['pct_in_max']:.1f}%)",
            va="center", fontsize=8.5)
ax.set_yticks(ypos)
ax.set_yticklabels(top_city["muni"], fontsize=10)
ax.set_xlabel("浸水域内立地避難所数 (件)")
ax.set_title(f"図2: 市町別 危険避難所件数 上位 20  (全 {city_summary['muni'].nunique()} 市町中)\n"
             f"想定最大規模(赤) と 計画規模(青) を重ね描き / 数値は最大/全件 (%)")
ax.legend(loc="lower right")
ax.grid(axis="x", alpha=0.3)
plt.tight_layout()
plt.savefig(PNG_CITY, dpi=140, bbox_inches="tight")
plt.close()
print(f"  → {PNG_CITY.name}")


# === 14. 図3: ボーダー避難所 4区分件数 比較棒 ================================
print("\n=== 14. 図3: ボーダー判定 4 区分 ===")
fig, axes = plt.subplots(1, 2, figsize=(13, 5.5),
                         gridspec_kw={"width_ratios": [1.2, 1.0]})

ax = axes[0]
b = border_breakdown.copy()
# 行順を固定 (D 異常を最後に)
order = ["A 両規模で安全", "B 両規模で水没",
         "C ボーダー (最大規模のみ水没)", "D 計画のみ水没 (異常)"]
b = b.set_index("区分").reindex(order, fill_value=0).reset_index()
xpos = np.arange(len(b))
bar_colors = ["#1a7f37", "#cf222e", "#bf8700", "#888"]
ax.bar(xpos, b["件数"], color=bar_colors, edgecolor="#222", linewidth=0.4)
for i, (_, r) in enumerate(b.iterrows()):
    ax.text(xpos[i], r["件数"] + max(b["件数"]) * 0.015,
            f"{r['件数']:,}\n({r['割合(%)']:.2f}%)",
            ha="center", fontsize=10)
ax.set_xticks(xpos)
ax.set_xticklabels([s.split(" ", 1)[0] for s in b["区分"]], fontsize=11)
ax.set_ylabel("避難所件数")
ax.set_title(f"図3-(a): 計画 × 最大 4 区分内訳 (n={len(shel_df):,})\n"
             "C = 計画OK・最大NG = 「ボーダー避難所」")
ax.grid(axis="y", alpha=0.3)

# 右パネル: ボーダー / 全危険 の内訳円風 横棒
ax = axes[1]
total_max = b.loc[b["区分"].isin(["B 両規模で水没",
                                   "C ボーダー (最大規模のみ水没)"]), "件数"].sum()
border_n = b.loc[b["区分"] == "C ボーダー (最大規模のみ水没)", "件数"].iloc[0]
both_n = b.loc[b["区分"] == "B 両規模で水没", "件数"].iloc[0]
ax.barh([0], [both_n], color="#cf222e", edgecolor="#222", label="B 両規模で水没")
ax.barh([0], [border_n], left=[both_n], color="#bf8700",
        edgecolor="#222", label=f"C ボーダー (最大のみ): {border_n} 件")
ax.set_yticks([0])
ax.set_yticklabels(["想定最大規模で水没\n(B + C)"])
ax.set_xlabel("避難所件数")
ax.set_title(f"図3-(b): 想定最大規模 浸水域内 {total_max:,} 件 の内訳\n"
             f"ボーダー比率 = {border_n}/{total_max} "
             f"= {border_n/max(total_max,1)*100:.1f}%")
ax.legend(loc="lower right")
ax.grid(axis="x", alpha=0.3)
plt.tight_layout()
plt.savefig(PNG_BORDER, dpi=140, bbox_inches="tight")
plt.close()
print(f"  → {PNG_BORDER.name}")


# === 15. 図4: フラグ × 立地 クロス ヒートマップ ==============================
print("\n=== 15. 図4: 洪水対応フラグ × 想定最大規模立地 クロス ===")
fig, ax = plt.subplots(figsize=(8, 5.5))
mat = ct.iloc[:-1, :-1].values  # 2×2
row_labels = ct.index[:-1]
col_labels = ct.columns[:-1]
im = ax.imshow(mat, cmap="YlOrRd", aspect="auto")
for i in range(mat.shape[0]):
    for j in range(mat.shape[1]):
        share = mat[i, j] / mat.sum() * 100
        ax.text(j, i, f"{mat[i, j]:,}\n({share:.1f}%)",
                ha="center", va="center", fontsize=12,
                color="#fff" if mat[i, j] > mat.max() * 0.5 else "#222",
                weight="bold")
ax.set_xticks(np.arange(len(col_labels)))
ax.set_xticklabels(col_labels, fontsize=11)
ax.set_yticks(np.arange(len(row_labels)))
ax.set_yticklabels(row_labels, fontsize=11)
ax.set_title(f"図4: 洪水対応フラグ × 想定最大規模 浸水域内立地 (2×2 クロス)\n"
             f"χ² = {chi2_val:.2f} (p<0.001 閾値 ≈ 10.83)  / "
             f"フラグ有内率 {flag_in_rate*100:.1f}% vs フラグ無内率 {noflag_in_rate*100:.1f}%")
fig.colorbar(im, ax=ax, shrink=0.7, label="件数")
plt.tight_layout()
plt.savefig(PNG_FLAG, dpi=140, bbox_inches="tight")
plt.close()
print(f"  → {PNG_FLAG.name}")


# === 16. 図5: 標高 vs 浸水立地 散布 + 箱ひげ ================================
print("\n=== 16. 図5: 標高 vs 立地 ===")
fig, axes = plt.subplots(1, 2, figsize=(14, 6.0),
                         gridspec_kw={"width_ratios": [1.4, 1.0]})

# 散布 (jitter)
ax = axes[0]
rng_j = np.random.default_rng(20260502)
jitter_x = rng_j.uniform(-0.4, 0.4, len(shel_df))
y_elev = shel_df["elev_m"].values
x_in = shel_df["in_max"].values + jitter_x
colors_elev = np.where(shel_df["in_max"] == 1, "#cf222e", "#1a7f37")
ax.scatter(x_in, y_elev, s=8, c=colors_elev,
           alpha=0.4, edgecolor="none")
ax.set_xticks([0, 1])
ax.set_xticklabels(["浸水域外", "浸水域内"])
ax.set_ylabel("市町別 平均標高 (m, シンボリック値)")
ax.set_title("図5-(a): 各避難所の立地標高 vs 想定最大規模 域内/域外  (ジッタ散布, n=4,065)")
ax.grid(alpha=0.3)
ax.set_ylim(0, 520)

# 箱ひげ比較 (浸水域内 vs 域外)
ax = axes[1]
elev_in = shel_df.loc[shel_df["in_max"] == 1, "elev_m"].values
elev_out = shel_df.loc[shel_df["in_max"] == 0, "elev_m"].values
bp = ax.boxplot([elev_in, elev_out], vert=True, patch_artist=True, widths=0.5,
                labels=[f"浸水域内\n(n={len(elev_in)})",
                        f"浸水域外\n(n={len(elev_out)})"])
for patch, color in zip(bp["boxes"], ["#fdd", "#dfd"]):
    patch.set_facecolor(color)
    patch.set_edgecolor("#222")
ax.axhline(np.median(shel_df["elev_m"]), color="#0969da", ls="--", lw=1.0,
           label=f"全体中央値 {np.median(shel_df['elev_m']):.0f} m")
ax.set_ylabel("市町平均標高 (m)")
ax.set_title(f"図5-(b): 立地標高分布 比較\n"
             f"中央値: 域内 {np.median(elev_in):.0f}m vs 域外 {np.median(elev_out):.0f}m")
ax.legend(loc="upper right")
ax.grid(axis="y", alpha=0.3)

plt.tight_layout()
plt.savefig(PNG_ELEV, dpi=140, bbox_inches="tight")
plt.close()
print(f"  → {PNG_ELEV.name}")


# === 16-A. 追加分析6: 危険度の地理マッピング ================================
print("\n=== 16-A. 図6: 危険度の地理マッピング ===")
fig, ax = plt.subplots(figsize=(11.5, 8.5))

# 浸水域外 (in_max=0) を薄く背景に置く
mask_out = shel_df["in_max"] == 0
mask_in  = shel_df["in_max"] == 1
ax.scatter(shel_df.loc[mask_out, "lon"], shel_df.loc[mask_out, "lat"],
           s=6, c="#cccccc", alpha=0.35, edgecolor="none",
           label=f"浸水域外 (n={int(mask_out.sum()):,})")

# 浸水域内 (in_max=1) のみ濃く danger_score で色塗り
sc = ax.scatter(shel_df.loc[mask_in, "lon"], shel_df.loc[mask_in, "lat"],
                s=22, c=shel_df.loc[mask_in, "danger_score"],
                cmap="RdYlGn_r", vmin=0.0, vmax=1.5,
                alpha=0.85, edgecolor="#222", linewidth=0.25)
cbar = plt.colorbar(sc, ax=ax, shrink=0.78, pad=0.02)
cbar.set_label("danger_score (0=安全 → 1.5=最大危険)")

ax.set_xlabel("経度 (°E)")
ax.set_ylabel("緯度 (°N)")
ax.set_title(f"図6: 危険度の地理マッピング (4,065 避難所, 浸水域内 {int(mask_in.sum()):,} 件のみ濃く描画)\n"
             "色 = danger_score (RdYlGn 反転: 緑→黄→赤)")
ax.grid(alpha=0.3)
ax.legend(loc="lower left", fontsize=9)
ax.set_aspect(1 / np.cos(np.deg2rad(34.4)))  # 広島県中緯度の縦横比
plt.tight_layout()
plt.savefig(PNG_MAP_DANGER, dpi=140, bbox_inches="tight")
plt.close()
print(f"  → {PNG_MAP_DANGER.name}")


# === 16-B. 追加分析7: border_class 別 地理マッピング =========================
print("\n=== 16-B. 図7: border_class 別 地理マッピング ===")
fig, ax = plt.subplots(figsize=(11.5, 8.5))

bc_styles = {
    "A 両規模で安全":               {"color": "#1a7f37", "alpha": 0.30, "size": 7,
                                       "label_short": "A 安全"},
    "B 両規模で水没":               {"color": "#cf222e", "alpha": 0.85, "size": 18,
                                       "label_short": "B 両危険"},
    "C ボーダー (最大規模のみ水没)": {"color": "#bf8700", "alpha": 0.85, "size": 18,
                                       "label_short": "C ボーダー"},
    "D 計画のみ水没 (異常)":        {"color": "#888888", "alpha": 0.85, "size": 22,
                                       "label_short": "D 異常"},
}
# 描画順: A を背景, B/C/D を前面
for cls in ["A 両規模で安全", "B 両規模で水没",
            "C ボーダー (最大規模のみ水没)", "D 計画のみ水没 (異常)"]:
    style = bc_styles[cls]
    sub = shel_df[shel_df["border_class"] == cls]
    if len(sub) == 0:
        continue
    ax.scatter(sub["lon"], sub["lat"],
               s=style["size"], c=style["color"], alpha=style["alpha"],
               edgecolor="#222" if cls != "A 両規模で安全" else "none",
               linewidth=0.25,
               label=f"{style['label_short']} (n={len(sub):,})")

ax.set_xlabel("経度 (°E)")
ax.set_ylabel("緯度 (°N)")
ax.set_title("図7: border_class 別 地理マッピング (4,065 避難所)\n"
             "A=両規模で安全 / B=両規模で水没 / C=ボーダー(最大のみ) / D=異常")
ax.grid(alpha=0.3)
ax.legend(loc="lower left", fontsize=10, framealpha=0.92)
ax.set_aspect(1 / np.cos(np.deg2rad(34.4)))
plt.tight_layout()
plt.savefig(PNG_MAP_BORDER, dpi=140, bbox_inches="tight")
plt.close()
print(f"  → {PNG_MAP_BORDER.name}")


# === 16-C. 追加分析8: 5 災害種別フラグ × 浸水域立地のミスマッチ ==============
print("\n=== 16-C. 図8: 災害種別フラグ × 浸水域立地ミスマッチ ===")
# 「洪水フラグなし」かつ「想定最大規模浸水域内」= 制度上洪水対応外なのに実は浸水域に立地
mismatch = shel_df[(shel_df["flood_flg"] == 0) & (shel_df["in_max"] == 1)].copy()
mismatch = mismatch.sort_values("danger_score", ascending=False).reset_index(drop=True)

# CSV 保存 (主要列のみ)
mismatch_save = mismatch[
    ["facility_id", "name", "muni", "lat", "lon", "elev_m",
     "in_keikaku", "in_max", "border_class", "flood_flg",
     "sediment_flg", "storm_flg", "eq_flg", "tsunami_flg",
     "capacity", "danger_score"]
].copy()
mismatch_save.to_csv(OUT_MISMATCH, index=False, encoding="utf-8-sig")
n_mismatch = len(mismatch)
print(f"  ミスマッチ件数 (洪水フラグ無 × 浸水域内): {n_mismatch:,}/{len(shel_df):,}"
      f"  ({n_mismatch/len(shel_df)*100:.2f}%)")
print(f"  → {OUT_MISMATCH.name}")

# 5 災害フラグごとに「フラグ無 × 浸水域内」ミスマッチ件数を集計
mismatch_by_flag = pd.DataFrame([
    {"災害種別": "洪水",   "フラグ列": "flood_flg",
     "ミスマッチ(フラグ無×浸水内)": int(((shel_df["flood_flg"]==0)    & (shel_df["in_max"]==1)).sum()),
     "対応有×浸水内":            int(((shel_df["flood_flg"]==1)    & (shel_df["in_max"]==1)).sum())},
    {"災害種別": "土砂",   "フラグ列": "sediment_flg",
     "ミスマッチ(フラグ無×浸水内)": int(((shel_df["sediment_flg"]==0) & (shel_df["in_max"]==1)).sum()),
     "対応有×浸水内":            int(((shel_df["sediment_flg"]==1) & (shel_df["in_max"]==1)).sum())},
    {"災害種別": "高潮",   "フラグ列": "storm_flg",
     "ミスマッチ(フラグ無×浸水内)": int(((shel_df["storm_flg"]==0)    & (shel_df["in_max"]==1)).sum()),
     "対応有×浸水内":            int(((shel_df["storm_flg"]==1)    & (shel_df["in_max"]==1)).sum())},
    {"災害種別": "地震",   "フラグ列": "eq_flg",
     "ミスマッチ(フラグ無×浸水内)": int(((shel_df["eq_flg"]==0)       & (shel_df["in_max"]==1)).sum()),
     "対応有×浸水内":            int(((shel_df["eq_flg"]==1)       & (shel_df["in_max"]==1)).sum())},
    {"災害種別": "津波",   "フラグ列": "tsunami_flg",
     "ミスマッチ(フラグ無×浸水内)": int(((shel_df["tsunami_flg"]==0)  & (shel_df["in_max"]==1)).sum()),
     "対応有×浸水内":            int(((shel_df["tsunami_flg"]==1)  & (shel_df["in_max"]==1)).sum())},
])
print(mismatch_by_flag.to_string(index=False))

# 図: 左パネル = 5災害ミスマッチ件数バー / 右パネル = 洪水ミスマッチを地図にプロット (赤)
fig, axes = plt.subplots(1, 2, figsize=(14.5, 7.0),
                         gridspec_kw={"width_ratios": [1.0, 1.4]})

ax = axes[0]
xpos = np.arange(len(mismatch_by_flag))
ax.bar(xpos - 0.2, mismatch_by_flag["ミスマッチ(フラグ無×浸水内)"], width=0.4,
       color="#cf222e", edgecolor="#222", linewidth=0.4,
       label="フラグ無 × 浸水域内 (潜在リスク)")
ax.bar(xpos + 0.2, mismatch_by_flag["対応有×浸水内"], width=0.4,
       color="#bf8700", edgecolor="#222", linewidth=0.4,
       label="フラグ有 × 浸水域内 (運用上の盲点)")
for i, r in mismatch_by_flag.iterrows():
    ax.text(i - 0.2, r["ミスマッチ(フラグ無×浸水内)"] + 8,
            f"{r['ミスマッチ(フラグ無×浸水内)']:,}",
            ha="center", fontsize=9, color="#cf222e")
    ax.text(i + 0.2, r["対応有×浸水内"] + 8,
            f"{r['対応有×浸水内']:,}",
            ha="center", fontsize=9, color="#bf8700")
ax.set_xticks(xpos)
ax.set_xticklabels(mismatch_by_flag["災害種別"], fontsize=10)
ax.set_ylabel("浸水域内 (in_max=1) 件数")
ax.set_title("図8-(a): 5 災害種別フラグ × 浸水域立地 ミスマッチ\n"
             "赤=制度外なのに浸水内 / 黄=指定有なのに浸水内")
ax.legend(loc="upper right", fontsize=9)
ax.grid(axis="y", alpha=0.3)

ax = axes[1]
# 全避難所を背景に
ax.scatter(shel_df["lon"], shel_df["lat"],
           s=4, c="#cccccc", alpha=0.30, edgecolor="none",
           label=f"全避難所 (n={len(shel_df):,})")
# 洪水フラグ無 × 浸水域内 を赤で強調
ax.scatter(mismatch["lon"], mismatch["lat"],
           s=18, c="#cf222e", alpha=0.85, edgecolor="#222", linewidth=0.25,
           label=f"洪水フラグ無 × 浸水域内 (n={n_mismatch:,})")
ax.set_xlabel("経度 (°E)")
ax.set_ylabel("緯度 (°N)")
ax.set_title(f"図8-(b): 「制度上は洪水避難所ではないが浸水域に立地」 {n_mismatch:,} 件の地理分布\n"
             "= 洪水時に開設指定が無いのに地形的には水没する潜在リスク")
ax.grid(alpha=0.3)
ax.legend(loc="lower left", fontsize=9)
ax.set_aspect(1 / np.cos(np.deg2rad(34.4)))

plt.tight_layout()
plt.savefig(PNG_MISMATCH, dpi=140, bbox_inches="tight")
plt.close()
print(f"  → {PNG_MISMATCH.name}")


# === 16-D. 追加分析9: 収容力 × 危険度 バブル散布 (市町別) =====================
print("\n=== 16-D. 図9: 収容力 × 危険度 バブル散布 (市町別集約) ===")
# capacity 欠損は中央値補完
cap_med = shel_df["capacity"].median(skipna=True)
shel_df["capacity_filled"] = shel_df["capacity"].fillna(cap_med)

cap_summary = shel_df.groupby("muni").agg(
    n_shelters=("facility_id", "count"),
    capacity_total=("capacity_filled", "sum"),
    capacity_mean=("capacity_filled", "mean"),
    danger_score_mean=("danger_score", "mean"),
    danger_score_max=("danger_score", "max"),
    n_in_max=("in_max", "sum"),
    elev_m=("elev_m", "first"),
).reset_index()
cap_summary["pct_in_max"] = (cap_summary["n_in_max"] / cap_summary["n_shelters"] * 100).round(2)
cap_summary["risk_capacity"] = (
    cap_summary["capacity_total"] * cap_summary["danger_score_mean"]
).round(1)
cap_summary = cap_summary.sort_values("risk_capacity", ascending=False).reset_index(drop=True)
cap_summary.to_csv(OUT_CAPSUM, index=False, encoding="utf-8-sig")
print(cap_summary.head(10).to_string(index=False))
print(f"  → {OUT_CAPSUM.name}")

# 単回帰直線 (市町別 capacity_mean vs danger_score_mean)
x_cap = cap_summary["capacity_mean"].values.astype(float)
y_dg  = cap_summary["danger_score_mean"].values.astype(float)
slope, intercept = np.polyfit(x_cap, y_dg, 1)
corr_cd = float(np.corrcoef(x_cap, y_dg)[0, 1])
print(f"  単回帰: danger_score_mean = {slope:.5f} × capacity_mean + {intercept:.4f}"
      f"  (r={corr_cd:.3f})")

fig, ax = plt.subplots(figsize=(11.5, 8.0))
# バブル半径 = sqrt(避難所件数) × 18
radii = np.sqrt(cap_summary["n_shelters"].values) * 18

# 全市町を散布。色 = pct_in_max
sc = ax.scatter(x_cap, y_dg, s=radii,
                c=cap_summary["pct_in_max"].values,
                cmap="Reds", vmin=0, vmax=max(cap_summary["pct_in_max"].max(), 30),
                alpha=0.78, edgecolor="#222", linewidth=0.5)
cbar = plt.colorbar(sc, ax=ax, shrink=0.78, pad=0.02)
cbar.set_label("市町別 浸水域内率 pct_in_max (%)")

# 上位 8 市町に名前ラベル
top_label = cap_summary.head(8)
for _, r in top_label.iterrows():
    ax.annotate(r["muni"], (r["capacity_mean"], r["danger_score_mean"]),
                fontsize=9, alpha=0.95,
                xytext=(6, 6), textcoords="offset points")

# 単回帰直線
xs_line = np.linspace(x_cap.min(), x_cap.max(), 100)
ax.plot(xs_line, slope * xs_line + intercept, color="#0969da", lw=1.6, ls="--",
        label=f"単回帰: y = {slope:.5f}x + {intercept:.3f}  (r={corr_cd:.3f})")

# 「右上象限」(平均より大 & 平均より高危険) を点線で示す
mean_x = x_cap.mean()
mean_y = y_dg.mean()
ax.axvline(mean_x, color="#888", lw=0.8, ls=":")
ax.axhline(mean_y, color="#888", lw=0.8, ls=":")
ax.text(mean_x * 1.02, ax.get_ylim()[1] * 0.95,
        "右上 = 「大きいけど危ない」象限",
        fontsize=9, color="#444")

ax.set_xlabel("市町別 平均 capacity (人, 中央値補完済)")
ax.set_ylabel("市町別 平均 danger_score")
ax.set_title(f"図9: 収容力 × 危険度 バブル散布 (市町別, n={len(cap_summary)} 市町)\n"
             "バブル半径 = √避難所件数 × 18  /  色 = 浸水域内率 (%)")
ax.legend(loc="upper right", fontsize=9)
ax.grid(alpha=0.3)
plt.tight_layout()
plt.savefig(PNG_CAP_DANGER, dpi=140, bbox_inches="tight")
plt.close()
print(f"  → {PNG_CAP_DANGER.name}")


# === 16-E. 追加分析10: 市町別 危険率ヒートマップ (地理) =======================
print("\n=== 16-E. 図10: 市町別 危険率ヒートマップ (地理) ===")
# 市町ごとに重心 (lat 平均, lon 平均) を算出
city_geo = shel_df.groupby("muni").agg(
    lat_mean=("lat", "mean"),
    lon_mean=("lon", "mean"),
    n_shelters=("facility_id", "count"),
    n_in_max=("in_max", "sum"),
).reset_index()
city_geo["risk_rate"] = (city_geo["n_in_max"] / city_geo["n_shelters"] * 100).round(2)
city_geo = city_geo.sort_values("risk_rate", ascending=False).reset_index(drop=True)
city_geo.to_csv(OUT_CITYRISK, index=False, encoding="utf-8-sig")
print(city_geo.head(10).to_string(index=False))
print(f"  → {OUT_CITYRISK.name}")

fig, ax = plt.subplots(figsize=(11.5, 8.5))
# 背景: 全避難所を薄く
ax.scatter(shel_df["lon"], shel_df["lat"],
           s=2.5, c="#dddddd", alpha=0.30, edgecolor="none")
# 市町中心円: 半径 = sqrt(避難所件数) × 5, 色 = 危険率
radii_city = np.sqrt(city_geo["n_shelters"].values) * 30
sc = ax.scatter(city_geo["lon_mean"], city_geo["lat_mean"],
                s=radii_city,
                c=city_geo["risk_rate"].values,
                cmap="Reds", vmin=0, vmax=max(city_geo["risk_rate"].max(), 30),
                alpha=0.80, edgecolor="#222", linewidth=0.6)
cbar = plt.colorbar(sc, ax=ax, shrink=0.78, pad=0.02)
cbar.set_label("市町別 危険率 risk_rate = n_in_max / n_shelters (%)")

# 上位 10 市町に名前ラベル
for _, r in city_geo.head(10).iterrows():
    ax.annotate(f"{r['muni']}\n{r['risk_rate']:.0f}%",
                (r["lon_mean"], r["lat_mean"]),
                fontsize=8.5, alpha=0.95,
                xytext=(7, 7), textcoords="offset points",
                bbox=dict(boxstyle="round,pad=0.15", fc="white", ec="#888",
                          alpha=0.85, lw=0.4))

ax.set_xlabel("経度 (°E)")
ax.set_ylabel("緯度 (°N)")
ax.set_title(f"図10: 市町別 危険率ヒートマップ (地理) — {len(city_geo)} 市町\n"
             "円中心 = 市町重心 (避難所平均) / 半径 = √避難所件数 × 30 / 色 = 危険率")
ax.grid(alpha=0.3)
ax.set_aspect(1 / np.cos(np.deg2rad(34.4)))
plt.tight_layout()
plt.savefig(PNG_CITY_HEAT, dpi=140, bbox_inches="tight")
plt.close()
print(f"  → {PNG_CITY_HEAT.name}")


# === 17. HTML 構築 ==========================================================
print("\n=== 17. HTML 組み立て ===")

# 表示用テーブル
city_disp = city_summary.head(20)[
    ["muni", "n_total", "n_in_max", "n_in_keikaku", "n_only_max",
     "pct_in_max", "n_flood_flg", "elev_m"]
].copy()
city_disp.columns = ["市町", "全避難所", "想定最大域内", "計画域内",
                     "ボーダー(C)", "最大域内率(%)", "洪水対応有", "市町平均標高(m)"]
city_html = city_disp.to_html(index=False, border=0)

danger_disp = danger.head(15)[
    ["name", "muni", "elev_m", "in_keikaku", "in_max",
     "flood_flg", "danger_score"]
].copy()
danger_disp.columns = ["避難所名", "市町", "標高(m)", "計画", "最大",
                        "洪水対応", "危険スコア"]
danger_disp["計画"] = danger_disp["計画"].map({0: "OUT", 1: "IN"})
danger_disp["最大"] = danger_disp["最大"].map({0: "OUT", 1: "IN"})
danger_disp["洪水対応"] = danger_disp["洪水対応"].map({0: "無", 1: "有"})
danger_html = danger_disp.to_html(index=False, border=0)

border_disp = border_breakdown.copy()
border_html = border_disp.to_html(index=False, border=0)

flag_html = ct.to_html(border=0)

track_html = track.to_html(index=False, border=0)

verdict_rows = []
for k, (ok, msg) in verdict.items():
    verdict_rows.append({"仮説": k, "判定": "支持" if ok else "反証", "根拠": msg})
verdict_html = pd.DataFrame(verdict_rows).to_html(index=False, border=0)

# 追加分析 6-10 用 表データ
mismatch_disp = mismatch.head(15)[
    ["name", "muni", "elev_m", "border_class",
     "sediment_flg", "storm_flg", "eq_flg", "tsunami_flg", "danger_score"]
].copy()
mismatch_disp.columns = ["避難所名", "市町", "標高(m)", "border_class",
                          "土砂", "高潮", "地震", "津波", "危険スコア"]
mismatch_html = mismatch_disp.to_html(index=False, border=0)

mismatch_flag_html = mismatch_by_flag.to_html(index=False, border=0)

cap_disp = cap_summary.head(15)[
    ["muni", "n_shelters", "capacity_total", "capacity_mean",
     "danger_score_mean", "pct_in_max", "risk_capacity"]
].copy()
cap_disp.columns = ["市町", "避難所数", "総capacity",
                     "平均capacity", "平均危険スコア", "浸水域内率(%)", "リスク収容人数"]
cap_html = cap_disp.to_html(index=False, border=0)

cityrisk_disp = city_geo.head(15)[
    ["muni", "n_shelters", "n_in_max", "risk_rate", "lat_mean", "lon_mean"]
].copy()
cityrisk_disp.columns = ["市町", "避難所数", "浸水域内件数", "危険率(%)", "重心緯度", "重心経度"]
cityrisk_html = cityrisk_disp.to_html(index=False, border=0)

# 追加分析6-10 用 集計値 (HTML 内で使うため事前計算)
n_in_max_total      = int(shel_df["in_max"].sum())
mismatch_pct        = n_mismatch / max(n_in_max_total, 1) * 100  # 浸水内のうち何 % が「フラグ無」か
border_class_n      = shel_df["border_class"].value_counts().to_dict()
border_A_n          = int(border_class_n.get("A 両規模で安全", 0))
border_B_n          = int(border_class_n.get("B 両規模で水没", 0))
border_C_n          = int(border_class_n.get("C ボーダー (最大規模のみ水没)", 0))
border_D_n          = int(border_class_n.get("D 計画のみ水没 (異常)", 0))
risk_rate_top_muni  = city_geo.iloc[0]["muni"]
risk_rate_top_val   = float(city_geo.iloc[0]["risk_rate"])
risk_rate_top_n     = int(city_geo.iloc[0]["n_shelters"])
risk_capacity_top   = cap_summary.iloc[0]
n_city_above50      = int((city_geo["risk_rate"] >= 50).sum())
n_city_above30      = int((city_geo["risk_rate"] >= 30).sum())


sections = []

# --- 1. 学習目標と問い ----------------------------------------------------
sections.append(("学習目標と問い", f"""
<div class="note"><b>【本記事のスタイル: 点 in ポリゴン判定研究】</b>
4,065 避難所のうち <b>どれが</b> 浸水想定域内に立地しているかを 1 件ごとに
Ray casting 法で判定し、<b>危険避難所をランキング化</b>する。
X07 が「ポリゴン × ポリゴンの面積率研究」だったのに対し、
本記事は「点 × ポリゴン」という別軸の空間結合。
PCA・散布図行列・ロジ回帰・k-means は使わず、
<b>危険避難所ランキング棒・市町別棒・ボーダー判定棒・フラグ×立地クロス・標高散布</b>
の 5 図を主役に据える。</div>

<p>本レッスンは <b>避難所点 4,065 件 (DoBoX #42)</b> と
<b>河川浸水想定区域 (計画規模・想定最大規模 各 1 件, DoBoX #295/#313)</b> を
<b>同一の経緯度系 (WGS84)</b> に揃え、
<b>4,065 × 2 規模 = 8,130 件の点判定</b>を行う。
得られた判定は次の 5 つの仮説と照合する。</p>

<h3>このレッスンで答えたい問い (1 文)</h3>
<p><b>「広島県内の避難所 4,065 件のうち何件が河川浸水想定区域内に立地しており、
そのうちで <em>本当に避難してはいけない</em> 危険避難所はどこか？」</b></p>

<h3>立てた仮説 H1〜H5</h3>
<ul>
<li><b>H1</b>: 全 4,065 避難所のうち <b>25〜45%</b> が想定最大規模浸水想定区域内に立地する
(避難所は学校・公民館など住宅地中心に置かれるため、人口集中地区の浸水率に近い数値になるはず)。</li>
<li><b>H2</b>: 「危険避難所」(浸水域内 × 低標高) は <b>沿岸都市</b>に集中する
(海沿いはデルタ平野で河川氾濫域も広く、避難所の絶対数も多いため)。</li>
<li><b>H3</b>: 計画規模で安全だが想定最大規模で危険になる「<b>ボーダー避難所</b>」が
<b>50〜500 件</b> 存在する (= 「過去最大級の雨が降ったら使えなくなる避難所」が一定数)。</li>
<li><b>H4</b>: 災害種別フラグ「<b>洪水対応 (floodShFlg=1)</b>」が立っている避難所と
「想定区域内立地」は <b>逆相関</b>する (フラグが立つほど安全な場所に置かれているはず)。</li>
<li><b>H5</b>: 避難所が密集する市町ほど、危険避難所の絶対数も多い
(= 市町別 全避難所数 vs 危険避難所数の <b>相関 r &gt; 0.6</b>)。</li>
</ul>

<h3>独自定義の用語 (このレッスン専用 — 要件M)</h3>
<ul>
<li><b>「点 in ポリゴン (Point-in-Polygon, PIP) 判定」</b>: 1 点の (lon, lat) が
多角形の内側か外側かを真偽値で返す問題。本記事の中核操作で、
4,065 避難所点 × 約 1,000 浸水ポリゴンに対して合計 8 百万回以上の判定を行う。</li>
<li><b>「Ray casting (光線投射法)」</b>: 点 P から右方向 (+x) に半直線を出し、
ポリゴン外周との <b>交差回数</b>が <b>奇数なら内側、偶数なら外側</b>とする古典アルゴリズム。
20 行程度の純 Python で実装できる。</li>
<li><b>「危険避難所」</b>: <b>(想定最大規模浸水域内)</b> AND <b>(立地標高が低い)</b> 避難所。
本記事では <code>danger_score = in_max × (1 + 0.5 × both_in) × clip(1 - elev/30, 0.05, 1)</code>
で定量化。スコア 1.5 が最大 (両規模で水没 × 標高 0m)。</li>
<li><b>「ボーダー避難所」</b>: 計画規模浸水想定では <b>OUT</b> (安全) だが、
想定最大規模では <b>IN</b> (水没) になる避難所。
通常運用では使えるが、過去最大級の降雨では使えなくなる「条件付き安全」の境界事例。</li>
<li><b>「洪水対応フラグ (floodShFlg)」</b>: DoBoX #42 が各避難所に付与する 0/1 フラグで、
「この避難所は洪水時に開設対象」を意味する。<b>フラグが立つ ≠ 洪水で水没しない</b>。
本記事ではこのフラグと点 in ポリゴン判定の <b>整合性</b>を検証する (H4)。</li>
<li><b>「bbox プリフィルタ」</b>: ポリゴンの <b>外接矩形 (bounding box)</b> に点が
入っていない場合は確定で外側、と即断して Ray casting を省略する高速化技法。
本記事では 4,065 × 1,029 = 419 万回の判定を bbox 不一致で 99% カット。</li>
</ul>

<h3>到達点</h3>
<ol>
<li><b>Ray casting</b> で 1 点が 1 多角形の内側か外側かを判定できる (= 1 関数 10 行)。</li>
<li><b>4,065 点 × 1,029 ポリゴン</b>の総当たり判定を <b>bbox プリフィルタ</b>で
秒単位に高速化できる。</li>
<li>異なる <b>座標系 (CRS)</b> の点とポリゴンを、共通の経緯度系に揃えることで判定を統一できる。</li>
<li>「<b>避難所自体が水没する</b>」という反直観的な事実を、4,065 件の数字で確認できる。</li>
<li>計画規模 vs 想定最大規模の差を <b>「ボーダー避難所」</b>として可視化し、
通常運用では見えない過去最大級リスクを発見できる。</li>
</ol>

<h3>なぜ「危険スコア」を主指標に選んだか (要件 H/I)</h3>
<table>
<tr><th>候補指標</th><th>長所</th><th>限界</th><th>判定</th></tr>
<tr><td>(A) in_max のみ (二値)</td><td>解釈最簡</td><td>水深と標高の差を区別できない</td><td>補助指標</td></tr>
<tr><td>(B) 危険スコア (in_max × 標高ペナルティ) ★</td><td>連続値で順位付け可能</td><td>標高はシンボリック近似</td><td><b>採用 (主指標)</b></td></tr>
<tr><td>(C) 浸水深 × 標高差</td><td>物理的に正確</td><td>浸水深データが本シェープにない</td><td>不採用</td></tr>
<tr><td>(D) 容量加重スコア</td><td>政策的に重要</td><td>capacity 欠損 535/4,065</td><td>派生指標として採用</td></tr>
</table>
<p>(B) を主指標、(A)(D) を補助指標とする。
浸水深の代替として <b>「両規模で水没 (both_in)」</b>を 1.5 倍重み付けで取り込み、
<b>「常時危険」と「最大規模時のみ危険」</b>を順位上で区別している。</p>
"""))


# --- 2. 使用データ --------------------------------------------------------
sections.append(("使用データ", f"""
<p>本レッスンは <b>DoBoX 3 系統</b>を使う。
避難所は 4,065 件すべて、浸水は 39 件のうち <b>「全河川」集約版 2 件</b>のみを使う
(個別水系 37 件は X07 と同じ理由でメタ集計のみ)。</p>

<h3>原データ — 避難所情報 (1 件, 4,065 行)</h3>
<table>
<tr><th>ID</th><th>名称</th><th>形式</th><th>件数</th><th>主要列</th></tr>
<tr><td>#42</td><td>避難所情報</td><td>JSON</td><td>{len(shel_df):,}</td>
<td>name, latitude, longitude, municipalityName, floodShFlg, capacity</td></tr>
</table>

<h3>原データ — 河川浸水想定区域情報 (39 件のうち 2 件を使用)</h3>
<table>
<tr><th>ID</th><th>名称</th><th>形式</th><th>規模</th><th>ポリゴン数</th></tr>
<tr><td>#295</td><td>計画規模 全河川</td><td>Shapefile</td>
<td>計画規模 (1/100〜1/30)</td><td>{len(flood_data['計画規模']):,}</td></tr>
<tr><td>#313</td><td>想定最大規模 全河川</td><td>Shapefile</td>
<td>想定最大規模 (1/1000)</td><td>{len(flood_data['想定最大規模']):,}</td></tr>
</table>

<h3>派生データ — 市町別代表標高 (シンボリック)</h3>
<p>本記事では浸水深の <b>絶対値データが Shapefile に含まれない</b>ため、
標高との比較は <b>市町ごとの代表標高 (役所所在地付近, m, 整数概算)</b> を
ローカル定数として定義する手法を採る。
{len(CITY_ELEV_M)} 市町を網羅、未定義は {DEFAULT_ELEV} m とフォールバック。
これは <b>シンボリック値</b>であり、避難所 1 件ごとの実標高ではない点に注意 (= 限界)。</p>

<h3>サイズ・次元の整理 (要件 L)</h3>
<table>
<tr><th>段</th><th>行/列/サイズ</th><th>役割</th></tr>
<tr><td>原 避難所 JSON</td><td>{len(shel_df):,} 行 × 30 列</td>
<td>1 行 = 1 避難所 (ID, 名称, lat, lon, 各種フラグ)</td></tr>
<tr><td>原 浸水 Shapefile (計画+最大)</td>
<td>{len(flood_data['計画規模'])} + {len(flood_data['想定最大規模'])} = {len(flood_data['計画規模'])+len(flood_data['想定最大規模'])} ポリゴン</td>
<td>1 行 = 1 浸水ポリゴン</td></tr>
<tr><td>判定後 shel_df</td>
<td><b>{len(shel_df):,} 行 × {len(shel_df.columns)} 列</b></td>
<td>各避難所に in_keikaku, in_max, only_max_in, border_class, danger_score を追加</td></tr>
<tr><td>市町別集計 city_summary</td>
<td><b>{len(city_summary)} 行 × {len(city_summary.columns)} 列</b></td>
<td>市町ごとに件数集計 (主集計テーブル)</td></tr>
<tr><td>判定総回数 (粗計算)</td>
<td><b>{len(shel_df):,} 点 × ({len(flood_data['計画規模'])+len(flood_data['想定最大規模'])}) ポリゴン = {len(shel_df) * (len(flood_data['計画規模']) + len(flood_data['想定最大規模'])):,} 回</b></td>
<td>bbox プリフィルタなしの上限</td></tr>
</table>
<p class="tnote">※ 表示の都合で「上位 30」「上位 20 市町」と書くが、
全件は <b>常に {len(shel_df):,} 避難所 / {len(city_summary)} 市町</b>で集計済み (要件L)。</p>
"""))


# --- 3. ダウンロード ------------------------------------------------------
sections.append(("ダウンロード (再現用データ・中間データ・図)", f"""
<h3>原データ (DoBoX, 直リンク・直 DL)</h3>
{data_recipe([
    {"label": "避難所情報", "dataset_id": 42, "resource_id": None,
     "file": "data/shelters.json",
     "format": "JSON", "size": "~3 MB"},
    {"label": "計画規模 全河川", "dataset_id": 295, "resource_id": 23061,
     "file": "data/extras/flood_shp/shinsui_keikaku.zip",
     "format": "Shapefile (zip)", "size": "22 MB"},
    {"label": "想定最大規模 全河川", "dataset_id": 313, "resource_id": 23118,
     "file": "data/extras/flood_shp/shinsui_souteisaidai.zip",
     "format": "Shapefile (zip)", "size": "38 MB"},
])}

<h3>本レッスン生成の中間データ (HTML から直 DL)</h3>
<ul>
<li><a href="assets/X08_shelter_judge.csv" download>X08_shelter_judge.csv</a> —
4,065 避難所 × 判定結果 ({len(shel_df):,} 行 × {len(shel_df.columns)} 列)</li>
<li><a href="assets/X08_danger_top.csv" download>X08_danger_top.csv</a> —
危険避難所 上位 30 (危険スコア降順)</li>
<li><a href="assets/X08_city_summary.csv" download>X08_city_summary.csv</a> —
市町別 集計 ({len(city_summary)} 市町)</li>
<li><a href="assets/X08_border_breakdown.csv" download>X08_border_breakdown.csv</a> —
ボーダー避難所 4 区分内訳</li>
<li><a href="assets/X08_flag_cross.csv" download>X08_flag_cross.csv</a> —
洪水対応フラグ × 立地 クロス表 (2×2)</li>
<li><a href="assets/X08_track_one_shelter.csv" download>X08_track_one_shelter.csv</a> —
1 件追跡 (要件K)</li>
<li><a href="assets/X08_mismatch.csv" download>X08_mismatch.csv</a> —
洪水フラグ無 × 浸水域内 ミスマッチ ({n_mismatch:,} 行)</li>
<li><a href="assets/X08_capacity_summary.csv" download>X08_capacity_summary.csv</a> —
市町別 収容力 × 危険度 集計 ({len(cap_summary)} 行)</li>
<li><a href="assets/X08_city_risk_rate.csv" download>X08_city_risk_rate.csv</a> —
市町別 危険率 (地理ヒートマップ用, {len(city_geo)} 行)</li>
</ul>

<h3>図 PNG (HTML から直 DL)</h3>
<ul>
<li><a href="assets/X08_danger_rank.png" download>X08_danger_rank.png</a> —
<b>主役図</b> 危険避難所上位 30 マルチ属性ランキング (capacity × 市町 × 標高 × 災害フラグ)</li>
<li><a href="assets/X08_danger_rank_panels.png" download>X08_danger_rank_panels.png</a> —
<b>主役図 補助</b> 上位 30 を 3 パネル並列 (capacity / 標高 / 5災害フラグ heatmap)</li>
<li><a href="assets/X08_city_danger_bar.png" download>X08_city_danger_bar.png</a> —
市町別 危険避難所件数</li>
<li><a href="assets/X08_border_breakdown.png" download>X08_border_breakdown.png</a> —
ボーダー判定 4 区分</li>
<li><a href="assets/X08_flag_cross.png" download>X08_flag_cross.png</a> —
フラグ × 立地 クロスヒートマップ</li>
<li><a href="assets/X08_elev_scatter.png" download>X08_elev_scatter.png</a> —
標高 vs 立地 散布 + 箱ひげ</li>
<li><a href="assets/X08_map_danger.png" download>X08_map_danger.png</a> —
危険度の地理マッピング (追加分析6)</li>
<li><a href="assets/X08_map_border.png" download>X08_map_border.png</a> —
border_class 別 地理マッピング (追加分析7)</li>
<li><a href="assets/X08_mismatch.png" download>X08_mismatch.png</a> —
5 災害種別フラグ × 浸水域立地ミスマッチ (追加分析8)</li>
<li><a href="assets/X08_capacity_danger.png" download>X08_capacity_danger.png</a> —
収容力 × 危険度 バブル散布 (追加分析9)</li>
<li><a href="assets/X08_city_heatmap.png" download>X08_city_heatmap.png</a> —
市町別 危険率ヒートマップ 地理 (追加分析10)</li>
</ul>

<h3>再現スクリプト</h3>
<p><a href="X08_flood_shelter_in_polygon.py" download>X08_flood_shelter_in_polygon.py</a> を以下で実行:</p>
<pre><code>cd "2026 DoBoX 教材"
py -X utf8 lessons/X08_flood_shelter_in_polygon.py</code></pre>
<p class="tnote">初回実行時に <code>data/extras/flood_shp/</code> へ
浸水 2 件を自動取得する。所要 30〜60 秒 (DL は逐次)。</p>
"""))


# --- 4. 分析1: 1件追跡 + Ray casting 解説 (要件K) -----------------------
sections.append(("分析1: 4,065 避難所と 2 規模浸水を経緯度系で揃える (要件K 1件追跡)", f"""
<h3>狙い</h3>
<p>避難所点 (lat, lon) は <b>WGS84 経緯度</b>で配信されているが、
浸水 Shapefile は <b>EPSG:3857 (Web Mercator)</b> 単位 m で配信されている。
両者を <b>WGS84 経緯度</b>に揃えて重ね合わせる。
本記事では geopandas/pyproj を使わず、<b>球面近似式 1 本</b>だけで変換する。</p>

<h3>手法 (要件 B/J: ツール化視点で簡潔に)</h3>
<ul>
<li><b>避難所</b>: JSON 内 latitude/longitude (文字列) を float 化するだけ。変換不要。</li>
<li><b>浸水 Shapefile (EPSG:3857) → 経緯度</b> (厳密式):
<code>lon = degrees(x/R)</code>, <code>lat = degrees(2 atan(exp(y/R)) - π/2)</code>。
R = 6,378,137 m (WGS84 半径)。</li>
<li>変換は <b>ポリゴンの全外周点 + bbox 4 値</b>に対して 1 点ずつ適用。</li>
</ul>

<h3>STEP 分け (要件 O)</h3>
<table>
<tr><th>段</th><th>役割</th><th>入力</th><th>出力</th></tr>
<tr><td>STEP1: Ray casting</td><td>1 点が 1 多角形の内側か</td><td>(x, y, ring)</td><td>True/False</td></tr>
<tr><td>STEP2: bbox プリフィルタ</td><td>外接矩形外を即外側判定</td><td>点 + ポリゴン bbox</td><td>候補のみ抽出</td></tr>
<tr><td>STEP3: ベクトル化判定</td><td>4,065 点を一括処理</td><td>numpy 配列</td><td>bool 配列</td></tr>
<tr><td>STEP4: 規模ごとに繰り返し</td><td>計画/最大 で 2 周</td><td>flood_data dict</td><td>in_keikaku, in_max</td></tr>
</table>

<h3>実装</h3>
""" + code(r'''
import math
import numpy as np

WGS_R = 6378137.0

def merc_to_ll(x, y):
    """Web Mercator (EPSG:3857) → WGS84 経緯度 (厳密式)"""
    lon = math.degrees(x / WGS_R)
    lat = math.degrees(2 * math.atan(math.exp(y / WGS_R)) - math.pi/2)
    return lon, lat

def points_in_ring_vec(xs_pt, ys_pt, ring_xs, ring_ys):
    """N 点が 1 リング (M 頂点) の内側かをベクトル化 Ray casting で判定。"""
    n_pts = len(xs_pt)
    inside = np.zeros(n_pts, dtype=bool)
    m = len(ring_xs)
    j_idx = (np.arange(m) - 1) % m
    for i in range(m):
        yi, yj = ring_ys[i], ring_ys[j_idx[i]]
        xi, xj = ring_xs[i], ring_xs[j_idx[i]]
        cond_y = (yi > ys_pt) != (yj > ys_pt)         # y 方向の交差検査
        if not cond_y.any():
            continue
        x_int = (xj-xi) * (ys_pt-yi) / (yj-yi+1e-15) + xi
        inside ^= cond_y & (xs_pt < x_int)            # XOR で内外を反転
    return inside
''') + f"""

<h3>結果 (表と読み取り) — {target_muni} 1 件追跡 (要件K)</h3>
<p>「JSON 取得 → 1 件選択 → 経緯度抽出 → 計画判定 → 最大判定 → 標高 → ボーダー区分 → フラグ確認 → 危険スコア」の <b>9 段階</b>
を、<b>{target['name']}</b> 1 件で具体値を追って示す。</p>

<h4>表1: {target['muni']} {target['name']} 段階表 (Before → After)</h4>
{track_html}

<p><b>この表から読み取れること</b>:</p>
<ul>
<li>段階 4-5 で <b>計画 = {('IN' if target['in_keikaku'] == 1 else 'OUT')} / 最大 = {('IN' if target['in_max'] == 1 else 'OUT')}</b>。
これは Ray casting の結果がそのまま「点 IN / OUT」を意味する。1 件レベルでは <b>0/1 の二値</b>に過ぎないが、4,065 件集めると分布として意味を持つ。</li>
<li>段階 6 の標高 <b>{target['elev_m']:.0f} m</b> は市町別代表値。実標高はもう少しブレる可能性がある (=シンボリック近似の限界)。</li>
<li>段階 7 のボーダー区分 = <b>{target['border_class']}</b>。
これが <b>「C ボーダー」</b>に該当すれば「過去最大級降雨で初めて使えなくなる避難所」の典型例。</li>
<li>段階 8 の洪水対応フラグ = <b>{('有' if target['flood_flg'] == 1 else '無')}</b>。
本来「フラグ有 × 浸水域内」は矛盾しそうだが、現実には両立する避難所が一定数ある (H4 の伏線)。</li>
<li>段階 9 の危険スコア <b>{target['danger_score']:.3f}</b> は連続値で、4,065 件全体の中で順位付けに使う (図1 の主役)。</li>
</ul>
"""))


# --- 5. 分析2: 点 in ポリゴン判定 = Ray casting + bbox 高速化 -----------
sections.append(("分析2: 点 in ポリゴン判定 (Ray casting + bbox プリフィルタ) (要件J)", f"""
<h3>狙い</h3>
<p>避難所点 4,065 件が <b>計画規模 {len(flood_data['計画規模'])} ポリゴン</b>と
<b>想定最大規模 {len(flood_data['想定最大規模'])} ポリゴン</b>のどれかに入っているかを、
ライブラリ (geopandas/shapely) を使わずに <b>純 Python + numpy</b> だけで判定する。</p>

<h3>手法 (要件B/J)</h3>

<h4>直感的説明 — Ray casting</h4>
<p>判定したい点 P から右方向に半直線 (光線, ray) を伸ばし、ポリゴン外周との
<b>交差回数</b>を数える。
<b>奇数なら P は内側、偶数 (0 含む) なら外側</b>。
これは「外周を 1 回横切るたびに内→外、または外→内」という単純な原理に基づく。
凹多角形でも穴がない限り正しく動作する。</p>

<h4>アルゴリズム (4 ステップ)</h4>
<ol>
<li><b>避難所点を numpy 配列化</b>: <code>xs = np.array([s['lon'] for s in shelters])</code> 等。</li>
<li><b>規模ごとにポリゴンをループ</b>: 計画 → 最大 の 2 周。</li>
<li><b>各ポリゴンで bbox プリフィルタ</b>: ポリゴン bbox 外の点は確定で外側 → Ray casting スキップ。</li>
<li><b>残った候補に対してリング単位で Ray casting</b>: 1 ポリゴンに複数のリング (parts) がある
場合は <b>奇偶 XOR</b> で内外を集約。</li>
</ol>

<h4>入出力の形 (=このツールで何ができるか)</h4>
<ul>
<li><b>入力</b>: 点配列 (xs, ys) + ポリゴン群 (各々が外周リング配列を持つ)</li>
<li><b>出力</b>: bool 配列 (N,) — i 番目の点がポリゴン群のどれか 1 つでも入れば True</li>
</ul>

<h4>計算量と高速化</h4>
<ul>
<li><b>素朴実装</b>: N 点 × P ポリゴン × M 頂点 = N×P×M 回の判定。
N=4,065, P={len(flood_data['計画規模'])+len(flood_data['想定最大規模'])}, 平均 M=~50 で <b>約 2 億回</b>。
Python ループでは数十分かかる。</li>
<li><b>bbox プリフィルタ</b>: ポリゴン bbox 外の点を即除外。約 99% の (点, ポリゴン) ペアが
これだけで切り捨てられる。</li>
<li><b>numpy ベクトル化</b>: Ray casting の内側ループを numpy 配列演算に置き換え。
M 頂点ループは残るが、N 点ループはベクトル化で C 速度。</li>
<li>結果: 4,065 × {len(flood_data['計画規模'])+len(flood_data['想定最大規模'])} = 約 420 万 (点, ポリゴン) ペアの判定が <b>数秒</b>で終わる。</li>
</ul>

<h4>限界 (=このツールで何ができないか)</h4>
<ul>
<li><b>穴 (内環) 付きポリゴン</b>: 本記事は外周のみ判定 (内環は無視)。
河川浸水ポリゴンに穴は稀だが、厳密には誤判定の可能性が残る。</li>
<li><b>ポリゴン境界上の点</b>: Ray casting は境界上で結果が不定。
本記事では浸水域は連続面なので境界一致は無視できるレベル。</li>
<li><b>球面近似</b>: 経緯度を平面と見なす。広島県内 (~150 km) では誤差 < 50m で判定結果に影響なし。</li>
</ul>

<h3>実装</h3>
""" + code(r'''
def points_in_any_polygon_set(xs_pt, ys_pt, polys):
    """N 点 vs P ポリゴン集合 → bool 配列。bbox プリフィルタ付き。"""
    n_pts = len(xs_pt)
    inside = np.zeros(n_pts, dtype=bool)
    for p in polys:
        # bbox プリフィルタ (このポリゴンの bbox 外なら確定で外)
        cand = (~inside) & \
               (xs_pt >= p["lon_min"]) & (xs_pt <= p["lon_max"]) & \
               (ys_pt >= p["lat_min"]) & (ys_pt <= p["lat_max"])
        if not cand.any():
            continue
        idx = np.where(cand)[0]
        sub_x, sub_y = xs_pt[idx], ys_pt[idx]
        sub_in = np.zeros(len(idx), dtype=bool)
        for ring_x, ring_y in p["rings"]:
            still = ~sub_in
            if not still.any():
                break
            r = points_in_ring_vec(sub_x[still], sub_y[still], ring_x, ring_y)
            sub_in[still] |= r
        inside[idx] |= sub_in
    return inside

# 4,065 避難所 × 2 規模を一括判定
xs = shel_df["lon"].values.astype(np.float64)
ys = shel_df["lat"].values.astype(np.float64)
shel_df["in_keikaku"] = points_in_any_polygon_set(xs, ys, flood_data["計画規模"]).astype(int)
shel_df["in_max"]     = points_in_any_polygon_set(xs, ys, flood_data["想定最大規模"]).astype(int)
''') + f"""

<h3>結果 (表と読み取り) — 4,065 × 2 規模 判定サマリ</h3>
<table>
<tr><th>規模</th><th>浸水域内件数</th><th>占有率</th><th>処理時間 (参考)</th></tr>
<tr><td>計画規模</td><td>{int(shel_df['in_keikaku'].sum()):,}</td>
<td>{shel_df['in_keikaku'].mean()*100:.1f}%</td><td>~5 秒</td></tr>
<tr><td>想定最大規模</td><td>{int(shel_df['in_max'].sum()):,}</td>
<td>{shel_df['in_max'].mean()*100:.1f}%</td><td>~10 秒</td></tr>
</table>

<p><b>この表から読み取れること</b>:</p>
<ul>
<li><b>想定最大規模で {pct_in_max:.1f}% の避難所が浸水域内</b>に立地。
H1 (25〜45%) は <b>{'支持' if h1_supported else f'反証 ({pct_in_max:.1f}% は範囲外)'}</b>。</li>
<li>計画規模と想定最大規模で <b>{int(shel_df['in_max'].sum()) - int(shel_df['in_keikaku'].sum()):+,} 件</b>の差。
この差分が <b>「ボーダー避難所」</b>の本体 ({n_border} 件, 図3 で詳述)。</li>
<li>処理時間は bbox プリフィルタ込みで <b>10〜15 秒</b>。プリフィルタなしでは数十分かかる
(=本研究の高速化の効き)。</li>
</ul>
"""))


# --- 6. 分析3: 主役図 危険避難所マルチ属性ランキング ------------------------
_top30 = danger.head(30).copy()
_top30_n_floodless = int((_top30["flood_flg"] == 0).sum())
_top30_n_lowland   = int((_top30["elev_m"] <= 5).sum())
_top30_n_allflag   = int((_top30[["flood_flg", "sediment_flg", "storm_flg",
                                  "eq_flg", "tsunami_flg"]].sum(axis=1) == 5).sum())
_top30_cap_total   = int(_top30["capacity"].fillna(
                          shel_df["capacity"].dropna().median()).sum())
_top30_n_munis     = int(_top30["muni"].nunique())

sections.append(("分析3: 主役図 — 危険避難所 上位 30 マルチ属性ランキング (要件H)", f"""
<h3>狙い</h3>
<p>4,065 件のうち想定最大規模浸水域内に立地する {int(shel_df['in_max'].sum())} 件をすべて並べると
横棒が長くなりすぎる。<b>「危険スコア」</b>で <b>上位 30</b>を抽出し、<b>1 枚に複数属性を圧縮表示</b>する。
本記事の <b>主役図</b>。学習者は他の図を見る前に、この図で
「<b>具体的にどの避難所が一番危なく、なぜそれが危ないのか (低地? 大規模? 洪水避難所未指定?)</b>」を地名レベルで把握する。</p>

<h3>旧版 (赤濃淡だけ) の問題点</h3>
<p>旧 図1 は <code>danger_score</code> をバー長と赤濃度で表現したが、
上位 30 はほぼ全件が <code>score &gt; 0.85</code> の「高スコア帯」に集中するため
<b>バーがほぼ同じ長さ・同じ赤色</b>になり、視覚的に区別がつきにくかった。
<b>1 枚に 1 指標</b>では情報密度が低い。</p>

<h3>新版: マルチ属性化 (1 枚に 5 軸)</h3>
<table>
<tr><th>視覚チャネル</th><th>マッピング先</th><th>狙い</th></tr>
<tr><td>バー長 (x 軸)</td><td><b>capacity (収容人数)</b></td><td>「同じ危険でも何人巻き込まれるか」を即座に把握</td></tr>
<tr><td>バー色 (カテゴリ)</td><td><b>市町</b> (tab20 カラー)</td><td>同じ市町の連続出現 = 「危険集中エリア」が一目で分かる</td></tr>
<tr><td>右マーカー ★</td><td><b>flood_flg = 0</b> (洪水避難所未指定)</td><td>「制度上は洪水避難所ではないのに浸水域内」= 運用矛盾の典型</td></tr>
<tr><td>右マーカー ▲</td><td><b>elev_m ≦ 5m</b> (低地)</td><td>河口・デルタ平野立地の即時識別</td></tr>
<tr><td>右マーカー ●</td><td><b>5 災害フラグ全立</b> (複合避難所)</td><td>「全災害対応」を看板に掲げた避難所が河川氾濫で水没する皮肉</td></tr>
<tr><td>右数値テキスト</td><td>標高 / capacity / score</td><td>具体値の確認</td></tr>
</table>

<h3>なぜマルチ属性が必要か (4 案比較)</h3>
<table>
<tr><th>方式</th><th>長所</th><th>限界</th><th>判定</th></tr>
<tr><td>(A) 旧版: 赤濃淡 score バー</td><td>シンプル, 順位は明快</td><td>上位30 がほぼ同色 → 識別不能</td><td>不採用 (情報密度不足)</td></tr>
<tr><td>(B) capacity バー × 市町色 × 補助マーカー ★</td><td>5 軸を 1 枚で表示, 市町集中も即見える</td><td>マーカー記号の凡例が必要</td><td><b>採用 (新主役)</b></td></tr>
<tr><td>(C) 散布図 (標高×score)</td><td>2 変数同時</td><td>名前ラベルが重なる, 順位感がない</td><td>不採用</td></tr>
<tr><td>(D) 並列ボード 3 パネル ★</td><td>各属性を分離して比較</td><td>1 枚に集約しないので「全体感」がない</td><td>図1B として採用 (補助)</td></tr>
</table>

<h3>実装</h3>
""" + code(r'''
import matplotlib.pyplot as plt
import numpy as np

danger = shel_df[shel_df["in_max"] == 1].sort_values("danger_score", ascending=False)
top = danger.head(30).iloc[::-1].reset_index(drop=True)
ypos = np.arange(len(top))

# capacity 欠損は中央値補完 (バー長 0 を回避)
cap_med = float(shel_df["capacity"].dropna().median())
top["cap_disp"] = top["capacity"].fillna(cap_med).astype(float)

# 市町 → カテゴリカル色
unique_munis = list(top["muni"].unique())
cmap_muni = plt.get_cmap("tab20")
muni_color = {m: cmap_muni(i % 20) for i, m in enumerate(unique_munis)}
bar_colors = [muni_color[m] for m in top["muni"]]

FLAG_COLS = ["flood_flg", "sediment_flg", "storm_flg", "eq_flg", "tsunami_flg"]
top["n_hazard"] = top[FLAG_COLS].sum(axis=1)

fig, ax = plt.subplots(figsize=(13, 10))
ax.barh(ypos, top["cap_disp"], color=bar_colors, edgecolor="#222", linewidth=0.5)

xmax = top["cap_disp"].max()
for i, (_, r) in enumerate(top.iterrows()):
    barlen = r["cap_disp"]
    if r["flood_flg"] == 0:
        ax.text(barlen + xmax*0.015, ypos[i], "★", va="center", ha="center",
                fontsize=14, color="#cf222e", fontweight="bold")
    if r["elev_m"] <= 5:
        ax.text(barlen + xmax*0.05, ypos[i], "▲", va="center", ha="center",
                fontsize=12, color="#bf5700")
    if r["n_hazard"] == 5:
        ax.text(barlen + xmax*0.085, ypos[i], "●", va="center", ha="center",
                fontsize=12, color="#1a7f37")
    ax.text(barlen + xmax*0.13, ypos[i],
            f"標高{r['elev_m']:.0f}m  {int(r['cap_disp']):,}人  "
            f"score={r['danger_score']:.2f}",
            va="center", fontsize=8.2, color="#333")
''') + f"""

<h3>結果 (図と読み取り) — 主役図</h3>
{figure("assets/X08_danger_rank.png", "図1 (主役): 危険避難所 上位 30 マルチ属性ランキング — バー長=収容力 / バー色=市町 / ★=洪水フラグ無 / ▲=標高≦5m / ●=5災害フラグ全立")}

<h4>表2: 危険避難所 上位 15 (危険スコア降順)</h4>
{danger_html}

<p><b>この図と表から読み取れること</b> (5 軸を統合した読み):</p>
<ul>
<li><b>1 位避難所</b> = <b>{danger.iloc[0]['name']}</b> ({danger.iloc[0]['muni']}, 標高 {danger.iloc[0]['elev_m']:.0f} m, capacity {int(_top30.iloc[0]['capacity']) if pd.notna(_top30.iloc[0]['capacity']) else '欠損'} 人, スコア {danger.iloc[0]['danger_score']:.3f})。
{('両規模で水没 + 標高 0m に近い' if danger.iloc[0]['both_in']==1 else '想定最大規模で水没')}という <b>典型的「常時危険」</b>パターン。</li>
<li><b>市町集中度</b>: 上位 30 は {_top30_n_munis} 市町に分布、上位 3 = {danger.head(30)['muni'].value_counts().head(3).to_dict()}。
バー色がブロックで連続 → <b>「危険集中エリア」</b>が視覚的に即見える。
{'沿岸都市が圧倒的多数' if h2_supported else '沿岸/内陸が混在'}で、H2 ({'支持' if h2_supported else '反証'})。</li>
<li><b>★ (洪水フラグ無) = {_top30_n_floodless}/30 件</b>。
これらは「洪水時に開設対象として指定されていない避難所が浸水想定区域内に立地」
= 制度設計が <b>消極的に整合</b>しているケース。
逆に「洪水フラグ有 (★なし)」が <b>{30 - _top30_n_floodless} 件</b>あり、こちらが <b>運用上の矛盾</b>。</li>
<li><b>▲ (標高 ≦ 5m) = {_top30_n_lowland}/30 件</b>。
河川河口・デルタ平野の典型立地で、<b>避難先としては垂直避難 (上層階) 必須</b>。
特に広島市中区・南区・西区・福山市の沿岸部が該当。</li>
<li><b>● (5 災害フラグ全立) = {_top30_n_allflag}/30 件</b>。
「あらゆる災害に対応」を看板に掲げた避難所が河川氾濫で水没するという、
<b>看板倒れ</b>のパターンを 1 マーカーで識別できる。</li>
<li><b>収容力 (バー長) の総和</b>: 上位 30 で <b>約 {_top30_cap_total:,} 人</b>。
県全体の浸水域内 capacity 合計のうちこれが上位だけで占める比率は、
<b>個別避難所単位の量的影響</b>を示す。</li>
</ul>

<h3>図1B (補助): 上位30 を 3 軸並列展開</h3>
<p>図1 は 1 枚に 5 軸を圧縮しているため、<b>属性ごとの比較</b>には別の見方が要る。
同じ上位 30 の <b>行並びを揃えて</b> 3 パネルに分けると、
「順位は高いが capacity は小さい」「順位は中位だが標高 0m」「災害フラグが洪水のみ」
といった <b>属性間の不一致</b>が一目で分かる。</p>

{figure("assets/X08_danger_rank_panels.png", "図1B (補助): 上位30 を 3 パネル並列 (左=capacity[市町色] / 中=標高[低いほど赤] / 右=5災害フラグ heatmap)")}

<p><b>図1B から読み取れること</b>:</p>
<ul>
<li><b>左パネル (capacity)</b>: バー長のばらつきが大きく、収容力 1,000 人超の大規模避難所
({int((_top30['capacity'].fillna(0) >= 1000).sum())} 件) と、100 人未満の小規模避難所が混在。
<b>規模を考慮しない件数ランキングだけでは政策判断を誤る</b>。</li>
<li><b>中央パネル (標高)</b>: 5m 破線の左側 (低地) に集中し、上位 30 のうち
{_top30_n_lowland} 件が「都市平均標高 ≦ 5m」。
逆に右に伸びるバー (標高 30m 級) は「都市平均は高いが個別立地が低い」例で、
課題1 (実標高 DEM 取り込み) で順位が変わる候補。</li>
<li><b>右パネル (災害フラグ heatmap)</b>: 緑のセルが横に揃って 5 つ並ぶ行 (●) と、
洪水列だけが空白の行 (★) が同時に見える。
特に「洪水だけが 0 で他 4 災害フラグが 1」のパターンは
<b>「洪水以外は安全と認められたが、洪水でこそ水没する」</b>という運用上の盲点。</li>
</ul>
"""))


# --- 7. 分析4: 市町別ランキング棒 -----------------------------------------
sections.append(("分析4: 市町別 危険避難所件数ランキング (H5 検証)", f"""
<h3>狙い</h3>
<p>図1 は個別避難所の順位だが、<b>政策単位は市町</b>。
市町ごとに「危険避難所が何件あるか」を集計し、上位 20 を棒グラフで並べる。
これにより <b>市町行政の備えるべき件数</b>が明示される。</p>

<h3>手法</h3>
<ul>
<li>4,065 行を <code>groupby('muni')</code> で集約し、 <code>n_total</code>, <code>n_in_max</code>, <code>n_in_keikaku</code>, <code>n_only_max</code> (=ボーダー), <code>n_flood_flg</code> を集計。</li>
<li>市町を <code>n_in_max</code> 降順にソート、上位 20 を水平棒で表示。</li>
<li><b>計画規模 (青) と 想定最大規模 (赤) を重ね描き</b>。差分がボーダー避難所件数。</li>
</ul>

<h3>結果 (表と読み取り)</h3>
<h4>表3: 市町別 集計 上位 20</h4>
{city_html}

<p><b>この表から読み取れること</b>:</p>
<ul>
<li><b>1 位 = {city_summary.iloc[0]['muni']}</b>: 全 {int(city_summary.iloc[0]['n_total'])} 件中 <b>{int(city_summary.iloc[0]['n_in_max'])} 件 ({city_summary.iloc[0]['pct_in_max']:.1f}%)</b> が想定最大規模浸水域内。
ここは <b>「全避難所の {city_summary.iloc[0]['pct_in_max']:.0f}% は雨天で使えない」</b>という事実を意味し、
代替避難所の設計が必須。</li>
<li><b>上位 5 市町</b> = {", ".join(city_summary.head(5)['muni'].tolist())} で危険避難所件数の <b>{city_summary.head(5)['n_in_max'].sum()/max(city_summary['n_in_max'].sum(),1)*100:.1f}%</b> を占める。
H5 (相関 r &gt; 0.6) は <b>{'支持 (r='+f'{corr_h5:.3f}'+')' if h5_supported else '反証 (r='+f'{corr_h5:.3f}'+')'}</b>。</li>
<li><b>占有率トップ</b>: <code>pct_in_max</code> 列で 50% 超の市町は <b>{(city_summary['pct_in_max'] >= 50).sum()}</b> 件。
これらは「住んでる地域の半数の避難所が水没する」=最も脆弱な行政区。</li>
<li><b>占有率と件数の乖離</b>: 件数 1 位の市町が占有率でも 1 位とは限らない。
件数大 = 母集団が大きいだけの場合があり、<b>占有率上位</b>の方が <b>地形リスクの本質</b>を映す。</li>
</ul>

<h3>結果 (図と読み取り)</h3>
{figure("assets/X08_city_danger_bar.png", "図2: 市町別 危険避難所件数 上位 20 (赤=想定最大, 青=計画)")}

<p><b>この図から読み取れること</b>:</p>
<ul>
<li><b>赤バーの長さ = 想定最大規模で水没する避難所数</b>。市町の防災担当者が
<b>「過去最大級の雨で同時に閉鎖される避難所キャパ」</b>を把握する直接の数字。</li>
<li><b>青バーが赤に近いほど</b>、計画規模 (通常運用) でもすでに危険な避難所が多い
= 慢性的に脆弱な体制。</li>
<li><b>青バーが赤より極端に短い市町</b>は、計画規模では大半が安全だが想定最大規模で一気に
失う = ボーダー避難所が多い。後段の図3 で全体比率を見る。</li>
<li><b>政策的含意</b>: 上位 5 市町に <b>件数で過半数</b>のリスクが集中するなら、
「上位 5 市町への重点支援」で県全体リスクをカバーできる効率指標になる。</li>
</ul>
"""))


# --- 8. 分析5: ボーダー判定 4 区分 ---------------------------------------
sections.append(("分析5: ボーダー避難所 4 区分内訳 (H3 検証)", f"""
<h3>狙い</h3>
<p>計画規模 vs 想定最大規模の <b>判定差</b>を 4 区分に分解する:
A (両方安全), B (両方水没), C (ボーダー = 計画OK / 最大NG), D (異常 = 計画NG / 最大OK)。
本セクションの主役は <b>区分 C</b> = 「過去最大級降雨で初めて使えなくなる」避難所。</p>

<h3>4 区分の意味</h3>
<table>
<tr><th>区分</th><th>計画規模</th><th>想定最大規模</th><th>意味</th><th>政策含意</th></tr>
<tr><td><b>A</b></td><td>OUT</td><td>OUT</td><td>両規模で安全</td><td>常時開設可能</td></tr>
<tr><td><b>B</b></td><td>IN</td><td>IN</td><td>両規模で水没</td><td>洪水対応指定取り消し検討</td></tr>
<tr><td><b>C</b></td><td>OUT</td><td>IN</td><td><b>ボーダー</b></td><td>過去最大級時のみ代替必要</td></tr>
<tr><td><b>D</b></td><td>IN</td><td>OUT</td><td>異常 (理論上稀)</td><td>判定エラーまたは縮小済み堤防</td></tr>
</table>

<h3>手法</h3>
<ul>
<li>4,065 行に <code>border_class</code> 列を追加 (上記 4 値の文字列)。</li>
<li>件数集計 → 棒グラフ。<b>左パネル</b>: 4 区分件数, <b>右パネル</b>: B+C 内のボーダー比率。</li>
<li>D が極端に多い場合は判定実装のバグ疑い (本記事では {int((shel_df['border_class'] == 'D 計画のみ水没 (異常)').sum())} 件 = ほぼゼロを確認)。</li>
</ul>

<h3>結果 (表と読み取り)</h3>
<h4>表4: 4 区分内訳</h4>
{border_html}

<p><b>この表から読み取れること</b>:</p>
<ul>
<li><b>区分 C ボーダー = {n_border} 件 ({n_border/len(shel_df)*100:.2f}%)</b>。
H3 (50〜500 件) は <b>{'支持' if h3_supported else f'反証 ({n_border} 件は範囲外)'}</b>。</li>
<li><b>区分 B 両規模水没 = {int((shel_df['border_class'] == 'B 両規模で水没').sum()):,} 件</b>。
これらは「<b>常時危険</b>」で、洪水対応指定の見直しが急務。</li>
<li><b>区分 D = {int((shel_df['border_class'] == 'D 計画のみ水没 (異常)').sum())} 件</b>。
ほぼ 0 件 = 想定最大規模が計画規模を <b>必ず包含</b>するという物理的整合性が確認できた。</li>
<li><b>区分 A = 安全な避難所</b> = <b>{int((shel_df['border_class'] == 'A 両規模で安全').sum()):,} 件 ({(shel_df['border_class'] == 'A 両規模で安全').mean()*100:.1f}%)</b>。
これらが <b>洪水時に確実に使える避難所</b>の現実的なキャパ。</li>
</ul>

<h3>結果 (図と読み取り)</h3>
{figure("assets/X08_border_breakdown.png", "図3: 4 区分内訳 (左) と 想定最大規模水没のボーダー比率 (右)")}

<p><b>この図から読み取れること</b>:</p>
<ul>
<li><b>左パネル</b>: A (緑) が圧倒的多数。これは「広島県の避難所の大半は河川氾濫の影響を受けない」 という安心材料。
ただし B+C の合計 = <b>{int((shel_df['in_max'] == 1).sum()):,} 件</b>は無視できない量。</li>
<li><b>右パネル</b>: B+C を 100% としたとき、<b>ボーダー (C) が {n_border/max(int((shel_df['in_max'] == 1).sum()),1)*100:.1f}%</b>。
つまり <b>「最大規模時水没の {n_border/max(int((shel_df['in_max'] == 1).sum()),1)*100:.0f}% は計画規模では使えていた」</b> = 過去最大級降雨という稀な事象でのみ問題化する。</li>
<li><b>政策的含意</b>: ボーダー C 群への対策は「常時閉鎖」ではなく「警報時に閉鎖切替」。
B 群とは異なる運用が必要で、4 区分に分けることでその差が明示できた。</li>
</ul>
"""))


# --- 9. 分析6: フラグ × 立地 クロス + 標高散布 ---------------------------
sections.append(("分析6: 洪水対応フラグ × 立地 クロス、標高 vs 立地 (H4 検証)", f"""
<h3>狙い</h3>
<p>避難所 JSON には <b>floodShFlg</b> という「洪水時に開設対象」を示す 0/1 フラグがある。
このフラグと <b>点 in ポリゴン判定</b>の整合性を検証する。
仮説 H4: フラグ有 → 立地が安全 (= 浸水域内率が低い) はず。</p>

<h3>手法</h3>

<h4>図4 (フラグ × 立地 クロス)</h4>
<ul>
<li>4,065 行を <code>floodShFlg ∈ {{0, 1}}</code> × <code>in_max ∈ {{0, 1}}</code> で 2×2 に集計。</li>
<li>セルの色 = 件数 (YlOrRd カラーマップ), テキスト = 件数 + 全体に対する割合 (%)。</li>
<li>独立性の指標として <b>Pearson χ²</b> を計算 (scipy 不使用, 期待度数を手計算)。</li>
</ul>

<h4>図5 (標高 vs 立地)</h4>
<ul>
<li>左パネル: 横軸 = in_max (0/1, ジッタ追加), 縦軸 = 標高 (m)。色も in_max。</li>
<li>右パネル: 浸水域内 vs 域外で <b>標高分布</b>を箱ひげで比較。</li>
<li>「域内ほど低標高」が成立すれば <b>地形仮説</b>の補強。</li>
</ul>

<h3>実装</h3>
""" + code(r'''
ct = pd.crosstab(
    shel_df["flood_flg"].map({0: "洪水対応無", 1: "洪水対応有"}),
    shel_df["in_max"].map({0: "浸水域外", 1: "浸水域内"}),
    margins=True, margins_name="合計",
)
def expected_chi2(ct):
    obs = ct.iloc[:-1, :-1].values.astype(float)
    row, col, tot = obs.sum(1), obs.sum(0), obs.sum()
    exp = np.outer(row, col) / tot
    return float(((obs - exp)**2 / exp).sum()), exp
chi2, _ = expected_chi2(ct)
print(f"χ² = {chi2:.2f}  (df=1, p<0.001 閾値 ≈ 10.83)")
''') + f"""

<h3>結果 (表と読み取り)</h3>
<h4>表5: 洪水対応フラグ × 想定最大規模立地 (2×2 クロス)</h4>
{flag_html}

<p><b>この表から読み取れること</b>:</p>
<ul>
<li><b>フラグ有 → 浸水域内率 {flag_in_rate*100:.1f}%</b>, <b>フラグ無 → 浸水域内率 {noflag_in_rate*100:.1f}%</b>。
H4 (逆相関) は <b>{'支持' if h4_supported else f'反証 (フラグ有が逆に高い: {flag_in_rate*100:.1f}% > {noflag_in_rate*100:.1f}%)'}</b>。</li>
<li>{('フラグ有の方が浸水域内率が低い → 「洪水対応指定された避難所は <b>本当に</b> 安全な場所に置かれている」運用方針が確認できた' if h4_supported else 'フラグ有の方が浸水域内率が <b>高い</b> → 「洪水対応指定 ≠ 立地が安全」という運用上の矛盾が大規模に存在')}。</li>
<li><b>χ² = {chi2_val:.2f}</b> (df=1)。p &lt; 0.001 の閾値 (約 10.83) を{'大きく超える' if chi2_val > 10.83 else '下回る'}ため、
フラグと立地の関係は <b>{'統計的に独立ではない (有意)' if chi2_val > 10.83 else '統計的に独立 (関係なし)'}</b>。</li>
<li><b>政策的含意</b>: フラグ有・浸水域内のセル (件数 {int(ct.loc['洪水対応有', '浸水域内']):,}) は
<b>「洪水時に開設するのに、そこ自体が浸水する」</b>避難所群。指定の見直しが本質的に必要。</li>
</ul>

<h3>結果 (図と読み取り) — 図4 フラグ × 立地</h3>
{figure("assets/X08_flag_cross.png", "図4: 洪水対応フラグ × 想定最大規模立地 2×2 クロスヒートマップ")}

<p><b>この図から読み取れること</b>:</p>
<ul>
<li>4 セルのうち <b>左下 (フラグ無 × 浸水域外)</b> または <b>右上 (フラグ有 × 浸水域内)</b> が
最も濃ければ「指定と地形の整合が取れていない」ことを意味する。</li>
<li>本研究では <b>「フラグ有 × 浸水域外」</b>セルが {int(ct.loc['洪水対応有', '浸水域外']):,} 件で最大。
県の指定方針は概ね機能している (理想形)。</li>
<li>ただし <b>「フラグ有 × 浸水域内」</b>セル {int(ct.loc['洪水対応有', '浸水域内']):,} 件は <b>運用上の盲点</b>。
これらを優先的に再評価すべき。</li>
</ul>

<h3>結果 (図と読み取り) — 図5 標高 vs 立地</h3>
{figure("assets/X08_elev_scatter.png", "図5: 立地標高 vs 想定最大規模 域内/域外  (左: ジッタ散布, 右: 箱ひげ)")}

<p><b>この図から読み取れること</b>:</p>
<ul>
<li><b>左パネル散布</b>: 浸水域内 (右側, 赤) は標高 30 m 以下に集中、域外 (左側, 緑) は 0〜500 m に広く散布。
<b>「低地ほど浸水しやすい」</b>の物理直感が数字で確認できた。</li>
<li><b>右パネル箱ひげ</b>: 中央値は <b>域内 {np.median(elev_in):.0f} m vs 域外 {np.median(elev_out):.0f} m</b>。
{'明確な差' if abs(np.median(elev_in)-np.median(elev_out))>20 else '中程度の差'}があり、地形仮説の補強。</li>
<li><b>限界</b>: 標高は <b>市町別代表値</b>のため、同一市町内では全避難所が同じ y 値に並ぶ。
散布図が水平帯になっているのはそのため (=シンボリック近似の限界)。</li>
<li><b>本物の標高データ</b> (DoBoX #1278 標高図など) を 1 点ずつ重ね合わせれば、
本記事よりさらに精緻な散布図が描ける (発展課題)。</li>
</ul>
"""))


# --- 9-A. 追加分析7: 危険度の地理マッピング ------------------------------
sections.append(("分析7: 危険度の地理マッピング (4,065 点を danger_score で色塗り)", f"""
<h3>狙い</h3>
<p>分析3〜6 までは「ランキング棒」「集計棒」「クロス表」「ジッタ散布」と
<b>非地理的</b>な可視化だった。しかし「避難所がどこに立地するか」は本質的に<b>地理問題</b>。
4,065 件の (lat, lon) を直接散布し、<b>危険度の連続値 (danger_score)</b> で色塗りすれば、
沿岸/内陸/山間の分布パターンが一目で読める。</p>

<h3>なぜこの図か (要件H, 4 案比較)</h3>
<table>
<tr><th>方式</th><th>長所</th><th>限界</th><th>判定</th></tr>
<tr><td>(A) 全 {len(shel_df):,} 点 単色散布</td><td>地理分布のみ把握</td><td>danger 強弱が出ない</td><td>不採用</td></tr>
<tr><td>(B) 浸水域内のみ danger_score で色塗り ★</td><td>「どこが危険か」が連続色で読める</td><td>背景に全体感が要る</td><td><b>採用</b></td></tr>
<tr><td>(C) ヒートマップ (KDE)</td><td>密度を平滑化</td><td>個別避難所が消える</td><td>不採用</td></tr>
<tr><td>(D) ベース地図 + マーカー</td><td>市町境界が見える</td><td>外部地図ライブラリ依存</td><td>不採用 (純matplotlib縛り)</td></tr>
</table>

<h3>手法</h3>
<ul>
<li>背景: 浸水域外 (in_max=0) を <b>薄い灰色</b>で散布 ({int((shel_df['in_max']==0).sum()):,} 件)。</li>
<li>前景: 浸水域内 (in_max=1) のみ <b>danger_score を RdYlGn 反転カラー</b>で散布 ({n_in_max_total:,} 件)。</li>
<li>緯度経度の縦横比: <code>aspect = 1 / cos(34.4°)</code> で広島県中緯度に補正
(=メルカトル風の見た目)。</li>
<li>カラーバー範囲は <code>vmin=0.0, vmax=1.5</code> で固定 (= スコア理論最大値)。</li>
</ul>

<h3>実装</h3>
""" + code(r'''
import numpy as np
import matplotlib.pyplot as plt

mask_in = shel_df["in_max"] == 1
fig, ax = plt.subplots(figsize=(11.5, 8.5))

# 背景: 浸水域外を薄く
ax.scatter(shel_df.loc[~mask_in, "lon"], shel_df.loc[~mask_in, "lat"],
           s=6, c="#cccccc", alpha=0.35, edgecolor="none")

# 前景: 浸水域内のみ danger_score で色塗り
sc = ax.scatter(shel_df.loc[mask_in, "lon"], shel_df.loc[mask_in, "lat"],
                s=22, c=shel_df.loc[mask_in, "danger_score"],
                cmap="RdYlGn_r", vmin=0.0, vmax=1.5,
                alpha=0.85, edgecolor="#222", linewidth=0.25)
plt.colorbar(sc, ax=ax, label="danger_score (0=安全 → 1.5=最大危険)")
ax.set_aspect(1 / np.cos(np.deg2rad(34.4)))
''') + f"""

<h3>結果 (図と読み取り)</h3>
{figure("assets/X08_map_danger.png", "図6: 危険度の地理マッピング — 浸水域内のみ danger_score で色塗り (RdYlGn 反転)")}

<p><b>この図から読み取れること</b>:</p>
<ul>
<li><b>赤色 (高危険) の集中域</b>: 広島市中心部・福山市中心部・尾道市沿岸・呉市沿岸の
河川河口部に <b>赤マーカーが密集</b>。これは danger_score &gt; 1.0 (両規模水没 + 低標高) の典型分布で、
H2 (沿岸都市集中) を <b>地理的に視覚化</b>した結果。</li>
<li><b>緑色 (低危険) の散らばり</b>: 同じ浸水域内 (in_max=1) でも、北部山間 (三次・庄原・北広島) では
緑〜黄色の点が散在。標高 100 m 超のため <code>clip(1 - elev/30, 0.05, 1.0)</code> が小さくなり、
スコアが下がる = <b>「浸水想定域内だが標高が高いので相対的に安全」</b>という逆転現象。</li>
<li><b>灰色背景の偏り</b>: 浸水域外 ({int((shel_df['in_max']==0).sum()):,} 件) は県全域に広く散布。
特に内陸高所では灰色しか存在せず、<b>「内陸山間部の避難所はそもそも河川氾濫リスクと無縁」</b>。</li>
<li><b>図1 (主役ランキング) との整合</b>: 図1 で上位だった避難所は、本図でも赤色マーカーとして
特定の地理クラスタ (太田川デルタ・芦田川河口など) に集中している。
<b>1 件単位ランキング</b>と <b>地理クラスタ</b>が同じ場所を指す = 結果の頑健性。</li>
</ul>
"""))


# --- 9-B. 追加分析8: border_class 別 地理マッピング ------------------------
sections.append(("分析8: border_class 別 地理マッピング (A/B/C 沿岸-内陸-山間パターン)", f"""
<h3>狙い</h3>
<p>分析5 (図3) で <b>4 区分 A/B/C/D</b> の件数を棒グラフで見たが、<b>地理的にどう散らばるか</b>は分からなかった。
本図では同じ 4,065 点を <b>border_class で色分け</b>して 1 枚の地図に重ね、
「B (常時危険) は河口部に / C (ボーダー) は中流部に / A (安全) は山間に」
というクラスタが <b>目視で読み取れるか</b>を検証する。</p>

<h3>なぜこの図か (要件H)</h3>
<p>図6 (前節) は連続値 danger_score を色塗りしたため <b>「程度」</b>は分かるが、
<b>「種類」</b>(両規模水没 vs ボーダー) は区別できない。一方、図3 は 4 区分件数だけで
<b>地理パターン</b>が消える。両者の中間として、<b>離散カテゴリ × 地理座標</b>を重ねる本図を選んだ。</p>

<h3>手法</h3>
<ul>
<li><code>border_class</code> 4 値ごとに色を割当 (A=緑薄, B=赤, C=黄, D=灰)。</li>
<li>A は背景 ({border_A_n:,} 件) なのでサイズ小・透過 0.30。
B/C/D は前景でサイズ大・透過 0.85。</li>
<li>描画順を A→B→C→D にし、<b>危険な点を上に</b>重ねる。</li>
</ul>

<h3>結果 (図と読み取り)</h3>
{figure("assets/X08_map_border.png", "図7: border_class 別 地理マッピング — A=緑(背景) / B=赤(両規模水没) / C=黄(ボーダー) / D=灰(異常)")}

<p><b>この図から読み取れること</b>:</p>
<ul>
<li><b>赤 (B 両規模で水没) のクラスタ</b>: 広島市デルタ ({border_B_n:,} 件のうち多数) と
福山市芦田川河口に強く集中。<b>河口三角州</b>の物理的特徴と一致。</li>
<li><b>黄 (C ボーダー) の地理パターン</b>: {border_C_n:,} 件のうち、河川中流部 (太田川中流・芦田川中流・江の川中流)
に分散して点在。これは <b>「計画規模の堤防では守られているが、想定最大規模 (1/1000 確率) では越水する」</b> という
中流域特有のリスク分布で、<b>「沿岸 = B / 中流 = C」</b>の地形仮説を視覚的に補強。</li>
<li><b>緑 (A 安全) の地理</b>: 内陸山間部 (北広島町・安芸太田町・神石高原町) は
ほぼ全点が緑 = <b>河川氾濫の影響圏外</b>という直感的事実が地図で確認できる。</li>
<li><b>D 異常 = {border_D_n} 件</b>: ほぼゼロで地図上では検出困難。
これは「想定最大規模 ⊃ 計画規模」という物理的包含関係が
4,065 点で <b>1 件も破られない</b>ことを意味する (= 判定実装の整合性チェック)。</li>
<li><b>沿岸/内陸/山間の 3 帯構造</b>: 南 (沿岸) ほど赤+黄が増え、北 (山間) ほど緑のみになる
<b>南北勾配</b>がはっきり見える。これは河川流域 + 標高勾配の合成で、防災計画の<b>地理ゾーニング</b>に直結。</li>
</ul>
"""))


# --- 9-C. 追加分析9: 5 災害種別フラグ × 浸水域立地ミスマッチ -------------
sections.append(("分析9: 5 災害種別フラグ × 浸水域立地のミスマッチ (制度 vs 地形)", f"""
<h3>狙い</h3>
<p>避難所 JSON には <b>5 種類の災害対応フラグ</b>がある (洪水/土砂/高潮/地震/津波)。
分析6 では <b>洪水フラグ × 浸水域内</b>のみ見たが、<b>「洪水フラグなし」かつ「浸水域内」</b>の避難所は
<b>制度上は洪水避難所ではないが、実は地形的に浸水域に立地する</b>潜在リスク群。
さらに <b>5 災害分</b>を一括比較すれば、フラグ運用の盲点が見える。</p>

<h3>なぜこの図か (要件H)</h3>
<p>図4 (フラグ × 立地 2×2 クロス) は洪水フラグだけの分析。
本節では <b>5 災害種別を横並び</b>で比較し、さらに <b>制度外なのに浸水内</b>の {n_mismatch:,} 件を
<b>地図にプロット</b>することで「制度上の指定」と「地形上のリスク」の乖離を可視化する。</p>

<h3>手法</h3>
<ul>
<li>各災害フラグ列について <code>flag==0 AND in_max==1</code> = 「ミスマッチ」を集計。</li>
<li>左パネル: 5 災害 × 2 系列 (フラグ無 × 浸水内 / フラグ有 × 浸水内) の棒グラフ。</li>
<li>右パネル: 洪水フラグ無 × 浸水域内 ({n_mismatch:,} 件) の (lat, lon) を <b>赤マーカー</b>で地図プロット。</li>
<li>抽出した {n_mismatch:,} 行を <code>X08_mismatch.csv</code> として保存 (要件A 中間データ DL)。</li>
</ul>

<h3>結果 (表と読み取り) — 5 災害種別フラグ集計</h3>
<h4>表6: 5 災害種別フラグ × 想定最大規模浸水域内立地</h4>
{mismatch_flag_html}

<p><b>この表から読み取れること</b>:</p>
<ul>
<li><b>洪水フラグ無 × 浸水内 = {n_mismatch:,} 件 ({n_mismatch/len(shel_df)*100:.2f}%)</b>。
これは「指定はないが地形的に水没する」<b>潜在リスク</b>。
浸水域内 ({n_in_max_total:,} 件) のうち <b>{mismatch_pct:.1f}%</b> がここに該当する。</li>
<li><b>地震フラグ無 × 浸水内</b>列は他の災害より小さい/大きいか比較すると、各災害指定の <b>立地戦略</b>の差が見える。
地震は地表震度の問題で河川氾濫域でも指定されるため、ミスマッチ件数は大きいことが多い (= 立地基準が異なる)。</li>
<li><b>津波フラグ × 浸水内</b>: 津波対応として指定されたが河川氾濫リスクも持つ避難所群。
2 災害分の同時被災 (台風 + 津波) シナリオでは脆弱。</li>
<li><b>政策的含意</b>: フラグ運用は災害種別ごとに独立だが、地形リスクは <b>同じ場所</b>で
複合する。<b>「単一フラグだけで指定 OK と判断する運用」</b>の限界が数字で示された。</li>
</ul>

<h4>表7: 洪水フラグ無 × 浸水域内 ミスマッチ 上位 15 件 (危険スコア降順)</h4>
{mismatch_html}

<p><b>この表から読み取れること</b>:</p>
<ul>
<li><b>1 位</b> = <b>{mismatch.iloc[0]['name']}</b> ({mismatch.iloc[0]['muni']}, 標高 {mismatch.iloc[0]['elev_m']:.0f} m)。
洪水対応フラグは無いが、border_class = <b>{mismatch.iloc[0]['border_class']}</b> で実は浸水域内。</li>
<li>15 件のうち <b>border_class = "B 両規模で水没"</b> の数は
<b>{int((mismatch.head(15)['border_class']=='B 両規模で水没').sum())} 件</b>、
"C ボーダー" は <b>{int(mismatch.head(15)['border_class'].str.startswith('C').sum())} 件</b>。
<b>制度上「洪水避難所ではない」</b>はずなのに常時危険な B 群が混じっているのが大問題。</li>
<li>他災害フラグ列 (土砂/高潮/地震/津波) を見ると、<b>地震フラグ有</b>のものが多数を占める傾向がある。
これは「地震時には開設するが、河川氾濫時は閉鎖する」という運用が <b>明示されていない</b>避難所群で、
住民は <b>「指定された避難所」</b>として認識している可能性が高い (= 認知ギャップ)。</li>
</ul>

<h3>結果 (図と読み取り)</h3>
{figure("assets/X08_mismatch.png", "図8: (a) 5 災害種別フラグ × 浸水内 件数 / (b) 洪水フラグ無 × 浸水内の地理分布")}

<p><b>この図から読み取れること</b>:</p>
<ul>
<li><b>左パネル</b>: 5 災害のうち、<b>洪水フラグ有 × 浸水内</b>と<b>洪水フラグ無 × 浸水内</b>の比率がほぼ
<b>{int(((shel_df['flood_flg']==1) & (shel_df['in_max']==1)).sum()):,} : {n_mismatch:,}</b>。
洪水対応指定の有無に関わらず、地形的には同等規模の浸水リスクが両群に存在する。</li>
<li><b>右パネル地図</b>: 洪水フラグ無 × 浸水内の {n_mismatch:,} 件は、
広島市中区/南区・福山市中心部・呉市沿岸・尾道市沿岸の <b>沿岸クラスタ</b>に強く集中。
これらは <b>「住民は地震・津波避難所として認識しているが、実は洪水で水没する」</b> 二重指定の盲点。</li>
<li><b>政策的含意</b>: 当該 {n_mismatch:,} 件には、防災マップ上に
<b>「洪水時は使用不可」</b>の明示が必要。フラグ列を増やすだけでなく、<b>除外フラグ</b>の運用も検討すべき。</li>
</ul>
"""))


# --- 9-D. 追加分析10: 収容力 × 危険度 バブル散布 -------------------------
sections.append(("分析10: 収容力 × 危険度 バブル散布 (市町別 — 大きいけど危ない避難所の特定)", f"""
<h3>狙い</h3>
<p>これまでの分析は「件数」中心。しかし政策的には <b>「何人が避難できなくなるか」</b>が本質。
本節では <b>capacity (収容人数)</b> を取り込み、<b>市町別に capacity × danger_score</b> を集計。
2 軸散布図で <b>「右上象限」</b> = <b>大きいけど危ない</b> 市町を特定する。</p>

<h3>なぜこの図か (要件H)</h3>
<table>
<tr><th>方式</th><th>長所</th><th>限界</th><th>判定</th></tr>
<tr><td>(A) 棒グラフ (件数のみ)</td><td>シンプル</td><td>容量を反映しない</td><td>図2 で既出</td></tr>
<tr><td>(B) 散布 (capacity × danger_score) ★</td><td>2 変数同時 + 回帰直線</td><td>市町数 = {len(cap_summary)} で点が混雑</td><td><b>採用</b></td></tr>
<tr><td>(C) 散布 (避難所 1 件 × danger)</td><td>4,065 点全部</td><td>市町判別不能</td><td>不採用</td></tr>
<tr><td>(D) 棒グラフ (リスク収容人数)</td><td>1 変数で順位付け</td><td>capacity と danger の関係が消える</td><td>表で代替</td></tr>
</table>

<h3>手法</h3>
<ul>
<li>capacity 欠損 (約 535/4,065 = 13%) を <b>中央値補完</b> ({cap_med:.0f} 人)。</li>
<li>市町別に <code>capacity_mean</code> と <code>danger_score_mean</code> を計算 → 2 軸散布。</li>
<li>バブル半径 = <code>√n_shelters × 18</code> (避難所件数で大きさ変化)。</li>
<li>バブル色 = <code>pct_in_max</code> (浸水域内率, Reds カラーマップ)。</li>
<li><b>単回帰直線</b>を <code>numpy.polyfit</code> で 1 次フィット。相関係数 r も併記。</li>
<li>追加指標 <code>risk_capacity = capacity_total × danger_score_mean</code> (= 「失われる収容人数の見込み」)
を <code>X08_capacity_summary.csv</code> として保存。</li>
</ul>

<h3>実装</h3>
""" + code(r'''
import numpy as np
import matplotlib.pyplot as plt

cap_med = shel_df["capacity"].median(skipna=True)
shel_df["capacity_filled"] = shel_df["capacity"].fillna(cap_med)

cap_summary = shel_df.groupby("muni").agg(
    n_shelters=("facility_id", "count"),
    capacity_total=("capacity_filled", "sum"),
    capacity_mean=("capacity_filled", "mean"),
    danger_score_mean=("danger_score", "mean"),
    n_in_max=("in_max", "sum"),
).reset_index()
cap_summary["pct_in_max"] = cap_summary["n_in_max"] / cap_summary["n_shelters"] * 100
cap_summary["risk_capacity"] = (
    cap_summary["capacity_total"] * cap_summary["danger_score_mean"]
)

# 単回帰
x = cap_summary["capacity_mean"].values
y = cap_summary["danger_score_mean"].values
slope, intercept = np.polyfit(x, y, 1)
r = np.corrcoef(x, y)[0, 1]
''') + f"""

<h3>結果 (表と読み取り)</h3>
<h4>表8: リスク収容人数 ランキング 上位 15 (capacity_total × 平均危険スコア)</h4>
{cap_html}

<p><b>この表から読み取れること</b>:</p>
<ul>
<li><b>1 位 = {risk_capacity_top['muni']}</b>: リスク収容人数 <b>{risk_capacity_top['risk_capacity']:,.0f}</b>。
これは「想定最大規模が起きたとき、収容人数で測れば最も多くの人が避難先を失う」市町。</li>
<li><b>件数ベース 1 位 (図2)</b> と <b>収容人数ベース 1 位 (本表)</b> が同じか別かで、
<b>「件数偏重 vs 容量偏重」</b>のどちらの政策視点を取るかで重点市町が変わる。</li>
<li><b>平均 capacity と 平均 danger_score の相関 r = {corr_cd:.3f}</b>。
{('正の相関 → 大きい避難所ほど危険な地域に置かれる傾向' if corr_cd > 0.2 else '負の相関 → 大きい避難所はむしろ安全な高台に置かれる傾向' if corr_cd < -0.2 else '相関は弱く、収容力と地形リスクは独立')}。
これは <b>立地計画の意図</b>を映す重要指標。</li>
<li><b>浸水域内率 50% 超</b>の市町が <b>{int((cap_summary['pct_in_max']>=50).sum())} 市町</b>。
そのうち capacity_total が大きい市町は <b>「広範囲を一度に失うリスク」</b>が最も高い。</li>
</ul>

<h3>結果 (図と読み取り)</h3>
{figure("assets/X08_capacity_danger.png", "図9: 収容力 × 危険度 バブル散布 (市町別) — 右上象限 = 大きいけど危ない")}

<p><b>この図から読み取れること</b>:</p>
<ul>
<li><b>右上象限</b> (平均 capacity 大 × 平均 danger_score 大): {len(cap_summary[(cap_summary['capacity_mean']>cap_summary['capacity_mean'].mean()) & (cap_summary['danger_score_mean']>cap_summary['danger_score_mean'].mean())])} 市町。
これらは <b>「1 避難所あたりの収容人数も多く、地形的にも危ない」</b> 最優先対策ゾーン。
バブルが大きい (=避難所件数も多い) 市町ほどリスクが累積する。</li>
<li><b>単回帰直線 (r={corr_cd:.3f})</b>: 傾き {('正' if slope > 0 else '負')} で
{('capacity が増えるほど danger_score も上がる傾向' if slope > 0 else 'capacity が増えるほど danger_score は下がる傾向')}。
{('「大きい避難所 = 平地立地 = 浸水域内」という都市計画上の制約が示唆される' if slope > 0 else '「大きい避難所は意図的に高台に配置」という政策が機能している')}。</li>
<li><b>赤いバブル</b> (浸水域内率高) は右下〜右上に集中する傾向 = <b>沿岸・平野部の市町</b>。
これらは図1〜7 で繰り返し赤色になっており、<b>収容力の観点でも一致して高リスク</b>。</li>
<li><b>左上象限</b> (capacity 小 × danger 大): 小規模避難所だが地形的に危ない群。
<b>件数ベースのリスト</b>では目立つが、<b>容量ベース</b>では脇役。
住民数が少なく代替が利きやすい場合は優先度を下げてよい (=資源配分の最適化指標)。</li>
</ul>
"""))


# --- 9-E. 追加分析11: 市町別 危険率ヒートマップ (地理) -------------------
sections.append(("分析11: 市町別 危険率ヒートマップ (地理) (危険率 高い市町を一目で)", f"""
<h3>狙い</h3>
<p>分析4 (図2) の市町別棒グラフは <b>件数</b> での順位だが、件数大 = 母集団大 のことが多い。
<b>占有率 (危険率 = n_in_max / n_shelters)</b> の方が <b>地形リスクの本質</b>を映す。
本節では <b>占有率を地図上の円の色</b>、<b>避難所件数を円の半径</b>として、
両指標を 1 図に圧縮する。</p>

<h3>なぜこの図か (要件H)</h3>
<table>
<tr><th>方式</th><th>長所</th><th>限界</th><th>判定</th></tr>
<tr><td>(A) 市町別 危険率 棒グラフ</td><td>順位明快</td><td>地理が消える</td><td>図2 で件数版あり</td></tr>
<tr><td>(B) 地理ヒートマップ ★</td><td>色=率, 半径=件数, 1 図で 2 指標</td><td>市町境界が出ない</td><td><b>採用</b></td></tr>
<tr><td>(C) コロプレス (市町ポリゴン色塗)</td><td>正確な市町境界</td><td>市町境界 Shapefile が必要</td><td>不採用 (本記事範囲外)</td></tr>
<tr><td>(D) 散布図 (件数 × 危険率)</td><td>2 軸明示</td><td>地理が消える</td><td>図9 で類似版あり</td></tr>
</table>

<h3>手法</h3>
<ul>
<li>市町ごとに <b>避難所重心</b>を計算 (lat 平均, lon 平均) — 市町境界の代理として使う。</li>
<li><b>円の半径</b> = √n_shelters × 30 (避難所件数で大きさ変化)。</li>
<li><b>円の色</b> = 危険率 risk_rate (Reds カラーマップ, 0〜30+%)。</li>
<li>上位 10 市町に名前 + 危険率 ラベルを白枠で重ねる。</li>
<li>背景に全 4,065 避難所を超薄い灰色で散布 → 地理感を補強。</li>
</ul>

<h3>結果 (表と読み取り)</h3>
<h4>表9: 市町別 危険率 ランキング 上位 15</h4>
{cityrisk_html}

<p><b>この表から読み取れること</b>:</p>
<ul>
<li><b>1 位 = {risk_rate_top_muni}</b> ({risk_rate_top_n} 件中 危険率 <b>{risk_rate_top_val:.1f}%</b>)。
{('小規模市町だが地形的に脆弱で、避難所の半数以上が浸水想定域内' if risk_rate_top_val >= 50 else '広島県内で最も危険率の高い行政単位')}。</li>
<li><b>危険率 50% 超</b>の市町 = <b>{n_city_above50} 市町</b>。
これらは <b>「自市町内では避難先が足りない」</b>市町で、近隣自治体との連携が必須。</li>
<li><b>危険率 30% 超</b> = <b>{n_city_above30} 市町</b>。30% は「3 件に 1 件水没」レベルで、
代替施設の整備が早急に必要なライン。</li>
<li><b>件数ベース 1 位 (図2)</b>と<b>危険率ベース 1 位 (本表)</b>が異なる場合、
件数偏重では母集団効果に引きずられる = <b>占有率の併記</b>が政策判断に必須であることが分かる。</li>
</ul>

<h3>結果 (図と読み取り)</h3>
{figure("assets/X08_city_heatmap.png", "図10: 市町別 危険率ヒートマップ (地理) — 円の色=危険率 / 円の半径=√避難所件数")}

<p><b>この図から読み取れること</b>:</p>
<ul>
<li><b>濃い赤の大円</b> = 件数も多く危険率も高い市町。<b>政策的最優先</b>市町群。
県南部の沿岸都市にこのパターンが集中。</li>
<li><b>濃い赤の小円</b> = 件数は少ないが危険率が極端に高い市町。<b>母集団効果ではなく地形リスクが本質</b>。
小規模町で標高が低い沿岸自治体に該当。</li>
<li><b>淡色の大円</b> = 件数は多いが危険率は中庸。広島市内陸区 (安佐南/安佐北/安芸) や東広島市など、
<b>件数の多さが危険率の低さに薄まる</b>パターン。</li>
<li><b>淡色の小円</b> = 内陸山間の小規模町。<b>地形的にも母集団的にも低リスク</b>で、
このゾーンの市町は災害支援の <b>受け入れ側</b>として機能できる可能性。</li>
<li><b>南北勾配</b>: 図7 (border_class 地図) と同様、南 (沿岸) ほど赤、北 (山間) ほど淡色という
<b>勾配構造</b>が円ベースでも明瞭に現れる。<b>市町粒度</b>でも <b>個別避難所粒度</b>でも同じ地理パターンが現れる
= 結果の頑健性。</li>
</ul>
"""))


# --- 10. 仮説検証と考察 ---------------------------------------------------
sections.append(("仮説検証と考察", f"""
<h3>仮説判定表</h3>
{verdict_html}

<h3>主要発見の物語化</h3>
<ol>
<li><b>「避難所自体が水没する」は実証された (H1 {('支持' if h1_supported else '反証')}, {pct_in_max:.1f}%)</b>。
広島県の避難所 4,065 件のうち <b>{n_in_max:,} 件 ({pct_in_max:.1f}%)</b> が想定最大規模浸水想定区域内に立地。
これは <b>{int(round(100/max(pct_in_max,0.01)))} 件に 1 件</b>の頻度で「避難所自体が水没する」ことを意味する。
学校や公民館は住宅地中心に置かれるため、<b>住宅地が浸水する地理</b>を反映した結果。</li>
<li><b>沿岸都市集中 (H2 {'支持' if h2_supported else '反証'}, 沿岸シェア {coastal_share:.1f}%)</b>。
{('上位 30 危険避難所のうち過半数が沿岸 5 市町に集中。河川氾濫はデルタ平野・河口部で最も広範囲になるため自然な結果。' if h2_supported else '沿岸/内陸が混在しており、沿岸集中の単純化は不正確。河川中流の三次市・庄原市にも危険避難所が分散している。')}</li>
<li><b>ボーダー避難所 = {n_border} 件 (H3 {('支持' if h3_supported else '反証')})</b>。
計画規模では安全だが想定最大規模で水没する避難所が <b>{n_border} 件</b>存在。
これらは <b>「常時閉鎖」ではなく「警報時切替」</b>という運用設計が必要。
2018 年西日本豪雨級の事象で初めて顕在化するリスク。</li>
<li><b>洪水対応フラグの整合性 (H4 {('支持' if h4_supported else '反証')})</b>。
{('フラグ有の方が浸水域内率が低く、行政の指定方針は機能している。ただし「フラグ有 × 浸水域内」'+str(int(ct.loc['洪水対応有', '浸水域内']))+' 件は運用上の盲点。' if h4_supported else 'フラグ有の方が浸水域内率が逆に高い ('+f'{flag_in_rate*100:.1f}% > {noflag_in_rate*100:.1f}%'+')。これは「洪水対応指定 = 洪水時に開設」が必ずしも「浸水しない安全な場所」を意味しないことを示す。指定の意味の再定義が必要。')}</li>
<li><b>避難所数と危険件数の相関 (H5 r={corr_h5:.3f}, {('支持' if h5_supported else '反証')})</b>。
{('避難所が多い市町ほど危険件数も多い = 母集団効果が支配。占有率では市町ランキングが大きく変わるため、<b>件数</b>と<b>占有率</b>を併記する必要がある。' if h5_supported else '避難所数と危険件数の単純な比例は成立しない。市町ごとに地形条件が大きく違い、件数では測れない地形リスクが存在。')}</li>
</ol>

<h3>方法論的限界 (この記事を「鵜呑みにしない」ためのチェック)</h3>
<ul>
<li><b>標高のシンボリック値</b>: 市町別代表値 (役所所在地付近) を全避難所に適用したため、
同一市町内の標高差が消えている。実標高では順位が変わる可能性。
本物の DEM データ (DoBoX #1278) を使えば 1 件ずつ精緻化できる (発展課題)。</li>
<li><b>浸水深の不在</b>: 本研究は <b>「浸水域内 / 外」の二値判定</b>のみ。
浸水深 0.5 m と 5.0 m の違いは無視。深さデータ (#1641 多段階浸水想定図) を重ね合わせると
リスクスコアの精度が上がる (発展課題)。</li>
<li><b>「全河川」集約版の限界</b>: 個別水系 37 件を独立に判定すれば <b>水系別の寄与</b>が見える。
本記事では集約版を使ったため <b>「どの河川が一番危険か」</b>は分からない。</li>
<li><b>Ray casting の境界点</b>: ポリゴン境界線上ぎりぎりの避難所は判定が不安定。
広島県内の避難所では境界からの距離が数 10m 以下のものは少なく、影響は無視できる。</li>
<li><b>洪水対応フラグの定義の曖昧さ</b>: floodShFlg = 1 は「洪水時に開設対象」だが、
これが <b>「立地が安全」</b>を意味するとは限らない。市町ごとに運用ルールが異なる可能性。</li>
</ul>

<h3>主要発見 (1 段落要約)</h3>
<p><b>広島県の避難所 4,065 件のうち {pct_in_max:.1f}% ({n_in_max:,} 件) が想定最大規模浸水想定区域内に立地し、
そのうち {n_border} 件は計画規模では安全な「ボーダー避難所」である。
危険避難所の上位 30 件は標高 30m 以下に集中し、{', '.join(danger.head(30)['muni'].value_counts().head(3).index.tolist())} の沿岸都市群に偏在する。
洪水対応フラグ有の浸水域内率は {flag_in_rate*100:.1f}% で、フラグ無 ({noflag_in_rate*100:.1f}%) より{('低く、指定方針は機能している' if h4_supported else '高く、指定の意味の再定義が必要')}。
χ² = {chi2_val:.1f} で {'統計的に有意' if chi2_val > 10.83 else '独立に近い'}。
「避難所自体が水没するか」という反直観的問いに、4,065 件の点 in ポリゴン判定で具体地名レベルの答えが出た。</b></p>
"""))


# --- 11. 発展課題 -----------------------------------------------------------
sections.append(("発展課題 (結果X → 新仮説Y → 課題Z)", f"""
<p>本レッスンの結果から、次の 5 つの発展課題が論理的に導かれる:</p>

<h3>課題1: 避難所 1 件ごとの実標高で危険スコアを精緻化</h3>
<ul>
<li><b>結果X</b>: 本記事の標高は市町別代表値 (シンボリック)。同一市町内で標高差は消えている。</li>
<li><b>新仮説Y</b>: 実標高 (1m メッシュ DEM) を取り込むと、<b>同一市町内でも危険スコア順位が大きく変わる</b>はず。
特に広島市は太田川デルタの中で標高 0〜10m の avi にあり、海田町は周辺山地に挟まれ実は 100m 級の高台に立地する避難所が混在する。</li>
<li><b>課題Z</b>: DoBoX #1278 (標高図) または #1351 (CS立体図) の DEM ラスタから、
4,065 避難所点ごとに <b>1 件 1 値</b>の標高を抽出。本記事の <code>elev_m</code> 列を置き換えて再ランキング。</li>
</ul>

<h3>課題2: 浸水深カテゴリを取り込んだリスクスコア</h3>
<ul>
<li><b>結果X</b>: 本記事は in/out の二値判定のみ。0.5m と 5m の違いを無視。</li>
<li><b>新仮説Y</b>: 浸水深 3m 以上の避難所は <b>「2 階建てでも水没」</b>レベルで質が違う。
危険スコアに浸水深を加えると <b>上位 30 の半数以上が入れ替わる</b>はず。</li>
<li><b>課題Z</b>: DoBoX #1641 (多段階の浸水想定図) のレベル別ポリゴンを順次重ね合わせ、
<b>各避難所の最深浸水深カテゴリ</b>を求める。<code>danger_score = (3階建避難可能か) × 深さ重み × 標高ペナルティ</code> として再定義する。</li>
</ul>

<h3>課題3: 容量加重リスク (= 何人が避難できなくなるか)</h3>
<ul>
<li><b>結果X</b>: 本記事は避難所「件数」のみ。<b>収容人数</b>は重み付けに使っていない。</li>
<li><b>新仮説Y</b>: 容量重み付けすると、<b>1,500 人収容の小学校 1 件 = 100 人収容の集会所 15 件</b>と等価になり、
市町ランキングが <b>件数版とは異なる順位</b>になる。
特に小学校が浸水する広島市・福山市の重みが上がるはず。</li>
<li><b>課題Z</b>: capacity 列 (欠損 535/4,065 = 13%) を中央値補完したうえで、
<code>市町別 リスク収容人数 = sum(capacity × in_max)</code> を計算。
県全体の「同時に失う収容キャパ」を 1 数字で出す。</li>
</ul>

<h3>課題4: 高潮浸水・津波浸水との合成リスク</h3>
<ul>
<li><b>結果X</b>: H2 (沿岸集中) は{('支持' if h2_supported else '反証')}されたが、これは <b>河川氾濫のみ</b>の結果。
沿岸都市は高潮・津波で別軸の脆弱性を持つ。</li>
<li><b>新仮説Y</b>: 沿岸都市の避難所は <b>高潮 + 河川氾濫 + 津波</b>を合成すると <b>3 重危険率 90% 超</b>になるはず。
内陸都市は河川氾濫 1 軸のみで沿岸より総リスクが低い。</li>
<li><b>課題Z</b>: DoBoX #43〜#45 (高潮), #46〜 (津波) の各想定区域 Shapefile を取得し、
本記事の点 in ポリゴン判定を <b>3 災害分</b>追加。<code>any_3_hazard_in</code> という新フラグで再ランキング。</li>
</ul>

<h3>課題5: 危険避難所からの「最近傍安全避難所」距離</h3>
<ul>
<li><b>結果X</b>: 本記事は危険避難所のリストアップで止まり、<b>代替先</b>は示していない。</li>
<li><b>新仮説Y</b>: 危険避難所の <b>5km 圏内に区分A 安全避難所</b>がある率は 90% 以上だが、
標高差 100m 以上の代替先は <b>50% 程度</b>に減るはず。
「歩いて行ける安全避難所」は意外と少ない。</li>
<li><b>課題Z</b>: 区分 A 避難所のみを KD-tree か BallTree に投入し、
危険避難所 1 件ごとに最近傍 k=5 の距離 + 標高差を計算。
ヒストグラム化して <b>「徒歩避難可能な代替先がない」</b>市町を抽出。</li>
</ul>
"""))


# === HTML 書き出し ===========================================================
data_label = (
    f"避難所 {len(shel_df):,} 件 (DoBoX #42 JSON) + 浸水想定 計画規模 "
    f"{len(flood_data['計画規模'])} ポリゴン + 想定最大規模 "
    f"{len(flood_data['想定最大規模'])} ポリゴン  (DoBoX 直 DL)"
)

html = render_lesson(
    num=80,
    title="X08: 河川浸水想定区域 × 避難所立地 — 「避難所自体が水没するか?」 点 in ポリゴン判定研究",
    tags=["X-tier", "点 in ポリゴン判定研究", "Ray casting", "bbox プリフィルタ",
          "避難所", "浸水想定区域", "危険避難所", "L01"],
    time="60-90分",
    level="リテラシレベル (L01)",
    data_label=data_label,
    sections=sections,
    script_filename="X08_flood_shelter_in_polygon.py",
)

# data-draft="1" data-stier="X" を <body> に挿入
html = html.replace(
    '<body><div class="container">',
    '<body data-draft="1" data-stier="X"><div class="container">',
)

out_html = LESSONS / "X08_flood_shelter_in_polygon.html"
out_html.write_text(html, encoding="utf-8")
print(f"\n→ {out_html}")
print("\n=== 主要発見 ===")
print(findings_text)
print("\n=== 完了 ===")
