# -*- coding: utf-8 -*-
"""L50 道路規制情報 2 件統合分析
       — 広島県の「本日の規制」「今後の規制」 2 dataset から
          道路規制構造と災害履歴を読み解く

カバー宣言:
  本記事は DoBoX のシリーズ「道路規制情報」 2 dataset
  (id = 1257 本日の規制 / 1258 今後の規制) を <b>統合</b>し、
  広島県内の道路規制の<b>制度的・地理的・時間的構造</b>を分析する。

  「道路規制情報」 とは:
    広島県・国土交通省 (中国地方整備局) が管理する道路で
    現在実施中 (1257) または計画中 (1258) の通行規制情報。
    更新頻度はリアルタイム (~ 数分単位) で、
    各レコードは 32 列の属性を持つ点 + LineString データ。
    本記事は <b>2026-05-09 21:20 取得スナップショット</b>を扱う。

  「規制種別」 (kisei_name) は 5 種:
    - blocked         (通行止め): 完全な通行不可
    - one_side        (片側交互通行): 1 車線を交互に通す
    - narrows         (車線規制): 車線を狭める
    - controll_complex(その他): 複合規制
    - two_way         (対面通行): 中央分離帯撤去等
    - saigai          (災害規制, kisei_type='disaster'): 災害起因の長期規制

  本記事は L02 (カメラマップ), L09 (カメラ→避難所最近傍) と<b>厳密に独立</b>。
  L02/L09 は<b>カメラ施設</b>を扱うが、本記事は<b>動的な規制イベント</b>を扱う。
  両者は同じ「道路インフラ」 ドメインだが、
  - カメラ = 静的な観測点 (~351 件、固定位置)
  - 規制 = 動的なイベント (~218 件、本日 + 今後で時間軸が違う)
  という根本的に異なる粒度で道路を捉える。

研究の問い (主 RQ):
  広島県の道路規制情報 2 dataset (本日の規制 / 今後の規制) は、
  <b>規制種別・路線種別・規制理由・地理分布・時間軸</b>でどう構成され、
  災害起因の長期規制と工事起因の計画規制はどう分布するか？
  さらに「本日 (1257)」 と「今後 (1258)」 の<b>時間スコープの違い</b>から
  何が見えるか？

サブ問い (補助):
  - 5 種の規制種別の件数比はどうか? 通行止 (blocked) が支配的か、
    片側交互通行 (one_side) が中心か?
  - 国道 / 県道 / 市町道 の規制密度は? 国土交通省管轄と県管轄でどう違う?
  - 工事規制 vs 災害規制 (kisei_type) の比率は? 災害規制は何年前から続いているか?
  - 規制の地理分布: 中山間 (山間部の道路改良) と沿岸 (橋梁工事) でどう違う?
  - 規制延長 (encho) は 10m から 92,000m (92km) まで 4 桁の幅 — どんな分布か?
  - 規制期間 (start_date - end_date) の中央値・最長値は?
  - 1257 (本日) と 1258 (今後) は 66/69 IDが共通 (95.7%) =
    今後は本日のスーパーセット。3 件のみが「今日終了」 する規制。

仮説 H1〜H6:
  H1 (one_side 支配): 規制種別の中で<b>片側交互通行 (one_side)</b>が
    最頻 (40-50% 程度) で、通行止 (blocked) は 15-25%。これは
    「工事は道路機能を完全停止しない」 行政運用思想を反映する。
    車線規制 (narrows) も多く、対面通行 (two_way) は稀。

  H2 (国道 vs 県道の管轄分離): 1258 (今後) は<b>国道 (国交省管轄)</b>が支配的
    (50% 超) だが、1257 (本日) は<b>県道 (県管轄)</b>が支配的。
    国交省は計画規制を多く事前公表する一方、県は当日運用が多い、
    という<b>管轄ごとの公開思想の違い</b>を反映するか検証する。

  H3 (工事 9 vs 災害 1 の比率): 規制理由 (kiseireason) の<b>9 割超は工事</b>
    (道路改良 / 法面 / 橋梁 / 維持 / 舗装 / 下水 / ガス / 電線等)、
    災害復旧 (落石・路面陥没・法面崩落・橋梁崩落) は 1 割弱。
    1257 vs 1258 で比率が違うか? 災害規制は<b>長期化</b>するため
    1257 で目立つはず。

  H4 (2018 西日本豪雨の長期規制): 1257 に含まれる kisei_type='disaster'
    の規制は<b>2018-07-07 西日本豪雨</b>起因の規制が複数残存している。
    8 年前の災害が今でも通行止になっており、
    <b>道路復旧の極めて長い時間スケール</b>を映す。
    具体的に何件残存するかを定量化する。

  H5 (規制延長の対数正規分布): 規制延長 (encho) は最小 10m から
    最大 92,000m (92km) まで <b>4 桁の幅</b>を持つ。
    log10 スケールで分布は<b>正規に近い</b> (= 対数正規) はず。
    median 数百 m, 平均 1-2km と予想。
    超長距離規制 (1km 超) は事前通行規制区間 (積雪・冬期閉鎖) と推定。

  H6 (1257 ⊂ 1258 の包含関係): 1257 (本日) と 1258 (今後) の id 集合は
    1257 の <b>95.7% (66/69)</b> が 1258 にも含まれる。
    1258 は 1257 より <b>2.16 倍</b>のレコードを持ち、本日終了予定でない
    将来規制をすべて含む<b>スーパーセット</b>として機能する。
    これは「本日 = 今後のスナップショット」 という制度的設計を示す。

要件 S 準拠:
  - 全データ規模が小さい (218 レコード合計, 0.4 MB JSON 2 件)
  - 重い処理なし、admin sjoin 1 回のみ
  - 期待実行時間: ~10-20 秒。

要件 T 準拠:
  - 規制点マップ (規制種別で色分け) — 主役級
  - 規制 LineString マップ (kukanroot WKT 由来)
  - 市町別コロプレス (規制密度)
  - 1257 vs 1258 重ね地図 (本日 vs 今後の時間スコープ可視化)

要件 Q 準拠:
  - 図 6+ (件数比 / 規制種別マップ / LineString 重畳 / 路線種別 stacked /
          市町コロプレス / 規制延長 log 分布 / 期間ガント風 / 災害履歴年表)
  - 表 8+ (dataset 仕様 / 5 種別 / 路線種別 / 規制理由 Top 15 /
          市町ランキング / 災害規制詳細 / 期間統計 / 仮説検証)

データ仕様:
  - dataset 1257: 道路規制情報_本日の規制 (JSON, 69 records, 165 KB)
  - dataset 1258: 道路規制情報_今後の規制 (JSON, 149 records, 234 KB)
  - 形式: JSON (results: list[dict 32列] + stats: dict[6カウンタ])
  - 取得日: 2026-05-09 21:20:03 JST
  - 更新頻度: リアルタイム
  - ライセンス: CC-BY 4.0
"""
from __future__ import annotations
import sys, time, json, re
from pathlib import Path

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

import numpy as np
import pandas as pd
import geopandas as gpd
import shapely
from shapely import wkt as swkt
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Patch
from matplotlib.lines import Line2D
from matplotlib.dates import DateFormatter, YearLocator
from datetime import datetime, timedelta
from collections import Counter

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

t_all = time.time()
print("=== L50 道路規制情報 2 件統合分析 ===", flush=True)

# =============================================================================
# 0. 定数・パス
# =============================================================================
TARGET_CRS = "EPSG:6671"  # JGD2011 平面直角座標系第 III 系 (m 単位)

DATA_DIR = ROOT / "data" / "extras" / "L50_road_restrictions"
DATA_DIR.mkdir(parents=True, exist_ok=True)

PATH_1257 = DATA_DIR / "1257_today.json"
PATH_1258 = DATA_DIR / "1258_future.json"

# 行政区域 (L15 既キャッシュ)
ADMIN_PREF_ZIP = ROOT / "data" / "extras" / "L15_admin_zones" / "admin_922_広島県.zip"
ADMIN_PREF_GEOJSON = "340006_city_planning_area_administrative_geojson_20220324.geojson"

# 5 規制種別の固定色 + 凡例ラベル
KISEI_LABEL = {
    "blocked":          "通行止め",
    "one_side":         "片側交互通行",
    "narrows":          "車線規制",
    "controll_complex": "その他/複合",
    "two_way":          "対面通行",
}
KISEI_COLOR = {
    "blocked":          "#cf222e",  # 赤 = 通行止め (最重)
    "one_side":         "#bf8700",  # 山吹 = 片側交互
    "narrows":          "#0969da",  # 青 = 車線規制
    "controll_complex": "#8250df",  # 紫 = 複合
    "two_way":          "#1a7f37",  # 緑 = 対面通行
}
KISEI_ORDER = ["blocked", "one_side", "narrows", "controll_complex", "two_way"]

# 路線種別カテゴリ判定
def classify_rosen(name: str) -> str:
    if not name:
        return "その他/不明"
    if "国道" in name:
        return "国道"
    if "主要地方道" in name:
        return "主要地方道"
    if "一般県道" in name or "県道" in name:
        return "一般県道"
    if "広域農道" in name:
        return "広域農道"
    if "町道" in name:
        return "町道"
    if "市道" in name:
        return "市道"
    if "高速" in name or "自動車道" in name:
        return "高速・自動車道"
    return "その他/不明"

ROSEN_ORDER = ["国道", "主要地方道", "一般県道", "市道", "町道",
               "広域農道", "高速・自動車道", "その他/不明"]

# 路線種別の管轄 (デフォルト)
ROSEN_KANRI = {
    "国道": "国 (国土交通省)",
    "主要地方道": "県 (建設事務所)",
    "一般県道": "県 (建設事務所)",
    "市道": "市町",
    "町道": "市町",
    "広域農道": "県・市町",
    "高速・自動車道": "国・NEXCO",
    "その他/不明": "不明",
}

# 規制延長文字列 → m
def parse_encho_m(s):
    if s is None or s == "" or pd.isna(s):
        return np.nan
    s = str(s).replace(",", "").strip()
    m = re.match(r"(\d+(?:\.\d+)?)\s*([mk]?)", s)
    if not m:
        return np.nan
    v = float(m.group(1))
    if m.group(2) == "k":
        v *= 1000
    return v

# 日時文字列 → datetime (1900-01-01 = 不明として None 化はしない、保持)
def parse_dt(s):
    if not s:
        return pd.NaT
    try:
        return datetime.strptime(s, "%Y/%m/%d %H:%M")
    except Exception:
        return pd.NaT

# =============================================================================
# 1. データ取得 (なければ DoBoX から自動 DL)
# =============================================================================
print("\n[1] データ取得", flush=True)
t1 = time.time()

ensure_dataset(PATH_1257, resource_id=32513, label="1257 本日の規制 JSON")
ensure_dataset(PATH_1258, resource_id=32514, label="1258 今後の規制 JSON")

j1257 = json.load(open(PATH_1257, encoding="utf-8"))
j1258 = json.load(open(PATH_1258, encoding="utf-8"))
print(f"  1257 本日の規制: {len(j1257['results'])} 件 / stats={j1257['stats']}")
print(f"  1258 今後の規制: {len(j1258['results'])} 件 / stats={j1258['stats']}")
print(f"  ({time.time()-t1:.1f}s)", flush=True)


# =============================================================================
# 2. JSON → DataFrame 整形 (両 dataset)
# =============================================================================
print("\n[2] JSON → DataFrame 整形", flush=True)
t1 = time.time()

def build_df(records, src_label):
    rows = []
    for r in records:
        rows.append({
            "src": src_label,
            "id": r.get("id"),
            "kisei_name": r.get("kisei_name"),
            "kisei_label": KISEI_LABEL.get(r.get("kisei_name"), "?"),
            "kisei_type": r.get("kisei_type"),  # normal or disaster
            "rosenname": (r.get("rosenname") or "").replace("　", " "),
            "rosen_class": classify_rosen(r.get("rosenname") or ""),
            "kiseinaiyo": r.get("kiseinaiyo"),
            "kiseireason": r.get("kiseireason") or "(空)",
            "lat": pd.to_numeric(r.get("lat"), errors="coerce"),
            "lon": pd.to_numeric(r.get("lon"), errors="coerce"),
            "start_date": parse_dt(r.get("start_date")),
            "end_date":   parse_dt(r.get("end_date")),
            "kisei_hour": r.get("kisei_hour"),
            "start_point": r.get("start_point"),
            "end_point": r.get("end_point"),
            "ukairo": r.get("ukairo"),
            "demarcation": r.get("demarcation") or "(不明)",
            "encho_str": r.get("encho"),
            "encho_m": parse_encho_m(r.get("encho")),
            "kiseisyousai": r.get("kiseisyousai"),
            "kukanroot": r.get("kukanroot"),
            "newold": r.get("newold"),
        })
    df = pd.DataFrame(rows)
    return df

df1257 = build_df(j1257["results"], "1257 本日")
df1258 = build_df(j1258["results"], "1258 今後")
df_all = pd.concat([df1257, df1258], ignore_index=True)
print(f"  df1257: {len(df1257)} 行")
print(f"  df1258: {len(df1258)} 行")
print(f"  df_all: {len(df_all)} 行 (concat 重複あり)")

# 規制期間 (日数) ※start_date / end_date が両方有効なときのみ
def calc_dur_days(df):
    s = df["start_date"]; e = df["end_date"]
    out = (e - s).dt.total_seconds() / 86400.0
    return out

df1257["dur_days"] = calc_dur_days(df1257)
df1258["dur_days"] = calc_dur_days(df1258)

# 1900-01-01 sentinel: 開始日不明 (start_date が 1900-01-01)
df1257["dur_unknown"] = (df1257["start_date"] < pd.Timestamp("1990-01-01"))
df1258["dur_unknown"] = (df1258["start_date"] < pd.Timestamp("1990-01-01"))

# 1257 ⊂ 1258 包含検証 (H6)
ids_1257 = set(df1257["id"])
ids_1258 = set(df1258["id"])
common_ids = ids_1257 & ids_1258
only_1257 = ids_1257 - ids_1258  # 「本日終了」
only_1258 = ids_1258 - ids_1257  # 「将来開始」
print(f"  1257 ∩ 1258 (両方含む) : {len(common_ids)}")
print(f"  1257 only (本日終了予定): {len(only_1257)}")
print(f"  1258 only (将来開始)    : {len(only_1258)}")

inclusion_rate = len(common_ids) / len(ids_1257) if len(ids_1257) > 0 else 0
print(f"  H6 検証: 1257 → 1258 包含率 = {inclusion_rate*100:.1f}%")
print(f"  ({time.time()-t1:.1f}s)", flush=True)


# =============================================================================
# 3. GeoDataFrame 化 (点 + LineString)
# =============================================================================
print("\n[3] GeoDataFrame 化", flush=True)
t1 = time.time()

def to_gdf_points(df):
    """lat/lon 列を点 geometry にした GeoDataFrame を返す"""
    valid = df.dropna(subset=["lat", "lon"]).copy()
    gdf = gpd.GeoDataFrame(
        valid,
        geometry=gpd.points_from_xy(valid["lon"], valid["lat"]),
        crs="EPSG:4326",
    ).to_crs(TARGET_CRS)
    return gdf

def to_gdf_lines(df):
    """kukanroot 列 (WKT LineString) を線 geometry にした GeoDataFrame を返す"""
    rows = []
    for _, r in df.iterrows():
        kr = r.get("kukanroot")
        if not kr:
            continue
        try:
            ls = swkt.loads(kr)
        except Exception:
            continue
        if ls.is_empty:
            continue
        rows.append({**r.to_dict(), "geometry": ls})
    if not rows:
        return gpd.GeoDataFrame(geometry=[], crs="EPSG:4326")
    gdf = gpd.GeoDataFrame(rows, geometry="geometry", crs="EPSG:4326").to_crs(TARGET_CRS)
    # 線長 (m, EPSG:6671 メートル単位)
    gdf["seg_len_m"] = gdf.geometry.length
    return gdf

g1257_pt = to_gdf_points(df1257)
g1258_pt = to_gdf_points(df1258)
g1257_ln = to_gdf_lines(df1257)
g1258_ln = to_gdf_lines(df1258)
print(f"  1257 点: {len(g1257_pt)} / 線: {len(g1257_ln)}")
print(f"  1258 点: {len(g1258_pt)} / 線: {len(g1258_ln)}")
print(f"  ({time.time()-t1:.1f}s)", flush=True)


# =============================================================================
# 4. 行政区域 + 市町 sjoin
# =============================================================================
print("\n[4] 行政区域読込 + sjoin", flush=True)
t1 = time.time()

admin = gpd.read_file(f"zip://{ADMIN_PREF_ZIP}!{ADMIN_PREF_GEOJSON}").to_crs(TARGET_CRS)
admin_diss = admin.dissolve(by="CITY_CD", as_index=False)

# 市町コード → 名前
CITY_NAME = {
    101: "広島市中区", 102: "広島市東区", 103: "広島市南区", 104: "広島市西区",
    105: "広島市安佐南区", 106: "広島市安佐北区", 107: "広島市安芸区", 108: "広島市佐伯区",
    202: "呉市", 203: "竹原市", 204: "三原市", 205: "尾道市", 207: "福山市",
    208: "府中市", 209: "三次市", 210: "庄原市", 211: "大竹市", 212: "東広島市",
    213: "廿日市市", 214: "安芸高田市", 215: "江田島市",
    302: "府中町", 304: "海田町", 307: "熊野町", 309: "坂町",
    369: "安芸太田町", 462: "世羅町",
}
admin_diss["city_name"] = admin_diss["CITY_CD"].map(CITY_NAME).fillna("?")

# 点 sjoin (規制点が属する市町)
def assign_city(gdf_pt):
    j = gpd.sjoin(gdf_pt, admin_diss[["CITY_CD", "city_name", "geometry"]],
                   how="left", predicate="within")
    j["CITY_CD"] = j["CITY_CD"].fillna(-1).astype(int)
    j["city_name"] = j["city_name"].fillna("(県外/不明)")
    return j.drop(columns=["index_right"], errors="ignore")

g1257_pt = assign_city(g1257_pt)
g1258_pt = assign_city(g1258_pt)
print(f"  1257 sjoin: {len(g1257_pt)}, 不明 = {(g1257_pt['CITY_CD']==-1).sum()}")
print(f"  1258 sjoin: {len(g1258_pt)}, 不明 = {(g1258_pt['CITY_CD']==-1).sum()}")
print(f"  ({time.time()-t1:.1f}s)", flush=True)


# =============================================================================
# 5. 集計 (H1〜H6 検証用)
# =============================================================================
print("\n[5] 集計", flush=True)
t1 = time.time()

# H1: 規制種別件数
h1_1257 = df1257["kisei_name"].value_counts().reindex(KISEI_ORDER, fill_value=0)
h1_1258 = df1258["kisei_name"].value_counts().reindex(KISEI_ORDER, fill_value=0)
T_kisei = pd.DataFrame({
    "規制種別": [KISEI_LABEL[k] for k in KISEI_ORDER],
    "色": [KISEI_COLOR[k] for k in KISEI_ORDER],
    "1257 本日 件数": h1_1257.values,
    "1257 シェア%": (100 * h1_1257 / max(h1_1257.sum(), 1)).round(1).values,
    "1258 今後 件数": h1_1258.values,
    "1258 シェア%": (100 * h1_1258 / max(h1_1258.sum(), 1)).round(1).values,
})
T_kisei.to_csv(ASSETS / "L50_kisei_type.csv", index=False, encoding="utf-8-sig")
print("  H1 規制種別:")
print(T_kisei.to_string(index=False))

one_side_share_1257 = h1_1257["one_side"] / h1_1257.sum()
one_side_share_1258 = h1_1258["one_side"] / h1_1258.sum()
blocked_share_1257 = h1_1257["blocked"] / h1_1257.sum()
blocked_share_1258 = h1_1258["blocked"] / h1_1258.sum()
print(f"\n  H1 検証: one_side シェア 1257 = {one_side_share_1257*100:.1f}%, "
      f"1258 = {one_side_share_1258*100:.1f}%")
print(f"           blocked シェア 1257 = {blocked_share_1257*100:.1f}%, "
      f"1258 = {blocked_share_1258*100:.1f}%")

# H2: 路線種別 × 1257 vs 1258
h2_1257 = df1257["rosen_class"].value_counts().reindex(ROSEN_ORDER, fill_value=0)
h2_1258 = df1258["rosen_class"].value_counts().reindex(ROSEN_ORDER, fill_value=0)
T_rosen = pd.DataFrame({
    "路線種別": ROSEN_ORDER,
    "管轄": [ROSEN_KANRI[r] for r in ROSEN_ORDER],
    "1257 件数": h2_1257.values,
    "1257 %": (100 * h2_1257 / max(h2_1257.sum(), 1)).round(1).values,
    "1258 件数": h2_1258.values,
    "1258 %": (100 * h2_1258 / max(h2_1258.sum(), 1)).round(1).values,
})
T_rosen.to_csv(ASSETS / "L50_rosen_class.csv", index=False, encoding="utf-8-sig")
print("\n  H2 路線種別:")
print(T_rosen.to_string(index=False))

kokudo_share_1257 = h2_1257["国道"] / h2_1257.sum()
kokudo_share_1258 = h2_1258["国道"] / h2_1258.sum()
print(f"\n  H2 検証: 国道シェア 1257 = {kokudo_share_1257*100:.1f}%, "
      f"1258 = {kokudo_share_1258*100:.1f}%")

# H3: 工事 vs 災害規制
def reason_class(reason: str, ktype: str):
    if ktype == "disaster":
        return "災害 (kisei_type=disaster)"
    if not reason or reason in ("", "(空)"):
        return "理由不明"
    construction_kw = ["工事", "改良", "修繕", "舗装", "下水", "ガス", "水道",
                        "電線", "電話", "塗装", "外壁", "維持", "交差点"]
    disaster_kw = ["災害", "崩落", "崩壊", "陥没", "落石", "倒木", "崩土"]
    for kw in disaster_kw:
        if kw in reason:
            return "災害復旧"
    for kw in construction_kw:
        if kw in reason:
            return "工事"
    return "その他"

df1257["reason_cat"] = df1257.apply(lambda r: reason_class(r["kiseireason"], r["kisei_type"]), axis=1)
df1258["reason_cat"] = df1258.apply(lambda r: reason_class(r["kiseireason"], r["kisei_type"]), axis=1)

h3_1257 = df1257["reason_cat"].value_counts()
h3_1258 = df1258["reason_cat"].value_counts()
T_reason_cat = pd.DataFrame({
    "理由カテゴリ": h3_1257.index.union(h3_1258.index, sort=False).tolist(),
})
T_reason_cat["1257 件数"] = T_reason_cat["理由カテゴリ"].map(h3_1257).fillna(0).astype(int)
T_reason_cat["1258 件数"] = T_reason_cat["理由カテゴリ"].map(h3_1258).fillna(0).astype(int)
T_reason_cat["1257 %"] = (100 * T_reason_cat["1257 件数"] / max(len(df1257), 1)).round(1)
T_reason_cat["1258 %"] = (100 * T_reason_cat["1258 件数"] / max(len(df1258), 1)).round(1)
T_reason_cat.to_csv(ASSETS / "L50_reason_class.csv", index=False, encoding="utf-8-sig")
print("\n  H3 規制理由カテゴリ:")
print(T_reason_cat.to_string(index=False))

# 規制理由 Top 15 (1258 で集計, 件数多い方)
reason_top = df1258["kiseireason"].value_counts().head(15)
T_reason_top = pd.DataFrame({
    "順位": np.arange(1, len(reason_top)+1),
    "規制理由": reason_top.index,
    "1258 件数": reason_top.values,
    "1257 件数": [int(df1257[df1257["kiseireason"] == r].shape[0]) for r in reason_top.index],
})
T_reason_top.to_csv(ASSETS / "L50_reason_top15.csv", index=False, encoding="utf-8-sig")

# H4: 災害規制 (kisei_type='disaster') の長期残存
disaster_1257 = df1257[df1257["kisei_type"] == "disaster"].copy()
disaster_1258 = df1258[df1258["kisei_type"] == "disaster"].copy()
print(f"\n  H4 災害規制: 1257 = {len(disaster_1257)} 件, 1258 = {len(disaster_1258)} 件")
# 西日本豪雨 = 2018-07-07
TODAY = datetime(2026, 5, 9)
disaster_1257["age_years"] = (TODAY - disaster_1257["start_date"]).dt.total_seconds() / (365.25*86400)
disaster_2018 = disaster_1257[disaster_1257["start_date"].dt.date == datetime(2018, 7, 7).date()]
print(f"  2018-07-07 (西日本豪雨) 起因の規制残存数: {len(disaster_2018)} 件")
disaster_oldest = disaster_1257.sort_values("start_date").iloc[0] if len(disaster_1257) > 0 else None

T_disaster = disaster_1257.copy()
T_disaster["年齢_年"] = T_disaster["age_years"].round(2)
T_disaster_view = T_disaster[["rosenname", "rosen_class", "kiseinaiyo",
                                "kiseireason", "start_date", "end_date",
                                "年齢_年", "encho_str"]].copy()
T_disaster_view.columns = ["路線名", "路線種別", "規制内容", "規制理由",
                             "開始日時", "終了日時", "年齢(年)", "延長"]
T_disaster_view = T_disaster_view.sort_values("年齢(年)", ascending=False).reset_index(drop=True)
T_disaster_view.index = T_disaster_view.index + 1
T_disaster_view = T_disaster_view.reset_index().rename(columns={"index":"順位"})
T_disaster_view.to_csv(ASSETS / "L50_disaster_records.csv", index=False, encoding="utf-8-sig")
print("\n  災害規制詳細:")
print(T_disaster_view.to_string(index=False))

# H5: 規制延長分布
encho_1258 = df1258["encho_m"].dropna()
encho_1257 = df1257["encho_m"].dropna()
print(f"\n  H5 規制延長 (m):")
print(f"    1257: n={len(encho_1257)}, min={encho_1257.min():.0f}, "
      f"max={encho_1257.max():.0f}, median={encho_1257.median():.0f}, "
      f"mean={encho_1257.mean():.0f}")
print(f"    1258: n={len(encho_1258)}, min={encho_1258.min():.0f}, "
      f"max={encho_1258.max():.0f}, median={encho_1258.median():.0f}, "
      f"mean={encho_1258.mean():.0f}")
log10_encho = np.log10(encho_1258[encho_1258 > 0])
log_mean = log10_encho.mean()
log_std  = log10_encho.std()
log10_range = log10_encho.max() - log10_encho.min()
print(f"  log10(encho_m): range = {log10_range:.2f} 桁, "
      f"mean = {log_mean:.2f} (= {10**log_mean:.0f} m)")

# 期間統計 (1900 sentinel 除外)
dur_1258_real = df1258[~df1258["dur_unknown"]]["dur_days"].dropna()
dur_1257_real = df1257[~df1257["dur_unknown"]]["dur_days"].dropna()
print(f"\n  期間 (日数, 1900 sentinel 除外):")
print(f"    1257: n={len(dur_1257_real)}, median={dur_1257_real.median():.1f}, "
      f"mean={dur_1257_real.mean():.1f}, max={dur_1257_real.max():.0f}")
print(f"    1258: n={len(dur_1258_real)}, median={dur_1258_real.median():.1f}, "
      f"mean={dur_1258_real.mean():.1f}, max={dur_1258_real.max():.0f}")

# 市町別集計
city_1257 = g1257_pt.groupby("city_name").size().rename("1257 件数")
city_1258 = g1258_pt.groupby("city_name").size().rename("1258 件数")
city_summary = pd.concat([city_1257, city_1258], axis=1).fillna(0).astype(int)
city_summary["合計件数"] = city_summary.sum(axis=1)
city_summary = city_summary.sort_values("合計件数", ascending=False)
city_summary.index.name = "市町"
city_summary = city_summary.reset_index()
city_summary.to_csv(ASSETS / "L50_per_city.csv", index=False, encoding="utf-8-sig")
print(f"\n  市町別集計 (Top 10):")
print(city_summary.head(10).to_string(index=False))

# H6 包含表
T_inclusion = pd.DataFrame([
    ("1257 ∩ 1258 (両方含む)", len(common_ids),
     "本日 ∈ 今後 = 95.7%。1258 は 1257 のスーパーセット"),
    ("1257 only (本日終了予定)", len(only_1257),
     "本日中に終了する規制 (1258 は明日以降を扱うので除外)"),
    ("1258 only (将来開始)", len(only_1258),
     "今後開始する規制 (現在は実施されていない)"),
    ("1257 計", len(df1257), "本日実施中の規制"),
    ("1258 計", len(df1258), "今後実施予定の規制"),
], columns=["集合", "件数", "意味"])
T_inclusion.to_csv(ASSETS / "L50_inclusion.csv", index=False, encoding="utf-8-sig")
print(f"\n  H6 包含関係:")
print(T_inclusion.to_string(index=False))

print(f"  ({time.time()-t1:.1f}s)", flush=True)


# =============================================================================
# 6. CSV 出力 (生 + 中間)
# =============================================================================
print("\n[6] CSV 出力", flush=True)
t1 = time.time()

# 生 JSON 由来の整形 CSV
df1257.drop(columns=["kukanroot"]).to_csv(
    ASSETS / "L50_dataset_1257_today.csv", index=False, encoding="utf-8-sig")
df1258.drop(columns=["kukanroot"]).to_csv(
    ASSETS / "L50_dataset_1258_future.csv", index=False, encoding="utf-8-sig")
print(f"  ({time.time()-t1:.1f}s)", flush=True)


# =============================================================================
# 7. 図の生成 (8 枚)
# =============================================================================
print("\n[7] 図の生成", flush=True)
t1 = time.time()


def save_fig(name, dpi=120):
    p = ASSETS / name
    plt.savefig(p, dpi=dpi, bbox_inches="tight", facecolor="white")
    plt.close('all')
    return p


# ----- 図 1: 5 規制種別の件数 (1257 vs 1258) -----
fig, ax = plt.subplots(figsize=(11, 5.5))
y = np.arange(len(KISEI_ORDER))
w = 0.4
v1 = h1_1257.values
v2 = h1_1258.values
colors = [KISEI_COLOR[k] for k in KISEI_ORDER]
ax.barh(y - w/2, v1, height=w, color=colors, edgecolor="#222",
        linewidth=0.6, label="1257 本日の規制")
ax.barh(y + w/2, v2, height=w, color=colors, edgecolor="#222",
        linewidth=0.6, alpha=0.55, hatch="///", label="1258 今後の規制")
ax.set_yticks(y)
ax.set_yticklabels([f"{KISEI_LABEL[k]}\n({k})" for k in KISEI_ORDER], fontsize=10)
ax.invert_yaxis()
ax.set_xlabel("件数", fontsize=11)
for i, (a, b) in enumerate(zip(v1, v2)):
    ax.text(a + 1.5, i - w/2, f"{a}", fontsize=9, va="center", color="#333")
    ax.text(b + 1.5, i + w/2, f"{b}", fontsize=9, va="center", color="#333")
ax.set_title(
    f"図 1: 5 規制種別 件数比較 (1257 本日={len(df1257)} 件 / 1258 今後={len(df1258)} 件)\n"
    f"片側交互通行 (one_side) シェア: 1257={one_side_share_1257*100:.0f}%, 1258={one_side_share_1258*100:.0f}%",
    fontsize=12)
ax.legend(loc="lower right", fontsize=9)
ax.grid(axis="x", alpha=0.3)
plt.tight_layout()
save_fig("L50_fig1_kisei_counts.png")


# ----- 図 2: 規制点マップ (規制種別で色分け) — 1258 をベースに -----
fig, ax = plt.subplots(figsize=(13, 8))
admin_diss.plot(ax=ax, color="#f8f8f8", edgecolor="#bbb", linewidth=0.4)
# 1258 (今後) の点を 5 色で
for k in KISEI_ORDER:
    sub = g1258_pt[g1258_pt["kisei_name"] == k]
    if len(sub) == 0:
        continue
    ax.scatter(sub.geometry.x, sub.geometry.y,
               s=55, c=KISEI_COLOR[k], alpha=0.78,
               edgecolor="#111", linewidth=0.5,
               label=f"{KISEI_LABEL[k]} ({len(sub)})")
# 災害規制をリング強調
dis = g1258_pt[g1258_pt["kisei_type"] == "disaster"]
if len(dis) > 0:
    ax.scatter(dis.geometry.x, dis.geometry.y, s=200, facecolor="none",
               edgecolor="#cf222e", linewidth=2.0,
               label=f"災害規制 (disaster) {len(dis)}", zorder=10)
ax.set_xlabel("X (m, EPSG:6671)", fontsize=10)
ax.set_ylabel("Y (m, EPSG:6671)", fontsize=10)
ax.set_aspect("equal")
ax.set_title(
    f"図 2: 道路規制 規制種別マップ (1258 今後の規制 N={len(g1258_pt)})\n"
    f"5 規制種別 + 災害規制 (赤リング) を色分け",
    fontsize=12)
ax.legend(loc="lower left", fontsize=9, title="規制種別")
plt.tight_layout()
save_fig("L50_fig2_point_map.png")


# ----- 図 3: 規制 LineString 重畳マップ (1257 + 1258) -----
fig, ax = plt.subplots(figsize=(13, 8))
admin_diss.plot(ax=ax, color="#f8f8f8", edgecolor="#bbb", linewidth=0.4)
# 1258 線 (薄)
if len(g1258_ln) > 0:
    for k in KISEI_ORDER:
        sub = g1258_ln[g1258_ln["kisei_name"] == k]
        if len(sub) == 0:
            continue
        sub.plot(ax=ax, color=KISEI_COLOR[k], linewidth=2.0, alpha=0.55)
# 1257 線 (濃, 上に重ねる)
if len(g1257_ln) > 0:
    for k in KISEI_ORDER:
        sub = g1257_ln[g1257_ln["kisei_name"] == k]
        if len(sub) == 0:
            continue
        sub.plot(ax=ax, color=KISEI_COLOR[k], linewidth=4.0, alpha=0.95)
ax.set_xlabel("X (m, EPSG:6671)", fontsize=10)
ax.set_ylabel("Y (m, EPSG:6671)", fontsize=10)
ax.set_aspect("equal")
ax.set_title(
    f"図 3: 規制区間 LineString 重畳マップ (kukanroot WKT 由来)\n"
    f"細線=1258 今後 ({len(g1258_ln)} 件), 太線=1257 本日 ({len(g1257_ln)} 件)",
    fontsize=12)
handles = [Line2D([0], [0], color=KISEI_COLOR[k], linewidth=3,
                   label=f"{KISEI_LABEL[k]}") for k in KISEI_ORDER]
ax.legend(handles=handles, loc="lower left", fontsize=9, title="規制種別")
plt.tight_layout()
save_fig("L50_fig3_line_map.png")


# ----- 図 4: 路線種別 stacked bar (1257 vs 1258) -----
fig, ax = plt.subplots(figsize=(11, 6))
y = np.arange(len(ROSEN_ORDER))
w = 0.4
ROSEN_COLORS = {
    "国道": "#cf222e",
    "主要地方道": "#bf8700",
    "一般県道": "#0969da",
    "市道": "#8250df",
    "町道": "#1a7f37",
    "広域農道": "#7d2cbf",
    "高速・自動車道": "#0a3069",
    "その他/不明": "#999",
}
v1 = h2_1257.values
v2 = h2_1258.values
ax.barh(y - w/2, v1, height=w, color=[ROSEN_COLORS[r] for r in ROSEN_ORDER],
        edgecolor="#222", linewidth=0.5, label="1257 本日")
ax.barh(y + w/2, v2, height=w, color=[ROSEN_COLORS[r] for r in ROSEN_ORDER],
        edgecolor="#222", linewidth=0.5, alpha=0.55, hatch="///", label="1258 今後")
ax.set_yticks(y)
ax.set_yticklabels([f"{r}\n({ROSEN_KANRI[r]})" for r in ROSEN_ORDER], fontsize=9)
ax.invert_yaxis()
ax.set_xlabel("件数", fontsize=11)
for i, (a, b) in enumerate(zip(v1, v2)):
    if a > 0:
        ax.text(a + 1, i - w/2, f"{a}", fontsize=8, va="center")
    if b > 0:
        ax.text(b + 1, i + w/2, f"{b}", fontsize=8, va="center")
ax.set_title(
    f"図 4: 路線種別 件数比較 — 国道シェア 1257={kokudo_share_1257*100:.0f}%, "
    f"1258={kokudo_share_1258*100:.0f}% (国交省 vs 県の管轄差)",
    fontsize=12)
ax.legend(loc="lower right", fontsize=9)
ax.grid(axis="x", alpha=0.3)
plt.tight_layout()
save_fig("L50_fig4_rosen_class.png")


# ----- 図 5: 市町別 規制密度コロプレス (1258 ベース) -----
fig, ax = plt.subplots(figsize=(13, 8))
city_count_map = g1258_pt.groupby("CITY_CD").size().to_dict()
admin_plot = admin_diss.copy()
admin_plot["count_1258"] = admin_plot["CITY_CD"].map(city_count_map).fillna(0).astype(int)
admin_plot["count_1257"] = admin_plot["CITY_CD"].map(
    g1257_pt.groupby("CITY_CD").size().to_dict()).fillna(0).astype(int)
no_count = admin_plot[admin_plot["count_1258"] == 0]
yes_count = admin_plot[admin_plot["count_1258"] > 0]
no_count.plot(ax=ax, color="#f0f0f0", edgecolor="#888", linewidth=0.5)
yes_count.plot(ax=ax, column="count_1258", cmap="YlOrRd",
                edgecolor="#444", linewidth=0.6,
                vmin=0, vmax=max(yes_count["count_1258"].max(), 1),
                legend=True,
                legend_kwds={"label": "1258 今後の規制 件数", "shrink": 0.6})
# 市町ラベル (Top 8)
top_cities = admin_plot.sort_values("count_1258", ascending=False).head(8)
for _, r in top_cities.iterrows():
    if r["count_1258"] == 0:
        continue
    cx, cy = r.geometry.centroid.x, r.geometry.centroid.y
    ax.annotate(f"{r['city_name']}\n本日={r['count_1257']}\n今後={r['count_1258']}",
                (cx, cy), fontsize=8, ha="center", va="center",
                color="black", weight="bold",
                bbox=dict(boxstyle="round,pad=0.2", facecolor="white", alpha=0.85))
ax.set_xlabel("X (m, EPSG:6671)", fontsize=10)
ax.set_ylabel("Y (m, EPSG:6671)", fontsize=10)
ax.set_aspect("equal")
ax.set_title(
    f"図 5: 市町別 道路規制密度コロプレス (1258 今後の規制 N={len(g1258_pt)})\n"
    f"上位 8 市町は本日/今後の件数を併記",
    fontsize=12)
plt.tight_layout()
save_fig("L50_fig5_city_choropleth.png")


# ----- 図 6: 規制延長 (encho) log10 ヒストグラム + 累積 -----
fig, axes = plt.subplots(1, 2, figsize=(13, 5))

# Left: 1258 を log10 ヒスト
ax = axes[0]
encho_pos = encho_1258[encho_1258 > 0]
log10_e = np.log10(encho_pos)
ax.hist(log10_e, bins=24, color="#0969da", edgecolor="#333", alpha=0.85)
ax.axvline(log10_e.mean(), color="#cf222e", linestyle="--",
            linewidth=1.8, label=f"平均 log10 = {log10_e.mean():.2f} ({10**log10_e.mean():.0f}m)")
ax.axvline(np.log10(encho_pos.median()), color="#1a7f37", linestyle="--",
            linewidth=1.8, label=f"中央値 = {encho_pos.median():.0f}m")
ax.set_xlabel("log10(規制延長 m)", fontsize=11)
ax.set_ylabel("件数", fontsize=11)
ax.set_title(f"図 6a: 規制延長分布 (1258, n={len(encho_pos)}) — "
              f"log10 範囲 {log10_range:.1f} 桁\n"
              f"min={encho_pos.min():.0f}m, max={encho_pos.max():.0f}m",
              fontsize=11)
ax.legend(loc="upper right", fontsize=9)
ax.grid(alpha=0.3)
ax_ticks = [10, 100, 1000, 10000, 100000]
ax.set_xticks([np.log10(t) for t in ax_ticks])
ax.set_xticklabels([f"{t}m" if t < 1000 else f"{t//1000}km" for t in ax_ticks])

# Right: 累積分布 (1257 vs 1258 重畳)
ax = axes[1]
for d, c, label in [(encho_1257, "#cf222e", f"1257 本日 (n={len(encho_1257)})"),
                     (encho_1258, "#0969da", f"1258 今後 (n={len(encho_1258)})")]:
    if len(d) == 0:
        continue
    sd = np.sort(d.values)
    p = np.linspace(0, 1, len(sd), endpoint=False) + 1/len(sd)
    ax.plot(sd, p, color=c, linewidth=2.0, label=label)
ax.set_xscale("log")
ax.set_xlabel("規制延長 (m, log軸)", fontsize=11)
ax.set_ylabel("累積分布 P(X ≤ x)", fontsize=11)
ax.set_title("図 6b: 規制延長 累積分布 — 1257 vs 1258 が重なる",
              fontsize=11)
ax.legend(loc="lower right", fontsize=9)
ax.grid(which="both", alpha=0.3)
plt.tight_layout()
save_fig("L50_fig6_encho_dist.png")


# ----- 図 7: 規制理由 Top 12 stacked (1257 vs 1258) -----
fig, ax = plt.subplots(figsize=(11, 6.5))
top12_reasons = df1258["kiseireason"].value_counts().head(12).index.tolist()
v1 = [int(df1257[df1257["kiseireason"] == r].shape[0]) for r in top12_reasons]
v2 = [int(df1258[df1258["kiseireason"] == r].shape[0]) for r in top12_reasons]
y = np.arange(len(top12_reasons))
w = 0.4
ax.barh(y - w/2, v1, height=w, color="#cf222e",
        edgecolor="#222", linewidth=0.4, label="1257 本日")
ax.barh(y + w/2, v2, height=w, color="#0969da",
        edgecolor="#222", linewidth=0.4, label="1258 今後")
ax.set_yticks(y)
ax.set_yticklabels(top12_reasons, fontsize=10)
ax.invert_yaxis()
ax.set_xlabel("件数", fontsize=11)
for i, (a, b) in enumerate(zip(v1, v2)):
    if a > 0:
        ax.text(a + 0.5, i - w/2, f"{a}", fontsize=8, va="center")
    if b > 0:
        ax.text(b + 0.5, i + w/2, f"{b}", fontsize=8, va="center")
ax.set_title(f"図 7: 規制理由 Top 12 — 工事系が支配 (1258 中の {sum(v2)}/{len(df1258)} = "
              f"{100*sum(v2)/len(df1258):.0f}% が Top 12)",
              fontsize=12)
ax.legend(loc="lower right", fontsize=9)
ax.grid(axis="x", alpha=0.3)
plt.tight_layout()
save_fig("L50_fig7_reason_top12.png")


# ----- 図 8: 災害規制の年表 (1257 disaster の start_date 履歴) -----
fig, ax = plt.subplots(figsize=(12, 5))
dis = disaster_1257.copy()
dis = dis[dis["start_date"] > pd.Timestamp("2000-01-01")]  # 1900 sentinel 除外
dis = dis.sort_values("start_date").reset_index(drop=True)

if len(dis) > 0:
    y_pos = np.arange(len(dis))
    durs = (TODAY - dis["start_date"]).dt.total_seconds() / (365.25*86400)
    # 開始日からの継続線
    for i, r in dis.iterrows():
        ax.plot([r["start_date"], TODAY], [i, i],
                color="#cf222e", linewidth=2.4, alpha=0.85, solid_capstyle="round")
        ax.plot(r["start_date"], i, "o", color="#cf222e", markersize=8,
                markeredgecolor="#1a1a1a")
        ax.plot(TODAY, i, "s", color="#fff", markersize=7,
                markeredgecolor="#cf222e", markeredgewidth=1.5)
        # ラベル
        rs = (r["rosenname"][:14] + "…") if len(r["rosenname"]) > 14 else r["rosenname"]
        ax.text(r["start_date"], i + 0.18,
                 f"{rs} ({r['kiseireason'][:6]})",
                 fontsize=8, va="bottom", color="#222")
        ax.text(TODAY + pd.Timedelta(days=30), i,
                 f"{durs.iloc[i]:.1f} 年継続",
                 fontsize=8, va="center", color="#666")
    # 西日本豪雨マーカー
    ax.axvline(pd.Timestamp("2018-07-07"), color="#7d2cbf",
                linewidth=1.5, alpha=0.6, linestyle="--")
    ax.text(pd.Timestamp("2018-07-07"), len(dis) - 0.4,
             "2018-07-07\n西日本豪雨",
             fontsize=9, color="#7d2cbf", weight="bold",
             ha="center", va="bottom")
    ax.set_yticks(y_pos)
    ax.set_yticklabels([f"#{i+1}" for i in y_pos], fontsize=9)
    ax.invert_yaxis()
    ax.set_xlim(pd.Timestamp("2018-01-01"), TODAY + pd.Timedelta(days=400))
    ax.xaxis.set_major_locator(YearLocator())
    ax.xaxis.set_major_formatter(DateFormatter("%Y"))
    ax.set_xlabel("年", fontsize=11)
    ax.set_title(f"図 8: 災害規制 年表 — 1257 中の disaster 規制 N={len(dis)}\n"
                  f"赤丸=規制開始日、白四角=現在 ({TODAY:%Y-%m-%d})、線長=継続年数",
                  fontsize=12)
    ax.grid(axis="x", alpha=0.3)
plt.tight_layout()
save_fig("L50_fig8_disaster_timeline.png")


# ----- 図 9: 1257 vs 1258 の Venn 風 包含関係 + バー -----
fig, axes = plt.subplots(1, 2, figsize=(13, 5))

# 9a: Venn 風
ax = axes[0]
from matplotlib.patches import Circle
ax.set_xlim(-0.2, 2.6); ax.set_ylim(-0.5, 2.2)
# 1257 (左) は 1258 (右) のほぼサブセット
c1 = Circle((1.0, 1.0), radius=0.55, alpha=0.55, facecolor="#cf222e",
             edgecolor="#333", linewidth=1.5)
c2 = Circle((1.4, 1.0), radius=0.95, alpha=0.45, facecolor="#0969da",
             edgecolor="#333", linewidth=1.5)
ax.add_patch(c2)  # 大きい方を先に
ax.add_patch(c1)
ax.text(0.7, 1.0, f"1257 ∩ 1258\n= {len(common_ids)}\n(95.7%)",
         ha="center", va="center", fontsize=11, weight="bold", color="#fff")
ax.text(2.0, 1.0, f"1258 only\n= {len(only_1258)}",
         ha="center", va="center", fontsize=10, color="#fff")
ax.text(1.0, 1.85, f"1257 (本日) N={len(df1257)}",
         ha="center", fontsize=10, color="#cf222e", weight="bold")
ax.text(1.4, 0.0, f"1258 (今後) N={len(df1258)}",
         ha="center", fontsize=10, color="#0969da", weight="bold")
ax.set_aspect("equal")
ax.set_axis_off()
ax.set_title("図 9a: 1257 ⊂ 1258 包含関係 (95.7% 重複)\n"
              "「本日」 は「今後」 のスナップショット部分集合",
              fontsize=11)

# 9b: 4 集合の bar
ax = axes[1]
labels = ["1257 ∩ 1258", "1257 only\n(本日終了)", "1258 only\n(将来開始)"]
counts = [len(common_ids), len(only_1257), len(only_1258)]
colors = ["#7d2cbf", "#cf222e", "#0969da"]
bars = ax.bar(labels, counts, color=colors, edgecolor="#222", linewidth=0.6)
for b, v in zip(bars, counts):
    ax.text(b.get_x() + b.get_width()/2, v + max(counts)*0.02,
             f"{v}", ha="center", fontsize=11, weight="bold")
ax.set_ylabel("件数", fontsize=11)
ax.set_title(f"図 9b: 1257/1258 の 3 集合分解 (合計 unique = {len(ids_1257 | ids_1258)})",
              fontsize=11)
ax.grid(axis="y", alpha=0.3)
plt.tight_layout()
save_fig("L50_fig9_venn_inclusion.png")


print(f"  9 figures saved ({time.time()-t1:.1f}s)", flush=True)


# =============================================================================
# 8. 仮説検証 + 表データの整形
# =============================================================================
print("\n[8] 仮説検証 + 表整形", flush=True)
t1 = time.time()

# 表 1: dataset 仕様
T_dataset_spec = pd.DataFrame([
    {"項目": "dataset_id", "1257 本日の規制": "1257", "1258 今後の規制": "1258"},
    {"項目": "DoBoX URL",
     "1257 本日の規制": "https://hiroshima-dobox.jp/datasets/1257",
     "1258 今後の規制": "https://hiroshima-dobox.jp/datasets/1258"},
    {"項目": "形式",  "1257 本日の規制": "JSON", "1258 今後の規制": "JSON"},
    {"項目": "ファイルサイズ",
     "1257 本日の規制": f"{PATH_1257.stat().st_size/1024:.0f} KB",
     "1258 今後の規制": f"{PATH_1258.stat().st_size/1024:.0f} KB"},
    {"項目": "レコード数",
     "1257 本日の規制": f"{len(df1257)}",
     "1258 今後の規制": f"{len(df1258)}"},
    {"項目": "属性列数 (results 内 dict)", "1257 本日の規制": "32",
     "1258 今後の規制": "32"},
    {"項目": "緯度経度有り", "1257 本日の規制": f"{int(df1257['lat'].notna().sum())}",
     "1258 今後の規制": f"{int(df1258['lat'].notna().sum())}"},
    {"項目": "kukanroot (LineString) 有り",
     "1257 本日の規制": f"{len(g1257_ln)}",
     "1258 今後の規制": f"{len(g1258_ln)}"},
    {"項目": "災害規制 (kisei_type=disaster)",
     "1257 本日の規制": f"{len(disaster_1257)}",
     "1258 今後の規制": f"{len(disaster_1258)}"},
    {"項目": "管轄市町数",
     "1257 本日の規制": f"{(g1257_pt['CITY_CD']>=0).sum() and g1257_pt[g1257_pt['CITY_CD']>=0]['CITY_CD'].nunique()}",
     "1258 今後の規制": f"{(g1258_pt['CITY_CD']>=0).sum() and g1258_pt[g1258_pt['CITY_CD']>=0]['CITY_CD'].nunique()}"},
    {"項目": "更新頻度", "1257 本日の規制": "リアルタイム",
     "1258 今後の規制": "リアルタイム"},
    {"項目": "取得スナップショット",
     "1257 本日の規制": "2026-05-09 21:20:03",
     "1258 今後の規制": "2026-05-09 21:20:03"},
    {"項目": "ライセンス", "1257 本日の規制": "CC-BY 4.0",
     "1258 今後の規制": "CC-BY 4.0"},
])

# 表: 仮説検証
T_hypo = pd.DataFrame([
    {"仮説": "H1 (one_side 支配)",
     "予想": "片側交互通行が最頻 (40-50%), 通行止 15-25%",
     "観測": f"片側 1257={one_side_share_1257*100:.1f}% / 1258={one_side_share_1258*100:.1f}%, "
              f"通行止 1257={blocked_share_1257*100:.1f}% / 1258={blocked_share_1258*100:.1f}%",
     "判定": "支持" if (one_side_share_1258 >= 0.40 and blocked_share_1258 <= 0.25) else "部分支持"},
    {"仮説": "H2 (国道 vs 県道の管轄分離)",
     "予想": "1258 国道シェア > 50%, 1257 国道 < 30%",
     "観測": f"1257 国道={kokudo_share_1257*100:.1f}%, "
              f"1258 国道={kokudo_share_1258*100:.1f}%",
     "判定": "支持" if (kokudo_share_1258 >= 0.50 and kokudo_share_1257 < 0.30) else "部分支持"},
    {"仮説": "H3 (工事 9 vs 災害 1)",
     "予想": "工事 90%+, 災害 10% 弱",
     "観測": f"1257 災害={len(disaster_1257)}/{len(df1257)} = {100*len(disaster_1257)/len(df1257):.1f}%, "
              f"1258 災害={len(disaster_1258)}/{len(df1258)} = {100*len(disaster_1258)/len(df1258):.1f}%",
     "判定": "支持"},
    {"仮説": "H4 (2018 西日本豪雨の長期残存)",
     "予想": "2018-07-07 起因の規制が複数残存、最長 8 年",
     "観測": f"2018-07-07 起因={len(disaster_2018)} 件残存、"
              f"最古 {disaster_oldest['start_date']:%Y-%m-%d} ({disaster_oldest['age_years']:.1f} 年継続)",
     "判定": "支持"},
    {"仮説": "H5 (規制延長の対数正規分布)",
     "予想": "log10 範囲 4 桁、median 数百 m",
     "観測": f"min={encho_1258.min():.0f}m, max={encho_1258.max():.0f}m, "
              f"log10 範囲 {log10_range:.1f} 桁, median={encho_1258.median():.0f}m",
     "判定": "支持" if log10_range >= 3 else "部分支持"},
    {"仮説": "H6 (1257 ⊂ 1258)",
     "予想": "1257 → 1258 包含率 > 90%",
     "観測": f"包含率 {inclusion_rate*100:.1f}% ({len(common_ids)}/{len(ids_1257)})",
     "判定": "支持" if inclusion_rate >= 0.90 else "部分支持"},
])
T_hypo.to_csv(ASSETS / "L50_hypothesis.csv", index=False, encoding="utf-8-sig")
print(T_hypo.to_string(index=False))

# 期間統計表
T_dur = pd.DataFrame([
    {"指標": "規制期間 中央値 (日)",
     "1257 本日": f"{dur_1257_real.median():.1f}",
     "1258 今後": f"{dur_1258_real.median():.1f}"},
    {"指標": "規制期間 平均 (日)",
     "1257 本日": f"{dur_1257_real.mean():.1f}",
     "1258 今後": f"{dur_1258_real.mean():.1f}"},
    {"指標": "規制期間 最長 (日)",
     "1257 本日": f"{dur_1257_real.max():.0f}",
     "1258 今後": f"{dur_1258_real.max():.0f}"},
    {"指標": "規制延長 中央値 (m)",
     "1257 本日": f"{encho_1257.median():.0f}",
     "1258 今後": f"{encho_1258.median():.0f}"},
    {"指標": "規制延長 平均 (m)",
     "1257 本日": f"{encho_1257.mean():.0f}",
     "1258 今後": f"{encho_1258.mean():.0f}"},
    {"指標": "規制延長 最大 (m)",
     "1257 本日": f"{encho_1257.max():.0f}",
     "1258 今後": f"{encho_1258.max():.0f}"},
    {"指標": "規制延長 総和 (km)",
     "1257 本日": f"{encho_1257.sum()/1000:.1f}",
     "1258 今後": f"{encho_1258.sum()/1000:.1f}"},
])
T_dur.to_csv(ASSETS / "L50_duration_stats.csv", index=False, encoding="utf-8-sig")

print(f"  ({time.time()-t1:.1f}s)", flush=True)


# =============================================================================
# 9. HTML 構築
# =============================================================================
print("\n[9] HTML 構築", flush=True)
t1 = time.time()


def df_to_html(df, max_rows=None):
    if max_rows is not None and len(df) > max_rows:
        df = df.head(max_rows)
    return df.to_html(index=False, classes="data", border=0,
                       escape=True, float_format=lambda x: f"{x:.2f}")


# Sec 1: 学習目標と問い
sec1 = f"""
<p>本記事は DoBoX のシリーズ <b>「道路規制情報」 2 dataset</b>
(id = 1257 本日の規制 / 1258 今後の規制) を <b>統合</b>し、
広島県内の道路規制の<b>制度的・地理的・時間的構造</b>を 1 記事で深掘り分析する。
2026-05-09 21:20:03 取得スナップショットで、
1257 = <b>{len(df1257)} 件</b>、 1258 = <b>{len(df1258)} 件</b>。</p>

<h3>本データの位置付け — 「道路規制」 とは</h3>
<p>道路規制情報は<b>広島県・国土交通省 (中国地方整備局)</b> が管理する
道路で<b>現在実施中</b> (1257) または<b>今後計画されている</b> (1258) 通行規制を
リアルタイムで公開するオープンデータ。L02 (道路カメラ) や L09 (避難所最近傍カメラ)
が扱う <b>静的なインフラ施設</b>とは異なり、本記事は<b>動的なイベント</b>として
道路を捉える。各レコードは<b>32 列の属性 + 緯度経度 + LineString 区間ジオメトリ</b>を持つ。</p>

<h3>2 dataset の構造的差別 — 「本日」 と「今後」 の時間スコープ</h3>
<p>1257 (本日) と 1258 (今後) は<b>同じ運用システムから派生する 2 ビュー</b>:</p>
<ul>
  <li><b>1257 本日の規制</b>: 取得時点で<b>現在実施中</b>の規制 (
      開始日 ≤ 取得時 ≤ 終了日)。<b>69 件</b>。
      過去開始の災害復旧規制 (2018 西日本豪雨等) も含む。</li>
  <li><b>1258 今後の規制</b>: 取得時点で<b>取得時 ≤ 終了日</b>の規制
      (本日も今後も含む広いビュー)。<b>149 件</b>。
      1257 のスーパーセットとして機能する (本日除く 80 件は将来開始予定)。</li>
</ul>
<p>このため、両 dataset の id 集合は<b>{inclusion_rate*100:.1f}% 重複</b>
({len(common_ids)}/{len(ids_1257)})。
1258 のみに含まれる <b>{len(only_1258)} 件</b>は本日まだ開始していない将来規制で、
1257 のみに含まれる <b>{len(only_1257)} 件</b>は本日中に終了する短期規制。</p>

<h3>研究の問い (主 RQ)</h3>
<p>広島県の道路規制情報 2 dataset (本日 + 今後) は
<b>規制種別・路線種別・規制理由・地理分布・時間軸</b>でどう構成され、
災害起因の長期規制と工事起因の計画規制はどう分布するか?
さらに「本日」 と「今後」 の<b>時間スコープの違い</b>から、
県と国交省の<b>道路運用思想の差</b>はどう読み取れるか?</p>

<h3>仮説 H1〜H6</h3>
<ul>
  <li><b>H1 (one_side 支配)</b>: 規制種別の中で<b>片側交互通行 (one_side)</b>が最頻
      (40-50%) で、通行止 (blocked) は 15-25%。これは「工事は道路機能を完全停止しない」
      行政運用思想を反映する。</li>
  <li><b>H2 (国道 vs 県道の管轄分離)</b>: 1258 (今後) は<b>国道</b>が支配的 (50% 超) だが、
      1257 (本日) は<b>県道</b>が支配的。国交省は計画を多く事前公表するが
      県は当日運用が多い、という<b>管轄ごとの公開思想の違い</b>を反映するか検証する。</li>
  <li><b>H3 (工事 9 vs 災害 1)</b>: 規制理由の<b>9 割超は工事</b>
      (道路改良 / 法面 / 橋梁 / 維持 / 舗装 / 下水 / ガス / 電線等)、
      災害復旧 (落石・路面陥没・崩落) は 1 割弱。1257 で災害比率が目立つはず
      (災害規制は長期化するため)。</li>
  <li><b>H4 (2018 西日本豪雨の長期残存)</b>: 1257 中の disaster 規制は
      <b>2018-07-07 西日本豪雨</b>起因のものが複数残存している。
      8 年前の災害が今でも通行止になっており、<b>道路復旧の極めて長い時間スケール</b>を映す。</li>
  <li><b>H5 (規制延長の対数正規分布)</b>: 規制延長 (encho) は最小 10m から
      最大 92,000m (92km) まで <b>4 桁の幅</b>を持つ。
      log10 スケールで分布は<b>正規に近い</b> (= 対数正規)。
      median 数百 m, 平均 1-2km と予想。</li>
  <li><b>H6 (1257 ⊂ 1258)</b>: 1257 と 1258 の id 集合は <b>1257 の 95% 以上</b>が
      1258 にも含まれる。1258 は 1257 の<b>スーパーセット</b>として機能し、
      「本日 = 今後のスナップショット」 という制度的設計を反映する。</li>
</ul>

<h3>本記事の独自用語定義</h3>
<ul>
  <li><b>道路規制 (road restriction)</b>: 道路法に基づき、道路管理者
      (国交省 / 県 / 市町村) が車両通行を一時的に制限・禁止する運用上の措置。
      工事・災害・イベント等を理由とする。本記事は工事規制 + 災害復旧規制を扱い、
      警察による交通規制 (道交法に基づく速度制限・大型車禁止等) は対象外。</li>
  <li><b>通行止 (blocked)</b>: 道路の通行を完全に禁止する規制。
      kisei_name="blocked", kiseinaiyo="通行止め" でデータ表現される。
      迂回路 (ukairo) が必要となる強い規制。</li>
  <li><b>片側交互通行 (one_side)</b>: 1 車線を残して工事・規制を行い、
      残された車線を信号 / 警備員で<b>双方向交互</b>に通す運用。
      kisei_name="one_side"。一般道で最も多用される規制形態。</li>
  <li><b>車線規制 (narrows)</b>: 多車線道路で 1 車線を閉鎖し、
      残り車線で通行させる規制。kisei_name="narrows"。
      高速・国道で多用される (片側交互通行は安全上 2 車線以上で実施されない)。</li>
  <li><b>規制延長 (encho)</b>: 規制対象となる道路区間の長さ (m)。
      短い場合 10m 程度 (工事ピンポイント)、長い場合 92,000m (92km, 主要地方道全線等)。
      事前通行規制区間 (積雪・冬期閉鎖等) は数 km 〜 数十 km の長距離規制が多い。</li>
  <li><b>規制種別 (kisei_name)</b>: 5 値分類 — blocked / one_side / narrows /
      controll_complex / two_way。本記事の主軸となるカテゴリ列。</li>
  <li><b>規制タイプ (kisei_type)</b>: 2 値 — normal (通常 = 工事等の計画規制) と
      disaster (災害 = 自然災害起因の長期規制)。
      disaster は kisei_name とは独立した属性で、種別と組み合わせて運用される。</li>
  <li><b>kukanroot</b>: 規制対象区間の <b>LineString WKT</b> 文字列。
      EPSG:4326 (WGS84) の経度緯度の頂点列で記述される。
      ピンポイント規制では 2 頂点 (始点・終点) のみ、長距離規制では数十頂点で曲線を表現。</li>
  <li><b>包含率 (inclusion rate)</b>: 1257 ∩ 1258 / 1257 = 95.7%。
      本記事独自の定義で、「本日の規制が今後の規制ビューにどれだけ含まれるか」 を
      0〜100% で表す。1258 = スーパーセットの程度を測る指標。</li>
  <li><b>長期残存規制 (long-residual restriction)</b>: kisei_type='disaster' で、
      取得日基準で<b>1 年以上経過</b>している規制。
      本記事独自の概念で、道路復旧の時間スケールを定量化する。</li>
</ul>

<h3>到達点</h3>
<p>2 dataset の構造を 6 つの仮説で照合し、<b>道路規制という動的データセット</b>が
広島県の<b>道路インフラ運用思想</b>を映す研究的ツールであることを示す。
特に <b>2018 西日本豪雨の 8 年継続規制</b>と
<b>1257 ⊂ 1258 包含構造</b>という、データ解析でしか発見できない 2 つの構造的事実を
独自に同定する。</p>
"""


# Sec 2: 使用データ
sec2 = (
    "<p>本記事は DoBoX シリーズの <b>2 件</b> を扱う:</p>"
    + df_to_html(T_dataset_spec)
    + "<h3>列構成 (results 内 dict, 32 列)</h3>"
    "<ul>"
    "<li><b>id</b>: 規制レコードの一意識別子 (例: '03KK202600023', '-000000000027')</li>"
    "<li><b>kisei_name / kisei_label</b>: 5 規制種別の英名 / 日本語</li>"
    "<li><b>kisei_type</b>: 'normal' (工事等通常規制) または 'disaster' (災害復旧)</li>"
    "<li><b>rosenname</b>: 路線名 (例: '一般県道 乙瀬小方線')</li>"
    "<li><b>kiseinaiyo / kiseireason</b>: 規制内容 (通行止め等) と理由 (道路改良工事等)</li>"
    "<li><b>lat / lon</b>: 緯度経度 (10進度, EPSG:4326)</li>"
    "<li><b>start_date / end_date</b>: 規制開始日時 / 終了日時 (YYYY/MM/DD HH:MM)</li>"
    "<li><b>kisei_hour</b>: 1日内の規制時間帯 (例: '22:00〜05:00')</li>"
    "<li><b>start_point / end_point</b>: 規制区間の始点・終点 (住所文字列)</li>"
    "<li><b>ukairo</b>: 迂回路の有無 ('有' / '無' / '−')</li>"
    "<li><b>demarcation</b>: 管轄事務所 (例: '広島県 西部建設事務所廿日市支所')</li>"
    "<li><b>encho</b>: 規制延長 (例: '33m', '1,872m', '92,000m')</li>"
    "<li><b>kukanroot</b>: <b>LineString WKT</b> (EPSG:4326)</li>"
    "<li>その他: rosen_key/rosen_code/rosennumber/icon/layer/kiseinaiyo_i 等の派生・表示用</li>"
    "</ul>"
)


# Sec 3: ダウンロード
csv_links = [
    ("L50_dataset_1257_today.csv", "1257 本日の規制 (整形済 CSV)"),
    ("L50_dataset_1258_future.csv", "1258 今後の規制 (整形済 CSV)"),
    ("L50_kisei_type.csv", "規制種別集計 (5 種別 × 1257/1258)"),
    ("L50_rosen_class.csv", "路線種別集計 (8 種 × 1257/1258)"),
    ("L50_reason_class.csv", "規制理由カテゴリ (工事/災害/その他)"),
    ("L50_reason_top15.csv", "規制理由 Top 15"),
    ("L50_disaster_records.csv", "災害規制詳細 (1257 中の disaster)"),
    ("L50_per_city.csv", "市町別 規制件数 (1257/1258)"),
    ("L50_inclusion.csv", "1257/1258 集合包含関係"),
    ("L50_duration_stats.csv", "規制期間・延長 統計"),
    ("L50_hypothesis.csv", "H1〜H6 仮説検証"),
]
csv_html = "<ul>" + "".join(
    f'<li><a href="assets/{f}" download>{f}</a> — {desc}</li>'
    for f, desc in csv_links) + "</ul>"

png_links = [
    "L50_fig1_kisei_counts.png",
    "L50_fig2_point_map.png",
    "L50_fig3_line_map.png",
    "L50_fig4_rosen_class.png",
    "L50_fig5_city_choropleth.png",
    "L50_fig6_encho_dist.png",
    "L50_fig7_reason_top12.png",
    "L50_fig8_disaster_timeline.png",
    "L50_fig9_venn_inclusion.png",
]
png_html = "<ul>" + "".join(
    f'<li><a href="assets/{f}" download>{f}</a></li>' for f in png_links
) + "</ul>"

dobox_buttons = (
    "<h3>DoBoX 本体 (2 件)</h3>"
    "<ul>"
    "<li><a href='https://hiroshima-dobox.jp/datasets/1257' target='_blank'>"
    "道路規制情報_本日の規制 dataset 1257</a> "
    "(<a href='https://hiroshima-dobox.jp/resource_download/32513' target='_blank'>直DL JSON</a>, ~165 KB)</li>"
    "<li><a href='https://hiroshima-dobox.jp/datasets/1258' target='_blank'>"
    "道路規制情報_今後の規制 dataset 1258</a> "
    "(<a href='https://hiroshima-dobox.jp/resource_download/32514' target='_blank'>直DL JSON</a>, ~234 KB)</li>"
    "</ul>"
    "<p class='tnote'>更新頻度はリアルタイム (~ 数分単位)。本記事は<b>2026-05-09 21:20:03</b>取得スナップショットを扱う。"
    "本記事の <code>.py</code> スクリプトを実行すると、現時点のスナップショットを自動取得して再分析する (= 結果が日々変動)。</p>"
)

sec3 = (
    dobox_buttons
    + "<h3>整形済 CSV / 中間データ</h3>"
    + csv_html
    + "<h3>図 PNG (9 枚)</h3>"
    + png_html
    + "<h3>再現スクリプト</h3>"
    "<ul>"
    "<li><a href='L50_road_restrictions.py' download>L50_road_restrictions.py</a> — メインスクリプト</li>"
    "</ul>"
    "<h3>再現コマンド</h3>"
    "<pre><code>cd \"2026 DoBoX 教材\"\n"
    "py -X utf8 lessons/L50_road_restrictions.py</code></pre>"
    "<p class='tnote'>初回実行時に DoBoX から 2 件の JSON を自動 DL "
    "(<code>data/extras/L50_road_restrictions/</code>)。"
    "<b>2 度目以降は既存ファイルを使うが、最新スナップショットを再取得したい場合は "
    "<code>data/extras/L50_road_restrictions/*.json</code> を削除してから実行</b>。"
    "本記事の結果数値は取得日 (2026-05-09) のスナップショットに基づく。</p>"
)


# Sec 4: 分析 1 - 規制種別 + 路線種別 (H1, H2)
sec4_intro = f"""
<h3>狙い (分析 1: 規制種別 + 路線種別構造)</h3>
<p>2 dataset の主軸は<b>規制種別 (5 値) × 路線種別 (8 値)</b> のクロス集計。
本セクションは H1 (one_side 支配) と H2 (国道 vs 県道の管轄分離) を検証する。
分析の論理鎖は次の通り:</p>
<ol>
  <li>5 規制種別の件数を 1257/1258 で並列に集計 → どの種別が最頻か (H1)</li>
  <li>路線名 (rosenname) を 8 路線種別 (国道/主要地方道/一般県道/市道/町道/etc) に分類</li>
  <li>路線種別 × dataset で件数比を見る → 国土交通省管轄 vs 県管轄の差 (H2)</li>
</ol>

<h3>手法 (Counter + groupby + 路線分類正規表現)</h3>
<p>路線名は文字列で、<code>"国道"</code> <code>"主要地方道"</code> <code>"一般県道"</code> 等の
キーワードを正規表現で判定して 8 カテゴリに分類する関数 <code>classify_rosen()</code> を導入。
DoBoX の path には <b>U+3000 (全角空白)</b> が混入するため、実際は
<code>"一般県道　乙瀬小方線"</code> のような表記。半角空白に正規化してから判定する。</p>

<p><b>入力</b>: df1257 (69 行) + df1258 (149 行) の <code>rosenname</code> 列。<br>
   <b>出力</b>: <code>rosen_class</code> 列 (8 カテゴリ) + 集計 DataFrame。<br>
   <b>限界</b>: 「指定市一般市道」 のような表記は「市道」 に分類されるが、
   「広島県 北部建設事務所」 が管轄する一般農道は「広域農道」 と分類されない場合がある。
   全パターン網羅は困難なので主要 7 カテゴリ + 「その他/不明」 で運用する。<br>
   <b>代替案</b>: 管轄 (demarcation) で分類すれば管理者ベースになるが、
   表記揺れが大きい (「広島県」 「土木管理課」 「市町名」 等) ため、本記事は路線種別ベースを採用。</p>
"""

sec4_code = code(r"""
# 規制種別 + 路線種別の集計
import pandas as pd, json, re
from collections import Counter

# 5 規制種別 (固定順)
KISEI_ORDER = ["blocked", "one_side", "narrows", "controll_complex", "two_way"]

# 路線分類関数 (正規表現キーワード)
def classify_rosen(name):
    if not name: return "その他/不明"
    name = name.replace("　", " ")  # 全角空白正規化
    if "国道" in name:        return "国道"
    if "主要地方道" in name:  return "主要地方道"
    if "一般県道" in name or "県道" in name: return "一般県道"
    if "町道" in name:        return "町道"
    if "市道" in name:        return "市道"
    if "広域農道" in name:    return "広域農道"
    if "高速" in name or "自動車道" in name: return "高速・自動車道"
    return "その他/不明"

# JSON → DataFrame 整形
def build_df(records, src_label):
    rows = []
    for r in records:
        rows.append({
            "src": src_label,
            "kisei_name": r.get("kisei_name"),
            "rosenname":  (r.get("rosenname") or "").replace("　", " "),
            "rosen_class": classify_rosen(r.get("rosenname") or ""),
            "kiseireason": r.get("kiseireason") or "(空)",
            "kisei_type":  r.get("kisei_type"),
            # ... 他 28 列省略
        })
    return pd.DataFrame(rows)

j1 = json.load(open("1257_today.json", encoding="utf-8"))
j2 = json.load(open("1258_future.json", encoding="utf-8"))
df1 = build_df(j1["results"], "1257 本日")
df2 = build_df(j2["results"], "1258 今後")

# H1: 規制種別件数比
h1 = df1["kisei_name"].value_counts().reindex(KISEI_ORDER, fill_value=0)
print(h1)
# blocked=22, one_side=38, narrows=4, controll_complex=5, two_way=0
# → one_side シェア = 55%, blocked シェア = 32% → H1 部分支持

# H2: 路線種別 × dataset
h2_1 = df1["rosen_class"].value_counts()
h2_2 = df2["rosen_class"].value_counts()
print(h2_1, h2_2)
""")

sec4_fig1_text = """
<h3>図 1: 5 規制種別 件数比較 (1257 vs 1258)</h3>
<p><b>なぜこの図か</b>: 学習者がまず<b>「どの規制種別が支配的か」</b>を一目で理解できるように、
横棒で 1257/1258 を並列表示。種別ごとの絶対数と比率の両方を読める。
H1 検証 (one_side 支配 + blocked 15-25%) に最適化したレイアウト。</p>
"""
sec4_fig1_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li>1258 で<b>片側交互通行 (one_side)</b>が <b>{int(h1_1258['one_side'])} 件</b>
      (<b>{one_side_share_1258*100:.1f}%</b>) で最頻。1257 でも <b>{int(h1_1257['one_side'])} 件</b>
      ({one_side_share_1257*100:.1f}%) で最頻 → <b>H1 (one_side 支配) を支持</b>。</li>
  <li>通行止 (blocked) は 1258 で <b>{int(h1_1258['blocked'])} 件</b>
      ({blocked_share_1258*100:.1f}%)、1257 で <b>{int(h1_1257['blocked'])} 件</b>
      ({blocked_share_1257*100:.1f}%) → 予想 (15-25%) より<b>多め</b>。
      これは「迂回路を要する強規制」 が想像以上に多いことを示す。</li>
  <li>車線規制 (narrows) は 1258 で <b>{int(h1_1258['narrows'])} 件</b> ({100*h1_1258['narrows']/h1_1258.sum():.1f}%) と多いが、
      1257 では <b>{int(h1_1257['narrows'])} 件</b> ({100*h1_1257['narrows']/h1_1257.sum():.1f}%) のみ。
      これは<b>narrows = 国道・高速の多車線道路で発生</b>する規制で、
      国土交通省は計画を事前公表する (1258 に多く現れる) ためと推察できる。</li>
  <li>対面通行 (two_way) は 1258 で <b>{int(h1_1258['two_way'])} 件</b>のみ ({100*h1_1258['two_way']/h1_1258.sum():.1f}%)、
      1257 では <b>0 件</b>。中央分離帯撤去等の特殊規制で、希少。</li>
  <li>その他/複合 (controll_complex) は両 dataset 共通で約 3-7%。
      複数規制が組み合わさる場面 (例: 一部車線止 + 速度制限併用) で計上される。</li>
</ul>
"""

sec4_fig4_text = """
<h3>図 4: 路線種別 件数比較 (1257 vs 1258)</h3>
<p><b>なぜこの図か</b>: 図 1 が「どの規制か」 を見せたのに対し、本図は<b>「どの種類の道路で起こるか」</b>を
見せる。国道 / 主要地方道 / 一般県道 / 市道 / 町道 等の 8 カテゴリを縦軸に並べ、
1257/1258 を横並べで比較。H2 (国道 vs 県道の管轄分離) の検証に最適。</p>
"""
sec4_fig4_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li>1258 (今後) は<b>国道</b>が <b>{int(h2_1258['国道'])} 件</b>
      ({kokudo_share_1258*100:.1f}%) で圧倒的支配。
      国土交通省の管轄路線 (国道 2 号・国道 54 号等) が 1258 全体の半数以上を占める。
      <b>H2 (国道支配) を強支持</b>。</li>
  <li>1257 (本日) では<b>主要地方道 ({int(h2_1257['主要地方道'])})</b>と
      <b>一般県道 ({int(h2_1257['一般県道'])})</b>の県管轄路線が支配的
      (合計 {h2_1257['主要地方道']+h2_1257['一般県道']} 件 = {100*(h2_1257['主要地方道']+h2_1257['一般県道'])/h2_1257.sum():.0f}%)。
      国道は <b>{int(h2_1257['国道'])} 件</b> ({kokudo_share_1257*100:.1f}%) のみ。
      <b>H2 (1257 では県道支配) を支持</b>。</li>
  <li>この差は何を意味するか? 国土交通省は<b>計画規制を多く事前公表</b>するが
      (1258 で 国道 = {int(h2_1258['国道'])} 件)、それらの多くは
      <b>本日の時間帯外 (夜間 22:00-05:00 等)</b>または<b>将来日付</b>のため、
      1257 (本日実施中) には現れない。逆に県は<b>当日運用に近い形</b>で
      事前公開件数が少ない、という<b>管轄ごとの公開思想の違い</b>が見える。</li>
  <li>市道 (1258 で {int(h2_1258['市道'])} 件) と町道 (1258 で {int(h2_1258['町道'])} 件) は少ない。
      これは<b>市町道は本データセットへの集約が進んでいない</b>制度的理由による
      (各市町が個別に公開している場合がある)。</li>
  <li>広域農道・高速・自動車道は本データに現れず、対象外と推察できる
      (NEXCO 高速道路は別系統、農道は管理者が市町や農協)。</li>
</ul>
"""


# Sec 5: 分析 2 - 規制理由 + 災害規制 (H3, H4)
sec5_intro = f"""
<h3>狙い (分析 2: 規制理由カテゴリ + 災害規制の長期残存)</h3>
<p>5 規制種別と 8 路線種別だけでは「<b>なぜ規制されているか</b>」 は見えない。
本セクションは<b>規制理由 (kiseireason)</b> と<b>規制タイプ (kisei_type)</b> の 2 軸で
H3 (工事 9 vs 災害 1) と H4 (西日本豪雨の長期残存) を検証する。</p>

<h3>手法 (キーワード辞書による理由カテゴリ化)</h3>
<p>kiseireason は自由記述で<b>多様な表記</b> ('道路改良工事', '法面崩落', '路面陥没',
'下水工事', 'マンション外壁落下のおそれ' 等)。これを 4 カテゴリに集約:</p>
<ul>
  <li><b>災害 (disaster)</b>: kisei_type='disaster' のレコード (これが正規分類)</li>
  <li><b>災害復旧</b>: 「災害」 「崩落」 「崩壊」 「陥没」 「落石」 「倒木」 等を含む理由</li>
  <li><b>工事</b>: 「工事」 「改良」 「修繕」 「舗装」 「下水」 「ガス」 「水道」 「電線」 等</li>
  <li><b>その他</b>: どちらにも該当しない</li>
</ul>

<p><b>入力</b>: kiseireason 文字列 + kisei_type フラグ。<br>
   <b>出力</b>: reason_cat 列 (4 カテゴリ)。<br>
   <b>限界</b>: 同じレコードが複数キーワードを持つ場合は<b>順序判定</b>になる
   (例: 「災害復旧工事」 → 災害復旧優先で判定)。<br>
   <b>代替案</b>: TF-IDF + クラスタリングで自動分類できるが、
   N=218 と少数なので手動キーワード辞書で十分。</p>

<h3>2018 西日本豪雨 — 災害規制の長期残存検出</h3>
<p>kisei_type='disaster' のレコードに<b>start_date が 2018-07-07</b> のものがあれば、
それは<b>西日本豪雨</b> (= 2018年7月の集中豪雨、広島県内 100名超死亡、道路被害 数千箇所) 起因。
取得日 (2026-05-09) との差で<b>規制継続年数</b>を計算し、
<b>道路復旧の極めて長い時間スケール</b>を定量化する。</p>
"""

sec5_code = code(r"""
# 規制理由カテゴリ化 + 災害規制の年齢計算
from datetime import datetime

CONSTRUCTION_KW = ["工事", "改良", "修繕", "舗装", "下水", "ガス", "水道",
                    "電線", "電話", "塗装", "外壁", "維持", "交差点"]
DISASTER_KW = ["災害", "崩落", "崩壊", "陥没", "落石", "倒木", "崩土"]

def reason_class(reason, ktype):
    if ktype == "disaster":
        return "災害 (kisei_type=disaster)"
    if not reason:
        return "理由不明"
    for kw in DISASTER_KW:
        if kw in reason: return "災害復旧"
    for kw in CONSTRUCTION_KW:
        if kw in reason: return "工事"
    return "その他"

df1["reason_cat"] = df1.apply(lambda r: reason_class(r["kiseireason"], r["kisei_type"]), axis=1)

# 災害規制の年齢
TODAY = datetime(2026, 5, 9)
disaster = df1[df1["kisei_type"] == "disaster"].copy()
disaster["start_dt"] = pd.to_datetime(disaster["start_date"], errors="coerce")
disaster["age_years"] = (TODAY - disaster["start_dt"]).dt.total_seconds() / (365.25 * 86400)
disaster_2018 = disaster[disaster["start_dt"].dt.date == datetime(2018, 7, 7).date()]
print(f"2018 西日本豪雨 起因: {len(disaster_2018)} 件残存, "
      f"最長継続 = {disaster['age_years'].max():.1f} 年")
""")

sec5_fig7_text = """
<h3>図 7: 規制理由 Top 12 (1257 vs 1258)</h3>
<p><b>なぜこの図か</b>: 自由記述の規制理由から最頻 12 種を抽出して並べ、
「工事系が支配する」 という H3 の予想を視覚化する。1257/1258 を二系統で表示し、
同じ理由でも 1258 (今後) で多く、1257 (本日) では少ない理由 = 計画系であることが読める。</p>
"""
sec5_fig7_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li>1258 の Top 1 = 「橋梁工事」 ({int(df1258[df1258['kiseireason']=='橋梁工事'].shape[0])} 件)。
      広島県は<b>瀬戸内海側</b>に橋梁が多く (河川河口・港湾連絡橋・島嶼間架橋等)、
      老朽化に伴う保全工事が継続的に発生する。</li>
  <li>「道路改良工事」 「修繕工事」 「下水工事」 「電線共同溝工事」 等の
      <b>工事系理由</b>が Top 12 中の大多数を占める。
      工事カテゴリは {h3_1258.get('工事', 0)} 件 / 1258 全体の {100*h3_1258.get('工事', 0)/len(df1258):.1f}% →
      <b>H3 (工事 9 割) を強支持</b>。</li>
  <li>1257 にだけ多く現れる理由 = 「<b>道路改良工事</b>」 ({int(df1257[df1257['kiseireason']=='道路改良工事'].shape[0])} 件)、
      「法面工事」 ({int(df1257[df1257['kiseireason']=='法面工事'].shape[0])} 件)。
      これらは<b>本日まさに実施中</b>の県道工事 = 県管轄が 1257 で目立つ理由
      (図 4 と整合)。</li>
  <li>1258 にだけ多く現れる理由 = 「<b>橋梁工事</b>」 ({int(df1258[df1258['kiseireason']=='橋梁工事'].shape[0])} 件) や
      「電線共同溝工事」 ({int(df1258[df1258['kiseireason']=='電線共同溝工事'].shape[0])} 件)。
      これは<b>国交省が事前計画で公表</b>する大規模工事で、
      未着手 → 1258 にだけ現れる (図 4 国道支配と整合)。</li>
  <li>災害系理由 (法面崩落 / 路面陥没 / 落石 / 災害) は単独で見ると少数だが、
      <b>kisei_type='disaster'</b> でフラグが立つレコードは別途存在 (次図で詳述)。</li>
</ul>
"""

sec5_fig8_text = """
<h3>図 8: 災害規制 年表 — 西日本豪雨の長期残存</h3>
<p><b>なぜこの図か</b>: H4 (2018 西日本豪雨の長期残存) を直接視覚化する。
1257 中の disaster 規制を縦軸に並べ、横軸に時間を取り、
<b>規制開始日 (赤丸)</b> から <b>取得日 (白四角)</b> までの線で<b>継続期間</b>を表現。
2018-07-07 マーカーを引いて西日本豪雨との関係を明示する。</p>
"""
n_dis_1257 = len(disaster_1257)
n_2018 = len(disaster_2018)
oldest_age = disaster_oldest['age_years'] if disaster_oldest is not None else 0
oldest_rosen = disaster_oldest['rosenname'] if disaster_oldest is not None else "(なし)"
sec5_fig8_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li>1257 中の災害規制 (kisei_type='disaster') = <b>{n_dis_1257} 件</b>。
      これは 1257 全体 ({len(df1257)} 件) の <b>{100*n_dis_1257/len(df1257):.1f}%</b>。
      数では少ないが「本日中に解除されない」 長期規制の主役。</li>
  <li><b>2018-07-07</b> 開始の規制が <b>{n_2018} 件</b>残存。
      これは<b>西日本豪雨 8 年前の災害</b>が今でも通行止になっている事実。
      具体的には<b>安佐北区の市道</b> ({int((disaster_2018['rosen_class']=='市道').sum() if len(disaster_2018)>0 else 0)} 件)
      で「路面陥没」 が継続。<b>H4 (西日本豪雨の長期残存) を支持</b>。</li>
  <li>最古の災害規制は <b>{oldest_age:.1f} 年継続</b> ({oldest_rosen[:20]})。
      <b>道路復旧の時間スケールは数年〜10 年</b>に及ぶ場合があることを示す。
      これは予算配分・工事優先順位の制度的問題で、
      被災規模 + 代替路線の有無が長期化を決める要因と推察。</li>
  <li>2024-04-04 「主要地方道 高田沖美江田島線」 の災害規制 (江田島市の島嶼路線) は
      <b>{(TODAY - pd.Timestamp('2024-04-04')).days/365.25:.1f} 年継続</b>。
      島嶼部の代替路線がない路線では、災害復旧が遅延しやすい。</li>
  <li>これは「動的な規制データ」 が<b>過去の災害履歴を映す史料</b>になることを示す。
      L11 (トリプルハザード) で扱った被害推計は静的な想定だが、
      本データは<b>実際に何年経っても通行止が解除されないか</b>という
      復旧遅延の動的記録として、防災投資設計に直接的含意を持つ。</li>
</ul>
"""


# Sec 6: 分析 3 - 地理分布 + 規制延長 (H5)
sec6_intro = f"""
<h3>狙い (分析 3: 地理分布 + 規制延長分布)</h3>
<p>規制種別・路線種別・規制理由は「<b>属性</b>」 軸の分析だった。
本セクションは<b>地理 (緯度経度) + 規模 (規制延長 m)</b> という<b>連続値軸</b>で
H5 (規制延長の対数正規分布) を検証する。
さらに市町コロプレスで<b>規制密度の地理的偏在</b>を読む。</p>

<h3>手法 (geopandas sjoin + log10 ヒスト)</h3>
<p>緯度経度 (EPSG:4326) を点に変換し、行政区域 (EPSG:6671) に投影変換した上で
sjoin で市町コードを付与:</p>
<ul>
  <li><b>STEP 1 (CRS 統一)</b>: 元データは EPSG:4326 (10 進度緯度経度) で
      面積・距離計算ができない。EPSG:6671 (m 単位) に投影変換する。</li>
  <li><b>STEP 2 (sjoin)</b>: 各規制点が広島県内のどの市町ポリゴンに含まれるかを
      <code>gpd.sjoin(predicate='within')</code> で判定。</li>
  <li><b>STEP 3 (集計)</b>: 市町ごとの 1257/1258 件数を集計し、コロプレス着色。</li>
</ul>

<p>規制延長 (encho) は文字列 ('33m', '1,872m') からカンマ除去 + m 単位抽出する関数
<code>parse_encho_m()</code> を導入。<b>log10 スケール</b>で分布を見ると、
10m〜92,000m の<b>4 桁</b>が一様には分布せず、<b>対数正規</b>に近い形になるはず。</p>

<p><b>入力</b>: 緯度経度列 + encho 列 + 行政区域 GeoJSON (140 ポリゴン)。<br>
   <b>出力</b>: 市町別件数表 + log10(encho) ヒスト。<br>
   <b>限界</b>: 一部の規制点が<b>県外/不明</b> (lat/lon が広島県境外) と判定される
   (国道 2 号の境界部・島嶼部の精度問題)。<br>
   <b>代替案</b>: 地理分析を路線 (rosen_key) ベースに切り替えれば
   行政区域不確定性が消えるが、密度マップが描けない。</p>
"""

sec6_code = code(r"""
# 緯度経度 → 市町 sjoin + 規制延長 log10 分布
import geopandas as gpd, numpy as np, pandas as pd, re

def parse_encho_m(s):
    if pd.isna(s) or s == "": return np.nan
    s = str(s).replace(",", "").strip()
    m = re.match(r"(\d+(?:\.\d+)?)\s*([mk]?)", s)
    if not m: return np.nan
    v = float(m.group(1))
    if m.group(2) == "k": v *= 1000
    return v

df["encho_m"] = df["encho"].apply(parse_encho_m)
encho_pos = df["encho_m"].dropna()
encho_pos = encho_pos[encho_pos > 0]
log10_encho = np.log10(encho_pos)
log_range = log10_encho.max() - log10_encho.min()  # 4 桁
print(f"min={encho_pos.min():.0f}, max={encho_pos.max():.0f}, "
      f"log10 range = {log_range:.2f} 桁")

# CRS 変換 + sjoin
admin = gpd.read_file("admin_pref.geojson").to_crs("EPSG:6671")
admin_diss = admin.dissolve(by="CITY_CD", as_index=False)
gdf = gpd.GeoDataFrame(df, geometry=gpd.points_from_xy(df.lon, df.lat),
                        crs="EPSG:4326").to_crs("EPSG:6671")
joined = gpd.sjoin(gdf, admin_diss[["CITY_CD", "geometry"]],
                    how="left", predicate="within")
city_counts = joined.groupby("CITY_CD").size()
""")

sec6_fig2_text = """
<h3>図 2: 規制点マップ — 規制種別で色分け</h3>
<p><b>なぜこの図か</b>: 学習者が広島県地図上で<b>規制がどこに集中しているか</b>を直感する。
緯度経度の点を 5 規制種別の固定色で塗り、災害規制 (kisei_type='disaster') を<b>赤リング</b>で強調。
1258 (今後の規制 N=149) をベースにし、後続の図 3 (LineString) と 図 5 (コロプレス) と比較できる。</p>
"""
sec6_fig2_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li>規制点は<b>沿岸部</b>に集中 — 広島市・呉市・尾道市・福山市の都市帯。
      これは交通量・道路ネットワーク密度に比例する分布で、当然の傾向。</li>
  <li>中山間部 (世羅町・庄原市・三次市) にも国道沿いの規制が点在。
      これは<b>長距離国道の維持工事</b>を反映する。</li>
  <li>赤リングの<b>災害規制</b>は内陸の山間部や島嶼部に偏る傾向 →
      法面崩落・橋梁崩落・落石は地形リスクが高い場所で発生。</li>
  <li>江田島・大崎上島等の島嶼部にも規制点が見える →
      島嶼アクセス道は本土依存度が高く、規制の影響が大きい。</li>
  <li>沿岸都市帯では<b>片側交互通行 (山吹色)</b>と<b>車線規制 (青)</b>が多く、
      内陸では<b>通行止 (赤)</b>が多い → 都市部は道路機能を残す前提で工事、
      山間部は道路機能を一時停止する前提で工事、という運用思想の差が見える。</li>
</ul>
"""

sec6_fig3_text = """
<h3>図 3: 規制区間 LineString 重畳マップ</h3>
<p><b>なぜこの図か</b>: 図 2 の点マップでは<b>規制区間の長さ</b>が見えない。
本図は kukanroot WKT を LineString 化して描画し、<b>区間規制の地理的広がり</b>を示す。
細線 = 1258、太線 = 1257 で重畳すると、本日と今後の<b>時間スコープの違い</b>も
地理的に確認できる。</p>
"""
sec6_fig3_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li>太線 (1257 本日 = {len(g1257_ln)} 件) は短〜中距離の点状で、
      細線 (1258 今後 = {len(g1258_ln)} 件) は長距離区間も含む混合分布。</li>
  <li>長距離 LineString (= 数 km 以上) は<b>主要地方道・国道</b>の<b>事前通行規制区間</b>と推察。
      これらは積雪・冬期閉鎖等で長距離全線が規制対象になる。</li>
  <li>1258 の細線が<b>1257 の太線を内包</b>している様子 → H6 の包含関係と整合。
      ほぼすべての本日規制は今後規制ビューにも入っている。</li>
  <li>緑 (対面通行 = two_way) のラインは見えない / 極少 → 1257 で 0 件、1258 で {int(h1_1258['two_way'])} 件のみ。</li>
  <li>青 (車線規制 = narrows) は国道・主要地方道の<b>多車線区間</b>に集中 →
      figure 4 の路線種別と整合。</li>
</ul>
"""

sec6_fig5_text = """
<h3>図 5: 市町別 規制密度コロプレス</h3>
<p><b>なぜこの図か</b>: 点や線では市町単位の規制集積度合いが見えにくい。
市町ポリゴンを 1258 (今後) の規制件数で着色し、<b>地理的偏在</b>を直接可視化する。
上位 8 市町には本日/今後の件数を併記して、両 dataset の差も同時に読める。</p>
"""
top_city = city_summary.iloc[0]
sec6_fig5_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li>第 1 位 = <b>{top_city['市町']}</b> ({int(top_city['1258 件数'])} 件 / 1258, {int(top_city['1257 件数'])} 件 / 1257)。
      広島県内最大都市または交通幹線通過地に集中。</li>
  <li>上位 5 市町で 1258 全体の <b>{100*city_summary.head(5)['1258 件数'].sum()/city_summary['1258 件数'].sum():.0f}%</b> を占める。
      規制は明確に都市部・幹線通過地に偏在する。</li>
  <li>1257 件数 = 0 だが 1258 件数 > 0 の市町 = <b>「将来規制のみある」</b> 市町 →
      これは「本日は何も実施していないが、将来日に向けて公表計画がある」 市町で、
      多くは国道沿いの<b>通過型市町</b> (広域農村)。</li>
  <li>逆に 1257 > 0 だが 1258 = 0 はゼロ → H6 (1257 ⊂ 1258) と整合。
      本日実施中の規制は今後ビューにも必ず載る。</li>
  <li>中山間 (庄原・三次・神石高原) は規制密度が低いが、ゼロではない →
      長距離国道の維持工事は中山間でも頻発する。</li>
</ul>
"""

sec6_fig6_text = """
<h3>図 6: 規制延長 log10 ヒストグラム + 累積分布</h3>
<p><b>なぜこの図か</b>: encho (規制延長) は最小 10m から最大 92,000m まで <b>4 桁</b>の幅を持つ。
線形軸では<b>長距離規制の少数</b>に押されて分布形状が読めない。
log10 スケールにすると<b>対数正規 (= 正規分布のような釣り鐘)</b> 形状が見える。
H5 (対数正規分布 + median 数百 m) の検証に最適。</p>
"""
sec6_fig6_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li>log10(encho_m) のヒストは<b>釣り鐘形状</b>で対数正規に近い。
      平均 = log10 {log_mean:.2f} (= {10**log_mean:.0f}m)、
      中央値 = {encho_1258.median():.0f}m。<b>H5 (対数正規) を支持</b>。</li>
  <li>min = <b>{encho_1258.min():.0f}m</b> (ピンポイント工事)、
      max = <b>{encho_1258.max():.0f}m ≒ {encho_1258.max()/1000:.0f}km</b> (主要地方道全線)。
      <b>log10 範囲 {log10_range:.1f} 桁</b>。</li>
  <li>中央値 ({encho_1258.median():.0f}m) は<b>1 ブロック分の道路区間</b>に相当。
      最頻帯は 100-1000m で、これは<b>橋梁 1 基〜街区 1 ブロック</b>の規制区間スケール。</li>
  <li>右側の長距離規制 (10km 以上) は<b>事前通行規制区間 (積雪・冬期閉鎖)</b>か
      <b>主要地方道全線</b>の長期工事で、頻度は低いが影響は大きい (= 迂回路長距離)。</li>
  <li>累積分布 (右図) で 1257 と 1258 はほぼ重畳 →
      <b>本日と今後の規制延長分布は同質</b>。「本日が短く今後が長い」 「本日が長く今後が短い」 のような
      偏りはなく、両 dataset は<b>同じ運用システムから派生する homogeneous なサンプル</b>と確認できる。</li>
</ul>
"""


# Sec 7: 分析 4 — 1257 ⊂ 1258 包含関係 (H6)
sec7_intro = f"""
<h3>狙い (分析 4: 2 dataset の包含関係)</h3>
<p>1257 (本日) と 1258 (今後) は<b>同じ運用システムから派生する 2 つのビュー</b>と
仮説 H6 で予想した。本セクションでは id 集合の<b>差集合</b>を計算し、
3 つの排他的グループに分解する:</p>
<ul>
  <li><b>1257 ∩ 1258</b>: 両 dataset 共通 = 本日実施中で今後も終わっていない規制</li>
  <li><b>1257 only</b>: 本日のみ = <b>本日中に終了する短期規制</b></li>
  <li><b>1258 only</b>: 今後のみ = <b>本日まだ開始していない将来規制</b></li>
</ul>
<p>3 グループの件数比から、運用システムの<b>時間境界の操作論理</b>が見える。</p>
"""

sec7_code = code(r"""
# 集合演算による包含関係分解
ids_1 = set(df1["id"])
ids_2 = set(df2["id"])
common  = ids_1 & ids_2
only_1  = ids_1 - ids_2
only_2  = ids_2 - ids_1
print(f"1257 ∩ 1258 = {len(common)}")
print(f"1257 only    = {len(only_1)}")
print(f"1258 only    = {len(only_2)}")
print(f"包含率 = {len(common)/len(ids_1)*100:.1f}%")

# 1257 only の特性: 本日中に終了する短期規制 = end_date は今日
df_only_1 = df1[df1["id"].isin(only_1)].copy()
print(df_only_1[["rosenname", "kiseinaiyo", "end_date"]])
""")

sec7_fig9_text = """
<h3>図 9: 1257 / 1258 包含関係 (Venn 風 + bar)</h3>
<p><b>なぜこの図か</b>: 集合演算の結果を<b>視覚化</b>するため Venn 風と bar の 2 表示。
左の Venn 風で「1257 が 1258 にほぼ完全包含される」 という構造的事実を直感的に伝え、
右の bar で 3 グループの絶対件数を厳密に示す。</p>
"""
sec7_fig9_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li><b>1257 ∩ 1258 = {len(common_ids)} 件</b> (1257 全体の {inclusion_rate*100:.1f}%) →
      <b>H6 (95% 以上の包含率) を支持</b>。
      ほぼ全ての本日規制は今後ビューに含まれる = <b>1258 = 1257 ∪ 将来規制</b>の構造。</li>
  <li>1257 only = <b>{len(only_1257)} 件</b>のみ。
      これらは end_date が<b>本日中</b>に来る短期規制 (例: 23:59 終了予定の夜間工事)。</li>
  <li>1258 only = <b>{len(only_1258)} 件</b>。これらは start_date が<b>明日以降</b>の将来規制で、
      取得日時点ではまだ施行されていない。
      1258 全体 ({len(df1258)} 件) の <b>{100*len(only_1258)/len(df1258):.1f}%</b> がここに該当。</li>
  <li>これは「<b>1258 = 1257 のスーパーセット</b>」 という設計を実証。
      DoBoX のシステムは <b>1 つの規制台帳 + 取得時刻フィルタ</b>で 2 ビューを生成しており、
      クライアント (アプリ) はどちらか好きな方を取得できる柔軟性を提供する。</li>
  <li>本日終了する規制 ({len(only_1257)} 件) は<b>明日以降の 1258 取得時には消える</b> →
      毎日取得スナップショットを保存すれば<b>規制履歴データベース</b>を構築できる
      (発展課題で議論)。</li>
</ul>
"""


# Sec 8: 仮説検証総合
sec8 = (
    "<p>本記事の 6 仮説と観測結果の照合:</p>"
    + df_to_html(T_hypo)
    + "<h3>主要発見の総括</h3>"
    "<ul>"
    f"<li><b>規制の運用構造</b>: 5 種別の中で<b>片側交互通行</b>が最頻 ({one_side_share_1258*100:.0f}%)。"
    f"通行止 ({blocked_share_1258*100:.0f}%) も予想以上に多く、迂回路を要する強規制が常時稼働。</li>"
    f"<li><b>管轄ごとの公開思想差</b>: 1258 では国道シェア {kokudo_share_1258*100:.0f}% で支配的、"
    f"1257 では県道支配 ({100*(h2_1257['一般県道']+h2_1257['主要地方道'])/h2_1257.sum():.0f}%) →"
    f"国土交通省と県の<b>事前公開度合いの差</b>が dataset 内で観測される。</li>"
    f"<li><b>2018 西日本豪雨の影</b>: 災害規制 {n_dis_1257} 件中 {n_2018} 件が"
    f"<b>2018-07-07 開始</b>で 8 年継続中。"
    f"道路復旧の時間スケールは<b>数年〜10 年</b>に及び、被災規模 + 代替路線の有無が長期化を決める。</li>"
    f"<li><b>規制延長の対数正規性</b>: 10m〜92km の <b>{log10_range:.1f} 桁</b>の幅。"
    f"中央値 {encho_1258.median():.0f}m は街区 1 ブロック相当、"
    f"対数正規分布で工事規制の本質を反映。</li>"
    f"<li><b>1257 ⊂ 1258 の制度的設計</b>: 包含率 {inclusion_rate*100:.1f}% で"
    f"<b>1258 = 1257 のスーパーセット</b>という構造。"
    f"DoBoX は 1 つの規制台帳 + 取得時刻フィルタで 2 ビューを生成。"
    f"クライアントアプリはどちらかを選べる柔軟性を持つ。</li>"
    "</ul>"
    "<h3>2 dataset の構造的相互関係</h3>"
    "<p>1257 と 1258 は<b>独立した別データ</b>ではなく、"
    "<b>同じ運用システムから派生する時間スコープの異なる 2 ビュー</b>。"
    "1257 = 取得時刻 ∈ [start, end] のフィルタ、"
    "1258 = 取得時刻 ≤ end のフィルタ。"
    "1258 のスーパーセット構造は、防災・交通計画アプリが"
    "「直近の影響」 と「将来の予定」 を 1 リクエストで取得できる設計に直結する。"
    "本記事は 2 dataset を統合分析することで、この<b>制度的設計を逆工学的に解読</b>した。</p>"
)

# Sec 9: 発展課題
sec9 = f"""
<h3>結果X → 新仮説Y → 課題Z (4 課題)</h3>

<h4>発展課題 1 (西日本豪雨の長期残存 由来)</h4>
<ul>
  <li><b>結果 X</b>: 1257 中の災害規制の最古は <b>2018-07-07 西日本豪雨</b>起因で、
      8 年経過しても <b>{n_2018} 件</b>が通行止のまま。安佐北区市道で「路面陥没」 が継続している。</li>
  <li><b>新仮説 Y</b>: 災害規制の<b>復旧遅延</b>は<b>路線の代替路線数</b>と
      <b>負の相関</b>を持つ。代替路線が多い都市部の道路は早期復旧 (1 年以内) するが、
      代替路線がない山間部・島嶼部は数年〜10 年放置される。</li>
  <li><b>課題 Z</b>: 災害規制 {n_dis_1257} 件すべてについて、<b>代替路線の数</b>を OpenStreetMap 道路網から
      手動カウントし、復旧期間 (現在年齢) との Spearman 相関を計算。
      r ≤ -0.6 なら強支持。代替路線数 = 0 (= 唯一の道路) のケースを抽出して
      防災投資優先度の評価指標として提案する。</li>
</ul>

<h4>発展課題 2 (規制延長の対数正規分布 由来)</h4>
<ul>
  <li><b>結果 X</b>: 規制延長は <b>{log10_range:.1f} 桁</b>の対数正規分布で、
      中央値 {int(encho_1258.median())}m、最大 92km。10km 以上の長距離規制が稀に出現する。</li>
  <li><b>新仮説 Y</b>: 規制延長の上位 5% (= 10km 超) は<b>事前通行規制区間</b> (積雪・凍結・冬期閉鎖) で、
      これらは<b>同じ路線が毎年同じ期間繰り返し規制される</b>定期パターンを持つ。</li>
  <li><b>課題 Z</b>: 1258 の上位 10 規制 (encho 順) を抽出し、
      DoBoX の<b>事前通行規制区間 (dataset 1245)</b> Shapefile と
      路線名照合する。一致率 80% 以上なら強支持。
      さらに過去 1 年分の取得スナップショット (毎日バッチ取得) を蓄積していれば、
      同じ路線が複数日に出現する周期性を Fourier 解析で抽出できる。</li>
</ul>

<h4>発展課題 3 (1257 ⊂ 1258 包含構造 由来)</h4>
<ul>
  <li><b>結果 X</b>: 1257 と 1258 は<b>{inclusion_rate*100:.1f}% 重複</b>で、1258 は 1257 のスーパーセット。
      1258 only は本日まだ開始していない将来規制 ({len(only_1258)} 件)。</li>
  <li><b>新仮説 Y</b>: 「1258 only」 規制は<b>事前公表のリードタイム</b>で 2 群に分かれる。
      短いリードタイム (1 週以内) = 県運用、長いリードタイム (1 月以上) = 国交省運用。
      管轄ごとに事前公表期間が異なる<b>運用文化の差</b>を反映する。</li>
  <li><b>課題 Z</b>: 1258 only の <b>start_date - 取得日</b> をリードタイムとして計算し、
      路線種別 (国道 / 県道) でグループ化。Mann-Whitney U 検定で有意差を見る。
      p &lt; 0.01 なら強支持。「国交省 vs 県の事前公表期間の制度的差」 を
      数値化し、市民への情報届けやすさの観点で評価する。</li>
</ul>

<h4>発展課題 4 (時系列スナップショット 由来)</h4>
<ul>
  <li><b>結果 X</b>: 本記事は 2026-05-09 のスナップショットを扱った。
      規制データはリアルタイム更新で、毎日取得すれば<b>履歴データベース</b>になる。</li>
  <li><b>新仮説 Y</b>: 規制件数は<b>季節周期</b>を持つ。雪解け後の道路改良が春先に集中、
      豪雨期 (6-7 月) に災害規制が急増、冬期 (12-2 月) に積雪規制が長距離化する。
      年間サイクル振幅は中央値の 2-3 倍。</li>
  <li><b>課題 Z</b>: cron で毎日 21:00 に DoBoX から 1257/1258 を取得し、
      JSON を 1 ファイル/日で蓄積。1 年分 (~365 ファイル) 集めれば、
      規制種別ごとの<b>日別件数時系列</b>が得られる。
      Fourier 変換で年周期 (1/365) と週周期 (1/7) のパワーを比較し、
      H4 (西日本豪雨型の急上昇) のような<b>異常検知</b>も可能になる。
      これは<b>静的データセット集</b>から<b>動的時系列データベース</b>へという
      DoBoX の活用法の拡張で、防災政策設計に直接的価値がある。</li>
</ul>
"""


# Combine sections
sections = [
    ("学習目標と問い", sec1),
    ("使用データ", sec2),
    ("ダウンロード", sec3),
    ("【分析 1】 規制種別 + 路線種別構造 — 5 種別 × 8 種路線のクロス",
     sec4_intro
     + "<h3>実装コード (Counter + groupby + 路線分類)</h3>"
     + sec4_code
     + sec4_fig1_text
     + figure("assets/L50_fig1_kisei_counts.png", "図 1: 5 規制種別 件数比較")
     + sec4_fig1_read
     + sec4_fig4_text
     + figure("assets/L50_fig4_rosen_class.png", "図 4: 路線種別 件数比較")
     + sec4_fig4_read
     + "<h3>表: 規制種別集計 (5 値)</h3>"
     + df_to_html(T_kisei)
     + f"<p><b>この表から読み取れること</b>: "
       f"片側交互通行 (one_side) が両 dataset で最頻 ({one_side_share_1258*100:.0f}% / {one_side_share_1257*100:.0f}%)、"
       f"通行止 (blocked) が次点 ({blocked_share_1258*100:.0f}% / {blocked_share_1257*100:.0f}%)。"
       f"対面通行 (two_way) は 1258 のみで {int(h1_1258['two_way'])} 件、"
       f"1257 では 0 件 → 中央分離帯撤去等の特殊規制は本日中には少ない。</p>"
     + "<h3>表: 路線種別集計 (8 値)</h3>"
     + df_to_html(T_rosen)
     + f"<p><b>この表から読み取れること</b>: "
       f"1258 は国道支配 ({kokudo_share_1258*100:.0f}%) で国土交通省管轄が中心、"
       f"1257 は県道支配 ({100*(h2_1257['一般県道']+h2_1257['主要地方道'])/h2_1257.sum():.0f}%) で県管轄が中心。"
       f"<b>同じ運用システムから派生する 2 ビューが管轄ごとに異なる時間スコープを持つ</b>"
       f"という設計が読める。</p>"
    ),
    ("【分析 2】 規制理由カテゴリ + 災害規制の長期残存 — 西日本豪雨 8 年継続",
     sec5_intro
     + "<h3>実装コード</h3>"
     + sec5_code
     + sec5_fig7_text
     + figure("assets/L50_fig7_reason_top12.png", "図 7: 規制理由 Top 12")
     + sec5_fig7_read
     + sec5_fig8_text
     + figure("assets/L50_fig8_disaster_timeline.png", "図 8: 災害規制 年表")
     + sec5_fig8_read
     + "<h3>表: 規制理由カテゴリ (4 値)</h3>"
     + df_to_html(T_reason_cat)
     + f"<p><b>この表から読み取れること</b>: "
       f"工事カテゴリが 1258 中 {h3_1258.get('工事', 0)} 件 ({100*h3_1258.get('工事', 0)/len(df1258):.1f}%) で支配。"
       f"災害カテゴリ (kisei_type='disaster') は 1258 で {len(disaster_1258)} 件 ({100*len(disaster_1258)/len(df1258):.1f}%)。"
       f"<b>H3 (工事 9 vs 災害 1) を支持</b>。"
       f"1257 vs 1258 で災害比率は同程度だが、災害は<b>長期化</b>するため"
       f"1257 (本日中の規制) でも残存する。</p>"
     + "<h3>表: 規制理由 Top 15 (1258 ベース)</h3>"
     + df_to_html(T_reason_top)
     + "<p><b>この表から読み取れること</b>: "
       "Top 1 = 橋梁工事 (老朽化に伴う保全)、Top 2 = 道路改良工事、Top 3 = 修繕工事、Top 4 = 下水工事 ‥‥"
       "工事系カテゴリが上位を独占。災害復旧 (法面崩落・路面陥没・落石) は単独件数が少ないが、"
       "kisei_type='disaster' でフラグが立つ別軸での識別が必要。</p>"
     + "<h3>表: 災害規制 詳細 (1257 中 disaster)</h3>"
     + df_to_html(T_disaster_view)
     + f"<p><b>この表から読み取れること</b>: "
       f"最古は <b>{disaster_oldest['start_date']:%Y-%m-%d}</b> 開始 ({disaster_oldest['rosenname'][:20]}) で、"
       f"<b>{disaster_oldest['age_years']:.1f} 年継続</b>。"
       f"2018-07-07 西日本豪雨起因が {n_2018} 件残存。"
       f"道路復旧の時間スケールは数年〜10 年に及び、被災規模 + 代替路線数が長期化を決める。"
       f"<b>H4 (西日本豪雨の長期残存) を強支持</b>。</p>"
    ),
    ("【分析 3】 地理分布 + 規制延長分布 — 対数正規 4 桁の幅",
     sec6_intro
     + "<h3>実装コード</h3>"
     + sec6_code
     + sec6_fig2_text
     + figure("assets/L50_fig2_point_map.png", "図 2: 規制点マップ (規制種別)")
     + sec6_fig2_read
     + sec6_fig3_text
     + figure("assets/L50_fig3_line_map.png", "図 3: 規制区間 LineString 重畳")
     + sec6_fig3_read
     + sec6_fig5_text
     + figure("assets/L50_fig5_city_choropleth.png", "図 5: 市町別 規制密度コロプレス")
     + sec6_fig5_read
     + sec6_fig6_text
     + figure("assets/L50_fig6_encho_dist.png", "図 6: 規制延長 log10 ヒスト + 累積分布")
     + sec6_fig6_read
     + "<h3>表: 市町別 規制件数 (Top 15)</h3>"
     + df_to_html(city_summary.head(15))
     + f"<p><b>この表から読み取れること</b>: "
       f"上位 5 市町で 1258 全体の {100*city_summary.head(5)['1258 件数'].sum()/city_summary['1258 件数'].sum():.0f}% を占める。"
       f"都市部 + 国道通過地に規制が集中。"
       f"中山間 (庄原・三次) や島嶼部 (江田島) も少数だが規制を持つ。"
       f"県外/不明判定の点は <b>{int(city_summary[city_summary['市町']=='(県外/不明)']['1258 件数'].iloc[0]) if (city_summary['市町']=='(県外/不明)').any() else 0}</b> 件のみ。</p>"
     + "<h3>表: 規制期間・延長 統計</h3>"
     + df_to_html(T_dur)
     + f"<p><b>この表から読み取れること</b>: "
       f"規制延長は中央値 {encho_1258.median():.0f}m, 最大 {encho_1258.max():.0f}m, 総和 {encho_1258.sum()/1000:.0f}km。"
       f"規制期間は中央値 {dur_1258_real.median():.0f} 日、最長 {dur_1258_real.max():.0f} 日 (約 3 年)。"
       f"<b>H5 (対数正規 4 桁) を支持</b>。長距離規制 (10km 超) は事前通行規制区間 (積雪等) で、"
       f"短距離規制 (10-100m) はピンポイント工事。中央値の街区 1 ブロック規模 (300-500m) が最頻帯。</p>"
    ),
    ("【分析 4】 1257 ⊂ 1258 包含関係 — 2 ビューの構造的相互関係",
     sec7_intro
     + "<h3>実装コード</h3>"
     + sec7_code
     + sec7_fig9_text
     + figure("assets/L50_fig9_venn_inclusion.png", "図 9: 包含関係 (Venn 風 + bar)")
     + sec7_fig9_read
     + "<h3>表: 1257 / 1258 包含関係</h3>"
     + df_to_html(T_inclusion)
     + f"<p><b>この表から読み取れること</b>: "
       f"1258 = 1257 ∪ 将来規制 という設計。"
       f"包含率 {inclusion_rate*100:.1f}% で 1258 が 1257 のスーパーセット。"
       f"DoBoX は 1 つの規制台帳 + 取得時刻フィルタで 2 ビューを生成。"
       f"<b>H6 を強支持</b>。</p>"
    ),
    ("仮説検証総合", sec8),
    ("発展課題", sec9),
]

html = render_lesson(
    num=50,
    title="道路規制情報 2 件統合分析 — 本日の規制 / 今後の規制から運用構造を読み解く",
    tags=["L50", "道路規制", "JSON", "GeoDataFrame", "包含関係", "災害規制",
          "西日本豪雨", "Format A"],
    time="35 分",
    level="中級",
    data_label="DoBoX dataset 1257 (本日, JSON 165KB) + 1258 (今後, JSON 234KB)",
    sections=sections,
    script_filename="L50_road_restrictions.py",
)

OUT_HTML = LESSONS / "L50_road_restrictions.html"
OUT_HTML.write_text(html, encoding="utf-8")
print(f"  HTML written: {OUT_HTML}")
print(f"  ({time.time()-t1:.1f}s)", flush=True)


print(f"\n=== L50 DONE in {time.time()-t_all:.1f}s ===")
