"""L16 都市計画区域 21 市町統合分析 — 広島県の都市計画指定構造と広域圏域

カバー宣言:
  本記事は 都市計画区域情報_区域データ_都市計画区域 シリーズ全 21 件 = 21 dataset_id を
  統合し、広島県全域の都市計画区域指定構造を分析する研究記事である。

  - 20 市町別 GeoJSON: ds=787 広島市 / 798 呉市 / 808 竹原市 / 815 三原市 / 825 尾道市
    / 833 福山市 / 841 府中市 / 851 三次市 / 857 庄原市 / 863 大竹市 / 869 東広島市
    / 879 廿日市市 / 889 安芸高田市 / 895 江田島市 / 901 府中町 / 906 海田町
    / 912 熊野町 / 917 坂町 / 936 北広島町 / 942 世羅町
  - 県全域 集約ファイル: ds=923 広島県（21 市町分のポリゴンを 102 件で含む）
  → 合計 21 dataset_id（市町別 20 + 県全域 1）

研究の問い (RQ):
  広島県内に指定された都市計画区域は、どのような種類・規模・行政市町との関係を持ち、
  「広域都市計画区域（○○圏）」はどう機能しているか？

仮説 (D):
  H1. 広島県の市町は約 3 割しか都市計画区域に指定されておらず、
       中山間地域では「白地」（非指定）が支配的である
  H2. 「○○圏都市計画区域」は複数市町をまたぐ広域指定として機能する。
       特に「広島圏」は周辺町を含む大都市圏の典型である
  H3. 21 市町別ファイルと県全域 ds=923 の合計はポリゴン数・面積で完全一致する
  H4. 都市計画区域の指定面積は人口集中地域に偏在し、小面積でも都市部の町
       （海田町・府中町・熊野町等）はほぼ全域指定、広面積の中山間市町は低指定率
  H5. TOKEI_CD=2（準都市計画区域）は本来の都市計画区域（TOKEI_CD=1）と異なる
       周縁的役割を持ち、広島市湯来地区の限定指定に留まる

要件S 準拠: 1 分以内で完走（ポリゴン総数 102、軽量）。
要件T 準拠: 主題図 + 広域指定 zonal map + 行政区域との重ね合わせ + small multiples
要件Q 準拠: 図 8 枚以上、表 9 枚以上。

注: 本記事は L15（行政区域）と密接に関係するが、「合体」ではない。
    都市計画区域=L16 のメイン分析対象、行政区域=L15 のメイン分析対象。
    本記事内では行政区域は「比率分母」と「ベースマップ」として最小限に参照する。
"""
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
from matplotlib.lines import Line2D
import geopandas as gpd

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

t0 = time.time()
print("=== L16 都市計画区域 21市町統合分析 ===", flush=True)

# =============================================================================
# 0. 定数: 21 dataset_id, 行政面積参照値
# =============================================================================
DATA_DIR = ROOT / "data" / "extras" / "L16_city_planning_zones"
ADMIN_DIR = ROOT / "data" / "extras" / "L15_admin_zones"
TARGET_CRS = "EPSG:6671"  # JGD2011 平面直角 III, 広島県, m 単位

# (dataset_id, 市町名, 海岸 bool, タイプ)
CITY_DEFS = [
    (787, "広島市",     True,  "政令市"),
    (798, "呉市",       True,  "市"),
    (808, "竹原市",     True,  "市"),
    (815, "三原市",     True,  "市"),
    (825, "尾道市",     True,  "市"),
    (833, "福山市",     True,  "市"),
    (841, "府中市",     False, "市"),
    (851, "三次市",     False, "市"),
    (857, "庄原市",     False, "市"),
    (863, "大竹市",     True,  "市"),
    (869, "東広島市",   True,  "市"),
    (879, "廿日市市",   True,  "市"),
    (889, "安芸高田市", False, "市"),
    (895, "江田島市",   True,  "離島自治体"),
    (901, "府中町",     False, "町"),
    (906, "海田町",     True,  "町"),
    (912, "熊野町",     False, "町"),
    (917, "坂町",       True,  "町"),
    (936, "北広島町",   False, "町"),
    (942, "世羅町",     False, "町"),
]
KEN_DSID = 923

# 行政区域用 dsid (L15 と同じ; 比率計算の分母として使う)
ADMIN_DSID = {
    "広島市":786, "呉市":797, "竹原市":807, "三原市":814, "尾道市":824, "福山市":832,
    "府中市":840, "三次市":850, "庄原市":856, "大竹市":862, "東広島市":868, "廿日市市":878,
    "安芸高田市":888, "江田島市":894, "府中町":900, "海田町":905, "熊野町":911, "坂町":916,
    "北広島町":935, "世羅町":941,
}

TYPE_COLOR = {
    "政令市":     "#cf222e",
    "市":         "#0969da",
    "離島自治体": "#bf8700",
    "町":         "#1f883d",
}

# =============================================================================
# 1. 21 市町 GeoJSON 統合読み込み
# =============================================================================
print("\n[1] 21 市町 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.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()))

frames = []
miss = []
for dsid, name, coastal, ctype in CITY_DEFS:
    z = DATA_DIR / f"city_planning_{dsid}_{name}.zip"
    if not z.exists():
        miss.append((dsid, name))
        continue
    g = load_geojson_zip(z)
    g["source_city"] = name
    g["source_dsid"] = dsid
    g["coastal"] = coastal
    g["ctype"] = ctype
    frames.append(g)

assert not miss, f"missing zip: {miss}"
zone_all = gpd.GeoDataFrame(pd.concat(frames, ignore_index=True),
                            geometry="geometry", crs=frames[0].crs)
zone_all = zone_all.to_crs(TARGET_CRS)
zone_all["poly_area_km2"] = zone_all.geometry.area / 1e6
zone_all["poly_perim_km"] = zone_all.geometry.length / 1e3
print(f"  21 市町 統合: {len(zone_all)} ポリゴン, CRS={zone_all.crs.to_epsg()}, "
      f"{time.time()-t1:.2f}s", flush=True)

# 県全域版 (ds=923) - 整合性検証用
ken_zip = DATA_DIR / f"city_planning_{KEN_DSID}_広島県.zip"
ken_gdf = load_geojson_zip(ken_zip).to_crs(TARGET_CRS)
ken_gdf["poly_area_km2"] = ken_gdf.geometry.area / 1e6
print(f"  県全域版 ds={KEN_DSID}: {len(ken_gdf)} ポリゴン", flush=True)

# 行政区域 (L15 と共有; 都計指定率の分母用)
admin_frames = []
for city, dsid in ADMIN_DSID.items():
    z = ADMIN_DIR / f"admin_{dsid}_{city}.zip"
    g = load_geojson_zip(z)
    g["city"] = city
    admin_frames.append(g)
admin_all = gpd.GeoDataFrame(pd.concat(admin_frames, ignore_index=True),
                              geometry="geometry", crs=admin_frames[0].crs)
admin_all = admin_all.to_crs(TARGET_CRS)
admin_all["admin_area_km2"] = admin_all.geometry.area / 1e6
admin_diss = admin_all.dissolve(by="city").reset_index()
admin_diss["admin_area_km2"] = admin_diss.geometry.area / 1e6
print(f"  行政区域 (L15共有) 読込: {len(admin_diss)} 市町", flush=True)

# =============================================================================
# 2. TOKEI_NAME 集約と広域圏域の同定
# =============================================================================
print("\n[2] TOKEI_NAME 集約・広域圏域の同定", flush=True)
t1 = time.time()

# (1) TOKEI_NAME 別ジオメトリ統合（dissolve）
zone_diss_name = zone_all.dissolve(by="TOKEI_NAME", aggfunc={
    "TOKEI_CD": "first",
}).reset_index()

def n_parts(geom):
    if geom.geom_type == "MultiPolygon":
        return len(list(geom.geoms))
    return 1

zone_diss_name["n_parts"] = zone_diss_name.geometry.apply(n_parts)
zone_diss_name["area_km2"] = zone_diss_name.geometry.area / 1e6
zone_diss_name["perim_km"] = zone_diss_name.geometry.length / 1e3
zone_diss_name["compactness"] = (4*np.pi*zone_diss_name["area_km2"]*1e6) / \
                                 (zone_diss_name["perim_km"]*1e3)**2

# 各 TOKEI_NAME がいくつの市町をまたぐか
crosstab_zc = pd.crosstab(zone_all["TOKEI_NAME"], zone_all["source_city"])
zone_diss_name["cities_count"] = zone_diss_name["TOKEI_NAME"].map(
    crosstab_zc.gt(0).sum(axis=1))
zone_diss_name["zone_kind"] = zone_diss_name["TOKEI_CD"].map(
    {1: "都市計画区域", 2: "準都市計画区域"})
# 広域指定（複数市町またぎ）か単一市町指定か
zone_diss_name["scope"] = zone_diss_name["cities_count"].apply(
    lambda n: "広域（複数市町）" if n >= 2 else "単一市町")

# 「○○圏」判定（名前ベース）
zone_diss_name["name_has_kw"] = zone_diss_name["TOKEI_NAME"].str.contains("圏")
zone_diss_name = zone_diss_name.sort_values("area_km2", ascending=False).reset_index(drop=True)

# (2) 市町別 都計指定面積と指定率
city_zone_agg = zone_all.groupby("source_city").agg(
    n_polys=("poly_area_km2", "size"),
    zone_area_km2=("poly_area_km2", "sum"),
    n_zone_names=("TOKEI_NAME", "nunique"),
).reset_index().rename(columns={"source_city": "city"})

# 行政区域面積をマージ
admin_areas = admin_diss.set_index("city")["admin_area_km2"]
city_zone_agg["admin_area_km2"] = city_zone_agg["city"].map(admin_areas)
city_zone_agg["zone_ratio_pct"] = city_zone_agg["zone_area_km2"] / city_zone_agg["admin_area_km2"] * 100
city_zone_agg["white_area_km2"] = city_zone_agg["admin_area_km2"] - city_zone_agg["zone_area_km2"]
# coastal/ctype をマージ
city_meta = pd.DataFrame([{"city": n, "coastal": c, "ctype": t}
                          for _, n, c, t in CITY_DEFS])
city_zone_agg = city_zone_agg.merge(city_meta, on="city")
# 並び順固定
CITY_ORDER = [c[1] for c in CITY_DEFS]
city_zone_agg["__o"] = city_zone_agg["city"].map({c: i for i, c in enumerate(CITY_ORDER)})
city_zone_agg = city_zone_agg.sort_values("__o").drop(columns="__o").reset_index(drop=True)

print(f"  TOKEI_NAME: {len(zone_diss_name)} 種類", flush=True)
print(f"  広域指定（2市町以上）: {(zone_diss_name['cities_count']>=2).sum()} 種類", flush=True)
print(f"  指定率最大: {city_zone_agg.loc[city_zone_agg['zone_ratio_pct'].idxmax(),'city']} "
      f"({city_zone_agg['zone_ratio_pct'].max():.1f}%)", flush=True)
print(f"  指定率最小: {city_zone_agg.loc[city_zone_agg['zone_ratio_pct'].idxmin(),'city']} "
      f"({city_zone_agg['zone_ratio_pct'].min():.1f}%)", flush=True)

# =============================================================================
# 3. 中間 CSV 出力
# =============================================================================
print("\n[3] 中間 CSV 出力", flush=True)
ASSETS.mkdir(parents=True, exist_ok=True)

# 市町別都計集計
city_zone_agg.round(3).to_csv(ASSETS / "L16_city_zone_agg.csv",
                              index=False, encoding="utf-8-sig")
# TOKEI_NAME 集計
zone_diss_name.drop(columns="geometry").round(4).to_csv(
    ASSETS / "L16_zone_summary.csv", index=False, encoding="utf-8-sig")
# クロス表
crosstab_area = zone_all.pivot_table(index="TOKEI_NAME", columns="source_city",
                                      values="poly_area_km2",
                                      aggfunc="sum", fill_value=0).round(3)
crosstab_area.to_csv(ASSETS / "L16_zone_city_crosstab_area.csv",
                     encoding="utf-8-sig")
# ポリゴン詳細
zone_all.drop(columns="geometry").round(4).to_csv(
    ASSETS / "L16_poly_detail.csv", index=False, encoding="utf-8-sig")
print(f"  saved 4 CSVs", flush=True)

# =============================================================================
# 4. 図 1: 23 都市計画区域 主題図 (TOKEI_NAME 別カラー)
# =============================================================================
print("\n[4] 図1: 23 都市計画区域 主題図", flush=True)
t1 = time.time()
fig, ax = plt.subplots(figsize=(11, 8.5))

# ベースに県の輪郭（行政区域 dissolve）
ken_outline = admin_diss.dissolve()
ken_outline.boundary.plot(ax=ax, color="#999999", linewidth=0.7, zorder=1)
admin_diss.boundary.plot(ax=ax, color="#cccccc", linewidth=0.4, zorder=2)

# 都市計画区域を TOKEI_NAME 別にカラー（広島圏=赤、備後圏=青、東広島=緑、その他=パステル）
import matplotlib.cm as cm
n_zones = len(zone_diss_name)
# 大面積上位は固有色、残りは tab20
fixed = {
    "広島圏都市計画区域": "#cf222e",
    "備後圏都市計画区域": "#0969da",
    "三次圏都市計画区域": "#8250df",
    "東広島都市計画区域": "#1f883d",
    "湯来準都市計画区域": "#bf8700",
}
cm_other = plt.get_cmap("tab20")
import matplotlib.colors as mcolors
zone_colors = []
others_idx = 0
for _, r in zone_diss_name.iterrows():
    if r["TOKEI_NAME"] in fixed:
        zone_colors.append(fixed[r["TOKEI_NAME"]])
    else:
        # tab20 (rgba tuple) を hex に変換して文字列に統一
        rgba = cm_other(others_idx / 20)
        zone_colors.append(mcolors.to_hex(rgba))
        others_idx += 1
zone_diss_name["plot_color"] = zone_colors

zone_diss_name.plot(ax=ax, color=zone_colors, edgecolor="white",
                     linewidth=0.5, alpha=0.85, zorder=3)

# ラベル: 上位 8 個に名前
top = zone_diss_name.head(8)
for _, r in top.iterrows():
    pt = r.geometry.representative_point()
    ax.annotate(r["TOKEI_NAME"], (pt.x, pt.y), ha="center", va="center",
                fontsize=8, color="white", fontweight="bold",
                bbox=dict(boxstyle="round,pad=0.2", fc="black", ec="none", alpha=0.45))

ax.set_title("広島県 23 都市計画区域 主題図（TOKEI_NAME 別塗分け）", fontsize=13)
ax.set_axis_off()
# 凡例（広域 vs 単一）
patches = [
    Patch(facecolor=fixed["広島圏都市計画区域"], label="広島圏 (8 市町)"),
    Patch(facecolor=fixed["備後圏都市計画区域"], label="備後圏 (4 市町)"),
    Patch(facecolor=fixed["三次圏都市計画区域"], label="三次圏 (1 市町)"),
    Patch(facecolor=fixed["東広島都市計画区域"], label="東広島 (1 市町、独立)"),
    Patch(facecolor=fixed["湯来準都市計画区域"], label="湯来準（準区域）"),
    Patch(facecolor="#cccccc", edgecolor="black", label="行政区域境界"),
]
ax.legend(handles=patches, loc="lower left", fontsize=9, frameon=True)
plt.tight_layout()
plt.savefig(ASSETS / "L16_zone_thematic.png", dpi=130, bbox_inches="tight")
plt.close("all")
print(f"  saved L16_zone_thematic.png ({time.time()-t1:.2f}s)", flush=True)

# =============================================================================
# 5. 図 2: 広域指定（広島圏・備後圏）クローズアップ
# =============================================================================
print("\n[5] 図2: 広域圏のクローズアップ + 構成市町", flush=True)
t1 = time.time()
fig, axes = plt.subplots(1, 2, figsize=(15, 7))

# 左: 広島圏都市計画区域 + 構成 8 市町
hir_zone = zone_diss_name[zone_diss_name["TOKEI_NAME"] == "広島圏都市計画区域"]
hir_cities = crosstab_zc.columns[crosstab_zc.loc["広島圏都市計画区域"] > 0].tolist()
hir_admin = admin_diss[admin_diss["city"].isin(hir_cities)]
hir_admin.plot(ax=axes[0], color="#fff8c5", edgecolor="#cccccc", linewidth=0.7, zorder=1)
hir_zone.plot(ax=axes[0], color="#cf222e", edgecolor="black",
              linewidth=0.6, alpha=0.7, zorder=3)
for _, r in hir_admin.iterrows():
    pt = r.geometry.representative_point()
    axes[0].annotate(r["city"], (pt.x, pt.y), ha="center", va="center",
                     fontsize=9, color="black", fontweight="bold")
axes[0].set_title("広島圏都市計画区域（赤）と 8 構成市町", fontsize=12)
axes[0].set_axis_off()

# 右: 備後圏都市計画区域 + 構成 4 市町
bin_zone = zone_diss_name[zone_diss_name["TOKEI_NAME"] == "備後圏都市計画区域"]
bin_cities = crosstab_zc.columns[crosstab_zc.loc["備後圏都市計画区域"] > 0].tolist()
bin_admin = admin_diss[admin_diss["city"].isin(bin_cities)]
bin_admin.plot(ax=axes[1], color="#ddf4ff", edgecolor="#cccccc", linewidth=0.7, zorder=1)
bin_zone.plot(ax=axes[1], color="#0969da", edgecolor="black",
              linewidth=0.6, alpha=0.7, zorder=3)
for _, r in bin_admin.iterrows():
    pt = r.geometry.representative_point()
    axes[1].annotate(r["city"], (pt.x, pt.y), ha="center", va="center",
                     fontsize=9, color="black", fontweight="bold")
axes[1].set_title("備後圏都市計画区域（青）と 4 構成市町", fontsize=12)
axes[1].set_axis_off()

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

# =============================================================================
# 6. 図 3: 都市計画区域指定率 choropleth + 白地マップ
# =============================================================================
print("\n[6] 図3: 指定率 choropleth + 白地マップ", flush=True)
t1 = time.time()
fig, axes = plt.subplots(1, 2, figsize=(15, 7))

# 左: 市町別 都計指定率 choropleth
ratio_gdf = admin_diss.merge(city_zone_agg[["city","zone_ratio_pct","ctype"]], on="city")
ratio_gdf.plot(ax=axes[0], column="zone_ratio_pct", cmap="RdYlGn",
                edgecolor="white", linewidth=0.6, legend=True,
                legend_kwds={"label": "都計指定率 (%)", "shrink": 0.7,
                             "orientation": "vertical"},
                vmin=0, vmax=100)
for _, r in ratio_gdf.iterrows():
    pt = r.geometry.representative_point()
    txt_color = "white" if r["zone_ratio_pct"] < 30 else "black"
    axes[0].annotate(f"{r['city']}\n{r['zone_ratio_pct']:.0f}%",
                     (pt.x, pt.y), ha="center", va="center",
                     fontsize=7, color=txt_color)
axes[0].set_title("市町別 都市計画区域指定率（都計面積 / 行政面積）", fontsize=12)
axes[0].set_axis_off()

# 右: 白地マップ（都計指定外 = 行政区域 - 都計区域）
# admin_diss を背景に灰色、その上に zone_diss_name を緑で重ねる
# 「白地」= 灰色のまま見える領域
admin_diss.plot(ax=axes[1], color="#bdbdbd", edgecolor="white",
                linewidth=0.6, zorder=1)
zone_diss_name.plot(ax=axes[1], color="#1f883d", edgecolor="none",
                    alpha=0.85, zorder=2)
# 県境
admin_diss.dissolve().boundary.plot(ax=axes[1], color="black",
                                     linewidth=0.7, zorder=3)
axes[1].set_title("広島県の都計指定エリア（緑）と白地（灰）", fontsize=12)
axes[1].set_axis_off()
# 凡例
patches = [
    Patch(facecolor="#1f883d", label=f"都市計画区域 ({zone_all['poly_area_km2'].sum():.0f} km²)"),
    Patch(facecolor="#bdbdbd", label=f"白地（指定外） ({(admin_diss['admin_area_km2'].sum() - zone_all['poly_area_km2'].sum()):.0f} km²)"),
]
axes[1].legend(handles=patches, loc="lower left", fontsize=10, frameon=True)
plt.tight_layout()
plt.savefig(ASSETS / "L16_zone_ratio_white.png", dpi=130, bbox_inches="tight")
plt.close("all")
print(f"  saved L16_zone_ratio_white.png ({time.time()-t1:.2f}s)", flush=True)

# =============================================================================
# 7. 図 4: 23 TOKEI_NAME small multiples
# =============================================================================
print("\n[7] 図4: 23 都市計画区域 small multiples", flush=True)
t1 = time.time()
n = len(zone_diss_name)
cols = 6
rows = int(np.ceil(n / cols))
fig, axes = plt.subplots(rows, cols, figsize=(15, 2.4*rows))
axes = axes.flatten()
for i, (_, r) in enumerate(zone_diss_name.iterrows()):
    ax = axes[i]
    sub = zone_all[zone_all["TOKEI_NAME"] == r["TOKEI_NAME"]]
    color = r["plot_color"]
    sub.plot(ax=ax, color=color, edgecolor="white", linewidth=0.4)
    ax.set_title(f"{r['TOKEI_NAME']}\n{r['area_km2']:.0f}km² / {r['cities_count']}市町 / {r['n_parts']}部",
                 fontsize=8)
    ax.set_axis_off()
# 余り
for j in range(n, rows*cols):
    axes[j].set_visible(False)
plt.suptitle("23 都市計画区域 個別形状 (パネルごとに最大化、スケール非統一)",
             fontsize=12, y=1.00)
plt.tight_layout()
plt.savefig(ASSETS / "L16_zones_small_multiples.png", dpi=120, bbox_inches="tight")
plt.close("all")
print(f"  saved ({time.time()-t1:.2f}s)", flush=True)

# =============================================================================
# 8. 図 5: 行政区域 vs 都市計画区域 (重ね合わせ)
# =============================================================================
print("\n[8] 図5: 行政 vs 都計 重ね合わせ", flush=True)
t1 = time.time()
fig, axes = plt.subplots(1, 2, figsize=(15, 7))

# 左: ctype 別行政 + 都計エリアを半透明で重ねる
admin_diss_meta = admin_diss.merge(city_meta, on="city")
admin_diss_meta.plot(ax=axes[0],
                     color=admin_diss_meta["ctype"].map(TYPE_COLOR),
                     edgecolor="white", linewidth=0.5, alpha=0.4)
zone_diss_name.plot(ax=axes[0], color="black", alpha=0.3, edgecolor="none")
for _, r in admin_diss_meta.iterrows():
    pt = r.geometry.representative_point()
    axes[0].annotate(r["city"], (pt.x, pt.y), ha="center", va="center",
                     fontsize=6, color="black")
axes[0].set_title("行政区域（ctype別カラー）に都計区域（黒）を重ねる",
                  fontsize=11)
axes[0].set_axis_off()
patches_l = [Patch(facecolor=TYPE_COLOR[t], label=t, alpha=0.4)
             for t in ["政令市","市","離島自治体","町"]]
patches_l.append(Patch(facecolor="black", label="都市計画区域", alpha=0.3))
axes[0].legend(handles=patches_l, loc="lower left", fontsize=8, frameon=True)

# 右: 散布 (行政面積 × 都計指定率) ctype 別
for ctype, c in TYPE_COLOR.items():
    sub = city_zone_agg[city_zone_agg["ctype"] == ctype]
    axes[1].scatter(sub["admin_area_km2"], sub["zone_ratio_pct"],
                    c=c, s=90, alpha=0.85, edgecolor="black",
                    linewidth=0.5, label=ctype)
    for _, r in sub.iterrows():
        axes[1].annotate(r["city"], (r["admin_area_km2"], r["zone_ratio_pct"]),
                         fontsize=7, xytext=(4, 3), textcoords="offset points")
axes[1].set_xscale("log")
axes[1].set_xlabel("行政区域面積 km² (log)")
axes[1].set_ylabel("都市計画区域指定率 (%)")
axes[1].set_title("行政面積 × 指定率: 大きい市町ほど指定率は低い？",
                  fontsize=11)
axes[1].axhline(50, color="gray", linewidth=0.5, linestyle="--")
axes[1].grid(True, alpha=0.3)
axes[1].legend(loc="upper right", fontsize=9)

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

# =============================================================================
# 9. 図 6: TOKEI_NAME 別 面積バー & 連結成分 + cities_count
# =============================================================================
print("\n[9] 図6: TOKEI_NAME 面積バー", flush=True)
t1 = time.time()
fig, axes = plt.subplots(1, 2, figsize=(15, 7.5))

zd = zone_diss_name.sort_values("area_km2", ascending=True).copy()
# 左: 面積横棒
colors_bar = ["#cf222e" if r["scope"] == "広域（複数市町）" else
              ("#bf8700" if r["zone_kind"] == "準都市計画区域" else "#0969da")
              for _, r in zd.iterrows()]
axes[0].barh(zd["TOKEI_NAME"], zd["area_km2"], color=colors_bar,
             edgecolor="black", linewidth=0.4)
for i, (_, r) in enumerate(zd.iterrows()):
    axes[0].text(r["area_km2"] + 5, i,
                  f"{r['cities_count']}市町・{r['n_parts']}部",
                  va="center", fontsize=8)
axes[0].set_xlabel("都市計画区域面積 km²")
axes[0].set_title("23 都市計画区域 面積ランキング（赤=広域, 橙=準, 青=単一）",
                   fontsize=11)
axes[0].grid(axis="x", alpha=0.3)

# 右: 連結成分数 vs 面積（パネル散布）
for kind in ["広域（複数市町）", "単一市町"]:
    sub = zone_diss_name[zone_diss_name["scope"] == kind]
    color = "#cf222e" if kind == "広域（複数市町）" else "#0969da"
    axes[1].scatter(sub["area_km2"], sub["n_parts"], s=80,
                     c=color, edgecolor="black", linewidth=0.5,
                     alpha=0.85, label=kind)
    for _, r in sub.iterrows():
        axes[1].annotate(r["TOKEI_NAME"], (r["area_km2"], r["n_parts"]),
                          fontsize=7, xytext=(4, 3), textcoords="offset points")
# 準
sub = zone_diss_name[zone_diss_name["zone_kind"] == "準都市計画区域"]
axes[1].scatter(sub["area_km2"], sub["n_parts"], s=130,
                 marker="*", c="#bf8700", edgecolor="black",
                 linewidth=0.6, label="準都市計画区域", zorder=4)
axes[1].set_xscale("log")
axes[1].set_xlabel("面積 km² (log)")
axes[1].set_ylabel("連結成分数（島嶼+飛び地）")
axes[1].set_title("面積 × 連結成分数（広域は分散, 単一は集約）", fontsize=11)
axes[1].legend(loc="upper left", fontsize=9)
axes[1].grid(True, alpha=0.3)

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

# =============================================================================
# 10. 整合性検証 (21 市町別 vs ds=923)
# =============================================================================
print("\n[10] 整合性検証", flush=True)
sum_city = zone_all["poly_area_km2"].sum()
sum_ken = ken_gdf["poly_area_km2"].sum()
diff_pct = (sum_city - sum_ken) / sum_ken * 100
n_city = len(zone_all)
n_ken = len(ken_gdf)
print(f"  21 市町別 ファイル合計: {sum_city:.3f} km², {n_city} ポリゴン")
print(f"  県全域 ds={KEN_DSID} 合計 : {sum_ken:.3f} km², {n_ken} ポリゴン")
print(f"  差: {sum_city - sum_ken:+.4f} km² ({diff_pct:+.5f}%)")

reconciliation = pd.DataFrame([
    {"source": "21 市町別 GeoJSON 積算",
     "area_km2": round(sum_city, 3),
     "polys": n_city,
     "TOKEI_NAME種類": zone_all["TOKEI_NAME"].nunique(),
     "note": "本記事のメインデータ"},
    {"source": f"県全域 ds={KEN_DSID} GeoJSON",
     "area_km2": round(sum_ken, 3),
     "polys": n_ken,
     "TOKEI_NAME種類": ken_gdf["TOKEI_NAME"].nunique(),
     "note": "DoBoX 集約版（重複コピー検証用）"},
])
reconciliation.to_csv(ASSETS / "L16_reconciliation.csv",
                       index=False, encoding="utf-8-sig")

# =============================================================================
# 11. 図 7: 整合性検証ビジュアル
# =============================================================================
print("\n[11] 図7: 整合性検証", flush=True)
t1 = time.time()
fig, axes = plt.subplots(1, 2, figsize=(14, 5.5))
labels = ["21市町別\n合計", f"ds={KEN_DSID}\n県全域版"]
vals_area = [sum_city, sum_ken]
vals_poly = [n_city, n_ken]

axes[0].bar(labels, vals_area, color=["#0969da", "#1f883d"],
             edgecolor="black", linewidth=0.7)
for i, v in enumerate(vals_area):
    axes[0].text(i, v + 20, f"{v:.1f} km²", ha="center", fontsize=11)
axes[0].set_title(f"面積整合性 (差: {diff_pct:+.5f}%)", fontsize=12)
axes[0].set_ylabel("総面積 km²")
axes[0].grid(axis="y", alpha=0.3)

axes[1].bar(labels, vals_poly, color=["#0969da", "#1f883d"],
             edgecolor="black", linewidth=0.7)
for i, v in enumerate(vals_poly):
    axes[1].text(i, v + 1, str(v), ha="center", fontsize=11)
axes[1].set_title(f"ポリゴン数整合性 (両者 {n_city} = {n_ken})", fontsize=12)
axes[1].set_ylabel("ポリゴン数")
axes[1].grid(axis="y", alpha=0.3)

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

# =============================================================================
# 12. 図 8: 市町別 都計面積 vs 行政面積 積み上げバー
# =============================================================================
print("\n[12] 図8: 市町別 都計 vs 白地 積み上げ", flush=True)
t1 = time.time()
fig, ax = plt.subplots(figsize=(11, 7))

cdf = city_zone_agg.sort_values("admin_area_km2", ascending=True).copy()
y = np.arange(len(cdf))
zone_vals = cdf["zone_area_km2"].values
white_vals = cdf["white_area_km2"].values
ax.barh(y, zone_vals, color="#1f883d", edgecolor="black",
        linewidth=0.4, label="都市計画区域")
ax.barh(y, white_vals, left=zone_vals, color="#bdbdbd",
        edgecolor="black", linewidth=0.4, label="白地（指定外）")
# 比率テキスト
for i, (_, r) in enumerate(cdf.iterrows()):
    total = r["admin_area_km2"]
    pct = r["zone_ratio_pct"]
    ax.text(total + 30, i, f"{pct:.0f}%", va="center", fontsize=8,
             color="black")
ax.set_yticks(y)
ax.set_yticklabels(cdf["city"])
ax.set_xlabel("面積 km²（緑=都計指定、灰=白地、右=指定率）")
ax.set_title("市町別 都市計画区域 vs 白地 積み上げ（行政面積順、最大下→最小上）",
              fontsize=12)
ax.legend(loc="lower right", fontsize=10)
ax.grid(axis="x", alpha=0.3)
plt.tight_layout()
plt.savefig(ASSETS / "L16_zone_white_stack.png", dpi=130, bbox_inches="tight")
plt.close("all")
print(f"  saved ({time.time()-t1:.2f}s)", flush=True)

# =============================================================================
# 13. 仮説検証
# =============================================================================
print("\n[13] 仮説検証", flush=True)
results = {}

# H1: 全県の都計指定率は約3割か
total_zone = zone_all["poly_area_km2"].sum()
total_admin = admin_diss["admin_area_km2"].sum()
total_ratio = total_zone / total_admin * 100
mountain_cities = ["庄原市", "三次市", "北広島町", "安芸高田市", "世羅町"]
mt_ratios = city_zone_agg[city_zone_agg["city"].isin(mountain_cities)]["zone_ratio_pct"]
results["H1"] = {
    "claim": "広島県の市町は約3割しか都計指定されておらず、中山間地は白地が支配的",
    "total_zone_km2": round(total_zone, 1),
    "total_admin_km2": round(total_admin, 1),
    "ratio_pct": round(total_ratio, 2),
    "mountain_cities_avg_ratio_pct": round(float(mt_ratios.mean()), 2),
    "mountain_cities_max_ratio_pct": round(float(mt_ratios.max()), 2),
    "verdict": "支持" if 25 < total_ratio < 40 and mt_ratios.max() < 15 else "部分支持",
}

# H2: 「○○圏」は複数市町にまたがる
ken_zones = zone_diss_name[zone_diss_name["TOKEI_NAME"].str.contains("圏")]
ken_multi = ken_zones[ken_zones["cities_count"] >= 2]
results["H2"] = {
    "claim": "「○○圏都市計画区域」は複数市町をまたぐ広域指定として機能する",
    "ken_zones": ken_zones["TOKEI_NAME"].tolist(),
    "ken_zones_with_multi_cities": ken_multi["TOKEI_NAME"].tolist(),
    "hiroshima_ken_cities": int(zone_diss_name[
        zone_diss_name["TOKEI_NAME"]=="広島圏都市計画区域"]["cities_count"].iloc[0]),
    "bingo_ken_cities": int(zone_diss_name[
        zone_diss_name["TOKEI_NAME"]=="備後圏都市計画区域"]["cities_count"].iloc[0]),
    "miyoshi_ken_cities": int(zone_diss_name[
        zone_diss_name["TOKEI_NAME"]=="三次圏都市計画区域"]["cities_count"].iloc[0]),
    "verdict": "部分支持（広島圏8・備後圏4は広域、三次圏は1市町のみで例外）"
              if len(ken_multi) >= 2 else "反証",
}

# H3: 21 vs ds=923 完全一致
results["H3"] = {
    "claim": "21 市町別と ds=923 はポリゴン数・面積で完全一致する",
    "city_polys": int(n_city), "ken_polys": int(n_ken),
    "city_area": round(sum_city, 3), "ken_area": round(sum_ken, 3),
    "diff_pct": round(diff_pct, 5),
    "verdict": "支持" if abs(diff_pct) < 0.001 and n_city == n_ken else "反証",
}

# H4: 都市部の小面積町ほど指定率が高い
small_towns = city_zone_agg[city_zone_agg["ctype"] == "町"]
big_inland = city_zone_agg[(city_zone_agg["coastal"]==False) &
                            (city_zone_agg["admin_area_km2"]>200)]
small_town_ratio = float(small_towns["zone_ratio_pct"].mean())
big_inland_ratio = float(big_inland["zone_ratio_pct"].mean())
# 行政面積 vs 指定率の Spearman
from scipy.stats import spearmanr
rho, pval = spearmanr(city_zone_agg["admin_area_km2"],
                       city_zone_agg["zone_ratio_pct"])
results["H4"] = {
    "claim": "小面積の都市部の町はほぼ全域指定、大面積の中山間市町は低指定率",
    "small_town_avg_ratio_pct": round(small_town_ratio, 2),
    "big_inland_avg_ratio_pct": round(big_inland_ratio, 2),
    "spearman_rho_admin_area_vs_ratio": round(float(rho), 3),
    "spearman_p": round(float(pval), 4),
    "verdict": "支持" if rho < -0.5 else "部分支持",
}

# H5: 準都計区域は周縁的役割
quasi = zone_diss_name[zone_diss_name["TOKEI_CD"] == 2]
quasi_total = float(quasi["area_km2"].sum())
results["H5"] = {
    "claim": "TOKEI_CD=2 (準都計区域) は周縁的で広島市湯来地区限定",
    "quasi_count": len(quasi),
    "quasi_total_area_km2": round(quasi_total, 2),
    "quasi_share_pct": round(quasi_total / total_zone * 100, 4),
    "quasi_zones": quasi["TOKEI_NAME"].tolist(),
    "verdict": "支持" if len(quasi) == 1 and quasi_total / total_zone < 0.01 else "部分支持",
}

(ASSETS / "L16_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)

# =============================================================================
# 14. HTML 生成
# =============================================================================
print("\n[14] HTML 生成", flush=True)
t1 = time.time()

SCRIPT_FILE = Path(__file__).read_text(encoding="utf-8")
sections = []

# === セクション1: 学習目標と問い ===
s1_html = f"""
<p>本レッスンは、広島県オープンデータポータル <b>DoBoX</b> の
「都市計画区域情報_区域データ_<b>都市計画区域</b>」シリーズ <b>21 件</b>を
1 つに統合し、広島県の<b>都市計画指定構造</b>と
<b>「○○圏都市計画区域」という広域指定の機能</b>を読み解く研究記事です。</p>

<div class="note">
<b>研究問い (RQ)</b><br>
広島県内に指定された都市計画区域は、どのような種類・規模・行政市町との関係を持ち、
「広域都市計画区域（○○圏）」はどう機能しているか？
</div>

<h3>独自用語の定義（要件M）</h3>
<ul>
  <li><b>都市計画区域</b>: 都市計画法に基づき<b>「一体の都市として総合的に整備・開発・保全する必要がある区域」</b>として指定された地区。
    本記事では <code>TOKEI_CD=1</code> がこれに対応。</li>
  <li><b>準都市計画区域</b>: 都市計画区域外であっても、<b>無秩序な開発の予防のため</b>に指定される区域。
    広島県では <code>TOKEI_CD=2</code>（広島市湯来地区のみ）。</li>
  <li><b>白地</b>: 都市計画区域にも準都市計画区域にも<b>指定されていない</b>行政区域内の領域。
    本記事では「行政区域面積 − 都計指定面積」で計算する派生指標。
    山林・田畑など、都市計画法による開発・建築規制が直接適用されない地域。</li>
  <li><b>広域都市計画区域</b>: <b>複数市町をまたぐ</b>都市計画区域。
    本記事では「<code>TOKEI_NAME</code> を <code>dissolve</code> した時に
    元データの <code>source_city</code> が 2 件以上記録されている区域」を本記事の独自定義として採用。
    本データでは「広島圏」「備後圏」がこれに該当（後者は名前ベースでも特定可）。</li>
  <li><b>指定率</b> (zone_ratio_pct): <b>都計指定面積 / 行政区域面積 × 100</b>。
    100% なら市町全域が都計指定、0% に近いほど白地優勢。</li>
  <li><b>連結成分数</b> (n_parts): <code>dissolve(by="TOKEI_NAME")</code>後の MultiPolygon の部分数。
    1 = 一塊、多いほど島嶼や飛び地に分散している。</li>
  <li><b>TOKEI_NAME</b>: GeoJSON の属性列で都市計画区域の<b>固有名</b>（例「広島圏都市計画区域」）。
    本記事の主要なグルーピングキー。</li>
</ul>

<h3>仮説 H1〜H5（要件D）</h3>
<ul>
  <li><b>H1</b>: 広島県の市町は<b>約 3 割しか都市計画区域に指定されておらず</b>、
       中山間地域では「白地」（非指定）が支配的である</li>
  <li><b>H2</b>: 「○○圏都市計画区域」は<b>複数市町をまたぐ</b>広域指定として機能する。
       特に「広島圏」は周辺町を含む大都市圏の典型である</li>
  <li><b>H3</b>: 21 市町別ファイルと県全域 ds={KEN_DSID} の合計は
       <b>ポリゴン数・面積で完全一致</b>する（DoBoX 内部の同一マスター由来）</li>
  <li><b>H4</b>: 都市計画区域の指定面積は<b>人口集中地域に偏在</b>し、
       小面積でも都市部の町（海田町・府中町・熊野町等）はほぼ全域指定、
       広面積の中山間市町は低指定率（<b>行政面積と指定率は負の Spearman 相関</b>）</li>
  <li><b>H5</b>: <code>TOKEI_CD=2</code>（準都計区域）は本来の都計区域（<code>TOKEI_CD=1</code>）と
       異なる周縁的役割を持ち、<b>広島市湯来地区の 1 件 5 km² 程度</b>に留まる</li>
</ul>

<h3>到達点</h3>
<ol>
  <li>21 GeoJSON を 1 個の <code>GeoDataFrame</code> に統合し、
      <code>TOKEI_NAME</code> でグループ化して 23 都市計画区域を識別する手法</li>
  <li><code>dissolve</code> による広域圏域の幾何学的同定
      （複数市町ファイルにまたがる同一名のポリゴンを1個にまとめる）</li>
  <li>都市計画区域指定率（都計面積 / 行政面積）を市町別に計算する手法
      —— L15 で構築した行政区域 GeoDataFrame を分母として再利用</li>
  <li>「白地」を派生指標として可視化する手法（重ね合わせと Stacked bar）</li>
  <li>整合性検証: 市町別ファイルと県全域ファイルの<b>ポリゴン単位の同一性</b>を 0.001% 精度で確認</li>
  <li>図 8 種・表 9 種で都市計画指定構造を多角的に提示</li>
</ol>

<div class="warn">
<b>注: 本記事の対象範囲</b><br>
本記事は <b>都市計画区域</b>（区域が指定されているか/外か）に集中する。
区域内の<b>用途地域</b>（住居/商業/工業 等の細分指定）は <b>L17</b>、
<b>市街化区域・市街化調整区域</b>（線引き）は <b>L18</b> で扱う。
本記事内では行政区域は「比率の分母」「ベースマップ」としてのみ参照し、
L15 と内容を重複させない。
</div>
"""
sections.append(("1. 学習目標と問い", s1_html))

# === セクション2: 使用データ ===
data_spec = f"""
<table>
  <tr><th>項目</th><th>値</th></tr>
  <tr><td>シリーズ</td><td>都市計画区域情報_区域データ_都市計画区域</td></tr>
  <tr><td>件数（カバー）</td><td>21 dataset_id（市町別 20 + 県全域 1）</td></tr>
  <tr><td>形式</td><td>GeoJSON（ZIP 内同梱）</td></tr>
  <tr><td>CRS</td><td>EPSG:6671 (JGD2011 平面直角第III系) ※全 21 件で統一</td></tr>
  <tr><td>列構造</td><td><code>FID</code>, <code>TOKEI_CD</code>, <code>CITY_CD</code>, <code>TOKEI_NAME</code>, <code>geometry</code>（5列、全 21 件で同一）</td></tr>
  <tr><td><code>TOKEI_CD</code> 意味</td><td><b>1=都市計画区域</b>, <b>2=準都市計画区域</b></td></tr>
  <tr><td><code>TOKEI_NAME</code> 例</td><td>「広島圏都市計画区域」「備後圏都市計画区域」「東広島都市計画区域」「湯来準都市計画区域」など</td></tr>
  <tr><td>ジオメトリ型</td><td>Polygon</td></tr>
  <tr><td>合計ポリゴン数</td><td>{n_city}（市町別 21 ファイル合計）／ {n_ken}（県全域 ds={KEN_DSID}）</td></tr>
  <tr><td>本記事で扱うサイズ</td><td>{n_city} ポリゴン × 5 列 ≈ 数 KB（軽量）</td></tr>
</table>
"""

ds_rows = []
for dsid, name, coastal, ctype in CITY_DEFS:
    ds_rows.append(
        f'<tr><td>{name}</td><td>{ctype}</td>'
        f'<td>{"海岸" if coastal else "内陸"}</td>'
        f'<td><a href="https://hiroshima-dobox.jp/datasets/{dsid}" target="_blank">DoBoX #{dsid}</a></td>'
        f'<td><code>data/extras/L16_city_planning_zones/city_planning_{dsid}_{name}.zip</code></td></tr>'
    )
ds_rows.append(
    f'<tr><td><b>広島県（全域集約）</b></td><td>—</td><td>—</td>'
    f'<td><a href="https://hiroshima-dobox.jp/datasets/{KEN_DSID}" target="_blank">DoBoX #{KEN_DSID}</a></td>'
    f'<td><code>data/extras/L16_city_planning_zones/city_planning_{KEN_DSID}_広島県.zip</code></td></tr>'
)
ds_table = (
    "<table><tr><th>市町</th><th>タイプ</th><th>海岸/内陸</th>"
    "<th>DoBoXページ</th><th>ZIP保存先</th></tr>" + "".join(ds_rows) + "</table>"
)

s2_html = (
    "<p>本記事で使う 21 dataset_id（市町別 20 + 県全域 1）は、"
    "DoBoX で「都市計画区域情報」配下「区域データ」配下の "
    "<b>都市計画区域</b> シリーズである。<b>列構造が 21 件すべてで完全に同一</b>"
    "（<code>FID</code>, <code>TOKEI_CD</code>, <code>CITY_CD</code>, "
    "<code>TOKEI_NAME</code>, <code>geometry</code> の 5 列）であることが事前監査で確認済みのため、"
    "<code>pd.concat</code> による単純な縦結合で県全域 GeoDataFrame が組み立てられる。</p>"
    "<h3>データ仕様</h3>" + data_spec
    + "<h3>21 dataset_id 一覧（直リンク）</h3>" + ds_table
    + '<p class="tnote">'
    "海岸/内陸 区分は本記事独自の分類（瀬戸内海岸線にポリゴンが接するかで決定）。"
    "三次市・庄原市・北広島町・世羅町・安芸高田市・府中市・府中町・熊野町は内陸。"
    "他は海岸を持つ。<b>L15 と同じ分類</b>を使うことで、L15-L18 の区域系記事間で参照軸を統一する。"
    "</p>"
)
sections.append(("2. 使用データ", s2_html))

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

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

<h3>(2) 中間 CSV（本スクリプトの出力）</h3>
<ul>
  <li><a href="assets/L16_city_zone_agg.csv" download>L16_city_zone_agg.csv</a> — 市町別 都計指定集計（行政面積・指定率・白地面積を含む）</li>
  <li><a href="assets/L16_zone_summary.csv" download>L16_zone_summary.csv</a> — 23 TOKEI_NAME 集約（面積・連結成分数・cities_count・scope）</li>
  <li><a href="assets/L16_zone_city_crosstab_area.csv" download>L16_zone_city_crosstab_area.csv</a> — TOKEI_NAME × 市町 面積クロス表（広域指定の構成市町を見るキー資料）</li>
  <li><a href="assets/L16_poly_detail.csv" download>L16_poly_detail.csv</a> — 全 102 ポリゴン詳細（属性のみ）</li>
  <li><a href="assets/L16_reconciliation.csv" download>L16_reconciliation.csv</a> — 整合性検証（市町別 vs 県全域）</li>
  <li><a href="assets/L16_hypothesis_results.json" download>L16_hypothesis_results.json</a> — 仮説 H1〜H5 検証結果（JSON）</li>
</ul>

<h3>(3) 図 PNG（本記事掲載）</h3>
<ul>
  <li><a href="assets/L16_zone_thematic.png" download>L16_zone_thematic.png</a> — 23 都市計画区域 主題図</li>
  <li><a href="assets/L16_wide_areas.png" download>L16_wide_areas.png</a> — 広島圏・備後圏クローズアップ</li>
  <li><a href="assets/L16_zone_ratio_white.png" download>L16_zone_ratio_white.png</a> — 指定率 choropleth と白地マップ</li>
  <li><a href="assets/L16_zones_small_multiples.png" download>L16_zones_small_multiples.png</a> — 23 区域 small multiples</li>
  <li><a href="assets/L16_admin_vs_zone.png" download>L16_admin_vs_zone.png</a> — 行政区域 vs 都計区域 重ね合わせ + 散布図</li>
  <li><a href="assets/L16_zone_areas_bar.png" download>L16_zone_areas_bar.png</a> — TOKEI_NAME 別面積バー + 連結成分散布</li>
  <li><a href="assets/L16_reconciliation.png" download>L16_reconciliation.png</a> — 市町別合計 vs 県全域 整合性</li>
  <li><a href="assets/L16_zone_white_stack.png" download>L16_zone_white_stack.png</a> — 市町別 都計 vs 白地 積み上げ</li>
</ul>

<h3>(4) 再現用 Python スクリプト</h3>
<ul>
  <li><a href="L16_city_planning_zones.py" download>L16_city_planning_zones.py</a> — 本記事の全コード</li>
</ul>
<p class="tnote">実行は <code>cd "2026 DoBoX 教材"; py -X utf8 lessons\\L16_city_planning_zones.py</code>。
21 ZIP がローカルにあれば <b>30 秒以内</b>で全図 + CSV 再生成（要件 S 準拠）。
ZIP が無い場合は事前に <code>fetch_city_planning_zones.py</code> を実行。</p>
"""
sections.append(("3. ダウンロード（再現用データ・中間データ・図・スクリプト）", dl_html))

# === セクション4: 分析1 — 21 GeoJSON 統合 ===
code_load = code('''
DATA_DIR = ROOT / "data" / "extras" / "L16_city_planning_zones"
TARGET_CRS = "EPSG:6671"  # JGD2011 平面直角 III, 広島県, m 単位

CITY_DEFS = [
    (787, "広島市", True, "政令市"),
    (798, "呉市",   True, "市"),
    # ... 計 20 行
]

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.endswith(".geojson")]
        with zf.open(gjs[0]) as f:
            return gpd.read_file(io.BytesIO(f.read()))

frames = []
for dsid, name, coastal, ctype in CITY_DEFS:
    z = DATA_DIR / f"city_planning_{dsid}_{name}.zip"
    g = load_geojson_zip(z)
    g["source_city"] = name; g["source_dsid"] = dsid
    g["coastal"]     = coastal; g["ctype"]    = ctype
    frames.append(g)

zone_all = gpd.GeoDataFrame(pd.concat(frames, ignore_index=True),
                             geometry="geometry", crs=frames[0].crs)
zone_all = zone_all.to_crs(TARGET_CRS)             # m 単位 化
zone_all["poly_area_km2"] = zone_all.geometry.area / 1e6
zone_all["poly_perim_km"] = zone_all.geometry.length / 1e3
''')

before_after = f"""
<h3>入出力 Before/After（具体例: ds=787 広島市）</h3>
<table>
<tr><th>段階</th><th>形</th><th>サイズ</th><th>このデータで起きていること</th></tr>
<tr><td>① 元 ZIP</td><td>ZIP（中身は1個の .geojson）</td><td>~1 MB</td><td>広島市の 36 ポリゴンが GeoJSON 1 ファイルに格納（8 区 + 区内分割）</td></tr>
<tr><td>② <code>load_geojson_zip()</code> 後</td><td>GeoDataFrame</td><td>36 行 × 5 列</td><td>FID, TOKEI_CD, CITY_CD, <b>TOKEI_NAME</b>, geometry。<code>TOKEI_NAME</code> は「広島圏都市計画区域」または「湯来準都市計画区域」</td></tr>
<tr><td>③ 属性付与</td><td>GeoDataFrame</td><td>36 行 × 9 列</td><td><code>source_city="広島市"</code>, <code>source_dsid=787</code>, <code>coastal=True</code>, <code>ctype="政令市"</code></td></tr>
<tr><td>④ <code>pd.concat</code>（21 市町分）</td><td>GeoDataFrame</td><td>{n_city} 行 × 9 列</td><td>21 市町分のポリゴン全部が1枚に</td></tr>
<tr><td>⑤ <code>to_crs(EPSG:6671)</code></td><td>GeoDataFrame</td><td>{n_city} 行</td><td>JGD2011 平面直角第III系（m 単位）に投影変換 → 面積計算可能化</td></tr>
<tr><td>⑥ 面積計算</td><td>GeoDataFrame</td><td>{n_city} 行 × 11 列</td><td><code>poly_area_km2</code>, <code>poly_perim_km</code> を追加</td></tr>
</table>
"""

s4_html = (
    "<h3>狙い</h3>"
    "<p>21 個の ZIP（市町ごとに別ファイル）を、"
    "<b>プログラム上は1個の GeoDataFrame として扱える形</b>にする。"
    "21 件すべてが同列構造（<code>FID/TOKEI_CD/CITY_CD/TOKEI_NAME/geometry</code> の5列）"
    "であることが事前監査で確認済みなので、和集合化なしで縦結合できる。</p>"

    "<h3>手法</h3>"
    "<p><b>直感</b>: ZIP→geopandas→属性付与→concat の4ステップ。"
    "ZIP は <code>zipfile</code> で開いて中身の .geojson を <code>io.BytesIO</code> に流し込み、"
    "それを <code>geopandas.read_file</code> に渡せば中間ファイルを書かずに直接読める。"
    "<b>L15（行政区域）と全く同じパターン</b>で実装する。これは「区域データシリーズ」が"
    "市町ごとの分割しか違わない統一スキーマだから可能。</p>"

    "<p><b>大筋（5 ステップ）</b></p>"
    "<ol>"
    "<li>21 件の ZIP を1個ずつ <code>load_geojson_zip()</code> で読む</li>"
    "<li>各 GeoDataFrame に <code>source_city/source_dsid/coastal/ctype</code> 列を付与</li>"
    "<li>21 個を <code>pd.concat</code> で縦結合 → 102 行 1 個</li>"
    "<li><code>to_crs(EPSG:6671)</code> で広島県平面直角座標系に投影変換</li>"
    "<li>面積（<code>geometry.area</code>）と周長（<code>geometry.length</code>）を列追加</li>"
    "</ol>"

    "<p><b>入出力</b>: 入力=21 ZIP、出力=" + str(n_city) + " 行 × 11 列の <code>GeoDataFrame</code> 1 個。</p>"
    "<p><b>前提と限界</b>: 21 件の列構造が同一であることが大前提（事前監査で OK 確認済）。"
    "DoBoX が将来列を増やしても <code>pd.concat</code> は和集合化するため列が増えて NaN が出るだけで、"
    "既存処理には影響しない。</p>"

    "<h3>実装</h3>"
    + code_load + before_after +

    "<h3>結果（次セクションで使う）</h3>"
    f"<p>このステップで <code>zone_all</code>（{n_city} 行 × 11 列）と"
    f"<code>ken_gdf</code>（県全域版 ds={KEN_DSID}、{n_ken} 行）が用意できた。"
    "以降の分析は全部この2つだけで完結する。</p>"
)
sections.append(("4. 分析1: 21 GeoJSON を1枚の GeoDataFrame に統合する", s4_html))

# === セクション5: 分析2 — TOKEI_NAME による広域区域同定 ===
code_dissolve_name = code('''
# TOKEI_NAME 別にジオメトリを統合 (dissolve)
# → 例えば「広島圏都市計画区域」は 8 市町ファイルに分割されているが、
#   1 個の MultiPolygon にまとまる
zone_diss_name = zone_all.dissolve(by="TOKEI_NAME", aggfunc={
    "TOKEI_CD": "first",
}).reset_index()

# 連結成分数 (n_parts): 統合後の物理的な「部分」数
def n_parts(geom):
    if geom.geom_type == "MultiPolygon":
        return len(list(geom.geoms))
    return 1
zone_diss_name["n_parts"] = zone_diss_name.geometry.apply(n_parts)

# 面積・円形度
zone_diss_name["area_km2"]  = zone_diss_name.geometry.area / 1e6
zone_diss_name["perim_km"]  = zone_diss_name.geometry.length / 1e3
zone_diss_name["compactness"] = (4*np.pi*zone_diss_name["area_km2"]*1e6) \\
                                / (zone_diss_name["perim_km"]*1e3)**2

# 各 TOKEI_NAME がいくつの市町をまたぐか
crosstab_zc = pd.crosstab(zone_all["TOKEI_NAME"], zone_all["source_city"])
zone_diss_name["cities_count"] = zone_diss_name["TOKEI_NAME"].map(
    crosstab_zc.gt(0).sum(axis=1))

# 広域 vs 単一の判定
zone_diss_name["scope"] = zone_diss_name["cities_count"].apply(
    lambda n: "広域（複数市町）" if n >= 2 else "単一市町")
''')

# 23 TOKEI_NAME 集約表
zone_table_rows = []
for _, r in zone_diss_name.iterrows():
    zone_table_rows.append(
        f"<tr><td>{r['TOKEI_NAME']}</td>"
        f"<td>{r['zone_kind']}</td>"
        f"<td>{int(r['cities_count'])}</td>"
        f"<td>{r['area_km2']:.1f}</td>"
        f"<td>{int(r['n_parts'])}</td>"
        f"<td>{r['compactness']:.3f}</td>"
        f"<td>{r['scope']}</td></tr>"
    )
zone_main_table = (
    "<table><tr><th>TOKEI_NAME</th><th>区域種別</th><th>構成市町数</th>"
    "<th>面積 km²</th><th>連結成分数</th><th>円形度</th><th>scope</th></tr>"
    + "".join(zone_table_rows) + "</table>"
)

# 「広島圏」「備後圏」の構成市町表
def member_cells(zone_name):
    members = crosstab_zc.columns[crosstab_zc.loc[zone_name] > 0].tolist()
    member_areas = []
    for c in members:
        a = zone_all[(zone_all["TOKEI_NAME"]==zone_name) &
                     (zone_all["source_city"]==c)]["poly_area_km2"].sum()
        member_areas.append((c, a))
    member_areas.sort(key=lambda x: -x[1])
    rows = []
    for c, a in member_areas:
        rows.append(f"<tr><td>{c}</td><td>{a:.2f}</td></tr>")
    return rows

hir_rows = member_cells("広島圏都市計画区域")
hir_table = "<table><tr><th>構成市町</th><th>都計指定面積 km²</th></tr>" + "".join(hir_rows) + "</table>"
bin_rows = member_cells("備後圏都市計画区域")
bin_table = "<table><tr><th>構成市町</th><th>都計指定面積 km²</th></tr>" + "".join(bin_rows) + "</table>"

n_wide = int((zone_diss_name['cities_count']>=2).sum())

s5_html = (
    "<h3>狙い</h3>"
    "<p>21 個の市町別ファイルにバラバラに格納されている都市計画区域を、"
    "<code>TOKEI_NAME</code>（区域固有名）でグルーピングして<b>1 区域 = 1 単位</b>にまとめる。"
    "これにより<b>広島圏のような複数市町をまたぐ広域指定</b>を初めて1個の幾何体として扱える。"
    "本記事の最重要ステップ。</p>"

    "<h3>手法（dissolve による幾何統合）</h3>"
    "<p><b>直感</b>: 「<b>同じ TOKEI_NAME を持つポリゴン同士を融合する</b>」。"
    "例えば広島圏都市計画区域は、広島市・廿日市市・大竹市・東広島市・海田町・府中町・熊野町・坂町の "
    "<b>8 市町ファイルに分割</b>されている。これを <code>dissolve(by=\"TOKEI_NAME\")</code> で"
    "1 個の MultiPolygon に統合できる（全市町境界線が消えて1個の広域区域として可視化できる）。</p>"

    "<p><b>大筋</b></p>"
    "<ol>"
    "<li><code>zone_all.dissolve(by=\"TOKEI_NAME\")</code> で 23 個の MultiPolygon を作る</li>"
    "<li>各 MultiPolygon の <b>連結成分数</b>（部分数）を <code>n_parts()</code> で数える"
    "（島嶼や飛び地で分散している場合の数）</li>"
    "<li>面積・周長・円形度を計算</li>"
    "<li><code>pd.crosstab</code> で <b>TOKEI_NAME × source_city</b> の表を作り、"
    "各区域がいくつの市町をまたぐか（<code>cities_count</code>）を算出</li>"
    "<li><code>cities_count >= 2</code> なら<b>広域</b>、=1 なら<b>単一市町</b>に分類</li>"
    "</ol>"

    "<p><b>入出力</b>: 入力=" + str(n_city) + " 行の <code>GeoDataFrame</code>、"
    "出力=23 行の集約 <code>GeoDataFrame</code>（区域単位）。</p>"

    "<p><b>前提と限界</b>: <code>dissolve</code> は同名ポリゴンを"
    "<b>幾何学的に融合</b>するため、市町境界をまたいだ部分が「シーム」として残る場合がある。"
    "本データでは事前にスナップ済みのため問題なし。</p>"

    "<h3>実装</h3>"
    + code_dissolve_name +

    "<h3>表（要件 G）: 23 都市計画区域 集約一覧（面積降順）</h3>"
    "<p><b>なぜこの表か</b>: 23 区域全体の俯瞰。各区域が<b>広域か単一か、面積・形状はどうか</b>を一覧で比較する。</p>"
    + zone_main_table +
    "<p><b>この表から読み取れること</b></p>"
    "<ul>"
    f"<li><b>広域指定は {n_wide} 種類</b>（複数市町をまたぐ）：広島圏（8市町）、備後圏（4市町）。"
    "残り 21 種類はすべて単一市町指定。</li>"
    "<li><b>「○○圏」と名付けられた区域</b>は 3 種類：広島圏・備後圏・三次圏。"
    "ただし<b>三次圏は 1 市町（三次市）のみ</b>で構成され、名前と裏腹に広域指定ではない。"
    "「圏」の名前は<b>都市計画上の包括的な命名慣習</b>で、必ずしも複数市町を意味しない。</li>"
    f"<li><b>面積最大は広島圏 ({zone_diss_name.iloc[0]['area_km2']:.0f} km²)</b>、"
    f"次が備後圏 ({zone_diss_name.iloc[1]['area_km2']:.0f} km²)、"
    f"3位が東広島都市計画区域 ({zone_diss_name.iloc[2]['area_km2']:.0f} km²、単一市町だが大規模)。"
    "上位 3 区域で県の都計指定面積の<b>"
    f"{zone_diss_name.head(3)['area_km2'].sum() / total_zone * 100:.0f}%</b>"
    "を占める。</li>"
    "<li><b>連結成分数</b>: 広島圏は 3 部に分散（離島・飛び地）、備後圏は 11 部（しまなみ各島）。"
    "因島瀬戸田都市計画区域も 8 部（しまなみの島々）。"
    "<b>瀬戸内の多島海地形がそのまま都計指定の幾何に反映</b>している。</li>"
    "<li><b>準都計区域</b>は唯一<b>湯来準都市計画区域</b>（広島市湯来地区、"
    f"{zone_diss_name[zone_diss_name['zone_kind']=='準都市計画区域']['area_km2'].iloc[0]:.1f} km²、5 部）。"
    "都計区域外の山間部に「予防的指定」として設定されている。仮説 H5 を強く支持。</li>"
    "</ul>"

    "<h3>表（要件 G）: 広島圏都市計画区域 構成 8 市町</h3>"
    "<p><b>なぜこの表か</b>: 「広島圏」と一口に言っても、各市町がどれだけの面積を寄与しているかを定量化する。</p>"
    + hir_table +
    "<p><b>この表から読み取れること</b>: "
    "広島市が約 4 割を占めるが、東広島市・廿日市市・大竹市も大きく寄与。"
    "東広島市など合併で広域化した市が、独自の都計区域（東広島都市計画区域）と"
    "広島圏の<b>両方に所属</b>するのは、都市計画上の<b>多重指定</b>の例。</p>"

    "<h3>表（要件 G）: 備後圏都市計画区域 構成 4 市町</h3>"
    + bin_table +
    "<p><b>この表から読み取れること</b>: "
    "福山市が圧倒的（県東部の中核）。三原市・尾道市・府中市が補完する。"
    "備後地域の<b>4市連携の経済・通勤圏</b>がそのまま都市計画上の広域指定として表現されている。</p>"
)
sections.append(("5. 分析2: TOKEI_NAME で広域圏域を同定する（dissolve）", s5_html))

# === セクション6: 分析3 — 主題図と広域区域の可視化 ===
s6_html = (
    "<h3>狙い</h3>"
    "<p>前セクションで識別した 23 区域を地図で可視化する。"
    "<b>「県内のどこにどの都市計画区域が指定され、広域圏はどう広がっているか」</b>"
    "を直感的に掴む（要件 T）。</p>"

    "<h3>図 1: 23 都市計画区域 主題図（TOKEI_NAME 別カラー）</h3>"
    "<p><b>なぜこの図か（要件 H）</b>: 県全域に対して<b>「どの色塗りでどの区域か」</b>"
    "の基準ビュー。以降のすべての分析はこの位置感覚を前提にする。"
    "色分けは TOKEI_NAME 別、上位の主要区域を固有色（広島圏=赤、備後圏=青、"
    "三次圏=紫、東広島=緑、湯来準=橙）で示し、行政区域境界も薄く重ねる。</p>"

    + figure("assets/L16_zone_thematic.png", "図1: 23 都市計画区域 主題図 (TOKEI_NAME 別塗り分け)") +

    "<p><b>この図から読み取れること（要件 F）</b></p>"
    "<ul>"
    "<li><b>広島圏（赤）と備後圏（青）が県の南部 2 大ブロック</b>を占める。"
    "両者の間（東広島市あたり）に独立の東広島都市計画区域（緑）が挟まる「3 大都計圏域」構造。</li>"
    "<li><b>北部・中央山地は広範に灰色（白地）</b>。庄原市・北広島町・安芸高田市・世羅町は"
    "各々小さな町中心市街地の周囲だけが小区域として点在。</li>"
    "<li><b>瀬戸内の島々</b>（呉市・尾道市・大竹市・福山市の島嶼部）は"
    "<b>各島ごとに小規模な都計区域</b>として指定。"
    "因島瀬戸田都市計画区域、安芸津都市計画区域、川尻安浦都市計画区域などがそれ。</li>"
    "<li><b>湯来準都市計画区域（橙）</b>は広島市の北西、湯来温泉エリアに小規模に存在。"
    "他の都計区域とは隔たって独立している。</li>"
    "<li><b>各市町の面積に対する都計の比率は地理的に大きく異なる</b>こと、"
    "中山間地域では大半が灰色（白地）であることが一目瞭然。</li>"
    "</ul>"

    "<h3>図 2: 広島圏・備後圏のクローズアップ + 構成市町</h3>"
    "<p><b>なぜこの図か</b>: 広域指定 2 区域の<b>正確な空間範囲</b>と"
    "<b>どの市町をどこまで含むか</b>を、構成市町の輪郭と並べて見せる。</p>"

    + figure("assets/L16_wide_areas.png", "図2: 左=広島圏(赤)+8市町、右=備後圏(青)+4市町") +

    "<p><b>この図から読み取れること</b></p>"
    "<ul>"
    "<li>広島圏は<b>広島市の8区すべて</b> + 周辺町（府中・海田・熊野・坂） + 廿日市市・大竹市・東広島市の"
    "都市部を一括で取り込む「広島大都市圏」のコア。</li>"
    "<li><b>各構成市町の中で、広島圏に含まれる範囲は中心市街地と沿岸部に限定</b>"
    "（廿日市市の北部・東広島市の北部山地は広島圏に含まれない）。</li>"
    "<li>備後圏は<b>福山市の中心と周辺市</b>が一体化した広域指定。"
    "<b>福山市が圧倒的な中核</b>で、三原市・尾道市の港湾部・府中市の市街地を周縁的に取り込む。"
    "備後圏のしまなみ部分（向島・因島・生口島）は<b>備後圏とは別</b>で「因島瀬戸田都市計画区域」として独立。</li>"
    "<li>広島圏は本土が連続的（3 部）、備後圏はしまなみ島嶼で 11 部に分散。"
    "<b>同じ「広域」でも幾何学的形状は対照的</b>。</li>"
    "</ul>"
)
sections.append(("6. 分析3: 主題図と広域圏域のクローズアップ", s6_html))

# === セクション7: 分析4 — 指定率 choropleth と白地マップ ===
ratio_table_rows = []
for _, r in city_zone_agg.sort_values("zone_ratio_pct", ascending=False).iterrows():
    ratio_table_rows.append(
        f"<tr><td>{r['city']}</td>"
        f"<td>{r['ctype']}</td>"
        f"<td>{r['admin_area_km2']:.1f}</td>"
        f"<td>{r['zone_area_km2']:.1f}</td>"
        f"<td>{r['white_area_km2']:.1f}</td>"
        f"<td>{r['zone_ratio_pct']:.1f}</td>"
        f"<td>{int(r['n_zone_names'])}</td></tr>"
    )
ratio_table = (
    "<table><tr><th>市町</th><th>ctype</th>"
    "<th>行政面積 km²</th><th>都計面積 km²</th><th>白地 km²</th>"
    "<th>指定率 %</th><th>区域種別数</th></tr>"
    + "".join(ratio_table_rows) + "</table>"
)

code_ratio = code('''
# 行政区域 (L15 と共有) を読み、市町別合計面積を計算
admin_diss = admin_all.dissolve(by="city").reset_index()
admin_diss["admin_area_km2"] = admin_diss.geometry.area / 1e6

# 市町別 都計指定面積
city_zone_agg = zone_all.groupby("source_city").agg(
    n_polys=("poly_area_km2", "size"),
    zone_area_km2=("poly_area_km2", "sum"),
    n_zone_names=("TOKEI_NAME", "nunique"),
).reset_index().rename(columns={"source_city": "city"})

# 行政面積をマージし、指定率を算出
admin_areas = admin_diss.set_index("city")["admin_area_km2"]
city_zone_agg["admin_area_km2"] = city_zone_agg["city"].map(admin_areas)
city_zone_agg["zone_ratio_pct"] = (
    city_zone_agg["zone_area_km2"] / city_zone_agg["admin_area_km2"] * 100
)
city_zone_agg["white_area_km2"] = (
    city_zone_agg["admin_area_km2"] - city_zone_agg["zone_area_km2"]
)
''')

s7_html = (
    "<h3>狙い</h3>"
    "<p>市町ごとに<b>「行政区域全体に占める都計指定の割合」</b>を計算する。"
    "都市計画区域に指定されない領域は<b>白地</b>と呼び、"
    "都市計画法の主要な開発・建築規制が適用されない。"
    "白地の地理的分布が、広島県の<b>都市計画の地理的偏り</b>を直接示す。</p>"

    "<h3>手法</h3>"
    "<p><b>直感</b>: 「分子=都計指定面積、分母=行政区域面積」で割り算。"
    "<b>分母はL15で構築した行政区域 GeoDataFrame</b>を再利用する。"
    "L15 を「合体」するのではなく、<b>分母としてだけ参照</b>するのがポイント。</p>"

    "<p><b>大筋</b></p>"
    "<ol>"
    "<li>行政区域を <code>admin_all.dissolve(by=\"city\")</code> で 20 市町合計面積に集約</li>"
    "<li>都計区域を <code>zone_all.groupby(\"source_city\").sum()</code> で市町別合計面積に</li>"
    "<li>両者をマージして <code>zone_ratio_pct = zone_area / admin_area * 100</code></li>"
    "<li><code>white_area = admin_area - zone_area</code> で白地面積</li>"
    "</ol>"

    "<p><b>前提</b>: 行政区域も都計区域も <b>同じ EPSG:6671（m 単位）</b>で投影変換済み。"
    "面積計算が単位整合的にできる。</p>"

    "<h3>実装</h3>"
    + code_ratio +

    "<h3>図 3: 市町別 都計指定率 choropleth + 白地マップ</h3>"
    "<p><b>なぜこの図か</b>: choropleth は「<b>どの市町が高指定率か</b>」の概観、"
    "右の白地マップは「<b>県全域でどこに指定外の山林・田畑が残っているか</b>」を視覚化。"
    "色を変えることで「都計優勢」と「白地優勢」のコントラストが立体的に伝わる。</p>"

    + figure("assets/L16_zone_ratio_white.png",
              "図3: 左=指定率 choropleth（赤=低、緑=高）、右=都計エリア（緑）と白地（灰）") +

    "<p><b>この図から読み取れること</b></p>"
    "<ul>"
    "<li><b>都市部の小さな町（熊野町・府中町・海田町・坂町）と竹原市は緑（指定率 ≥80%）</b>。"
    "面積が小さいぶん市街地が町全域を覆い、ほぼ全域が都計区域。</li>"
    "<li><b>北部・東部の山地（庄原市・北広島町・世羅町・安芸高田市・三次市）は深い赤</b>。"
    "指定率はわずか 2〜12%。中山間地域の大半が白地。</li>"
    "<li>右の白地マップで<b>県北部・西部山地が広く灰色</b>。"
    "都市計画法の規制が直接及ばない領域が広島県の<b>3分の2近く</b>を占める。</li>"
    "<li>逆に<b>緑の都計エリアは沿岸と平野部に集中</b>。"
    "瀬戸内沿岸＋盆地＋三次・庄原市街地周辺に限定的。</li>"
    "</ul>"

    "<h3>表（要件 G）: 市町別 都計指定率（指定率降順）</h3>"
    "<p><b>なぜこの表か</b>: choropleth の数値版。指定率の高低をランキング形式で確認。</p>"
    + ratio_table +
    "<p><b>この表から読み取れること</b></p>"
    "<ul>"
    f"<li><b>指定率 100% は 3 市町</b>: 熊野町・府中町・竹原市。"
    "府中町・熊野町は広島市周辺の都市近郊町、竹原市は中心市街地が市全体に及ぶ歴史的市街地。</li>"
    f"<li><b>指定率最低</b>は安芸高田市 ({city_zone_agg['zone_ratio_pct'].min():.2f}%)。"
    "次いで北広島町・世羅町・庄原市・三次市と続く。"
    "<b>5 市町が10%未満</b>。中山間地域の都市計画は<b>合併前の旧町中心部</b>に局限される。</li>"
    "<li><b>区域種別数</b>（n_zone_names）: 廿日市市・庄原市・尾道市・呉市・東広島市は<b>3 種類の都計区域</b>に所属。"
    "1 市町が複数の都計区域名を持つのは「合併で複数の旧都計区域を継承」した結果。</li>"
    "<li>広島市は <b>2 種類</b>: 広島圏 + 湯来準。"
    "湯来地区が独立したカテゴリ（準都計区域）として残されているのは平成大合併の名残。</li>"
    "</ul>"

    "<h3>図 4: 23 都市計画区域 small multiples</h3>"
    "<p><b>なぜこの図か</b>: 一覧表だけだと<b>各区域の幾何形状</b>が見えない。"
    "23 panels で並べて見ることで、<b>大都市圏型・島嶼型・中山間型</b>の形状の違いが一目で対比できる。</p>"

    + figure("assets/L16_zones_small_multiples.png",
              "図4: 23 都市計画区域 個別形状（パネルごとに最大化、スケール非統一）") +

    "<p><b>この図から読み取れること</b></p>"
    "<ul>"
    "<li><b>広島圏</b>（赤）は本土主部 + 似島・宮島東岸の小島で構成された連続的多角形。"
    "<b>備後圏</b>（青）は本土 + しまなみ島群で<b>11部に大きく分散</b>。同じ「広域」でも対照的形状。</li>"
    "<li><b>因島瀬戸田・安芸津・川尻安浦・宮島</b>は瀬戸内島嶼型（小さな島々の集合）。"
    "<b>多島海の都市計画</b>として、各島ごとに点在する小区域を1名前で束ねる工夫。</li>"
    "<li><b>東広島・本郷・河内・佐伯・東城・西城・上下・庄原</b>は中山間市街地型"
    "（盆地・河谷沿いの単一連続多角形）。形は単純で凸包に近い。</li>"
    "<li><b>湯来準都市計画区域</b>（5部）は広島市湯来温泉地区周辺の散在指定。"
    "他の区域とは形状学的にも分離した<b>「予防的小規模指定」</b>のサンプル。</li>"
    "</ul>"
)
sections.append(("7. 分析4: 都計指定率 choropleth と白地マップ", s7_html))

# === セクション8: 分析5 — 行政区域との重ね合わせ + 散布図 ===

# H4 用テスト統計
h4 = results["H4"]
spearman_text = (f"行政面積と指定率の Spearman 相関 ρ = <b>{h4['spearman_rho_admin_area_vs_ratio']}</b>"
                 f"（p = {h4['spearman_p']}）")

s8_html = (
    "<h3>狙い</h3>"
    "<p>都市計画区域は<b>人口・産業集中地に偏在</b>するという仮説（H4）を、"
    "行政区域との重ね合わせと散布図で確かめる。"
    "「面積の大きい中山間市町ほど指定率が低い」という負の相関が見える。</p>"

    "<h3>手法</h3>"
    "<p><b>STEP1: 行政区域に都計区域を重ねる</b><br>"
    "L15 で作った行政区域（ctype 別カラー）の上に、都計区域を半透明黒で重ねる。"
    "<b>「色が濃いところが都計指定範囲」</b>として直感把握。"
    "純粋な「重ね合わせマップ」で、関係性が即座に見える。</p>"

    "<p><b>STEP2: 行政面積 × 指定率の散布図</b><br>"
    "x軸=行政面積（log スケール）、y軸=指定率（%）。"
    "<b>負の傾向</b>があれば「大きな市町ほど都計指定が小さい比率」=「中山間広域市町は白地優勢」を支持。"
    "Spearman 順位相関係数で定量化（パラメトリックでない順位ベースなので、外れ値（広島市）に頑健）。</p>"

    "<h3>図 5: 行政 vs 都計 重ね合わせ + 散布図</h3>"
    "<p><b>なぜこの図か</b>: 重ね合わせは「<b>視覚的相関</b>」、散布は「<b>数値的相関</b>」。"
    "両方を並べることで「色で見ても数で見ても同じ結論」を学習者が確信できる。</p>"

    + figure("assets/L16_admin_vs_zone.png",
              "図5: 左=行政(色) + 都計(黒オーバレイ)、右=行政面積(log) × 指定率") +

    f"<p><b>この図から読み取れること</b></p>"
    "<ul>"
    "<li>左図: <b>政令市・市の沿岸部に黒（都計指定）が集中</b>、"
    "町（緑系）の小規模域はほぼ全域黒、北部の市・町は黒がほとんどない。</li>"
    f"<li>右図: 明確な<b>負の傾向</b>。{spearman_text}。"
    "<b>p &lt; 0.05</b> で統計的に有意（n=20 で適切）。仮説 H4 を強く支持。</li>"
    f"<li>町（緑）は<b>左上の高指定率象限</b>に集中（小面積・高指定率）。"
    "町は地理的にコンパクトで、都計指定が市町全域を覆いやすい。</li>"
    f"<li>政令市の<b>広島市は中央偏左下</b>（957 km²、42.3%）。"
    "面積は大きいが都計は南部市街地に限定され、北部山地（安佐北区など）は白地優勢。</li>"
    "<li><b>離島自治体 江田島市</b>は中央左（100 km²、35.6%）。"
    "離島だが都市計画区域は江田島・能美の中心部に限定され、"
    "山地・離島周縁部は白地。</li>"
    "</ul>"

    "<h3>図 6: TOKEI_NAME 別 面積バー + 連結成分散布</h3>"
    "<p><b>なぜこの図か</b>: 23 区域の<b>面積規模序列</b>を一目で見せ、"
    "右で<b>「広域は分散しがち、単一は集約的」</b>のパターンを散布で示す。</p>"

    + figure("assets/L16_zone_areas_bar.png",
              "図6: 左=面積ランキング(赤=広域,橙=準,青=単一)、右=面積×連結成分") +

    "<p><b>この図から読み取れること</b></p>"
    "<ul>"
    "<li>左図: 上位3 が広島圏・備後圏・東広島の <b>3 大都計圏</b>。"
    "下位は各 1〜30 km² 程度の小規模区域が連なる。"
    "<b>広島県の都計区域は二極化</b>: 大都市圏型 vs 旧町中心部型。</li>"
    "<li>右図: <b>赤（広域）は右上方向に伸び</b>"
    "（面積大・連結成分多）、青（単一）は<b>左中・右中</b>に集中。"
    "広域指定は本質的に分散的（複数市町をまたぐ）、単一は集約的。</li>"
    "<li><b>橙星マーク（湯来準）は左下隅</b>: 面積最小、連結成分は中（5 部）。"
    "「準区域」という制度上の周縁性が幾何にも反映されている。</li>"
    "</ul>"
)
sections.append(("8. 分析5: 行政区域との関係（重ね合わせ + 散布相関）", s8_html))

# === セクション9: 分析6 — 整合性検証 + 市町別 stack ===

recon_rows = []
for _, r in reconciliation.iterrows():
    recon_rows.append(
        f"<tr><td>{r['source']}</td>"
        f"<td>{r['area_km2']:,.3f}</td>"
        f"<td>{r['polys']}</td>"
        f"<td>{r['TOKEI_NAME種類']}</td>"
        f"<td>{r['note']}</td></tr>"
    )
recon_table = (
    "<table><tr><th>ソース</th><th>面積 km²</th><th>ポリゴン数</th>"
    "<th>TOKEI_NAME 種類</th><th>備考</th></tr>"
    + "".join(recon_rows) + "</table>"
)

code_recon = code('''
# 21 市町別合計
sum_city = zone_all["poly_area_km2"].sum()
n_city   = len(zone_all)

# 県全域版 ds=923
sum_ken  = ken_gdf["poly_area_km2"].sum()
n_ken    = len(ken_gdf)

# 差
diff_pct = (sum_city - sum_ken) / sum_ken * 100
print(f"21 ファイル合計: {sum_city:.3f} km², {n_city} ポリゴン")
print(f"ds=923 合計   : {sum_ken:.3f} km², {n_ken} ポリゴン")
print(f"差: {diff_pct:+.5f}%")
''')

s9_html = (
    "<h3>STEP1: 整合性検証（21 市町別 vs 県全域 ds=" + str(KEN_DSID) + "）</h3>"
    "<p><b>狙い</b>: 研究記事として「使ってるデータが信じられるか」を点検。"
    "DoBoX の市町別 21 ファイルと県全域 1 ファイルが<b>同じものを別書式で出しているだけか</b>を、"
    "ポリゴン数・面積・TOKEI_NAME種類数の3指標で確認する。</p>"

    "<p><b>手法</b>: 「足し算が合うかチェック」。"
    "<code>zone_all['poly_area_km2'].sum()</code> と <code>ken_gdf['poly_area_km2'].sum()</code> を比較し、"
    "± 0.001% 以内なら<b>同一マスター由来</b>と結論できる。</p>"

    "<h3>実装</h3>"
    + code_recon +

    "<h3>表（要件 G）: 整合性検証レポート</h3>"
    + recon_table +
    "<p><b>この表から読み取れること</b></p>"
    "<ul>"
    f"<li><b>ポリゴン数</b>: {n_city} = {n_ken} で<b>完全一致</b>。"
    "市町別ファイルを縦結合した結果と県全域版が1:1対応している。</li>"
    f"<li><b>面積</b>: 差は <b>{diff_pct:+.5f}%</b>。誤差は浮動小数点演算レベル。"
    "21市町別ファイルと県全域版は<b>同一マスターから書き出された冗長コピー</b>と結論できる。仮説 H3 を強く支持。</li>"
    "<li><b>TOKEI_NAME 種類数</b>: 両者で 23 種類で一致。</li>"
    "<li>研究上、どちらのファイルを使っても結果は同じだが、"
    "<b>市町別の方が source_city 情報を保持できる</b>ので分析向き。"
    "県全域版は CITY_CD（数字コード）で市町を識別する必要がある。</li>"
    "</ul>"

    + figure("assets/L16_reconciliation.png",
              "図7: 左=面積比較、右=ポリゴン数比較（両者完全一致）") +

    "<p><b>この図から読み取れること</b>: 棒グラフで両者の高さがほぼ同じ → 視覚的にも整合性確認。</p>"

    "<h3>STEP2: 市町別 都計 vs 白地 積み上げ</h3>"
    "<p><b>狙い</b>: 各市町の行政面積を<b>「都計指定」と「白地」の2区分に分けて</b>、積み上げ棒で並べる。"
    "ランキング順を「行政面積」にすることで、面積規模と指定率の関係が立体的に見える。</p>"

    "<p><b>なぜこの図か</b>: 単純な指定率 choropleth（図3）よりも、"
    "<b>「絶対量と比率を同時に見せる」</b>のが Stacked bar の利点。"
    "「庄原市は 1,247km² の93% が白地」のような<b>絶対面積の大きな白地</b>が圧倒的視覚インパクトで伝わる。</p>"

    + figure("assets/L16_zone_white_stack.png",
              "図8: 市町別 都計(緑) vs 白地(灰) 積み上げ（行政面積順、右数字=指定率）") +

    "<p><b>この図から読み取れること</b></p>"
    "<ul>"
    "<li><b>庄原市・広島市・三次市・北広島町</b>の上位 4 市町は行政面積 600 km² 超。"
    "うち広島市以外は<b>白地が圧倒的</b>（90% 以上が灰色）。</li>"
    "<li>広島市は行政面積では 2 位だが<b>都計指定率 42%</b>。"
    "県内で唯一「広面積でも都計指定率が中以上」の例。</li>"
    "<li>下位（小面積）の町は<b>大半が緑のみ</b>: 府中町・熊野町・坂町は白地ほぼゼロ。"
    "市町全域が都計区域に含まれるコンパクトな都市近郊町。</li>"
    "<li><b>白地面積の絶対量で見ると庄原市が最大</b>（約 1,170 km²、県全体の白地の 約 22%）。"
    "中山間広域自治体 1 市町だけで県の白地の 5 分の 1 を占める。</li>"
    "</ul>"
)
sections.append(("9. 分析6: 整合性検証 + 市町別 都計 vs 白地 積み上げ", s9_html))

# === セクション10: 仮説検証と考察 ===
hyp_rows = []
for hkey in ["H1", "H2", "H3", "H4", "H5"]:
    h = results[hkey]
    rationale = ", ".join(f"{k}={v}" for k, v in h.items()
                          if k not in ("claim", "verdict"))
    hyp_rows.append(
        f"<tr><td>{hkey}</td><td>{h['claim']}</td>"
        f"<td><b>{h['verdict']}</b></td>"
        f"<td style=\"font-size:11px;\">{rationale}</td></tr>"
    )
hyp_table = (
    "<table><tr><th>仮説</th><th>主張</th><th>判定</th><th>根拠</th></tr>"
    + "".join(hyp_rows) + "</table>"
)

s10_html = (
    "<h3>仮説検証 結果一覧</h3>"
    + hyp_table +
    "<p><b>この表から読み取れること</b></p>"
    "<ul>"
    f"<li><b>H1（白地優勢）</b>: 全県の指定率 <b>{results['H1']['ratio_pct']:.1f}%</b>、"
    f"中山間 5 市町の平均は <b>{results['H1']['mountain_cities_avg_ratio_pct']:.1f}%</b>。"
    "「広島県の都市計画は人口集中地に集中、それ以外は白地」という基本構造が定量化できた。</li>"
    "<li><b>H2（広域圏域）</b>: 「圏」名 3 種のうち広島圏（8市町）と備後圏（4市町）は広域指定として機能、"
    "<b>三次圏は名前は『圏』だが 1 市町のみ</b>。"
    "<b>命名と実態にずれがある</b>のは興味深い発見。"
    "「圏」は古い行政命名ルールの名残で、新しい合併で構成市町が縮小したと推察される。</li>"
    f"<li><b>H3（データ整合性）</b>: 差は <b>{results['H3']['diff_pct']}%</b> で完全一致。"
    "DoBoX の市町別と県全域は同一マスター由来の冗長コピーと確認。</li>"
    f"<li><b>H4（負の相関）</b>: Spearman ρ = "
    f"<b>{results['H4']['spearman_rho_admin_area_vs_ratio']}</b> で強い負の相関。"
    "大面積市町ほど低指定率という都市計画の地理的偏在が統計的に有意。</li>"
    f"<li><b>H5（準区域の周縁性）</b>: 湯来準のみ（{results['H5']['quasi_total_area_km2']:.1f} km²、"
    f"全都計の {results['H5']['quasi_share_pct']:.2f}%）。"
    "制度上の周縁性がデータにも完全に反映。</li>"
    "</ul>"

    "<h3>考察: なぜこの構造になったか</h3>"
    "<ol>"
    "<li><b>都市計画区域は人口集中地への限定指定</b>。"
    "都市計画法の本来の目的が「都市的土地利用の規制と誘導」であるため、"
    "<b>農地・山林（白地）には適用しない</b>のが立法趣旨。"
    "中山間地域の白地が多いのは制度的な「設計通り」。</li>"
    "<li><b>「○○圏」は経済圏の都市計画的表現</b>。"
    "広島圏は通勤・商業・教育で一体化した広島大都市圏、"
    "備後圏は福山市を核とする備後経済圏。"
    "両者とも<b>複数市町が連携して土地利用を計画</b>する制度的枠組み。</li>"
    "<li><b>瀬戸内多島海地形が都計区域を分散させる</b>。"
    "備後圏（11部）、因島瀬戸田（8部）、安芸津（8部）、川尻安浦（6部）など"
    "島嶼を複数含む区域は連結成分数が多い。"
    "<b>離島の都市計画は本土と分離した部分指定</b>として立てる必要があった歴史的経緯。</li>"
    "<li><b>合併と都計区域の継承</b>: 平成大合併で東広島市・尾道市・三原市・"
    "廿日市市・庄原市などは複数の旧町を吸収。"
    "<b>旧町ごとの都計区域名が残されたまま統合</b>されたため、"
    "1 市町が複数の TOKEI_NAME を持つ状態に。"
    "尾道市は『因島瀬戸田』『御調』『備後圏』の 3 つを継承。</li>"
    "<li><b>準都市計画区域の使われ方</b>: 広島市湯来準のみで、"
    "「準」という制度自体が広島県内ではほぼ未活用。"
    "<b>都市計画区域外の山間部における予防的指定</b>として湯来温泉エリアにのみ適用。</li>"
    "</ol>"

    "<h3>研究上の含意</h3>"
    "<ul>"
    "<li><b>都市計画区域の「面積」と「比率」は別の意味</b>: "
    "庄原市は絶対白地面積最大（約 1,170 km²）、"
    "安芸高田市は指定率最低（2.4%）、"
    "府中町は<b>絶対指定面積最小だが 100% 指定</b>。"
    "用途に応じて指標を選ぶ必要。</li>"
    "<li><b>L17（用途地域）・L18（市街化区域）は本記事の都計区域内でしか指定できない</b>。"
    "白地（67%）は<b>L17/L18 の対象外</b>。"
    "L16 は L17/L18 の<b>必須前提レイヤー</b>として機能する。</li>"
    "<li><b>広島圏 8 市町の都計連携</b>は政策研究の興味深い対象。"
    "市町ごとに都計の運用主体が分かれているが、広域指定では一体的な計画が要求される"
    "→ 自治体間調整の難しさが幾何構造から推察できる。</li>"
    "<li><b>データ整合性 ±0.00001%</b>: DoBoX の市町別と県全域はほぼ同等品。"
    "研究上どちらでも良いが、<b>source_city 情報が必要なら市町別、可視化なら県全域版が便利</b>。</li>"
    "</ul>"
)
sections.append(("10. 仮説検証と考察", s10_html))

# === セクション11: 発展課題 ===
s11_html = """
<p>各課題は「<b>結果X → 新仮説Y → 課題Z</b>」の3段で書く（要件E）。</p>

<h3>課題1: 広島圏 8 市町の都計内部多様性を測る</h3>
<ul>
<li><b>結果X</b>: 広島圏都市計画区域は 8 市町の都計指定領域を統合した広域指定だが、
    本記事では <b>面積寄与の市町別内訳</b>しか見ていない。
    各市町内のどこまで広島圏に含まれ、どの部分は別の都計区域なのかは未検証。</li>
<li><b>新仮説Y</b>: <b>「広島圏の境界線は 8 市町それぞれの中心市街地から距離 d 以内に同心円的に広がる」</b>
    という核-周辺モデル。中心は広島市役所、距離が遠ざかるほど広島圏に含まれる確率が下がる仮説。</li>
<li><b>課題Z</b>: 8 市町の市役所緯度経度（DoBoX 「公共施設」シリーズ）と
    広島圏ポリゴン境界の最近接距離を計算。
    <code>nearest_points()</code> でポリゴン境界線への最短距離を点ごとに測り、
    距離 vs 「広島圏含有率」の logistic 回帰。
    モデル係数で「圏域の射程距離」を推定する（例: 距離 30km で含有率 50%）。</li>
</ul>

<h3>課題2: 「圏」と名付けられた 3 区域の命名理由を歴史的に追跡</h3>
<ul>
<li><b>結果X</b>: 広島圏（8 市町）・備後圏（4 市町）は広域指定だが、
    三次圏（1 市町）は名前と実態が乖離している。</li>
<li><b>新仮説Y</b>: <b>「三次圏は元々複数市町をカバーしていたが、平成大合併で吸収された結果 1 市町指定になった」</b>。
    つまり過去には広域だった可能性。</li>
<li><b>課題Z</b>: 国立公文書館・広島県立文書館の都市計画決定告示を
    1970年代まで遡って調査。
    各「圏」の<b>初回指定時の構成市町</b>と<b>合併履歴</b>を表化。
    現代の 1 市町指定が「合併で1つの自治体に統合された結果」か
    「最初から 1 市町だった」かを判別する。</li>
</ul>

<h3>課題3: 白地（指定外）の地理的特性を地形・人口で説明する</h3>
<ul>
<li><b>結果X</b>: 県の 67% が白地で、特に北部・中央山地に集中することが分かった。</li>
<li><b>新仮説Y</b>: <b>「白地は標高 300m 以上 ＆ 人口密度 50 人/km² 未満の地域に強く対応する」</b>。
    都市計画法の「一体の都市として整備する必要」のない地域＝山地＋過疎地。</li>
<li><b>課題Z</b>: 国土地理院 5m 標高 DEM と DoBoX 性別年齢別人口を統合。
    白地ポリゴン（行政区域 - 都計区域、<code>geopandas.overlay(how="difference")</code>）に
    ピクセル単位で標高と人口密度を割り当て、
    都計指定 vs 白地で平均標高・人口密度を比較。
    ROC 曲線で「指定 vs 白地の判別精度」を計算。</li>
</ul>

<h3>課題4: 23 都市計画区域内の用途地域比率を比較（L17 への接続）</h3>
<ul>
<li><b>結果X</b>: 23 都計区域は面積でも形でも多様（広島圏 692km² 〜 西城 4km²）。
    しかし内部の<b>用途地域構成</b>（住居・商業・工業の比率）は未分析。</li>
<li><b>新仮説Y</b>: <b>「広域圏（広島圏・備後圏）は内部に住居系/商業系/工業系の3類型が均等に分布、
    単一市町区域は1〜2類型に偏在する」</b>。
    広域指定ほど多様な用途地域が必要、単一は中心市街地中心の限定構成という仮説。</li>
<li><b>課題Z</b>: DoBoX 「区域データ_各種用途地域」シリーズ（21 件、L17 で扱う）を本記事の都計区域と
    <code>gpd.overlay(how="intersection")</code> で重ね、
    各 TOKEI_NAME 別に用途地域分布を計算。
    Shannon 多様度指数で「用途地域の混在度」を比較。</li>
</ul>

<h3>課題5: 広島圏と備後圏の境界部の都計指定パターンを比較する</h3>
<ul>
<li><b>結果X</b>: 広島圏は本土が連続的（3部）、備後圏は島嶼で分散（11部）。
    両者の境界（東広島市〜三原市あたり）には独立の東広島都市計画区域・河内・本郷・安芸津が点在。</li>
<li><b>新仮説Y</b>: <b>「2 大広域圏の隙間に独立の中規模都計区域が並ぶのは、
    歴史的に旧町ごとに独自の都計を持っていた地域が広域圏に取り込まれずに残った結果」</b>。
    つまり広域圏域の<b>未来の拡張余地</b>とも読める。</li>
<li><b>課題Z</b>: 1970年代以降の都市計画決定史料から、
    東広島都市計画区域・河内・本郷・安芸津の<b>初回指定年と編入提案の有無</b>を調査。
    隣接する広島圏・備後圏との合併検討記録があれば抽出。
    <code>networkx</code> で「隣接する都計区域」グラフを作り、
    <b>合併圧力の高い隣接ペア</b>を中心性指標で同定する。</li>
</ul>
"""
sections.append(("11. 発展課題（結果X → 新仮説Y → 課題Z の3段）", s11_html))

# 全部まとめて render
html = render_lesson(
    num=16,
    title="L16 都市計画区域 21市町統合分析 — 広島県の都市計画指定構造と広域圏域",
    tags=["都市計画", "都市計画区域", "geopandas", "広域指定", "白地分析",
          "21市町統合", "21 dataset_id"],
    time="20-30 分（コード実行は約 30 秒）",
    level="リテラシ＋（geopandas 入門済を想定、L15 連携）",
    data_label="DoBoX 都市計画区域情報_区域データ_都市計画区域 21 件",
    sections=sections,
    script_filename="L16_city_planning_zones.py",
)

(LESSONS / "L16_city_planning_zones.html").write_text(html, encoding="utf-8")
print(f"  HTML 出力: {LESSONS / 'L16_city_planning_zones.html'} "
      f"({time.time()-t1:.2f}s)", flush=True)

# =============================================================================
# 15. ログ出力
# =============================================================================
total = time.time() - t0
print(f"\n=== 完了 ===  実行時間: {total:.2f} 秒")
print(f"出力:")
print(f"  HTML: lessons/L16_city_planning_zones.html")
print(f"  CSV : 5 種 (assets/L16_*.csv)")
print(f"  JSON: 1 種 (assets/L16_hypothesis_results.json)")
print(f"  PNG : 8 種 (assets/L16_*.png)")
