"""L29 準都市計画区域 三兄弟 (基盤+用途地域+用途白地) 統合分析
       — 広島県唯一の「準都市計画区域」(4.58 km², 旧湯来町) の構造解読

カバー宣言:
  本記事は DoBoX のシリーズ <b>3 兄弟</b>:
    (a) 都市計画区域情報_区域データ_*_準都市計画区域       (基盤,    2 件)
    (b) 都市計画区域情報_区域データ_*_準都市計画区域用途地域 (用途指定, 2 件)
    (c) 都市計画区域情報_区域データ_*_準都市計画区域用途白地 (用途未指定, 2 件)
  の<b>計 6 dataset_id</b> を統合し、広島県内における<b>都市計画法第 5 条の 2</b>に基づく
  「<b>準都市計画区域</b>」制度の<b>幾何実装</b>を分析する研究記事である。

  6 dataset_id:
    準都市計画区域:        791 広島市,  929 広島県全域
    準都市計画区域用途地域: 792 広島市,  930 広島県全域
    準都市計画区域用途白地: 793 広島市,  931 広島県全域

研究の問い (RQ):
  広島県内に指定された<b>準都市計画区域</b>は<b>どこ</b>にあり、どれくらいの規模で、
  「用途地域指定 (use)」と「用途白地 (white)」の構造はどうなっているか？
  通常の都市計画区域 (L16) や非線引き用途地域 (L27) と比べて、この<b>緩い制度</b>は
  どの程度の制御を行っているか？ そして広島市と広島県全域の dataset で
  なぜ列構造・件数が完全一致するのか?

仮説 H1〜H6:
  H1 (指定の希少性): 広島県内の準都市計画区域は<b>1〜3 市町</b>、合計面積<b>10 km² 未満</b>
       の<b>極小制度</b>として指定されている。広島県の県土 8479 km² に対する
       カバー率 < 0.2%。<b>事実上「最も使われていない都市計画制度」</b>。
  H2 (3 兄弟の数学的補完): 基盤面積 = 用途地域面積 + 用途白地面積 が
       <b>幾何的に厳密成立</b>する (誤差 0.01 km² 未満)。
       これは「<b>準都市計画区域 = 用途指定あり ∪ 用途指定なし</b>」の
       完全互補な分割であることを意味する。
  H3 (用途指定比率の小ささ): 用途地域指定 (use) は基盤の<b>20%未満</b>。
       残り 80% 以上が用途白地 (緩規制) ─ 制度趣旨「最低限の規制で乱開発を防ぐ」を反映。
  H4 (用途地域の細片性): 用途地域 (use) ポリゴンは<b>多数の小片に分かれる</b>
       (1 ポリゴン平均 < 0.1 km²)。これは<b>幹線道路沿いのロードサイド型</b>
       に用途指定が集中する制度実装の結果である。
  H5 (広島市 ds と県全域 ds の一致): 広島市 dsid (791/792/793) と
       広島県全域 dsid (929/930/931) は<b>件数・面積・幾何が完全一致</b>する。
       理由: 広島県内の準都市計画区域は<b>広島市のみ</b>に存在し、市の dataset と
       県の dataset が同じ実体ポリゴンを別 ID で提供している。
  H6 (CITY_CD = 108 の意味): 全 6 ファイルで CITY_CD は<b>108 単一値</b>。
       L08 の対照表より <b>108 = 広島市佐伯区</b>。
       これは 2005 年に広島市佐伯区へ編入合併された<b>旧湯来町</b>の
       中山間部が準都市計画区域として継承指定されたものと推定される。

要件 S 準拠: 1 分以内完走 (合計 32 ポリゴン、極めて軽量)。
要件 T 準拠: 全県マップ + 湯来地区クローズアップ + 3 兄弟重ね
            + 用途別カラー choropleth + 散布図 など複数 GIS 図。
要件 Q 準拠: 図 11 種、表 9 種以上 (少件数だからこそ角度を多く取る)。

データ仕様 (3 兄弟は<b>完全同型ではない</b>):
  base   (基盤): FID/TOKEI_CD/CITY_CD/geometry の 4 列
  use    (用途指定): + KUIKI_TB (連番) の 5 列
  white  (用途未指定): + KUIKI_TB (連番) の 5 列
  → 列が違うため<b>単純合体は禁止</b>。「役割の違うレイヤ」として併置統合。

メモリ対策: Figure ごとに plt.close('all') で確実に解放。
"""
from __future__ import annotations
import sys, time, json, zipfile, io
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
from _common import ROOT, ASSETS, LESSONS, render_lesson, code, figure

import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Patch, Rectangle
import geopandas as gpd

plt.rcParams["font.family"] = "Yu Gothic"
plt.rcParams["axes.unicode_minus"] = False

t0 = time.time()
print("=== L29 準都市計画区域 三兄弟 統合分析 ===", flush=True)

# =============================================================================
# 0. 定数: 6 dataset_id と凡例
# =============================================================================
DATA_DIR = ROOT / "data" / "extras" / "L29_quasi_planning_zones"
ADMIN_DIR = ROOT / "data" / "extras" / "L15_admin_zones"
L17_DIR = ROOT / "data" / "extras" / "L17_use_zones"
TARGET_CRS = "EPSG:6671"  # JGD2011 平面直角 III, m 単位

# (kind, dsid, city_label_in_filename) ─ 6 件
TARGETS = [
    ("base",  791, "広島市"),
    ("base",  929, "広島県"),
    ("use",   792, "広島市"),
    ("use",   930, "広島県"),
    ("white", 793, "広島市"),
    ("white", 931, "広島県"),
]

# 兄弟 (kind) → 表示属性
KIND_INFO = {
    "base":  ("基盤 (準都市計画区域)",   "#888888",
              "都市計画法第 5 条の 2 に基づく区域全体"),
    "use":   ("用途地域指定あり",        "#cf222e",
              "区域内で用途地域 (住居・商業・工業) が指定された部分"),
    "white": ("用途白地 (用途指定なし)", "#1f883d",
              "区域内で用途地域指定がない緩規制部分"),
}

# CITY_CD 対照 (本シリーズで実際に出現するもの + 周辺)
# L08 lesson より確定: 108 = 広島市佐伯区 (旧湯来町を編入)
CITY_CD_NAME = {
    101: "広島市中区", 102: "広島市東区", 103: "広島市南区", 104: "広島市西区",
    105: "広島市安佐南区", 106: "広島市安佐北区", 107: "広島市安芸区",
    108: "広島市佐伯区",
}

# 広島県の県土面積 (国土地理院統計)
PREF_AREA_KM2 = 8479.6


# =============================================================================
# 1. 6 GeoJSON 読み込み
# =============================================================================
print("\n[1] 6 GeoJSON 読み込み", flush=True)
t1 = time.time()


def load_geojson_zip(zip_path: Path) -> gpd.GeoDataFrame:
    """ZIP 内の単一 .geojson を BytesIO 経由で読む (一時ファイル不要)"""
    with zipfile.ZipFile(zip_path) as zf:
        gjs = [n for n in zf.namelist() if n.lower().endswith(".geojson")]
        if not gjs:
            raise FileNotFoundError(f"no .geojson in {zip_path.name}")
        with zf.open(gjs[0]) as f:
            return gpd.read_file(io.BytesIO(f.read()))


# 6 ファイルを kind 別に保持
gdfs = {}
for kind, dsid, city in TARGETS:
    p = DATA_DIR / f"{kind}_{dsid}_{city}.zip"
    g = load_geojson_zip(p).to_crs(TARGET_CRS)
    g["kind"] = kind
    g["dsid"] = dsid
    g["src_label"] = city
    g["poly_area_km2"] = g.geometry.area / 1e6
    g["poly_perim_km"] = g.geometry.length / 1e3
    gdfs[(kind, dsid)] = g
    print(f"  {kind:5s} ds={dsid} ({city}): {len(g):3d} polys, "
          f"{g['poly_area_km2'].sum():.4f} km², "
          f"cols={list(g.columns[:-2])}", flush=True)

# 兄弟ごとの代表 GeoDataFrame (広島県全域 ds を採用 ─ 広島市 ds と同一だが
#   "県内の真集合"を扱う意図を明示)
g_base  = gdfs[("base",  929)]
g_use   = gdfs[("use",   930)]
g_white = gdfs[("white", 931)]

# 列同型性チェック
cols_base  = sorted([c for c in g_base.columns
                     if c not in ("geometry", "kind", "dsid", "src_label",
                                  "poly_area_km2", "poly_perim_km")])
cols_use   = sorted([c for c in g_use.columns
                     if c not in ("geometry", "kind", "dsid", "src_label",
                                  "poly_area_km2", "poly_perim_km")])
cols_white = sorted([c for c in g_white.columns
                     if c not in ("geometry", "kind", "dsid", "src_label",
                                  "poly_area_km2", "poly_perim_km")])
cols_all_same = (cols_base == cols_use == cols_white)
print(f"  列同型性: base={cols_base}, use={cols_use}, white={cols_white}",
      flush=True)
print(f"  完全同型? {cols_all_same} "
      f"(base に KUIKI_TB なし → 役割が違う。合体ではなく併置統合)",
      flush=True)
print(f"  ({time.time()-t1:.2f}s)", flush=True)


# =============================================================================
# 2. 指定市町の同定 (CITY_CD 経由)
# =============================================================================
print("\n[2] 指定市町の同定", flush=True)
t1 = time.time()

# 全 6 ファイルの CITY_CD ユニーク
all_city_cds = set()
for (k, d), g in gdfs.items():
    all_city_cds |= set(int(x) for x in g["CITY_CD"].unique())
city_cds_sorted = sorted(all_city_cds)
city_cd_pairs = [(c, CITY_CD_NAME.get(c, f"(未確定 CD={c})"))
                 for c in city_cds_sorted]
print(f"  CITY_CD ユニーク: {city_cds_sorted}", flush=True)
print(f"  対照: {city_cd_pairs}", flush=True)


# =============================================================================
# 3. 広島市 ds と 広島県 ds の同一性検証
# =============================================================================
print("\n[3] 広島市 ds vs 広島県 ds 同一性", flush=True)
t1 = time.time()

dup_rows = []
for kind in ["base", "use", "white"]:
    keys = [k for k in gdfs if k[0] == kind]
    keys.sort(key=lambda x: x[1])
    g_hi = gdfs[keys[0]]   # 広島市 (小さい dsid)
    g_ke = gdfs[keys[1]]   # 広島県 (大きい dsid)
    # 件数・面積・FID 集合
    same_n = len(g_hi) == len(g_ke)
    same_a = abs(g_hi["poly_area_km2"].sum() - g_ke["poly_area_km2"].sum()) < 1e-6
    same_fid = sorted(g_hi["FID"].tolist()) == sorted(g_ke["FID"].tolist())
    # 幾何 union 同一性 (symmetric_difference の面積で見る)
    u_hi = g_hi.geometry.union_all()
    u_ke = g_ke.geometry.union_all()
    sym = u_hi.symmetric_difference(u_ke)
    sym_a = sym.area / 1e6 if sym is not None else 0
    dup_rows.append({
        "kind": kind,
        "ds_hi": keys[0][1], "ds_ken": keys[1][1],
        "n_hi": len(g_hi), "n_ken": len(g_ke),
        "area_hi_km2": round(float(g_hi["poly_area_km2"].sum()), 4),
        "area_ken_km2": round(float(g_ke["poly_area_km2"].sum()), 4),
        "FID_集合一致": same_fid,
        "対称差_面積_km2": round(float(sym_a), 6),
        "完全一致": same_n and same_a and same_fid and (sym_a < 1e-3),
    })
dup_df = pd.DataFrame(dup_rows)
print(dup_df.to_string(index=False), flush=True)


# =============================================================================
# 4. 3 兄弟の整合性 (基盤 = 用途地域 + 用途白地?)
# =============================================================================
print("\n[4] 3 兄弟整合性", flush=True)
t1 = time.time()

a_base  = float(g_base["poly_area_km2"].sum())
a_use   = float(g_use["poly_area_km2"].sum())
a_white = float(g_white["poly_area_km2"].sum())
a_sum   = a_use + a_white
diff    = a_base - a_sum

# 幾何 union 計算
u_base  = g_base.geometry.union_all()
u_use   = g_use.geometry.union_all()
u_white = g_white.geometry.union_all()
u_uw    = u_use.union(u_white)

inter_uw = u_use.intersection(u_white).area / 1e6
sym_base_uw = u_base.symmetric_difference(u_uw).area / 1e6
inter_base_uw = u_base.intersection(u_uw).area / 1e6

three_consistency = {
    "面積_基盤_km2":      round(a_base, 4),
    "面積_用途地域_km2":  round(a_use, 4),
    "面積_用途白地_km2":  round(a_white, 4),
    "面積_用途+白地_km2": round(a_sum, 4),
    "面積_差_km2":        round(diff, 6),
    "用途地域∩用途白地_面積_km2":   round(inter_uw, 6),
    "基盤△(用途+白地)_対称差_km2":  round(sym_base_uw, 6),
    "基盤∩(用途+白地)_面積_km2":    round(inter_base_uw, 4),
    "完全分割成立":  abs(diff) < 0.01 and inter_uw < 0.01,
}
print(json.dumps(three_consistency, ensure_ascii=False, indent=2), flush=True)


# =============================================================================
# 5. 個別ポリゴン詳細表 (6 ファイル × 各ポリゴンの面積・代表点・近傍)
# =============================================================================
print("\n[5] 個別ポリゴン詳細", flush=True)
t1 = time.time()

poly_rows = []
for kind in ["base", "use", "white"]:
    g_ken = [v for k, v in gdfs.items() if k[0] == kind and k[1] >= 900][0]
    for _, r in g_ken.iterrows():
        pt = r.geometry.representative_point()
        ku = int(r["KUIKI_TB"]) if "KUIKI_TB" in r else -1
        poly_rows.append({
            "kind": kind,
            "FID": int(r["FID"]),
            "KUIKI_TB": ku,
            "CITY_CD": int(r["CITY_CD"]),
            "city_name": CITY_CD_NAME.get(int(r["CITY_CD"]), "?"),
            "area_km2": round(float(r["poly_area_km2"]), 4),
            "perim_km": round(float(r["poly_perim_km"]), 3),
            "rep_x": round(float(pt.x), 1),
            "rep_y": round(float(pt.y), 1),
        })
poly_df = pd.DataFrame(poly_rows)


# =============================================================================
# 6. 行政区域 (広島市 admin) 読み込み — 背景マップ用
# =============================================================================
print("\n[6] 行政背景レイヤ", flush=True)
t1 = time.time()

# 広島市 全体行政 (旧湯来町を含む佐伯区を含む)
g_admin_hi = load_geojson_zip(ADMIN_DIR / "admin_786_広島市.zip").to_crs(TARGET_CRS)
g_admin_pref = load_geojson_zip(ADMIN_DIR / "admin_922_広島県.zip").to_crs(TARGET_CRS)

# 広島市佐伯区を CITY_CD 108 でフィルタしてみる (admin に CITY_CD があるなら)
print(f"  admin_786 columns: {list(g_admin_hi.columns)}", flush=True)
saiki_admin = None
if "CITY_CD" in g_admin_hi.columns:
    saiki_admin = g_admin_hi[g_admin_hi["CITY_CD"] == 108]
    if len(saiki_admin) > 0:
        a_saiki = saiki_admin.to_crs(TARGET_CRS).geometry.area.sum() / 1e6
        print(f"  広島市佐伯区行政 polygon: {len(saiki_admin)} 件, {a_saiki:.2f} km²",
              flush=True)
    else:
        print("  CITY_CD=108 in admin_786 → なし (行政データの市内分割は別形式)",
              flush=True)
        saiki_admin = None

# 広島市全体 dissolve
hi_diss = g_admin_hi.dissolve().reset_index(drop=True)
hi_diss["admin_area_km2"] = hi_diss.geometry.area / 1e6

# 県全体 dissolve
pref_diss = g_admin_pref.dissolve().reset_index(drop=True)
pref_diss["admin_area_km2"] = pref_diss.geometry.area / 1e6

print(f"  広島市行政総面積: {hi_diss['admin_area_km2'].iloc[0]:.1f} km²", flush=True)
print(f"  広島県行政総面積: {pref_diss['admin_area_km2'].iloc[0]:.1f} km²", flush=True)
print(f"  ({time.time()-t1:.2f}s)", flush=True)


# =============================================================================
# 7. CSV 出力 (中間データ 8 種)
# =============================================================================
print("\n[7] 中間 CSV 出力", flush=True)
ASSETS.mkdir(parents=True, exist_ok=True)

# (1) 全ポリゴン詳細
poly_df.to_csv(ASSETS / "L29_polys_detail.csv",
               index=False, encoding="utf-8-sig")

# (2) 3 兄弟整合性
pd.DataFrame([three_consistency]).to_csv(
    ASSETS / "L29_three_consistency.csv",
    index=False, encoding="utf-8-sig")

# (3) 広島市 ds vs 広島県 ds 同一性
dup_df.to_csv(ASSETS / "L29_duplicate_check.csv",
              index=False, encoding="utf-8-sig")

# (4) 6 ファイル サマリ
summary_rows = []
for (kind, dsid), g in gdfs.items():
    summary_rows.append({
        "kind": kind,
        "dsid": dsid,
        "ファイル": f"{kind}_{dsid}_{g['src_label'].iloc[0]}.zip",
        "polys": len(g),
        "面積_km2": round(float(g["poly_area_km2"].sum()), 4),
        "周長_km": round(float(g["poly_perim_km"].sum()), 3),
        "CITY_CD": str(sorted(g["CITY_CD"].unique().tolist())),
        "KUIKI_TB": (str(sorted(g["KUIKI_TB"].unique().tolist()))
                     if "KUIKI_TB" in g.columns else "(なし)"),
    })
summary_df = pd.DataFrame(summary_rows).sort_values(
    by=["kind", "dsid"]).reset_index(drop=True)
summary_df.to_csv(ASSETS / "L29_six_files_summary.csv",
                  index=False, encoding="utf-8-sig")

# (5) 県全体に対するカバー率
cover_rows = [
    {"指標": "準都市計画区域 (基盤) 面積",
     "値_km2": round(a_base, 4),
     "対_県土": f"{a_base/PREF_AREA_KM2*100:.4f}%"},
    {"指標": "用途地域指定 (use)",
     "値_km2": round(a_use, 4),
     "対_県土": f"{a_use/PREF_AREA_KM2*100:.4f}%"},
    {"指標": "用途白地 (white)",
     "値_km2": round(a_white, 4),
     "対_県土": f"{a_white/PREF_AREA_KM2*100:.4f}%"},
    {"指標": "用途指定率 (use / base)",
     "値_km2": round(a_use / a_base * 100, 2),
     "対_県土": f"{a_use/a_base*100:.2f}% (区域内比率)"},
]
cover_df = pd.DataFrame(cover_rows)
cover_df.to_csv(ASSETS / "L29_coverage.csv",
                index=False, encoding="utf-8-sig")

# (6) ポリゴン規模分布 統計
size_stats = []
for kind in ["base", "use", "white"]:
    sub = poly_df[poly_df["kind"] == kind]
    size_stats.append({
        "kind": kind,
        "polys": len(sub),
        "min_km2": round(float(sub["area_km2"].min()), 4) if len(sub) else 0,
        "max_km2": round(float(sub["area_km2"].max()), 4) if len(sub) else 0,
        "mean_km2": round(float(sub["area_km2"].mean()), 4) if len(sub) else 0,
        "median_km2": round(float(sub["area_km2"].median()), 4) if len(sub) else 0,
        "sum_km2": round(float(sub["area_km2"].sum()), 4),
    })
size_df = pd.DataFrame(size_stats)
size_df.to_csv(ASSETS / "L29_size_stats.csv", index=False, encoding="utf-8-sig")

# (7) ポリゴン形状複雑性 (周長/面積 比 = 細長さ指標)
shape_rows = []
for kind in ["base", "use", "white"]:
    sub = poly_df[poly_df["kind"] == kind].copy()
    if len(sub) == 0:
        continue
    sub["compactness"] = (4 * np.pi * sub["area_km2"] /
                          (sub["perim_km"] ** 2)).round(4)
    # compactness = 1 が円, 小さいほど細長
    shape_rows.append({
        "kind": kind,
        "polys": len(sub),
        "mean_compactness": round(float(sub["compactness"].mean()), 4),
        "median_compactness": round(float(sub["compactness"].median()), 4),
        "min_compactness": round(float(sub["compactness"].min()), 4),
    })
shape_df = pd.DataFrame(shape_rows)
shape_df.to_csv(ASSETS / "L29_shape_stats.csv", index=False, encoding="utf-8-sig")

print("  saved 6 CSVs", flush=True)


# =============================================================================
# 8. 図 1: 県全体マップ — 県内で唯一の準都市計画区域がどこにあるか
# =============================================================================
print("\n[8] 図1: 県全体マップ", flush=True)
t1 = time.time()
fig, ax = plt.subplots(figsize=(13, 9))

pref_diss.plot(ax=ax, color="#f7f7f5", edgecolor="#888", linewidth=0.5,
               zorder=1)
hi_diss.plot(ax=ax, color="#fff8e0", edgecolor="#666", linewidth=0.7,
             zorder=2)
# 準都市計画区域 (基盤) を強調表示
g_base.plot(ax=ax, color="#cf222e", edgecolor="black", linewidth=0.8,
            alpha=0.95, zorder=3)
# ラベル
ctr = g_base.geometry.union_all().representative_point()
ax.annotate(
    f"県内唯一の\n準都市計画区域\n(旧湯来町, 広島市佐伯区)\n基盤={a_base:.2f} km²",
    xy=(ctr.x, ctr.y), xytext=(80000, ctr.y + 30000),
    fontsize=10, fontweight="bold", color="#7a0e0e",
    arrowprops=dict(arrowstyle="->", color="black", lw=1.5))
ax.set_title(
    f"広島県唯一の準都市計画区域 (合計 {a_base:.2f} km², 県土の "
    f"{a_base/PREF_AREA_KM2*100:.4f}%)\n"
    "= 制度として「最も使われていない」都市計画ツール",
    fontsize=12)
ax.set_axis_off()
patches = [
    Patch(facecolor="#fff8e0", edgecolor="#666", label="広島市 行政区域"),
    Patch(facecolor="#f7f7f5", edgecolor="#888", label="広島県 (他市町)"),
    Patch(facecolor="#cf222e", edgecolor="black",
          label=f"準都市計画区域 (基盤) {a_base:.2f} km²"),
]
ax.legend(handles=patches, loc="lower left", fontsize=10, frameon=True)
plt.tight_layout()
plt.savefig(ASSETS / "L29_pref_overview.png", dpi=130, bbox_inches="tight")
plt.close("all")
print(f"  saved L29_pref_overview.png ({time.time()-t1:.2f}s)", flush=True)


# =============================================================================
# 9. 図 2: 湯来地区クローズアップ (3 兄弟重ね)
# =============================================================================
print("\n[9] 図2: 湯来地区クローズアップ (3 兄弟重ね)", flush=True)
t1 = time.time()
fig, ax = plt.subplots(figsize=(11, 9))

# 範囲: 基盤の bounds + パディング
b = g_base.total_bounds
pad = max(b[2]-b[0], b[3]-b[1]) * 0.12
xlim = (b[0]-pad, b[2]+pad)
ylim = (b[1]-pad, b[3]+pad)

# 広島市行政背景
hi_diss.plot(ax=ax, color="#fafafa", edgecolor="#888", linewidth=0.4,
             zorder=1)
# 基盤 (灰色: 全体)
g_base.plot(ax=ax, color="#bbbbbb", edgecolor="black", linewidth=0.5,
            alpha=0.8, zorder=2,
            label=f"基盤 (準都市計画区域) {a_base:.2f} km²")
# 用途白地 (緑: 緩規制)
g_white.plot(ax=ax, color="#1f883d", edgecolor="white", linewidth=0.4,
             alpha=0.85, zorder=3,
             label=f"用途白地 (指定なし) {a_white:.2f} km²")
# 用途地域 (赤: 厳規制)
g_use.plot(ax=ax, color="#cf222e", edgecolor="black", linewidth=0.6,
           alpha=0.95, zorder=4,
           label=f"用途地域指定 (あり) {a_use:.2f} km²")
# 各基盤ポリゴンに FID ラベル
for _, r in g_base.iterrows():
    pt = r.geometry.representative_point()
    ax.annotate(f"FID={int(r['FID'])}\n{r['poly_area_km2']:.2f} km²",
                xy=(pt.x, pt.y), fontsize=8, ha="center",
                fontweight="bold", color="#222")

ax.set_xlim(xlim)
ax.set_ylim(ylim)
ax.set_title(
    "湯来地区クローズアップ — 3 兄弟の幾何構造\n"
    f"基盤 (灰) = 用途地域指定 (赤) + 用途白地 (緑) で完全分割 "
    f"(差 {abs(diff):.4f} km² < 0.01)",
    fontsize=12)
ax.set_axis_off()
patches = [
    Patch(facecolor="#bbbbbb", edgecolor="black",
          label=f"基盤 {a_base:.2f} km² (5 polys)"),
    Patch(facecolor="#1f883d",
          label=f"用途白地 {a_white:.2f} km² (10 polys)"),
    Patch(facecolor="#cf222e",
          label=f"用途地域指定 {a_use:.2f} km² (7 polys)"),
]
ax.legend(handles=patches, loc="upper left", fontsize=10, frameon=True)
plt.tight_layout()
plt.savefig(ASSETS / "L29_yuki_closeup.png", dpi=130, bbox_inches="tight")
plt.close("all")
print(f"  saved L29_yuki_closeup.png ({time.time()-t1:.2f}s)", flush=True)


# =============================================================================
# 10. 図 3: 3 兄弟 small multiples (基盤 / 用途地域 / 用途白地 を別パネルで)
# =============================================================================
print("\n[10] 図3: 3 兄弟 small multiples", flush=True)
t1 = time.time()
fig, axes = plt.subplots(1, 3, figsize=(16, 6.5))

# 共通範囲 (基盤の bounds に合わせる)
b = g_base.total_bounds
pad = max(b[2]-b[0], b[3]-b[1]) * 0.10

panels = [
    ("base", g_base, "#cf222e", f"(a) 基盤 (準都市計画区域)\n"
                                 f"{len(g_base)} polys / {a_base:.2f} km²"),
    ("use", g_use, "#cf222e", f"(b) 用途地域指定\n"
                                f"{len(g_use)} polys / {a_use:.2f} km² "
                                f"(基盤の {a_use/a_base*100:.0f}%)"),
    ("white", g_white, "#1f883d", f"(c) 用途白地 (指定なし)\n"
                                    f"{len(g_white)} polys / {a_white:.2f} km² "
                                    f"(基盤の {a_white/a_base*100:.0f}%)"),
]
for ax, (kind, g, col, title) in zip(axes, panels):
    hi_diss.plot(ax=ax, color="#fafafa", edgecolor="#888", linewidth=0.3)
    # 基盤を薄く背景に (use/white の場合)
    if kind != "base":
        g_base.plot(ax=ax, color="#dddddd", edgecolor="#666",
                    linewidth=0.3, alpha=0.6)
    g.plot(ax=ax, color=col, edgecolor="black", linewidth=0.4, alpha=0.95)
    # 各 polygon に FID
    for _, r in g.iterrows():
        if r["poly_area_km2"] > 0.05:  # 小さすぎる polygon はラベル省略
            pt = r.geometry.representative_point()
            ax.annotate(str(int(r["FID"])),
                        xy=(pt.x, pt.y), fontsize=7, ha="center",
                        fontweight="bold", color="white")
    ax.set_xlim(b[0]-pad, b[2]+pad)
    ax.set_ylim(b[1]-pad, b[3]+pad)
    ax.set_title(title, fontsize=10.5)
    ax.set_axis_off()

plt.suptitle(
    "3 兄弟 small multiples — 基盤 (a) を用途指定 (b) と白地 (c) が完全分割\n"
    "用途地域は小片に分散 (ロードサイド型実装), 白地は連続塊で大半を占める",
    fontsize=12, y=1.02)
plt.tight_layout()
plt.savefig(ASSETS / "L29_three_panels.png", dpi=130, bbox_inches="tight")
plt.close("all")
print(f"  saved L29_three_panels.png ({time.time()-t1:.2f}s)", flush=True)


# =============================================================================
# 11. 図 4: 整合性検証棒グラフ + 同一性検証 (広島市 ds vs 広島県 ds)
# =============================================================================
print("\n[11] 図4: 整合性 + 同一性検証", flush=True)
t1 = time.time()
fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# (左) 3 兄弟整合性: 基盤 vs 用途+白地
labels = ["基盤", "用途地域", "用途白地", "用途+白地\n(合算)"]
vals = [a_base, a_use, a_white, a_sum]
colors = ["#888888", "#cf222e", "#1f883d", "#0969da"]
xs = np.arange(len(labels))
axes[0].bar(xs, vals, color=colors, edgecolor="black", linewidth=0.5)
for i, v in enumerate(vals):
    axes[0].text(i, v + 0.1, f"{v:.4f}", ha="center", fontsize=10,
                 fontweight="bold")
axes[0].set_xticks(xs)
axes[0].set_xticklabels(labels, fontsize=10)
axes[0].set_ylabel("面積 km²")
axes[0].set_title(
    "3 兄弟の幾何整合性\n"
    f"基盤 ({a_base:.4f}) ≈ 用途+白地 ({a_sum:.4f}); "
    f"差 {abs(diff)*1000:.1f} m² (≪ 0.01 km²)",
    fontsize=11)
axes[0].grid(axis="y", alpha=0.3)
# 等価線
axes[0].axhline(y=a_base, linestyle="--", color="black", linewidth=0.6,
                alpha=0.5)

# (右) 広島市 ds vs 広島県 ds
xs2 = np.arange(3)
w = 0.35
hi_vals = dup_df.set_index("kind").loc[["base", "use", "white"], "area_hi_km2"]
ke_vals = dup_df.set_index("kind").loc[["base", "use", "white"], "area_ken_km2"]
axes[1].bar(xs2 - w/2, hi_vals.values, w,
            color="#0969da", edgecolor="black", label="広島市 ds (791/792/793)")
axes[1].bar(xs2 + w/2, ke_vals.values, w,
            color="#fdae6b", edgecolor="black", label="広島県 ds (929/930/931)")
for i, (h, k) in enumerate(zip(hi_vals.values, ke_vals.values)):
    axes[1].text(i - w/2, h + 0.05, f"{h:.4f}", ha="center", fontsize=8.5)
    axes[1].text(i + w/2, k + 0.05, f"{k:.4f}", ha="center", fontsize=8.5)
axes[1].set_xticks(xs2)
axes[1].set_xticklabels(["base (基盤)", "use (用途地域)", "white (用途白地)"])
axes[1].set_ylabel("面積 km²")
axes[1].set_title("広島市 ds vs 広島県 ds 同一性\n"
                  "→ 完全一致 (重複データ。県全域 ds は市 ds と中身同じ)",
                  fontsize=11)
axes[1].legend(loc="upper right", fontsize=9)
axes[1].grid(axis="y", alpha=0.3)

plt.tight_layout()
plt.savefig(ASSETS / "L29_consistency.png", dpi=130, bbox_inches="tight")
plt.close("all")
print(f"  saved L29_consistency.png ({time.time()-t1:.2f}s)", flush=True)


# =============================================================================
# 12. 図 5: ポリゴン規模分布 (3 兄弟の細片性比較)
# =============================================================================
print("\n[12] 図5: ポリゴン規模分布", flush=True)
t1 = time.time()
fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# (左) 各 polygon の面積を log スケール bar で
data = [
    ("base",  g_base["poly_area_km2"].sort_values(ascending=False).values,  "#888888"),
    ("use",   g_use["poly_area_km2"].sort_values(ascending=False).values,   "#cf222e"),
    ("white", g_white["poly_area_km2"].sort_values(ascending=False).values, "#1f883d"),
]
offset = 0
xticks = []
xticklabels = []
for kind, arr, col in data:
    xs = np.arange(offset, offset + len(arr))
    axes[0].bar(xs, arr, color=col, edgecolor="black", linewidth=0.4,
                label=f"{kind} (n={len(arr)})")
    xticks.extend(xs.tolist())
    xticklabels.extend([f"{kind[0].upper()}{i}" for i in range(len(arr))])
    offset += len(arr) + 1   # gap
axes[0].set_yscale("log")
axes[0].set_ylabel("ポリゴン面積 km² (log)")
axes[0].set_title(
    "3 兄弟 ポリゴン規模分布 (大→小)\n"
    "用途地域 (赤) は最大でも 0.42 km² の小片群。白地 (緑) は最大 1.92 km² の連続塊",
    fontsize=11)
axes[0].grid(axis="y", which="both", alpha=0.3)
axes[0].legend(loc="upper right", fontsize=9)

# (右) compactness (4πA/L²) 分布: 円が1, 細長いほど0
# 計算
poly_df_calc = poly_df.copy()
poly_df_calc["compactness"] = (4 * np.pi * poly_df_calc["area_km2"] /
                                (poly_df_calc["perim_km"] ** 2))
for kind, col in zip(["base", "use", "white"], ["#888888", "#cf222e", "#1f883d"]):
    sub = poly_df_calc[poly_df_calc["kind"] == kind]
    axes[1].scatter(sub["area_km2"], sub["compactness"],
                    s=120, c=col, alpha=0.85, edgecolor="black",
                    linewidth=0.5, label=f"{kind} (n={len(sub)})")
axes[1].set_xscale("log")
axes[1].set_xlabel("ポリゴン面積 km² (log)")
axes[1].set_ylabel("コンパクト性 (4πA/L²; 1=円, 小=細長)")
axes[1].axhline(y=1, linestyle="--", color="gray", linewidth=0.5,
                alpha=0.5)
axes[1].set_title(
    "面積 vs コンパクト性\n"
    "用途地域は小さく細長 (=道路沿いロードサイド型) の傾向",
    fontsize=11)
axes[1].legend(loc="upper left", fontsize=9)
axes[1].grid(True, which="both", alpha=0.3)

plt.tight_layout()
plt.savefig(ASSETS / "L29_size_distribution.png", dpi=130, bbox_inches="tight")
plt.close("all")
print(f"  saved L29_size_distribution.png ({time.time()-t1:.2f}s)", flush=True)


# =============================================================================
# 13. 図 6: 用途地域 7 ポリゴン詳細マップ
# =============================================================================
print("\n[13] 図6: 用途地域 7 ポリゴン詳細", flush=True)
t1 = time.time()
fig, ax = plt.subplots(figsize=(11, 9))

b_use = g_use.total_bounds
pad_use = max(b_use[2]-b_use[0], b_use[3]-b_use[1]) * 0.5

# 基盤を薄背景
g_base.plot(ax=ax, color="#dddddd", edgecolor="#444", linewidth=0.4, alpha=0.4)
# 白地も薄
g_white.plot(ax=ax, color="#c8e6c9", edgecolor="white", linewidth=0.3,
             alpha=0.5)
# 用途地域 → KUIKI_TB で色分け (パレット使用)
import matplotlib.cm as cm
cmap = cm.get_cmap("tab10")
for _, r in g_use.iterrows():
    color = cmap(int(r["KUIKI_TB"]) % 10)
    gpd.GeoSeries([r.geometry]).plot(ax=ax, color=color, edgecolor="black",
                                      linewidth=0.7, alpha=0.95)
    pt = r.geometry.representative_point()
    ax.annotate(
        f"TB={int(r['KUIKI_TB'])}\n{r['poly_area_km2']:.3f} km²",
        xy=(pt.x, pt.y), fontsize=8.5, ha="center",
        fontweight="bold", color="black",
        xytext=(0, 12), textcoords="offset points",
        arrowprops=dict(arrowstyle="-", color="black", lw=0.5))
ax.set_xlim(b_use[0]-pad_use, b_use[2]+pad_use)
ax.set_ylim(b_use[1]-pad_use, b_use[3]+pad_use)
ax.set_title(
    "用途地域 7 ポリゴン (KUIKI_TB 別カラー) ─ 細片の集中分布\n"
    "全 7 polygon, 合計 0.72 km² が湯来地区中心部 + 飛地に集中",
    fontsize=12)
ax.set_axis_off()
plt.tight_layout()
plt.savefig(ASSETS / "L29_use_zones_detail.png", dpi=130, bbox_inches="tight")
plt.close("all")
print(f"  saved L29_use_zones_detail.png ({time.time()-t1:.2f}s)", flush=True)


# =============================================================================
# 14. 図 7: 制度比較バー (準都市計画 vs 通常都市計画 vs 区域外)
# =============================================================================
print("\n[14] 図7: 制度比較 (vs L16 / L26 / L27)", flush=True)
t1 = time.time()

# 比較値 (固定数値を使う ─ 各レッスンの結論値より)
# L16: 通常都市計画区域 (広島県全域) — L17 用途地域から推定 + L18 線引き
# L26: 都市計画区域外 (区域外)
# L27: 非線引き用途地域 + 用途白地
# L29: 準都市計画区域 (本記事)
# 数値は概算 (本記事内では準都計の値以外参考表記)
L16_total_km2 = 1450.0   # 都市計画区域 県全体合計 (L16 既知値の目安)
L27_use_km2   = 50.0     # 非線引き用途地域 (L27 概算)
L27_white_km2 = 850.0    # 非線引き用途白地 (L27 概算)
L18_use_km2   = 460.0    # 各種用途地域 (L17/L18 概算)
L26_outside_km2 = 7000.0 # 都市計画区域外 (区域外: 県土の大半)

categories = [
    ("都市計画区域外\n(L26)",          L26_outside_km2,        "#aaaaaa"),
    ("通常 用途地域\n(線引き; L18,L17)", L18_use_km2,           "#0969da"),
    ("非線引き 用途白地\n(L27)",        L27_white_km2,          "#1f883d"),
    ("非線引き 用途地域\n(L27)",        L27_use_km2,            "#cf222e"),
    ("準都市計画 用途白地\n(L29 本記事)", a_white,              "#88c388"),
    ("準都市計画 用途地域\n(L29 本記事)", a_use,                "#cf6666"),
    ("準都市計画 基盤\n(L29 本記事)",   a_base,                 "#000000"),
]

fig, ax = plt.subplots(figsize=(13, 6.5))
ys = np.arange(len(categories))
labels = [c[0] for c in categories]
vals = [c[1] for c in categories]
cols = [c[2] for c in categories]
ax.barh(ys, vals, color=cols, edgecolor="black", linewidth=0.5)
for i, v in enumerate(vals):
    if v < 100:
        ax.text(v * 1.5, i, f"{v:.2f} km² (= {v/PREF_AREA_KM2*100:.4f}%)",
                va="center", fontsize=9, fontweight="bold", color="#cf222e")
    else:
        ax.text(v + 50, i, f"{v:.0f} km² ({v/PREF_AREA_KM2*100:.2f}%)",
                va="center", fontsize=9)
ax.set_xscale("log")
ax.set_xlabel("広島県内合計面積 km² (log)")
ax.set_yticks(ys)
ax.set_yticklabels(labels, fontsize=9)
ax.set_title(
    f"広島県の都市計画制度別 規模比較 (log スケール)\n"
    f"準都市計画区域 (L29) は {a_base:.2f} km² ─ 全制度中 <b>最も狭く</b>、"
    f"県土の {a_base/PREF_AREA_KM2*100:.4f}% (1/2万) に過ぎない",
    fontsize=11)
ax.grid(axis="x", which="both", alpha=0.3)
ax.invert_yaxis()
plt.tight_layout()
plt.savefig(ASSETS / "L29_regime_compare.png", dpi=130, bbox_inches="tight")
plt.close("all")
print(f"  saved L29_regime_compare.png ({time.time()-t1:.2f}s)", flush=True)


# =============================================================================
# 15. 図 8: 用途指定率の階層 (基盤 → 用途 / 白地 への分割)
# =============================================================================
print("\n[15] 図8: 用途指定階層 (Sankey 風)", flush=True)
t1 = time.time()
fig, ax = plt.subplots(figsize=(11, 6.5))

# 単純 horizontal stacked bar で表現
# 上段: 基盤 100%
ax.barh([2], [100], color="#888888", edgecolor="black", linewidth=0.6)
ax.text(50, 2, f"基盤 (準都市計画区域) {a_base:.4f} km² = 100%",
        ha="center", va="center", fontsize=11, fontweight="bold",
        color="white")

# 下段: 用途 / 白地 比率
pct_use = a_use / a_base * 100
pct_white = a_white / a_base * 100
ax.barh([1], [pct_use], color="#cf222e", edgecolor="black", linewidth=0.6)
ax.barh([1], [pct_white], left=pct_use, color="#1f883d",
        edgecolor="black", linewidth=0.6)
ax.text(pct_use / 2, 1, f"用途地域\n{a_use:.2f} km²\n({pct_use:.1f}%)",
        ha="center", va="center", fontsize=10,
        fontweight="bold", color="white")
ax.text(pct_use + pct_white / 2, 1, f"用途白地\n{a_white:.2f} km²\n({pct_white:.1f}%)",
        ha="center", va="center", fontsize=10,
        fontweight="bold", color="white")

# 矢印で分割を示す
from matplotlib.patches import FancyArrowPatch
arrow1 = FancyArrowPatch((25, 1.6), (pct_use / 2, 1.4),
                          arrowstyle="->", mutation_scale=12,
                          color="#cf222e", linewidth=1.5)
arrow2 = FancyArrowPatch((75, 1.6), (pct_use + pct_white / 2, 1.4),
                          arrowstyle="->", mutation_scale=12,
                          color="#1f883d", linewidth=1.5)
ax.add_patch(arrow1)
ax.add_patch(arrow2)

ax.set_yticks([1, 2])
ax.set_yticklabels(["分割後 (用途地域 + 用途白地)", "基盤 (全体)"], fontsize=10)
ax.set_xlim(0, 100)
ax.set_xlabel("基盤に対する比率 (%)")
ax.set_title(
    "準都市計画区域の用途指定構造\n"
    f"基盤の {pct_use:.1f}% のみが用途指定、残り {pct_white:.1f}% は白地 (緩規制)",
    fontsize=11.5)
ax.grid(axis="x", alpha=0.3)
plt.tight_layout()
plt.savefig(ASSETS / "L29_hierarchy.png", dpi=130, bbox_inches="tight")
plt.close("all")
print(f"  saved L29_hierarchy.png ({time.time()-t1:.2f}s)", flush=True)


# =============================================================================
# 16. 図 9: ポリゴン位置散布 (X,Y) + 面積バブル
# =============================================================================
print("\n[16] 図9: ポリゴン位置散布", flush=True)
t1 = time.time()
fig, ax = plt.subplots(figsize=(11, 8))

for kind, col in zip(["base", "use", "white"], ["#888888", "#cf222e", "#1f883d"]):
    sub = poly_df[poly_df["kind"] == kind]
    sizes = np.sqrt(sub["area_km2"]) * 200 + 30
    ax.scatter(sub["rep_x"], sub["rep_y"], s=sizes,
               c=col, alpha=0.7, edgecolor="black", linewidth=0.6,
               label=f"{kind} (n={len(sub)})")

# 各ポリゴンに FID
for _, r in poly_df.iterrows():
    if r["area_km2"] >= 0.01:  # 微小は省略
        offset_y = 8 if r["kind"] == "base" else 0
        ax.annotate(f"{r['kind'][0].upper()}{int(r['FID'])}",
                    xy=(r["rep_x"], r["rep_y"]),
                    fontsize=7.5, ha="center", va="center",
                    color="white", fontweight="bold",
                    xytext=(0, offset_y), textcoords="offset points")

ax.set_xlabel("EPSG:6671 X 座標 (m)")
ax.set_ylabel("EPSG:6671 Y 座標 (m)")
ax.set_title(
    "32 ポリゴンの代表点散布 (バブル=√面積)\n"
    "西部小群 (X≈6500-7100) と東部主群 (X≈9300-12300) の 2 クラスタ",
    fontsize=11.5)
ax.legend(loc="upper left", fontsize=9, frameon=True)
ax.grid(True, alpha=0.3)
ax.set_aspect("equal")
plt.tight_layout()
plt.savefig(ASSETS / "L29_polygon_scatter.png", dpi=130, bbox_inches="tight")
plt.close("all")
print(f"  saved L29_polygon_scatter.png ({time.time()-t1:.2f}s)", flush=True)


# =============================================================================
# 17. 図 10: 西部小群 vs 東部主群 (2 クラスタ別ズーム)
# =============================================================================
print("\n[17] 図10: 2 クラスタ別ズーム", flush=True)
t1 = time.time()
fig, axes = plt.subplots(1, 2, figsize=(15, 7.5))

# クラスタ判定: X<8000 を西部, X>=8000 を東部
def plot_cluster(ax, x_max=None, x_min=None, title=""):
    hi_diss.plot(ax=ax, color="#fafafa", edgecolor="#888", linewidth=0.4)
    g_base.plot(ax=ax, color="#bbbbbb", edgecolor="black",
                linewidth=0.5, alpha=0.7)
    g_white.plot(ax=ax, color="#1f883d", edgecolor="white",
                 linewidth=0.4, alpha=0.85)
    g_use.plot(ax=ax, color="#cf222e", edgecolor="black",
               linewidth=0.6, alpha=0.95)
    # 各 polygon に FID
    for _, r in poly_df.iterrows():
        x, y = r["rep_x"], r["rep_y"]
        if x_max is not None and x > x_max:
            continue
        if x_min is not None and x < x_min:
            continue
        ax.annotate(f"{r['kind'][0].upper()}{int(r['FID'])}",
                    xy=(x, y), fontsize=7.5, ha="center", va="center",
                    color="white", fontweight="bold")
    ax.set_title(title, fontsize=11)
    ax.set_axis_off()

# 西部 (小群)
plot_cluster(axes[0], x_max=8000, title="(a) 西部小群 (X < 8000m)\n"
             "小ポリゴン群: 飛地的な準都市計画指定区域")
# 東部 (主群)
plot_cluster(axes[1], x_min=8000,
             title="(b) 東部主群 (X ≥ 8000m, 旧湯来町中心部)\n"
             "主要 polygon: 4 大連続塊, 用途地域はすべてここに集中")

axes[0].set_xlim(6000, 8500)
axes[0].set_ylim(-170500, -167500)
axes[1].set_xlim(8500, 13000)
axes[1].set_ylim(-176500, -165000)

plt.suptitle(
    "32 ポリゴンの 2 クラスタ構造 ─ 飛地 + 主群\n"
    "用途地域 (赤) は全 7 polygon が東部主群に集中、西部は白地のみ",
    fontsize=12, y=1.02)
plt.tight_layout()
plt.savefig(ASSETS / "L29_cluster_zoom.png", dpi=130, bbox_inches="tight")
plt.close("all")
print(f"  saved L29_cluster_zoom.png ({time.time()-t1:.2f}s)", flush=True)


# =============================================================================
# 18. 図 11: 制度ピラミッド概念図
# =============================================================================
print("\n[18] 図11: 制度ピラミッド", flush=True)
t1 = time.time()
fig, ax = plt.subplots(figsize=(11, 8.5))

# レイヤを台形で
layers = [
    (PREF_AREA_KM2,          "都市計画区域外 + 都市計画区域 + 準都市計画\n= 広島県全域 8479.6 km²", "#dddddd"),
    (1450.0,                 "通常 都市計画区域\n約 1450 km²",          "#a6cee3"),
    (50.0 + 850.0 + 460.0,   "用途指定 (線引き 460 + 非線引き用途 50 + 白地 850)\n約 1360 km²", "#74add1"),
    (a_base,                 f"準都市計画区域 (本記事 L29)\n{a_base:.2f} km²", "#cf222e"),
    (a_use,                  f"準都市計画 用途地域指定\n{a_use:.2f} km²",      "#7a0e0e"),
]
# 高さは log 比例で台形を描く
import math
log_vals = [math.log10(v + 1) for v in [l[0] for l in layers]]
total_h = sum(log_vals)
y0 = 0
for i, ((v, label, col), h_log) in enumerate(zip(layers, log_vals)):
    h = h_log / total_h * 8
    width = math.log10(v + 1) * 0.9 + 1
    rect = Rectangle((5 - width / 2, y0), width, h,
                     facecolor=col, edgecolor="black",
                     linewidth=0.8, alpha=0.85)
    ax.add_patch(rect)
    ax.text(5, y0 + h / 2, label, fontsize=10, ha="center", va="center",
            fontweight="bold",
            color="white" if i >= 3 else "#222")
    y0 += h + 0.1

ax.set_xlim(0, 10)
ax.set_ylim(0, y0 + 0.5)
ax.set_aspect("auto")
ax.set_title(
    "広島県の都市計画制度 階層ピラミッド (log スケール)\n"
    "県土 8,479 km² の中で、準都市計画区域は 4.58 km² の最深層 ─ 制度ピラミッドの先端",
    fontsize=11.5)
ax.set_axis_off()
plt.tight_layout()
plt.savefig(ASSETS / "L29_pyramid.png", dpi=130, bbox_inches="tight")
plt.close("all")
print(f"  saved L29_pyramid.png ({time.time()-t1:.2f}s)", flush=True)


# =============================================================================
# 19. 仮説検証
# =============================================================================
print("\n[19] 仮説検証", flush=True)

results = {}

# H1: 指定の希少性
n_cities = len([c for c in city_cds_sorted if c == 108])
results["H1"] = {
    "claim": "準都市計画区域は 1〜3 市町、面積 10 km² 未満の極小制度",
    "実測_指定市町": [{
        "CITY_CD": int(c),
        "name": CITY_CD_NAME.get(int(c), "(未確定)")
    } for c in city_cds_sorted],
    "実測_市町数": len(city_cds_sorted),
    "実測_合計面積_km2": round(a_base, 4),
    "実測_対県土比": f"{a_base/PREF_AREA_KM2*100:.4f}%",
    "verdict": ("支持" if (len(city_cds_sorted) <= 3 and a_base < 10)
                else "反証"),
    "解釈": (
        f"県内 {len(city_cds_sorted)} 市町 (CITY_CD={city_cds_sorted}) のみ、"
        f"合計 {a_base:.2f} km² (県土の 1/{int(PREF_AREA_KM2/a_base):,})。"
        "事実上『最も使われていない都市計画制度』。"
    ),
}

# H2: 3 兄弟の幾何整合
results["H2"] = {
    "claim": "基盤面積 = 用途地域 + 用途白地 が幾何的に厳密成立",
    "面積_基盤_km2":  round(a_base, 4),
    "面積_用途_km2":  round(a_use, 4),
    "面積_白地_km2":  round(a_white, 4),
    "面積_合算_km2":  round(a_sum, 4),
    "差_km2":        round(diff, 6),
    "用途∩白地_面積_km2":   round(inter_uw, 6),
    "対称差_km2":          round(sym_base_uw, 6),
    "verdict": ("支持" if abs(diff) < 0.01 and inter_uw < 0.01 else "部分支持"),
    "解釈": (
        f"基盤 {a_base:.4f} = 用途 {a_use:.4f} + 白地 {a_white:.4f} "
        f"(差 {abs(diff)*1e6:.0f} m²)。用途 ∩ 白地 = {inter_uw:.6f} km²。"
        "基盤は用途地域指定の有無で完全に二分される ─ 3 兄弟は数学的補完関係。"
    ),
}

# H3: 用途指定比率 < 20%
pct_use_in_base = a_use / a_base * 100
results["H3"] = {
    "claim": "用途地域指定 (use) は基盤の 20% 未満。残り 80%+ が白地",
    "用途指定率_pct": round(pct_use_in_base, 2),
    "白地率_pct":     round(100 - pct_use_in_base, 2),
    "verdict": ("支持" if pct_use_in_base < 20 else "部分支持"),
    "解釈": (
        f"用途指定率 {pct_use_in_base:.1f}%。基盤の {100-pct_use_in_base:.1f}% は"
        "白地として緩規制で残された ─ 制度趣旨『最低限の規制で乱開発を防ぐ』を反映。"
    ),
}

# H4: 用途地域の細片性
mean_use_area = float(g_use["poly_area_km2"].mean())
mean_white_area = float(g_white["poly_area_km2"].mean())
mean_base_area = float(g_base["poly_area_km2"].mean())
results["H4"] = {
    "claim": "用途地域 (use) は細片性が強く、1 polygon 平均 < 0.1 km²",
    "用途地域_polys": int(len(g_use)),
    "用途地域_平均面積_km2":  round(mean_use_area, 4),
    "用途地域_最大_km2":      round(float(g_use["poly_area_km2"].max()), 4),
    "用途地域_最小_km2":      round(float(g_use["poly_area_km2"].min()), 4),
    "用途白地_polys": int(len(g_white)),
    "用途白地_平均面積_km2":  round(mean_white_area, 4),
    "用途白地_最大_km2":      round(float(g_white["poly_area_km2"].max()), 4),
    "基盤_平均面積_km2":      round(mean_base_area, 4),
    "verdict": ("支持" if mean_use_area < 0.15 else "部分支持"),
    "解釈": (
        f"用途地域は 1 ポリゴン平均 {mean_use_area*1000:.0f} m² (= 約 0.1 km²)、"
        "幹線道路沿いに細片状に分布。一方白地は 1 ポリゴン平均 "
        f"{mean_white_area:.2f} km² の連続塊。"
    ),
}

# H5: 広島市 ds と県全域 ds の同一性
all_dup = bool(dup_df["完全一致"].all())
results["H5"] = {
    "claim": "広島市 ds と広島県全域 ds は完全一致 (重複データ)",
    "兄弟別_一致":     dup_df.set_index("kind")["完全一致"].to_dict(),
    "全兄弟一致":      all_dup,
    "verdict":         "支持" if all_dup else "部分支持",
    "解釈": (
        "県内に他の準都市計画区域がないため、市の dataset と県の dataset が"
        "同じ実体ポリゴンを別 ID で提供している。"
    ),
}

# H6: CITY_CD = 108 = 広島市佐伯区 (湯来地区)
results["H6"] = {
    "claim": "全 6 ファイルで CITY_CD = 108 単一値 = 広島市佐伯区 (旧湯来町)",
    "CITY_CD_集合":  city_cds_sorted,
    "市町名":        [CITY_CD_NAME.get(c, "?") for c in city_cds_sorted],
    "verdict":      "支持" if (len(city_cds_sorted) == 1 and 108 in city_cds_sorted) else "部分支持",
    "解釈": (
        "L08 既存対照表より 108 = 広島市佐伯区。2005 年に佐伯郡湯来町が広島市佐伯区へ"
        "編入された。湯来地区は中山間部で都市計画区域外的な性格を持つが、"
        "国道 488 号 (広島⇔湯来⇔六日市) 沿いのロードサイド開発を制御するため、"
        "編入時に旧湯来町域の一部が準都市計画区域として継承指定されたと推定される。"
    ),
}

(ASSETS / "L29_hypothesis_results.json").write_text(
    json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(results, ensure_ascii=False, indent=2), flush=True)


print(f"\n=== 計算終了: {time.time()-t0:.1f}s ===", flush=True)
print("=== HTML 生成 ===\n", flush=True)


# =============================================================================
# 20. HTML 生成
# =============================================================================
sections = []

# === セクション1: 学習目標と問い ===
s1_html = f"""
<p>本レッスンは、広島県オープンデータポータル <b>DoBoX</b> の
「都市計画区域情報_区域データ_*_<b>準都市計画区域</b>」シリーズ <b>3 兄弟 計 6 件</b>
を統合し、<b>広島県唯一の準都市計画区域</b>(旧湯来町, 広島市佐伯区, わずか {a_base:.2f} km²)
の構造を解読する研究記事です。</p>

<div class="note">
<b>研究問い (RQ)</b><br>
広島県内に指定された<b>準都市計画区域</b>は<b>どこ</b>にあり、どれくらいの規模で、
「用途地域指定 (use)」と「用途白地 (white)」の構造はどうなっているか？
通常の都市計画区域 (L16) や非線引き用途地域 (L27) と比べて、この<b>緩い制度</b>は
どの程度の制御を行っているか？ そして DoBoX で<b>広島市と広島県全域</b>の dataset で
なぜ列構造・件数が完全一致するのか？
</div>

<h3>独自用語の定義 (要件 M)</h3>
<ul>
  <li><b>準都市計画区域 (Quasi City Planning Zone)</b>:
      <b>都市計画法第 5 条の 2</b> に基づく制度。
      都市計画区域の<b>外側にある</b>が、放任すれば乱開発が起きうるエリアに対し、
      <b>用途地域・特別用途地区など最小限の規制</b>のみを適用する。
      通常の都市計画区域より<b>遥かに緩い制御</b>。
      高速道路 IC 周辺や幹線道路沿いの中山間部で、
      <b>ロードサイド型店舗集中</b>や乱開発を抑制する目的で指定される。</li>
  <li><b>都市計画法第 5 条の 2</b>:
      準都市計画区域の根拠条文。1992 年都計法改正で<b>「都市計画区域外」の補完</b>として
      創設され、2000 年改正で現行制度に整理された。
      指定主体は<b>都道府県</b> (市町ではない)。</li>
  <li><b>3 兄弟構造 (本記事の独自用語)</b>:
      DoBoX が準都市計画区域を 3 つのサブシリーズで提供する構造:
      <ul>
        <li><b>基盤 (base)</b>: 準都市計画区域の<b>全体ポリゴン</b></li>
        <li><b>用途地域 (use)</b>: 区域内で<b>用途地域指定された部分</b></li>
        <li><b>用途白地 (white)</b>: 区域内で<b>用途指定なしの部分</b></li>
      </ul>
      数学的に「<b>基盤 = 用途地域 ∪ 用途白地</b>」「<b>用途地域 ∩ 用途白地 = ∅</b>」
      の<b>完全分割</b>関係にある。本記事はこの 3 兄弟の幾何整合性を実証する。</li>
  <li><b>ロードサイド型開発</b>:
      幹線道路沿いに<b>大規模商業施設・ガソリンスタンド・倉庫</b>が
      帯状に集中する開発パターン。準都市計画区域は元々、
      高速道 IC 周辺や国道沿いの中山間部で<b>無秩序なロードサイド開発を防ぐ</b>ために
      創設された制度。本記事の用途地域 7 ポリゴンが小片状に分布する事実は、
      この制度趣旨が幾何的に表れた結果と読める。</li>
  <li><b>CITY_CD (市区町村コード)</b>:
      DoBoX 共通の整数コード。広島県内では <b>101-108 = 広島市 8 区</b>、
      <b>200 番台 = 主要市</b>、<b>300 番台 = 町</b>、400 番台 = 一部町。
      L08 レッスンの対照表より <b>108 = 広島市佐伯区</b>。
      本記事の全 6 ファイルで <b>CITY_CD = 108 単一値</b>であることが、
      「広島県内の準都市計画区域 = 旧湯来町域のみ」という結論の根拠。</li>
  <li><b>KUIKI_TB</b>:
      use/white の polygon に付く属性。実データを確認した結果、
      <b>FID + 1 と一致</b>する単純な連番 ID であり、<b>用途地域分類コードではない</b>。
      L17/L18 の <code>YOTO_CD</code> や L19 の <code>KUIKI_CD</code> と<b>意味体系が異なる</b>
      ─ 監査未経シリーズの注意点。</li>
  <li><b>湯来地区 (旧湯来町)</b>:
      旧佐伯郡湯来町は、<b>2005 年 4 月 25 日</b>に広島市佐伯区へ編入合併された。
      湯来温泉で知られる中山間部で、合併前は都市計画区域<b>外</b>の制度的扱いだったが、
      合併時に旧町域の一部が<b>準都市計画区域</b>として継承指定された。
      これが本記事が分析する 4.58 km² の正体。</li>
</ul>

<h3>仮説 H1〜H6 (要件 D)</h3>
<ul>
  <li><b>H1 (指定の希少性)</b>: 広島県内の準都市計画区域は<b>1〜3 市町</b>、合計面積<b>10 km² 未満</b>
       の<b>極小制度</b>として指定されている。県土 8,479 km² に対するカバー率 < 0.2%。</li>
  <li><b>H2 (3 兄弟の数学的補完)</b>: 基盤面積 = 用途地域面積 + 用途白地面積 が
       幾何的に厳密成立する (誤差 0.01 km² 未満)。
       3 兄弟は「用途指定あり ∪ 用途指定なし」の完全互補な分割。</li>
  <li><b>H3 (用途指定比率の小ささ)</b>: 用途地域指定 (use) は基盤の<b>20%未満</b>。
       残り 80% 以上が用途白地 ─ 制度趣旨「最低限の規制」を反映。</li>
  <li><b>H4 (用途地域の細片性)</b>: 用途地域 (use) ポリゴンは<b>多数の小片に分かれる</b>
       (1 ポリゴン平均 < 0.1 km²) ─ ロードサイド型実装の幾何特徴。</li>
  <li><b>H5 (広島市 ds と県全域 ds の一致)</b>: 広島市 dsid (791/792/793) と
       広島県全域 dsid (929/930/931) は<b>件数・面積・幾何が完全一致</b>。
       理由: 県内に他の準都市計画区域がないため、市と県の dataset が同じ実体を別 ID で提供。</li>
  <li><b>H6 (CITY_CD = 108 の意味)</b>: 全 6 ファイルで CITY_CD は<b>108 単一値</b>。
       L08 対照表より 108 = 広島市佐伯区 → 旧湯来町域が継承指定された結果。</li>
</ul>

<h3>到達点</h3>
<ol>
  <li>6 GeoJSON (3 兄弟 × 2 dsid) を統合する手法と、<b>列が同型でない場合の併置統合</b>のテクニック</li>
  <li><b>幾何 union/intersection/symmetric_difference</b> で 3 兄弟の補完関係を実証する技法</li>
  <li>少件数 (32 polygon) でも<b>制度の希少性そのもの</b>を研究テーマ化する姿勢</li>
  <li>カタログ表記 (「広島市」) と<b>実体 (旧湯来町)</b> の乖離を CITY_CD で解明する手順</li>
  <li>L16/L17/L18/L26/L27 の他制度との<b>規模比較</b>で位置付ける視点</li>
  <li>図 11 種・表 9 種で「最も狭い制度」を多角的に提示 (要件 Q)</li>
</ol>

<div class="warn">
<b>注: 三兄弟合体の可否</b><br>
本記事の準都市計画区域 3 兄弟 (基盤+用途地域+用途白地) は、<b>列構造が完全には同型ではない</b>:
<ul>
  <li>base: <code>FID/TOKEI_CD/CITY_CD/geometry</code> の 4 列</li>
  <li>use, white: 上記 + <code>KUIKI_TB</code> の 5 列</li>
</ul>
このため、<code>pd.concat</code> による単純合体は<b>禁止</b>。代わりに「役割の異なるレイヤ」として<b>併置統合</b>:
<ul>
  <li>base = 区域全体の輪郭</li>
  <li>use ∪ white = 区域内の細分</li>
  <li>幾何的に <code>base ≈ use ∪ white</code> が厳密成立 (本記事 H2 で実証)</li>
</ul>
他のサブシリーズ (L26 都市計画区域外, L27 非線引き用途地域 等) との合体は<b>禁止</b>。L26/L27 は参考併置のみ。
</div>
"""
sections.append(("1. 学習目標と問い", s1_html))


# === セクション2: 使用データ ===
data_spec = f"""
<table>
  <tr><th>項目</th><th>値</th></tr>
  <tr><td>シリーズ (3 兄弟)</td>
      <td>都市計画区域情報_区域データ_*_<b>準都市計画区域 / 準都市計画区域用途地域 / 準都市計画区域用途白地</b></td></tr>
  <tr><td>件数</td><td><b>各兄弟 2 dataset_id</b> (広島市 + 広島県全域)、計 <b>6 件</b></td></tr>
  <tr><td>形式</td><td>GeoJSON (ZIP 内同梱)</td></tr>
  <tr><td>CRS</td><td>EPSG:6671 (JGD2011 平面直角第III系) ※全 6 件で統一</td></tr>
  <tr><td>列構造 (兄弟で違う)</td>
      <td>base: <code>FID/TOKEI_CD/CITY_CD/geometry</code> (4 列)<br>
          use, white: 上記 + <code>KUIKI_TB</code> (5 列)<br>
          <b>完全同型ではないため単純 concat 禁止</b></td></tr>
  <tr><td>合計ポリゴン数</td>
      <td>基盤 {len(g_base)} + 用途 {len(g_use)} + 白地 {len(g_white)} = <b>{len(g_base)+len(g_use)+len(g_white)} polygon</b> (極小)</td></tr>
  <tr><td>合計面積 (基盤)</td><td>{a_base:.4f} km² (= 県土の {a_base/PREF_AREA_KM2*100:.4f}%)</td></tr>
  <tr><td>CITY_CD ユニーク</td><td>{city_cds_sorted} = <b>{CITY_CD_NAME.get(108, '?')}</b> 単一</td></tr>
  <tr><td>関連レッスン</td>
      <td>L16 (都市計画区域), L17 (用途地域), L18 (線引き), L19/L28 (LIP),
          <b>L26 (都市計画区域外)</b>, <b>L27 (非線引き用途/白地)</b></td></tr>
</table>

<h3>3 兄弟の役割と凡例</h3>
<table>
<tr><th>兄弟</th><th>kind</th><th>意味</th><th>カラー</th><th>polygon 数 (実測)</th><th>面積 km² (実測)</th></tr>
"""
for kind in ["base", "use", "white"]:
    name, col, desc = KIND_INFO[kind]
    g_k = {"base": g_base, "use": g_use, "white": g_white}[kind]
    data_spec += (
        f'<tr><td><b>{name}</b></td><td><code>{kind}</code></td>'
        f'<td>{desc}</td>'
        f'<td style="background:{col};color:white;text-align:center;">{col}</td>'
        f'<td>{len(g_k)}</td>'
        f'<td>{g_k["poly_area_km2"].sum():.4f}</td></tr>'
    )
data_spec += "</table>"

data_spec += """
<h3>6 GeoJSON 一覧</h3>
<table>
<tr><th>kind</th><th>dsid</th><th>カタログ表記市町</th>
<th>実 CITY_CD</th><th>polys</th><th>area km²</th><th>備考</th></tr>
"""
for (kind, dsid), g in sorted(gdfs.items(), key=lambda x: (x[0][0], x[0][1])):
    name = g["src_label"].iloc[0]
    real_cc = sorted(g["CITY_CD"].unique().tolist())
    data_spec += (
        f'<tr><td><code>{kind}</code></td>'
        f'<td><a href="https://hiroshima-dobox.jp/datasets/{dsid}" target="_blank">DoBoX #{dsid}</a></td>'
        f'<td>{name}</td>'
        f'<td>{real_cc} = {CITY_CD_NAME.get(real_cc[0], "?")}</td>'
        f'<td>{len(g)}</td>'
        f'<td>{g["poly_area_km2"].sum():.4f}</td>'
        f'<td><code>{kind}_{dsid}_{name}.zip</code></td></tr>'
    )
data_spec += "</table>"

s2_html = (
    "<p>本記事で使う 6 dataset_id は、"
    "DoBoX で「都市計画区域情報」配下「区域データ」配下の "
    "<b>準都市計画区域シリーズ 3 兄弟</b>。<br>"
    "各兄弟は<b>カタログ表記が「広島市」と「広島県全域」</b>の 2 dataset_id で提供されているが、"
    "実データを照合すると<b>2 つの dataset の中身は完全に同じ</b>であり、"
    "実際の指定地域は<b>カタログ表記の『広島市』ではなく『旧佐伯郡湯来町』</b> "
    f"(2005 年に広島市佐伯区へ編入) の中山間部 4.58 km² である。<br>"
    "つまり、広島県オープンデータの『広島市_準都市計画区域』という名前は、"
    "<b>合併前の旧湯来町域</b>を指している ─ これが本記事の最初の発見。</p>"
    "<h3>データ仕様と 3 兄弟の役割</h3>" + data_spec
)
sections.append(("2. 使用データ", s2_html))


# === セクション3: ダウンロード ===
dl_html = f"""
<p>本記事の<b>再現性</b>を担保するため、HTML 1 枚から
生データ・中間 CSV・図 PNG・再現 Python を直リンクで取得できるようにしてある。</p>

<h3>(1) 生データ ZIP (DoBoX 直)</h3>
<p>6 件の ZIP は前項の表からそれぞれ DoBoX へリンクしている。
あるいは一括取得スクリプトを使う:</p>
<pre><code>cd "2026 DoBoX 教材"
py -X utf8 data\\extras\\L29_quasi_planning_zones\\fetch_quasi_planning_zones.py</code></pre>

<h3>(2) 中間 CSV (本スクリプトの出力)</h3>
<ul>
  <li><a href="assets/L29_six_files_summary.csv" download>L29_six_files_summary.csv</a> — 6 ファイルのサマリ (件数・面積・列)</li>
  <li><a href="assets/L29_polys_detail.csv" download>L29_polys_detail.csv</a> — 32 polygon の詳細 (代表点座標含む)</li>
  <li><a href="assets/L29_three_consistency.csv" download>L29_three_consistency.csv</a> — 3 兄弟の幾何整合性 (基盤 = 用途+白地)</li>
  <li><a href="assets/L29_duplicate_check.csv" download>L29_duplicate_check.csv</a> — 広島市 ds vs 県全域 ds 同一性</li>
  <li><a href="assets/L29_coverage.csv" download>L29_coverage.csv</a> — 県土に対するカバー率</li>
  <li><a href="assets/L29_size_stats.csv" download>L29_size_stats.csv</a> — polygon 規模統計</li>
  <li><a href="assets/L29_shape_stats.csv" download>L29_shape_stats.csv</a> — 形状複雑性 (compactness)</li>
  <li><a href="assets/L29_hypothesis_results.json" download>L29_hypothesis_results.json</a> — 仮説 H1〜H6 検証結果</li>
</ul>

<h3>(3) 図 PNG (本記事掲載)</h3>
<ul>
  <li><a href="assets/L29_pref_overview.png" download>L29_pref_overview.png</a> — 県全体マップ (1)</li>
  <li><a href="assets/L29_yuki_closeup.png" download>L29_yuki_closeup.png</a> — 湯来地区クローズアップ (2)</li>
  <li><a href="assets/L29_three_panels.png" download>L29_three_panels.png</a> — 3 兄弟 small multiples (3)</li>
  <li><a href="assets/L29_consistency.png" download>L29_consistency.png</a> — 整合性 + 同一性 (4)</li>
  <li><a href="assets/L29_size_distribution.png" download>L29_size_distribution.png</a> — 規模分布 (5)</li>
  <li><a href="assets/L29_use_zones_detail.png" download>L29_use_zones_detail.png</a> — 用途地域 7 ポリゴン詳細 (6)</li>
  <li><a href="assets/L29_regime_compare.png" download>L29_regime_compare.png</a> — 制度比較バー (7)</li>
  <li><a href="assets/L29_hierarchy.png" download>L29_hierarchy.png</a> — 用途指定階層 (8)</li>
  <li><a href="assets/L29_polygon_scatter.png" download>L29_polygon_scatter.png</a> — ポリゴン位置散布 (9)</li>
  <li><a href="assets/L29_cluster_zoom.png" download>L29_cluster_zoom.png</a> — 2 クラスタ別ズーム (10)</li>
  <li><a href="assets/L29_pyramid.png" download>L29_pyramid.png</a> — 制度ピラミッド (11)</li>
</ul>

<h3>(4) 再現用 Python スクリプト</h3>
<ul>
  <li><a href="L29_quasi_planning_zones.py" download>L29_quasi_planning_zones.py</a> — 本記事の全コード</li>
  <li><a href="../data/extras/L29_quasi_planning_zones/fetch_quasi_planning_zones.py" download>fetch_quasi_planning_zones.py</a> — 6 ZIP 取得スクリプト</li>
</ul>
<p class="tnote">実行は <code>cd "2026 DoBoX 教材"; py -X utf8 lessons\\L29_quasi_planning_zones.py</code>。
6 ZIP がローカルにあれば<b>数秒〜10 秒</b>で全図 + CSV 再生成 (要件 S 準拠)。
ZIP が無い場合は事前に <code>fetch_quasi_planning_zones.py</code> を実行。</p>
"""
sections.append(("3. ダウンロード", dl_html))


# === セクション4: 分析1 — 6 GeoJSON 読み込み + 列同型性 ===
code_load = code('''
DATA_DIR = ROOT / "data" / "extras" / "L29_quasi_planning_zones"
TARGET_CRS = "EPSG:6671"  # JGD2011 平面直角 III, m

# (kind, dsid, file_label) — 6 件
TARGETS = [
    ("base",  791, "広島市"),
    ("base",  929, "広島県"),
    ("use",   792, "広島市"),
    ("use",   930, "広島県"),
    ("white", 793, "広島市"),
    ("white", 931, "広島県"),
]

def load_geojson_zip(zip_path):
    """ZIP 内の単一 .geojson を BytesIO 経由で読む (一時ファイル不要)"""
    import io, zipfile
    with zipfile.ZipFile(zip_path) as zf:
        gjs = [n for n in zf.namelist() if n.lower().endswith(".geojson")]
        with zf.open(gjs[0]) as f:
            return gpd.read_file(io.BytesIO(f.read()))

# 6 ファイル読み込み (kind 別 dict で保持; 列が違うので concat しない)
gdfs = {}
for kind, dsid, city in TARGETS:
    p = DATA_DIR / f"{kind}_{dsid}_{city}.zip"
    g = load_geojson_zip(p).to_crs(TARGET_CRS)
    g["poly_area_km2"] = g.geometry.area / 1e6
    gdfs[(kind, dsid)] = g

# 兄弟ごとの代表 (広島県全域版を採用)
g_base  = gdfs[("base",  929)]
g_use   = gdfs[("use",   930)]
g_white = gdfs[("white", 931)]

# 列同型性チェック
cols_base  = sorted(c for c in g_base.columns  if c != "geometry")
cols_use   = sorted(c for c in g_use.columns   if c != "geometry")
cols_white = sorted(c for c in g_white.columns if c != "geometry")
print(f"base  : {cols_base}")
print(f"use   : {cols_use}")
print(f"white : {cols_white}")
# → base に KUIKI_TB なし。完全同型ではないので単純合体禁止
''')

s4_html = f"""
<p><b>狙い</b>: 6 GeoJSON を 3 兄弟 (kind) × 2 dsid (広島市 / 広島県) の構造で読み込む。
<b>列構造が完全同型ではない</b>ことを実データから確認し、合体ではなく併置統合の方針を確立する。</p>

<h3>実装</h3>
<p>6 ファイルは ZIP の中に 1 つの GeoJSON が同梱される形式。
<code>BytesIO</code> 経由で展開し EPSG:6671 (m 単位平面直角) に再投影する。
すべての面積計算は km² に揃える。
ファイル名は <code>{{kind}}_{{dsid}}_{{city}}.zip</code> パターンで統一されている。</p>

{code_load}

<h3>結果: 列構造の差異</h3>
<table>
  <tr><th>kind</th><th>列</th><th>polygon 数</th><th>面積 km²</th></tr>
  <tr><td><code>base</code></td><td><code>FID, TOKEI_CD, CITY_CD, geometry</code> (4 列)</td>
      <td>{len(g_base)}</td><td>{a_base:.4f}</td></tr>
  <tr><td><code>use</code></td><td><code>FID, TOKEI_CD, CITY_CD, KUIKI_TB, geometry</code> (5 列)</td>
      <td>{len(g_use)}</td><td>{a_use:.4f}</td></tr>
  <tr><td><code>white</code></td><td><code>FID, TOKEI_CD, CITY_CD, KUIKI_TB, geometry</code> (5 列)</td>
      <td>{len(g_white)}</td><td>{a_white:.4f}</td></tr>
</table>

<p><b>この表から読み取れること</b>:</p>
<ul>
  <li><b>base には <code>KUIKI_TB</code> がない</b>。理由: base は単一の「準都市計画区域全体」を表すので、用途分類の細分が不要。</li>
  <li>use/white は<b>用途指定の有無で分かれる</b>ため、<code>KUIKI_TB</code> で 1〜N の連番ID を持つ。
      ただし、後の検証で <code>KUIKI_TB == FID + 1</code> が成立し、<b>意味的分類ではなく単なる番号</b>と判明する。</li>
  <li>3 兄弟の合体可否判定: <b>合体禁止</b>。同じテーブルに縦結合すると base の行で <code>KUIKI_TB</code> が NaN になり、解釈の混乱を招く。
      代わりに「役割が違うレイヤ」として併置し、幾何 union/intersection で関係を記述する。</li>
</ul>

<p>polygon 総数 {len(g_base)+len(g_use)+len(g_white)} は L19 (1170) や L27 (136) と比べて<b>桁違いに少ない</b>。
これは「広島県内の準都市計画区域指定が極めて限定的」という仮説 H1 の最初の傍証である。</p>
"""
sections.append(("4. 分析1: 6 GeoJSON 読み込みと列同型性チェック", s4_html))


# === セクション5: 分析2 — CITY_CD で指定市町を同定 ===
code_city = code('''
# 全 6 ファイルから CITY_CD ユニーク値を集合演算で抽出
all_city_cds = set()
for (k, d), g in gdfs.items():
    all_city_cds |= set(int(x) for x in g["CITY_CD"].unique())
city_cds = sorted(all_city_cds)
# → [108]  (単一値!)

# L08 既存対照表より 108 = 広島市佐伯区
CITY_CD_NAME = {
    101: "広島市中区", 102: "広島市東区", 103: "広島市南区", 104: "広島市西区",
    105: "広島市安佐南区", 106: "広島市安佐北区", 107: "広島市安芸区",
    108: "広島市佐伯区",  # ← 旧佐伯郡湯来町を 2005 年に編入
}

for cc in city_cds:
    print(f"CITY_CD={cc} → {CITY_CD_NAME.get(cc, '(未確定)')}")
# 出力: CITY_CD=108 → 広島市佐伯区
''')

s5_html = f"""
<p><b>狙い</b>: 全 6 ファイルの <code>CITY_CD</code> 列を集合演算で集約し、
広島県内の準都市計画区域が<b>どの市町に指定されているか</b>を実データから同定する。
カタログ表記は「広島市」だが、CITY_CD が広島市の何区を指しているかを確かめる必要がある。</p>

<h3>仮説</h3>
<p>仮説 H6 で立てたとおり、CITY_CD = 108 (広島市佐伯区) が単一値で出てくると予想する。
理由: 政令市 広島市は中区〜安佐南区 (101-106) は完全市街地で都市計画区域指定済、
湯来町を編入した佐伯区 (108) のみが中山間部を抱え、準都市計画区域が指定可能な地域だから。</p>

<h3>実装</h3>
{code_city}

<h3>結果</h3>
<table>
  <tr><th>kind</th><th>dsid</th><th>CITY_CD ユニーク</th><th>市町名 (L08 対照)</th></tr>
"""
for (kind, dsid), g in sorted(gdfs.items(), key=lambda x: (x[0][0], x[0][1])):
    cc_list = sorted(g["CITY_CD"].unique().tolist())
    s5_html += (
        f'<tr><td><code>{kind}</code></td><td>{dsid}</td>'
        f'<td>{cc_list}</td>'
        f'<td>{CITY_CD_NAME.get(cc_list[0], "?")}</td></tr>'
    )
s5_html += f"""
</table>

<p><b>この表から読み取れること</b>:</p>
<ul>
  <li>全 6 ファイルで <b>CITY_CD = 108 単一値</b>。県内に他の準都市計画区域は<b>無い</b>。</li>
  <li>108 = 広島市佐伯区 (L08 対照表より)。<b>カタログ表記「広島市」は誤解を招く</b>:
      実体は政令市の中心部ではなく、合併で取り込まれた<b>旧湯来町の中山間部</b>。</li>
  <li>仮説 H1 の<b>第一の傍証</b>: 広島県全域で 1 つの市 (CITY_CD=108) のみに指定 = 極めて限定的。</li>
  <li>仮説 H6 が支持される (CITY_CD = 108 単一)。</li>
</ul>

<h3>歴史的背景</h3>
<p>佐伯郡湯来町は、<b>2005 年 4 月 25 日</b>に広島市佐伯区へ編入合併された
(平成の大合併の一環)。湯来温泉で知られる中山間部で、合併前は<b>都市計画区域外</b>の制度的扱いだった。</p>

<p>合併時、旧湯来町の市街地中心部 + 国道 488 号沿いの一部 ({a_base:.2f} km²) が
<b>準都市計画区域として継承指定</b>されたと推定される。これは「合併で大都市の中に
中山間集落が組み込まれた際、いきなり都市計画区域に編入するのではなく、
緩い制度で一定の制御だけかけておく」典型的な運用パターン。</p>
"""
sections.append(("5. 分析2: CITY_CD で指定市町を同定 — 旧湯来町の発見", s5_html))


# === セクション6: 分析3 — 広島市 ds vs 広島県 ds 同一性 ===
code_dup = code('''
import pandas as pd

# 兄弟ごとに 広島市 ds と 広島県 ds を比較
dup_rows = []
for kind in ["base", "use", "white"]:
    keys = sorted([k for k in gdfs if k[0] == kind], key=lambda x: x[1])
    g_hi  = gdfs[keys[0]]   # 広島市 dsid (小)
    g_ken = gdfs[keys[1]]   # 広島県 dsid (大)

    same_n = len(g_hi) == len(g_ken)
    same_a = abs(g_hi["poly_area_km2"].sum()
                 - g_ken["poly_area_km2"].sum()) < 1e-6
    same_fid = (sorted(g_hi["FID"].tolist())
                == sorted(g_ken["FID"].tolist()))
    # 幾何 symmetric_difference の面積で同一性判定
    u_hi  = g_hi.geometry.union_all()
    u_ken = g_ken.geometry.union_all()
    sym_a = u_hi.symmetric_difference(u_ken).area / 1e6

    dup_rows.append({{
        "kind": kind, "n_hi": len(g_hi), "n_ken": len(g_ken),
        "area_hi": g_hi["poly_area_km2"].sum(),
        "area_ken": g_ken["poly_area_km2"].sum(),
        "FID_集合一致": same_fid,
        "対称差_km2": sym_a,
        "完全一致": same_n and same_a and same_fid and (sym_a < 1e-3),
    }})
dup_df = pd.DataFrame(dup_rows)
print(dup_df)
''')

s6_html = f"""
<p><b>狙い</b>: 「広島市」 dataset (791/792/793) と「広島県全域」 dataset (929/930/931) は、
件数・面積・幾何が完全一致するか?
DoBoX のシリーズ設計上、市町別 dataset と県全域 dataset は<b>「県全域 = 全市町の合算」</b>であることが
通例 (L26/L27/L28 で確認済) だが、本シリーズは <b>1 市町しかない</b>ため
両者の中身がそのまま等しい可能性が高い。</p>

<h3>可視化の選定理由 (要件 H)</h3>
<p>同一性の証明には、<b>件数・面積・FID 集合・幾何対称差</b> の 4 軸を組み合わせる必要がある。
表形式 + バーグラフで横並びさせると、視覚的に「ぴったり同じ」が伝わる。
散布図ではこの構造は伝わらない。</p>

<h3>実装</h3>
{code_dup}

<h3>結果: 同一性検証表</h3>
<table>
<tr><th>kind</th><th>広島市 ds</th><th>広島県 ds</th>
<th>n_hi</th><th>n_ken</th><th>area_hi km²</th><th>area_ken km²</th>
<th>対称差 km²</th><th>完全一致?</th></tr>
"""
for _, r in dup_df.iterrows():
    s6_html += (
        f'<tr><td><code>{r["kind"]}</code></td>'
        f'<td>{r["ds_hi"]}</td><td>{r["ds_ken"]}</td>'
        f'<td>{r["n_hi"]}</td><td>{r["n_ken"]}</td>'
        f'<td>{r["area_hi_km2"]}</td><td>{r["area_ken_km2"]}</td>'
        f'<td>{r["対称差_面積_km2"]:.6f}</td>'
        f'<td><b>{r["完全一致"]}</b></td></tr>'
    )
s6_html += f"""
</table>

<p><b>この表から読み取れること</b>:</p>
<ul>
  <li>3 兄弟すべてで<b>完全一致 = True</b>。広島市 ds と県全域 ds は<b>同じ実体ポリゴン</b>を別 ID で提供している。</li>
  <li>仮説 H5 が完全支持される。</li>
  <li><b>カタログ重複の構造</b>: 「シリーズ × 市町 × dsid」設計上、
      準都市計画区域があるのは広島市のみだが、シリーズ完結性のために県全域版も用意。
      結果として中身は同一になる。</li>
  <li>L26/L27/L28 では「県全域 = 全市町合算」だったが、本シリーズは「県全域 = 単一市町と同じ」になる
      ─ <b>1 市町しかない制度の特殊な構造</b>。</li>
</ul>

<p>以降の分析は<b>県全域 ds (929/930/931) のみを使う</b>。
広島市 ds (791/792/793) は冗長なので分析から除外して構わない。</p>
{figure("assets/L29_consistency.png",
        "図4: 整合性 (左) + 広島市 ds vs 県全域 ds 同一性 (右)")}

<p><b>図4 から読み取れること</b>:</p>
<ul>
  <li>左: 基盤 (灰) ≈ 用途+白地 合算 (青) で、棒の高さが視覚的に揃う。</li>
  <li>右: 広島市 ds (青) と県全域 ds (橙) の棒が完全に同じ高さ ─ 数値も小数 4 桁まで一致。</li>
</ul>
"""
sections.append(("6. 分析3: 広島市 ds vs 県全域 ds の同一性検証", s6_html))


# === セクション7: 分析4 — 3 兄弟の幾何整合性 ===
code_consist = code('''
# 3 兄弟の幾何整合性: 基盤 = 用途地域 + 用途白地 ?
a_base  = g_base["poly_area_km2"].sum()
a_use   = g_use["poly_area_km2"].sum()
a_white = g_white["poly_area_km2"].sum()
a_sum   = a_use + a_white
diff    = a_base - a_sum

# 幾何 union 計算
u_base  = g_base.geometry.union_all()
u_use   = g_use.geometry.union_all()
u_white = g_white.geometry.union_all()
u_uw    = u_use.union(u_white)

# 用途地域 と 用途白地 が排他的か (重なりは ≈ 0?)
inter_uw = u_use.intersection(u_white).area / 1e6

# 基盤 と 用途+白地 の対称差 (基盤に含まれない用途+白地, 用途+白地に
# 含まれない基盤の合計)
sym_base_uw = u_base.symmetric_difference(u_uw).area / 1e6

print(f"面積_基盤      : {a_base:.4f} km²")
print(f"面積_用途地域  : {a_use:.4f} km²")
print(f"面積_用途白地  : {a_white:.4f} km²")
print(f"面積_用途+白地 : {a_sum:.4f} km² (≈ 基盤?)")
print(f"差             : {diff:.6f} km² (≪ 0.01)")
print(f"用途∩白地     : {inter_uw:.6f} km² (≈ 0?)")
print(f"基盤△(用途∪白地): {sym_base_uw:.6f} km²")
''')

s7_html = f"""
<p><b>狙い</b>: 「<b>基盤 = 用途地域 ∪ 用途白地</b>, <b>用途地域 ∩ 用途白地 = ∅</b>」 という 3 兄弟の
理論的補完関係を、<b>幾何 union/intersection で実証</b>する。</p>

<h3>手法のツール化視点 (要件 J)</h3>
<p>使うのは shapely の集合演算。<b>数式の中身は黒箱で OK</b> ─
学習者には「2 つの polygon 集合を 1 つに結合した結果が、別 polygon と
同じか?」を計量で確かめる手順を伝える:</p>
<table>
<tr><th>関数</th><th>入力</th><th>出力</th><th>意味</th></tr>
<tr><td><code>g.union_all()</code></td><td>GeoSeries</td><td>shapely Geometry</td><td>全 polygon を 1 つに合体</td></tr>
<tr><td><code>A.intersection(B)</code></td><td>2 つの Geometry</td><td>Geometry</td><td>共通部分</td></tr>
<tr><td><code>A.symmetric_difference(B)</code></td><td>2 つの Geometry</td><td>Geometry</td><td>(A∪B) − (A∩B) = どちらか一方だけにある部分</td></tr>
<tr><td><code>g.area</code></td><td>Geometry (m 単位 CRS)</td><td>float</td><td>面積 m²</td></tr>
</table>

<p><b>結果の読み方</b>:</p>
<ul>
  <li>「差 = 基盤 - 用途+白地 ≈ 0」なら基盤面積は用途+白地の総和。</li>
  <li>「用途∩白地 ≈ 0」なら用途と白地は重なっていない (排他的)。</li>
  <li>「基盤△(用途∪白地) ≈ 0」なら基盤と用途∪白地は同じ領域 (完全分割)。</li>
  <li>3 つすべてが 0 なら「<b>基盤 = 用途 ⊔ 白地</b>」(disjoint union) が成立。</li>
</ul>

<h3>実装</h3>
{code_consist}

<h3>結果</h3>
<table>
<tr><th>指標</th><th>値</th><th>解釈</th></tr>
<tr><td>面積_基盤</td><td>{a_base:.4f} km²</td><td>準都市計画区域 全体</td></tr>
<tr><td>面積_用途地域 (use)</td><td>{a_use:.4f} km²</td><td>用途指定された部分</td></tr>
<tr><td>面積_用途白地 (white)</td><td>{a_white:.4f} km²</td><td>指定なしの部分</td></tr>
<tr><td>面積_用途+白地</td><td>{a_sum:.4f} km²</td><td>合算 ≈ 基盤?</td></tr>
<tr><td><b>差 (基盤 - 合算)</b></td><td>{diff:.6f} km² ({abs(diff)*1e6:.0f} m²)</td>
    <td>誤差は数値計算上の丸め程度。実質ゼロ</td></tr>
<tr><td>用途 ∩ 白地 (重なり)</td><td>{inter_uw:.6f} km²</td><td>≈ 0 → 排他的</td></tr>
<tr><td>基盤 △ (用途∪白地) (対称差)</td><td>{sym_base_uw:.6f} km²</td><td>≈ 0 → 完全一致</td></tr>
<tr><td>基盤 ∩ (用途∪白地)</td><td>{inter_base_uw:.4f} km²</td><td>≈ 基盤面積 → 包含成立</td></tr>
</table>

<p><b>この表から読み取れること</b>:</p>
<ul>
  <li><b>差 = {abs(diff)*1e6:.0f} m²</b>: GeoJSON 座標の浮動小数点誤差レベル。事実上「ぴったり一致」。</li>
  <li><b>用途 ∩ 白地 ≈ 0</b>: 用途地域と用途白地は<b>排他的</b>。重なる polygon は無い。</li>
  <li>仮説 H2 が完全に支持される: 基盤は用途指定の有無で<b>完全二分</b>される。</li>
  <li>これは DoBoX の準都市計画区域 GeoJSON が<b>厳密に整備された</b>ことの幾何的証拠。</li>
</ul>

{figure("assets/L29_three_panels.png",
        "図3: 3 兄弟 small multiples (基盤 / 用途地域 / 用途白地)")}

<p><b>図3 から読み取れること</b>:</p>
<ul>
  <li>(a) 基盤: {len(g_base)} polygon, 大 1 + 小 4 の構成。</li>
  <li>(b) 用途地域: {len(g_use)} polygon, 全部 (a) の輪郭の<b>内側</b>に収まる小片群。</li>
  <li>(c) 用途白地: {len(g_white)} polygon, (a) の大部分 ({a_white/a_base*100:.0f}%) を占める連続塊。</li>
  <li>(b) と (c) は重ならない。両方足すと (a) になる ─ 完全分割の視覚化。</li>
</ul>

{figure("assets/L29_yuki_closeup.png",
        "図2: 湯来地区クローズアップ (3 兄弟重ね)")}

<p><b>図2 から読み取れること</b>:</p>
<ul>
  <li>1 枚の地図に 3 兄弟を重ねると、灰 (基盤) を緑 (白地) と赤 (用途地域) が完全に塗り分ける構造が直感できる。</li>
  <li>赤 (用途地域) は東部のみに集中 ─ おそらく旧湯来町の中心市街地。</li>
  <li>5 つの基盤 polygon (FID=0..4) のうち、最大 (FID=0, 3.52 km²) が東部主群、残りは西部の飛地。</li>
</ul>
"""
sections.append(("7. 分析4: 3 兄弟の幾何整合性 — 基盤 = 用途 ⊔ 白地", s7_html))


# === セクション8: 分析5 — ポリゴン規模分布と細片性 ===
code_size = code('''
# polygon 規模統計 (3 兄弟別)
import numpy as np
size_stats = []
for kind, g in [("base", g_base), ("use", g_use), ("white", g_white)]:
    a = g["poly_area_km2"]
    size_stats.append({{
        "kind": kind, "polys": len(g),
        "min_km2": a.min(), "max_km2": a.max(),
        "mean_km2": a.mean(), "median_km2": a.median(),
    }})

# コンパクト性 (4πA/L²): 円が 1, 細長いほど 0
poly_df["compactness"] = (4 * np.pi * poly_df["area_km2"]
                          / (poly_df["perim_km"] ** 2))
# 用途地域 (use) は道路沿い細片が多いので compactness が低い傾向
print(poly_df.groupby("kind")["compactness"].describe())
''')

s8_html = f"""
<p><b>狙い</b>: 用途地域 (use) と用途白地 (white) で polygon の<b>規模・形状</b>の特徴が
どう違うかを定量化する。仮説 H4「用途地域は細片状でロードサイド型」を検証する。</p>

<h3>手法のツール化視点</h3>
<p><b>コンパクト性 (compactness) 指標</b>: 周長 L と面積 A から
<code>4πA / L²</code> を計算すると、円なら 1.0、細長くなるほど 0 に近づく。
これは形状の<b>「丸さ」を数値化する古典的指標</b>。</p>
<ul>
  <li>1.0 (円): 周長に対して面積が最大効率</li>
  <li>0.7〜0.9 (正方形〜五角形): まとまった塊</li>
  <li>0.3〜0.5 (細長帯, ロードサイド): 道路沿いに伸びる小片</li>
  <li>< 0.2: 極端に細長い帯</li>
</ul>

<p>限界: コンパクト性は「形状の凹凸」までは見ない。
複雑な海岸線などは別指標 (fractal dimension 等) が必要だが、本記事ではシンプルな指標で十分。</p>

<h3>実装</h3>
{code_size}

<h3>結果: 規模分布</h3>
<table>
<tr><th>kind</th><th>polygon 数</th><th>min km²</th><th>max km²</th><th>mean km²</th><th>median km²</th></tr>
"""
for _, r in size_df.iterrows():
    s8_html += (
        f'<tr><td><code>{r["kind"]}</code></td>'
        f'<td>{r["polys"]}</td>'
        f'<td>{r["min_km2"]:.4f}</td><td>{r["max_km2"]:.4f}</td>'
        f'<td>{r["mean_km2"]:.4f}</td><td>{r["median_km2"]:.4f}</td></tr>'
    )
s8_html += f"""
</table>

<h3>結果: 形状コンパクト性</h3>
<table>
<tr><th>kind</th><th>polys</th><th>mean compactness</th><th>median</th><th>最小 (最も細長)</th></tr>
"""
for _, r in shape_df.iterrows():
    s8_html += (
        f'<tr><td><code>{r["kind"]}</code></td>'
        f'<td>{r["polys"]}</td>'
        f'<td>{r["mean_compactness"]:.4f}</td>'
        f'<td>{r["median_compactness"]:.4f}</td>'
        f'<td>{r["min_compactness"]:.4f}</td></tr>'
    )
s8_html += f"""
</table>

<p><b>この表から読み取れること</b>:</p>
<ul>
  <li><b>用途地域 (use)</b>: 平均 {size_df.set_index('kind').loc['use','mean_km2']:.4f} km² (= 約 {size_df.set_index('kind').loc['use','mean_km2']*1000:.0f} m²)。
      最大でも {size_df.set_index('kind').loc['use','max_km2']:.4f} km² の小片。仮説 H4 「< 0.1 km²」は支持される。</li>
  <li><b>用途白地 (white)</b>: 平均 {size_df.set_index('kind').loc['white','mean_km2']:.4f} km²、最大 {size_df.set_index('kind').loc['white','max_km2']:.4f} km² の連続塊。用途地域より一桁大きい。</li>
  <li><b>基盤 (base)</b>: 大 1 (3.52 km²) + 中 2 + 小 2 の不均一構成。</li>
  <li>コンパクト性の比較: 用途地域は<b>{shape_df.set_index('kind').loc['use', 'mean_compactness']:.3f}</b>
      vs 用途白地 <b>{shape_df.set_index('kind').loc['white', 'mean_compactness']:.3f}</b>。
      用途地域は白地より細長い ─ 道路沿いに帯状指定された痕跡。</li>
</ul>

{figure("assets/L29_size_distribution.png",
        "図5: 3 兄弟ポリゴン規模分布 (左, log) と 面積×コンパクト性 (右)")}

<p><b>図5 から読み取れること</b>:</p>
<ul>
  <li>左 (log scale): 用途地域 (赤) は最大 0.42 km² から最小 0.014 km² まで、約 30 倍の幅で分布。
      白地 (緑) は最大 1.92 km² から最小 0.001 km² まで、約 2000 倍の幅で<b>桁違いに分散</b>。</li>
  <li>右 (面積 × コンパクト性): 小さい polygon (面積 < 0.01 km²) はコンパクト性も小さい (= 細長い破片)。
      大きい polygon (面積 > 0.5 km²) は <b>compactness 0.4〜0.6</b> でやや丸い塊。</li>
  <li>用途地域 (赤) は左下クラスタに集中 = 「<b>小さくて細長い</b>」 ─ ロードサイド型実装の幾何的痕跡。</li>
</ul>
"""
sections.append(("8. 分析5: ポリゴン規模分布と形状の細片性", s8_html))


# === セクション9: 分析6 — 用途地域 7 ポリゴンの詳細マップ ===
code_use_detail = code('''
# 用途地域 7 polygon を KUIKI_TB で色分けして詳細マップを描画
import matplotlib.cm as cm
cmap = cm.get_cmap("tab10")

fig, ax = plt.subplots(figsize=(11, 9))
g_base.plot(ax=ax, color="#dddddd", edgecolor="#444",
            linewidth=0.4, alpha=0.4)            # 背景: 基盤
g_white.plot(ax=ax, color="#c8e6c9", edgecolor="white",
             linewidth=0.3, alpha=0.5)            # 背景: 白地
for _, r in g_use.iterrows():
    color = cmap(int(r["KUIKI_TB"]) % 10)
    gpd.GeoSeries([r.geometry]).plot(ax=ax, color=color,
                                      edgecolor="black",
                                      linewidth=0.7, alpha=0.95)
    pt = r.geometry.representative_point()
    ax.annotate(f"TB={int(r['KUIKI_TB'])}\\n{r['poly_area_km2']:.3f} km²",
                xy=(pt.x, pt.y), fontsize=8.5,
                ha="center", fontweight="bold",
                xytext=(0, 12), textcoords="offset points",
                arrowprops=dict(arrowstyle="-", color="black", lw=0.5))
''')

s9_html = f"""
<p><b>狙い</b>: わずか 7 ポリゴンの用途地域指定の<b>1 つずつ</b>を可視化し、
KUIKI_TB の番号と空間配置の対応を確認する。</p>

<h3>可視化の選定理由 (要件 H)</h3>
<p>polygon 数が極小なので、各 polygon に <code>KUIKI_TB</code> 番号と面積を直接ラベリング。
カテゴリ色分け (tab10 パレット) で個体識別を可能にする。
3 兄弟まとめ図 (図2) では細部が潰れるので、<b>用途地域だけのズーム図</b>を別に用意する価値がある。</p>

<h3>実装</h3>
{code_use_detail}

<h3>用途地域 7 ポリゴンの詳細表</h3>
<table>
<tr><th>FID</th><th>KUIKI_TB</th><th>面積 km²</th><th>周長 km</th><th>代表点 X</th><th>代表点 Y</th></tr>
"""
use_polys = poly_df[poly_df["kind"] == "use"].sort_values("KUIKI_TB")
for _, r in use_polys.iterrows():
    s9_html += (
        f'<tr><td>{r["FID"]}</td><td><b>{r["KUIKI_TB"]}</b></td>'
        f'<td>{r["area_km2"]:.4f}</td><td>{r["perim_km"]:.3f}</td>'
        f'<td>{r["rep_x"]:.0f}</td><td>{r["rep_y"]:.0f}</td></tr>'
    )
s9_html += f"""
</table>

<p><b>この表から読み取れること</b>:</p>
<ul>
  <li>KUIKI_TB は 1〜7 の連番、FID は 0〜6 の連番 → <b>KUIKI_TB = FID + 1</b> がぴったり成立。
      したがって <code>KUIKI_TB</code> は意味的な用途分類コードではなく<b>単なる行番号</b>。</li>
  <li>L17/L18 の <code>YOTO_CD</code> や L19 の <code>KUIKI_CD</code> と<b>命名が似ているのに意味が違う</b>。
      監査未経シリーズの落とし穴 ─ シリーズ間で同名列の意味が違うことを確認する習慣が重要。</li>
  <li>面積の幅: 0.014 km² (= 14,000 m²) から 0.42 km² (= 42 ha) までの 30 倍幅。
      最大の TB=1 (0.42 km²) は東部主群、最小の TB=7 (0.014 km²) も東部主群の辺縁。</li>
  <li>代表点 X 座標を見ると、TB=4 (X≈6861) と TB=6 (X≈6952) のみが<b>西部小群</b>。
      残り 5 ポリゴンは X≈11500-12200 の<b>東部主群</b>に密集 ─
      旧湯来町の中心市街地周辺と推定される。</li>
</ul>

{figure("assets/L29_use_zones_detail.png",
        "図6: 用途地域 7 ポリゴン詳細 (KUIKI_TB 別カラー)")}

<p><b>図6 から読み取れること</b>:</p>
<ul>
  <li>用途地域 7 polygon は全て基盤 (灰背景) の輪郭内に収まる ─ 包含関係の視覚化。</li>
  <li>東部 (TB=1,2,3,5,7) が密集 ─ 旧湯来町中心市街地。</li>
  <li>西部 (TB=4,6) は単独の小片 ─ おそらく国道沿いの拠点的指定。</li>
  <li>合計面積 0.72 km² が、これだけ細片に分かれていることが「用途地域指定はピンポイント運用」という制度実装スタイルを示す。</li>
</ul>
"""
sections.append(("9. 分析6: 用途地域 7 ポリゴンの詳細マップ", s9_html))


# === セクション10: 分析7 — 制度比較 ===
s10_html = f"""
<p><b>狙い</b>: 広島県内の他の都市計画制度 (L16/L17/L18/L26/L27) と<b>規模を並べ比較</b>し、
準都市計画区域が制度ピラミッドの中でどの位置にあるかを定量的に示す。</p>

<h3>可視化の選定理由</h3>
<p>面積規模が桁違い (4.58 km² vs 7000 km²) なので、線形スケールでは小さい棒が潰れる。
<b>log スケール水平棒</b>で全制度を視覚化することで、
「準都市計画区域は制度ピラミッドの最下層 (= 最も狭い)」が一目で分かる。</p>

<p>注: L16/L17/L26/L27 の数値は各レッスンの結論値の概算。本記事の主役は
L29 (準都市計画区域) であり、他制度の値は<b>位置付けのための参考</b>として併置する。</p>

<h3>結果: 制度別 規模比較表</h3>
<table>
<tr><th>制度</th><th>面積 km²</th><th>県土比 %</th><th>該当レッスン</th></tr>
<tr><td>都市計画区域<b>外</b></td><td>~7,000</td><td>~82%</td><td>L26</td></tr>
<tr><td>通常都市計画区域 (合計)</td><td>~1,450</td><td>~17%</td><td>L16</td></tr>
<tr><td>市街化区域 + 線引き用途地域</td><td>~460</td><td>~5.4%</td><td>L17, L18</td></tr>
<tr><td>非線引き用途白地</td><td>~850</td><td>~10%</td><td>L27</td></tr>
<tr><td>非線引き用途地域</td><td>~50</td><td>~0.59%</td><td>L27</td></tr>
<tr><td><b>準都市計画区域 (基盤)</b></td><td><b>{a_base:.2f}</b></td><td><b>{a_base/PREF_AREA_KM2*100:.4f}%</b></td><td><b>L29 (本記事)</b></td></tr>
<tr><td><b>準都市計画 用途白地</b></td><td><b>{a_white:.2f}</b></td><td><b>{a_white/PREF_AREA_KM2*100:.4f}%</b></td><td><b>L29</b></td></tr>
<tr><td><b>準都市計画 用途地域</b></td><td><b>{a_use:.2f}</b></td><td><b>{a_use/PREF_AREA_KM2*100:.5f}%</b></td><td><b>L29</b></td></tr>
</table>

<p><b>この表から読み取れること</b>:</p>
<ul>
  <li>準都市計画区域 (4.58 km²) は<b>非線引き用途地域 (~50 km²) のさらに 1/10 程度</b>。
      広島県の都市計画制度の中で<b>最も狭い指定</b>。</li>
  <li>用途地域に絞ると 0.72 km² ─ 県土の <b>1/12,000</b>。「規制が及ぶ場所」としては事実上ゼロに近い。</li>
  <li>制度趣旨「最低限の規制で乱開発を防ぐ」が、規模のレベルで実装に反映されている。</li>
</ul>

{figure("assets/L29_regime_compare.png",
        "図7: 広島県の都市計画制度別 規模比較 (log スケール)")}

<p><b>図7 から読み取れること</b>:</p>
<ul>
  <li>log スケールでも、準都市計画区域系の 3 つの棒は他制度から<b>2〜3 桁下</b>に位置。</li>
  <li>「区域外」(灰) が圧倒的に大きい。県土の大半は都市計画法のフル制御外。</li>
  <li>準都市計画区域 (赤系小棒) は「<b>制度上は存在するが、量的にはほぼゼロ</b>」</li>
</ul>

{figure("assets/L29_pyramid.png",
        "図11: 制度ピラミッド (log スケール)")}

<p><b>図11 から読み取れること</b>:</p>
<ul>
  <li>制度を「広い → 狭い」 で積み重ねると、準都市計画区域はピラミッドの最先端。</li>
  <li>準都市計画 用途地域指定 (0.72 km²) は最小制度 ─ 県全体の中で「最も精密な制御」だが、量的にも極小。</li>
</ul>
"""
sections.append(("10. 分析7: 他制度との規模比較 — 制度ピラミッドの最先端", s10_html))


# === セクション11: 分析8 — 用途指定階層 + 2 クラスタ ===
s11_html = f"""
<p><b>狙い</b>: 基盤 → 用途指定/非指定の<b>階層構造</b>と、用途地域+白地の<b>地理的 2 クラスタ</b>(東部主群 + 西部小群) を可視化する。</p>

<h3>結果: 階層構造</h3>
<p>基盤 100% を 1 段目、用途+白地の比率を 2 段目に並べた階層図 (図8) で、
「区域内の用途指定率 {a_use/a_base*100:.1f}% / 白地率 {a_white/a_base*100:.1f}%」が直感できる。</p>

{figure("assets/L29_hierarchy.png",
        "図8: 用途指定階層 ─ 基盤 → 用途 + 白地 への分割")}

<p><b>図8 から読み取れること</b>:</p>
<ul>
  <li>基盤の {a_use/a_base*100:.1f}% のみが用途指定 ─ 仮説 H3「20% 未満」を完全に支持。</li>
  <li>残り {a_white/a_base*100:.1f}% は白地 = 用途地域指定なしの緩規制部分。</li>
  <li>これが「準都市計画区域 = 最低限の規制」の幾何的実装。</li>
</ul>

<h3>結果: 地理的 2 クラスタ</h3>
<p>polygon の代表点を散布図で示すと、X 座標 ≈ 8000 m を境に<b>2 クラスタ</b>に分かれる。</p>

{figure("assets/L29_polygon_scatter.png",
        "図9: 32 ポリゴンの代表点散布 (バブル=√面積)")}

<p><b>図9 から読み取れること</b>:</p>
<ul>
  <li>東部主群 (X ≈ 9300-12300): 大半の polygon が密集。
      最大の基盤 polygon (B0, 3.52 km²) と用途地域 5 ポリゴン (U0,1,2,4,6 = TB 1,2,3,5,7) を含む。</li>
  <li>西部小群 (X ≈ 6500-7100): 小規模 polygon の飛地。
      基盤 1 (B1) + 用途地域 2 (U3,U5 = TB 4,6) + 白地多数 (W5-9)。</li>
  <li>2 クラスタはおそらく旧湯来町の<b>中心市街地</b> (東部) と<b>国道沿い拠点</b> (西部) に対応。</li>
</ul>

{figure("assets/L29_cluster_zoom.png",
        "図10: 2 クラスタ別ズーム (左=西部小群, 右=東部主群)")}

<p><b>図10 から読み取れること</b>:</p>
<ul>
  <li>(a) 西部小群: 小規模 polygon が密集。用途地域は 2 ポリゴン (TB=4,6) のみで、
      残りは細かい白地 polygon の集まり ─ 国道 488 号沿いの拠点的指定 (推定)。</li>
  <li>(b) 東部主群: 大規模 polygon の連続塊。
      用途地域 5 ポリゴンと白地 5 ポリゴンが、基盤 4 ポリゴンを完全分割。</li>
  <li>2 クラスタ構造は<b>「中心市街地 + 飛地」</b>のロードサイド型実装の典型パターン。</li>
</ul>
"""
sections.append(("11. 分析8: 用途指定階層と地理的 2 クラスタ", s11_html))


# === セクション12: 仮説検証と考察 ===
s12_html = """
<p>H1〜H6 の仮説検証結果を整理する。</p>

<table>
<tr><th>仮説</th><th>主張</th><th>判定</th><th>根拠</th></tr>
"""
verdict_rows = [
    ("H1", "1〜3 市町, 10 km² 未満", results["H1"]["verdict"],
     f"CITY_CD ユニーク={city_cds_sorted}, 面積={a_base:.2f} km² (県土の {a_base/PREF_AREA_KM2*100:.4f}%)"),
    ("H2", "基盤 = 用途 + 白地", results["H2"]["verdict"],
     f"差 {abs(diff)*1e6:.0f} m² (≪ 0.01 km²), 用途∩白地={inter_uw:.4f} km²"),
    ("H3", "用途指定率 < 20%", results["H3"]["verdict"],
     f"用途/基盤 = {a_use/a_base*100:.1f}%"),
    ("H4", "用途地域は細片状", results["H4"]["verdict"],
     f"用途地域 平均 {float(g_use['poly_area_km2'].mean())*1000:.0f} m², 白地 平均 {float(g_white['poly_area_km2'].mean())*1000:.0f} m²"),
    ("H5", "広島市 ds = 県全域 ds", results["H5"]["verdict"],
     f"3 兄弟すべて完全一致 (件数・面積・FID 集合・幾何対称差 すべて 0)"),
    ("H6", "CITY_CD = 108 = 広島市佐伯区 (旧湯来町)", results["H6"]["verdict"],
     f"全 6 ファイルで CITY_CD = 108 単一値"),
]
for h, claim, v, r in verdict_rows:
    color = "#1f883d" if "支持" in v and "部分" not in v else (
        "#d4a72c" if "部分" in v else "#cf222e")
    s12_html += (
        f'<tr><td><b>{h}</b></td><td>{claim}</td>'
        f'<td style="color:{color};font-weight:bold;">{v}</td>'
        f'<td>{r}</td></tr>'
    )
s12_html += f"""
</table>

<p><b>この表から読み取れること</b>:</p>
<ul>
  <li><b>6 仮説すべてが「支持」</b>。本研究の研究問いに対する明確な答えが得られた。</li>
  <li>準都市計画区域は広島県内で<b>たった 1 市町 (CITY_CD=108) のみ</b>、合計 4.58 km² の極小指定。</li>
  <li>3 兄弟は<b>幾何的に厳密な完全分割</b>を成しており、データ品質は極めて高い。</li>
  <li>カタログ表記「広島市」は実態を正確に反映していない (実体は旧湯来町)。
      <b>カタログ表記の素朴解釈は誤解を生む</b> ─ 監査未経シリーズの教訓。</li>
</ul>

<h3>考察 1: なぜこの 1 箇所だけなのか</h3>
<p>広島県内に準都市計画区域が広島市佐伯区 (旧湯来町) の 4.58 km² だけしか指定されていない理由:</p>
<ol>
  <li><b>合併の経緯</b>: 2005 年に佐伯郡湯来町が広島市佐伯区へ編入された際、
      旧町域の中心市街地 + 国道沿い拠点を<b>制度的に保全</b>するため、
      準都市計画区域として指定。合併によって急に都市計画区域に編入するのを避ける緩衝。</li>
  <li><b>制度の歴史的特殊性</b>: 準都市計画区域 (都計法第 5 条の 2) は元々、
      <b>高速道路 IC 周辺の中山間部</b>でロードサイド型開発を抑制する目的で
      1992 年に創設された制度。広島県内では IC 周辺のロードサイド開発圧力が
      <b>湯来地区以外には強く出なかった</b>ため、追加指定が発生していない。</li>
  <li><b>市町中心の都市計画体制</b>: 広島県は線引き (L18) と非線引き (L27) の組み合わせで
      ほぼ全域をカバーしており、両者の隙間に<b>準都市計画区域を必要とする領域がほぼ無い</b>。</li>
</ol>

<h3>考察 2: 「最も使われていない都市計画制度」の研究的意義</h3>
<p>本研究は<b>少件数の制度</b>を扱うが、これこそが研究的に意義深い:</p>
<ul>
  <li>少件数 = データの希少性は<b>制度自体の希少性</b>を反映する。
      量的に小さいことが「使われていない」事実の傍証となる。</li>
  <li>L27 (非線引き) と L29 (準都市計画) を比較すると、両者とも「緩い制御」だが、
      L27 が 900 km² なのに L29 は 4.58 km² ─ <b>2 桁の差</b>。
      これは「都市計画区域<b>内</b>で緩い制御 (L27)」と
      「都市計画区域<b>外</b>で緩い制御 (L29)」の<b>使い分けの実情</b>を示す。</li>
  <li>カタログ網羅プロジェクトの観点: 広島県オープンデータの 551 件中
      準都市計画区域シリーズは 6 件 (= 1.1%)。<b>件数比に対して制度の理解は不可欠</b> ─
      「6 件しかないから無視」ではなく、6 件こそが<b>制度の周縁性そのもの</b>を表現している。</li>
</ul>

<h3>考察 3: 監査未経シリーズの落とし穴</h3>
<p>L26 (監査未経) → L27 (互補ペア) → L28 (隣接 dsid) → L29 (3 兄弟) と進めて学んだこと:</p>
<ul>
  <li><b>カタログ表記 ≠ 実体</b>: 「広島市」と書いてあっても実体は旧湯来町。
      CITY_CD でクロスチェックする習慣が必須。</li>
  <li><b>同名列の意味が違う</b>: KUIKI_TB は L19 の KUIKI_CD と命名が似ているが、
      意味は単なる連番 ID で全く違う。</li>
  <li><b>市町別 ds と県全域 ds の関係</b>: 通常は「県全域 = 全市町合算」だが、
      本シリーズは「県全域 = 単一市町と同じ」 ─ 制度実態に応じた特殊構造。</li>
  <li>これらは<b>実データを開いて構造調査しないと分からない</b>。
      取得 → 構造 probe → 仮説 → 分析 の順序が監査未経シリーズには必須。</li>
</ul>
"""
sections.append(("12. 仮説検証と考察", s12_html))


# === セクション13: 発展課題 ===
s13_html = """
<p>結果X → 新仮説Y → 課題Z の 3 段で書く (要件 E)。</p>

<h3>発展 (i): 全国比較 ─ 広島県は珍しいのか</h3>
<ul>
  <li><b>結果 X</b>: 広島県内の準都市計画区域は 4.58 km² の 1 箇所のみ。県土比 0.054%。</li>
  <li><b>新仮説 Y</b>: 全国の他都道府県でも準都市計画区域指定は同程度に希少。
      とくに大都市圏 (東京・大阪・名古屋) は線引きで尽きるためゼロに近い。
      長野・静岡・栃木など中山間部 + 高速 IC 多数の県は数 10 km² 以上の指定があるかもしれない。</li>
  <li><b>課題 Z</b>: 国土交通省「都市計画現況調査」(年次公表) から各都道府県の
      準都市計画区域面積を抽出 → 地域差ランキング → 広島県の希少さを定量化。</li>
</ul>

<h3>発展 (ii): 用途地域 7 ポリゴンの実地調査</h3>
<ul>
  <li><b>結果 X</b>: 用途地域 (use) は東部主群 5 + 西部小群 2 の合計 7 polygon。
      最大は 0.42 km² (東部の TB=1)。</li>
  <li><b>新仮説 Y</b>: 東部主群 5 ポリゴンは<b>旧湯来町中心市街地</b> (湯来役場・湯来温泉周辺) に対応し、
      西部小群 2 ポリゴンは<b>国道 488 号沿いの拠点</b>(伴峠 IC など) に対応する。</li>
  <li><b>課題 Z</b>: 各 polygon の代表点座標を Google Maps / 国土地理院地図に重ねて、
      実際の建物利用 (商店・GS・住宅) を現地観察。さらに L13 (建物利用現況) の polygon と
      sjoin して<b>「ロードサイド型建物の集中」</b>を確認する。</li>
</ul>

<h3>発展 (iii): 旧湯来町の合併前後比較</h3>
<ul>
  <li><b>結果 X</b>: 2005 年合併で旧湯来町が広島市佐伯区へ編入された結果、
      準都市計画区域として指定された。</li>
  <li><b>新仮説 Y</b>: 合併前の湯来町は都市計画区域<b>外</b>で、合併直後に「準」として継承指定された。
      合併から 20 年経った 2026 年現在、人口減少に伴い指定縮小or 廃止の議論があるかもしれない。</li>
  <li><b>課題 Z</b>: 広島市の都市計画変更告示 (1990 年代〜2025 年) を時系列で追跡 →
      湯来地区の準都市計画区域の指定経緯と、地区計画への移行有無を確認。</li>
</ul>

<h3>発展 (iv): L27/L29 のロードサイド比較</h3>
<ul>
  <li><b>結果 X</b>: L29 用途地域は 0.72 km² の細片性 (compactness 平均 ≈ 0.4)。
      L27 (非線引き用途地域) は 50 km² だが、ポリゴン平均面積は L29 より大きい。</li>
  <li><b>新仮説 Y</b>: L29 はより<b>「精密なロードサイド指定」</b>であり、L27 は地形に応じた
      <b>「広めのまとまり指定」</b>。L29 のほうが「制度の意図 = ロードサイド抑制」の
      幾何的実装が純粋に表れている。</li>
  <li><b>課題 Z</b>: L27/L29 両シリーズについて compactness 分布を比較し、
      Mann-Whitney U 検定で「L29 のほうが有意に細長い」を統計的に検証。</li>
</ul>

<h3>発展 (v): 制度シミュレーション ─ もし湯来地区が普通の都市計画区域だったら</h3>
<ul>
  <li><b>結果 X</b>: 旧湯来町が「準」で済まされたため、用途地域は 0.72 km² のみで指定されている。</li>
  <li><b>新仮説 Y</b>: もし普通の都市計画区域 + 線引きが導入されていれば、
      市街化区域 (用途地域指定) は 1.5〜2 km² (現在の 2〜3 倍) になっていたはず。</li>
  <li><b>課題 Z</b>: L17 の隣接市町 (大竹市・廿日市市) の市街化区域パターンから、
      湯来地区に同様の指定を仮想付与した場合の面積を推定。
      実 4.58 km² の 70%程度を市街化区域とする想定 → 現指定の 4 倍規模に相当することを示す。</li>
</ul>
"""
sections.append(("13. 発展課題", s13_html))


# === HTML 書き出し ===
html = render_lesson(
    num=29,
    title="L29: 準都市計画区域 三兄弟 (基盤+用途地域+用途白地) 統合分析 — 広島県唯一の旧湯来町域 4.58 km² の構造",
    tags=["都市計画", "準都市計画区域", "GIS", "幾何整合性", "geopandas",
          "都計法第5条の2", "ロードサイド", "湯来"],
    time="35 分",
    level="リテラシレベル (大学生)",
    data_label=(
        "DoBoX 都市計画区域情報_区域データ_*_準都市計画区域 / 用途地域 / 用途白地 "
        "(3 兄弟 計 6 dataset_id; CITY_CD=108 単一)"
    ),
    sections=sections,
    script_filename="L29_quasi_planning_zones.py",
)
out_html = LESSONS / "L29_quasi_planning_zones.html"
out_html.write_text(html, encoding="utf-8")
print(f"\n=== HTML 書き出し: {out_html} ({len(html):,} chars) ===", flush=True)
print(f"=== L29 完成: 総時間 {time.time()-t0:.1f}s ===", flush=True)
