# -*- coding: utf-8 -*-
"""L77 道路台帳付図 単独 3 研究例分析
       — 広島県 道路台帳付図 (dataset 1445) を 3 角度で解読

カバー宣言:
  本記事は DoBoX のデータセット「道路台帳付図」 (dataset_id = 1445)
  1 件を <b>単独</b>で取り上げ、 広島県の道路管理者 (道路河川管理課)
  が公開する<b>道路台帳付図 14,463 図郭 / 319 路線</b>を 3 つの独立した
  研究角度 (RQ1 / RQ2 / RQ3) で並列に分析する。

  「道路台帳付図」 とは:
    道路法第 28 条が道路管理者に作成・保管を義務付ける<b>「道路台帳」</b>の
    別冊資料の 1 つ。 各路線の道路区域・幅員・構造・横断構成・占用物件
    位置等を縮尺 1:1,000 〜 1:500 程度の<b>帯状図</b>として記録した
    法定図面である。 道路法施行規則第 4 条の 2 で記載項目が定められ、
    道路区域決定 / 工事 / 占用許可 / 境界確定 等の<b>行政事務の根拠
    図面</b>として用いられる。 県管理の国道・主要地方道・一般県道
    すべてを対象に、 1 図郭あたり数百メートル区間を 1 枚に納めた
    PDF 版が今般オープンデータ化された。

  本記事は <b>L72 緊急輸送道路 / L73 通行規制区間 / L74 注意区間 /
  L75 道路カメラ / L76 非常用電話</b>と<b>厳密に区別</b>:
    L72 = 緊急輸送道路 (LineString, 4 階層 / 2,790 km, 災害時生命線)
    L73 = 事前通行規制区間 (規制基準雨量, 約 70 区間)
    L74 = 注意区間 (異常気象時通行注意)
    L75 = 道路カメラ (CCTV 拠点)
    L76 = 非常用電話 (NTT 災害時公衆電話, 900 拠点)
    L77 = 道路台帳付図<b>単独</b> (POINT 14,463 図郭 / 319 路線, 県道路河川管理課)
    本記事は他シリーズと<b>合体しない</b>。 RQ2 で L72 緊急輸送道路 (cached
    JSON) を背景レイヤとして参照する形をとる。

  「路線」 と「図郭」 の関係:
    1 路線 (例: 国道 433 号 = D251-4 〜 N292) は、 路線延長を区切った
    複数の<b>図郭</b> (Map Sheet) で構成される。 1 図郭 = 1 PDF =
    1 緯度経度点 (= 図郭中心点) として CSV に記録される。 路線あたり
    平均 <b>45 図郭</b>、 最多は<b>国道 375 号 (424 図郭)</b>、 最少は
    1 図郭の支線 (停車場線等)。

研究の問い (3 RQ):
  RQ1 (主研究): 広島県の<b>道路台帳付図のデータ構造 — 公開単位 (路線/図郭) ×
       路線種 × 図番接頭辞 × 管理事務所</b>はどう描けるか? 14,463 図郭を
       4 軸で集計し、 「県の道路情報公開の物理形状」 を初めて系統的に記述
       する。 H1 = 国道は<b>路線数では 10% 未満</b>だが<b>図郭数では 20% 以上</b>
       のシェアを占める仮説 (= 国道は路線あたり図郭密度が高い、 = 詳細図化
       優先仮説)、 H2 = 路線種別図郭密度 (図郭数 / 路線数) は<b>国道 ≥ 主要
       地方道 ≥ 一般県道</b>の階層構造仮説。

  RQ2 (副研究 1): 道路台帳付図と<b>L72 緊急輸送道路 (4 階層 / 2,790 km) との
       照合 — 重要路線優先公開</b>はどう描けるか? 14,463 図郭が L72 緊急
       輸送道路 1km 圏内かを sjoin、 「重要路線優先公開」 仮説を量的検証する。
       H3 = 道路台帳付図 14,463 図郭のうち <b>>= 60%</b>が L72 緊急輸送道路
       1km 圏内 (= 防災重要路線が優先的にデジタル公開) 仮説、
       H4 = 路線種別 1km 圏内率は<b>国道 > 主要地方道 > 一般県道</b>仮説
       (= 路線種が高位ほど L72 と重なる)。

  RQ3 (副研究 2): 道路台帳付図の<b>データ公開設計 — 学習者の機械可読性と
       再利用可能性</b>はどう評価できるか? 1.1GB ZIP / 14,463 PDF / CSV
       関係資料の 3 層構造を学習者目線で評価し、 「PDF は人間可読だが機械
       可読性が低い」 「CSV 関係資料は機械可読だが PDF 中身は不可」 の
       <b>非対称公開構造</b>を量的記述する。 H5 = ZIP の総サイズ (1.1GB) /
       関係資料 CSV (3.4MB) の比は<b>>= 300 倍</b>、 「機械可読指標 (CSV)
       は本体 (PDF) の 0.3% 以下」 仮説。

仮説 (5):
  H1 (RQ1, 国道優先公開): 路線種別シェアで国道は<b>路線数 10% 未満</b>だが
       <b>図郭数 20% 以上</b>。 国道は路線あたり図郭密度が高く、 詳細図化が
       優先される仮説。

  H2 (RQ1, 階層的図郭密度): 路線あたり図郭密度 (図郭数 / 路線数) が
       <b>国道 ≥ 主要地方道 ≥ 一般県道</b>。 路線種が高位ほど 1 路線あたり
       詳細図郭が多い階層構造仮説。

  H3 (RQ2, L72 重なり): 道路台帳付図 14,463 図郭のうち L72 緊急輸送道路
       1km 圏内が <b>>= 60%</b>。 県の道路情報公開は<b>防災重要路線を優先</b>
       するという制度方針仮説。

  H4 (RQ2, 路線種 → L72 重なり階層): 路線種別 1km 圏内率は<b>国道 > 主要
       地方道 > 一般県道</b>。 路線種が高位ほど L72 と地理的に重なる
       階層構造仮説。

  H5 (RQ3, 機械可読性の非対称): ZIP 本体 (1.1GB PDF 集合) と関係資料 CSV
       (3.4MB) の比 = <b>>= 300 倍</b>。 メタデータ (機械可読) は本体
       (人間可読 PDF) の<b>0.3% 以下</b>であり、 学習者にとって機械的な
       中身解析は事実上不可能、 という非対称公開構造仮説。

要件 S 準拠 (1 分以内完走):
  - 関係資料 CSV (~3.4MB, 14,463 行) → ensure_dataset で取得
  - ZIP (1.1GB) は<b>取得しない</b> (時間 / 容量制約 + 教材趣旨)
  - ファイル名から管理事務所階層を抽出 (正規表現)
  - 14,463 POINT を行政界 sjoin (中山間判定)
  - L72 緊急輸送道路 1km バッファ → sjoin (空間インデックス必須)
  - 全体で ~30-50 秒目標 (1km バッファ計算が重い)

要件 T 準拠 (位置情報あり = 地図必須):
  - RQ1: 路線種別 点マップ + 管理事務所別 choropleth + 図郭密度バブル
  - RQ2: 道路台帳付図 + L72 緊急輸送道路 重ね合わせマップ + 1km バッファ
  - RQ3: PDF / CSV 容量比較 + 機械可読性スコアリング

要件 Q 準拠: 図 8+ / 表 11+ (3 RQ × 多角度: 構造×L72照合×公開設計)

データ仕様:
  - dataset 1445: 道路台帳付図 (2 リソース)
    - resource 93894: 道路台帳付図_2023-11-22 ZIP (~1.1GB, 14,463 PDF)
                       <b>本記事ではダウンロードしない</b>
    - resource 93895: 道路台帳付図_関係資料_2023-11-22 CSV (~3.4MB, 14,463 行 × 9 列)
  - CSV 列: 路線種, 路線名, 路線番号, 管理事務所名, 道路台帳図番,
            ファイル, 緯度, 経度, 利用規約
  - ライセンス: クリエイティブ・コモンズ表示 (CC-BY)

メモリ対策: Figure ごとに plt.close('all') で確実に解放。
"""
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
from shapely.geometry import Point, LineString
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
from matplotlib.patches import Patch

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

t_all = time.time()
print("=== L77 道路台帳付図 単独 3 研究例分析 ===", flush=True)

# =============================================================================
# 0. 定数・パス
# =============================================================================
TARGET_CRS = "EPSG:6671"
DATA_DIR = ROOT / "data" / "extras" / "L77_road_register"
DATA_DIR.mkdir(parents=True, exist_ok=True)
DATASET_ID = 1445
RESOURCE_ID_CSV = 93895   # 関係資料 CSV (3.4MB) — 本記事の主データ
RESOURCE_ID_ZIP = 93894   # ZIP 本体 (1.1GB) — 取得しない
RELATIONS_CSV = DATA_DIR / "road_register_relations.csv"

# 行政界キャッシュ (L44 から)
ADMIN_GPKG = ROOT / "data" / "extras" / "L44_storm_surge" / "_cache" / "admin_diss.gpkg"

# L72 緊急輸送道路 (RQ2 で参照, cached JSON)
L72_DIR = (ROOT / "data" / "extras" / "L72_emergency_road"
           / "340006_emergency_transport_road_20220908T000000")

# バッファ
ROUTE_BUFFER_M = 1000.0       # 図郭 ↔ L72 緊急輸送道路 1km

# CITY_CD → 市町名 (L72 と共通)
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: "坂町",
    368: "安芸太田町", 369: "安芸太田町", 412: "北広島町",
    462: "世羅町", 545: "神石高原町", 543: "神石高原町",
}
CHUSANKAN_CITIES = {
    "庄原市", "三次市", "安芸太田町", "安芸高田市",
    "北広島町", "神石高原町", "世羅町", "府中市",
}
COASTAL_ISLAND = {"江田島市", "大崎上島町"}

def geo_class(name):
    if name in CHUSANKAN_CITIES:
        return "中山間山地"
    if name in COASTAL_ISLAND:
        return "沿岸島嶼"
    if not name or "不明" in name:
        return "その他/不明"
    return "平野・沿岸都市"

# 路線種の表示順 + 色
RANK_ORDER = ["国道", "主要地方道", "一般県道"]
RANK_COLOR = {
    "国道": "#cf222e",        # 赤 (高位)
    "主要地方道": "#0969da",  # 青
    "一般県道": "#1a7f37",    # 緑
}

# 管理事務所 → 上位区分 (西部/東部/北部/呉/廿日市/安芸太田/東広島/三原/庄原)
def office_top(name):
    s = str(name)
    if s.startswith("西部建設事務所東広島"): return "西部 東広島支所"
    if s.startswith("西部建設事務所安芸太田"): return "西部 安芸太田支所"
    if s.startswith("西部建設事務所廿日市"): return "西部 廿日市支所"
    if s.startswith("西部建設事務所呉"): return "西部 呉支所"
    if s.startswith("西部建設事務所"): return "西部 本所"
    if s.startswith("東部建設事務所三原"): return "東部 三原支所"
    if s.startswith("東部建設事務所"): return "東部 本所"
    if s.startswith("北部建設事務所庄原"): return "北部 庄原支所"
    if s.startswith("北部建設事務所"): return "北部 本所"
    return "その他"

OFFICE_ORDER = [
    "西部 本所", "西部 呉支所", "西部 廿日市支所",
    "西部 安芸太田支所", "西部 東広島支所",
    "東部 本所", "東部 三原支所",
    "北部 本所", "北部 庄原支所",
]
OFFICE_COLOR = {
    "西部 本所": "#0969da", "西部 呉支所": "#3d8bfd",
    "西部 廿日市支所": "#6cb2ff", "西部 安芸太田支所": "#aad4ff",
    "西部 東広島支所": "#0a4f9e",
    "東部 本所": "#cf222e", "東部 三原支所": "#e8869b",
    "北部 本所": "#1a7f37", "北部 庄原支所": "#a4d8b3",
}

# 図郭番号接頭辞 (D=詳細図, N=基本図, その他=特殊)
def fig_prefix(s):
    s = str(s).strip()
    if s.startswith("D"): return "D系 (詳細図/分図)"
    if s.startswith("N"): return "N系 (基本図/標準)"
    return "その他 (A/B/C/E/G/H/I 系)"

PREFIX_ORDER = ["N系 (基本図/標準)", "D系 (詳細図/分図)", "その他 (A/B/C/E/G/H/I 系)"]
PREFIX_COLOR = {
    "N系 (基本図/標準)": "#0969da",
    "D系 (詳細図/分図)": "#cf6f00",
    "その他 (A/B/C/E/G/H/I 系)": "#888888",
}


# =============================================================================
# 1. データ取得
# =============================================================================
print("\n[1] データ取得", flush=True)
t1 = time.time()
ensure_dataset(RELATIONS_CSV, resource_id=RESOURCE_ID_CSV, min_bytes=1_000_000,
               label="L77 道路台帳付図 関係資料 CSV")
csv_size = RELATIONS_CSV.stat().st_size
print(f"  関係資料 CSV: {csv_size:,} bytes", flush=True)
print(f"  ({time.time()-t1:.1f}s)", flush=True)


# =============================================================================
# 2. CSV → GeoDataFrame
# =============================================================================
print("\n[2] CSV 読込 → POINT", flush=True)
t2 = time.time()

df = pd.read_csv(RELATIONS_CSV, encoding="utf-8-sig")
df.columns = [c.strip() for c in df.columns]
df["路線番号"] = pd.to_numeric(df["路線番号"], errors="coerce")
df["seg_id"] = [f"L77_{i:05d}" for i in range(len(df))]
df["事務所区分"] = df["管理事務所名"].apply(office_top)
df["図番接頭辞"] = df["道路台帳図番"].apply(fig_prefix)

# 緯度経度欠損行除外
df = df.dropna(subset=["緯度", "経度"]).reset_index(drop=True)
geom = [Point(lon, lat) for lon, lat in zip(df["経度"], df["緯度"])]
gdf = gpd.GeoDataFrame(df, geometry=geom, crs="EPSG:4326").to_crs(TARGET_CRS)

n_pts = len(gdf)
n_routes = gdf.groupby(["路線種", "路線名"]).ngroups
n_offices = gdf["事務所区分"].nunique()
print(f"  図郭 (PDF 1 枚): {n_pts:,}", flush=True)
print(f"  路線数: {n_routes}", flush=True)
print(f"  事務所区分: {n_offices}", flush=True)
print(f"  ({time.time()-t2:.1f}s)", flush=True)


# =============================================================================
# 3. 行政界 sjoin → 市町判定 (CSV 住所無し → 空間判定のみ)
# =============================================================================
print("\n[3] 行政界 sjoin (空間判定)", flush=True)
t3 = time.time()
admin = gpd.read_file(ADMIN_GPKG).to_crs(TARGET_CRS)
admin["市町名"] = admin["CITY_CD"].map(CITY_NAME).fillna(
    admin["CITY_CD"].astype(str))

joined = gpd.sjoin(gdf[["seg_id", "geometry"]],
                   admin[["CITY_CD", "市町名", "geometry"]],
                   how="left", predicate="within")
joined = joined.drop_duplicates("seg_id")
city_map = joined.set_index("seg_id")["市町名"]
gdf["市町_空間"] = gdf["seg_id"].map(city_map).fillna("不明")
gdf["地理クラス"] = gdf["市町_空間"].apply(geo_class)
gdf["is_chusankan"] = gdf["地理クラス"] == "中山間山地"

n_chusankan = int(gdf["is_chusankan"].sum())
share_chusankan = round(100 * n_chusankan / n_pts, 1)
n_unknown = int((gdf["市町_空間"] == "不明").sum())
print(f"  中山間: {n_chusankan:,}/{n_pts:,} ({share_chusankan}%)", flush=True)
print(f"  市町判定不明 (県外/離島端): {n_unknown}", flush=True)
print(f"  ({time.time()-t3:.1f}s)", flush=True)


# =============================================================================
# 4. RQ1: 路線種 × 管理事務所 × 図番接頭辞 × 路線あたり図郭密度
# =============================================================================
print("\n[4] RQ1 集計 — データ構造", flush=True)
t4 = time.time()

# (1) 路線種別
T_rank = (gdf.groupby("路線種")
          .agg(図郭数=("seg_id", "count"),
               路線数=("路線名", "nunique"))
          .reset_index())
T_rank["図郭シェア_%"] = (T_rank["図郭数"] / n_pts * 100).round(2)
T_rank["路線シェア_%"] = (T_rank["路線数"] / n_routes * 100).round(2)
T_rank["路線あたり図郭密度"] = (T_rank["図郭数"] / T_rank["路線数"]).round(1)
T_rank["順"] = T_rank["路線種"].apply(
    lambda x: RANK_ORDER.index(x) if x in RANK_ORDER else 999)
T_rank = T_rank.sort_values("順").drop(columns="順").reset_index(drop=True)

# H1 / H2 判定
n_kokudo = int(T_rank.loc[T_rank["路線種"] == "国道", "図郭数"].iloc[0])
share_kokudo_route = float(T_rank.loc[T_rank["路線種"] == "国道", "路線シェア_%"].iloc[0])
share_kokudo_fig = float(T_rank.loc[T_rank["路線種"] == "国道", "図郭シェア_%"].iloc[0])
density_kokudo = float(T_rank.loc[T_rank["路線種"] == "国道",
                                    "路線あたり図郭密度"].iloc[0])
density_shuyo = float(T_rank.loc[T_rank["路線種"] == "主要地方道",
                                   "路線あたり図郭密度"].iloc[0])
density_kendo = float(T_rank.loc[T_rank["路線種"] == "一般県道",
                                   "路線あたり図郭密度"].iloc[0])

h1_ok = (share_kokudo_route < 10.0) and (share_kokudo_fig >= 20.0)
h2_ok = (density_kokudo >= density_shuyo >= density_kendo)

# (2) 管理事務所別
T_office = (gdf.groupby("事務所区分")
            .agg(図郭数=("seg_id", "count"),
                 路線数=("路線名", "nunique"),
                 路線種数=("路線種", "nunique"))
            .reset_index())
T_office["シェア_%"] = (T_office["図郭数"] / n_pts * 100).round(2)
T_office["順"] = T_office["事務所区分"].apply(
    lambda x: OFFICE_ORDER.index(x) if x in OFFICE_ORDER else 999)
T_office = T_office.sort_values("順").drop(columns="順").reset_index(drop=True)

# (3) 図番接頭辞別 (D/N/その他)
T_prefix = (gdf.groupby("図番接頭辞")
            .agg(図郭数=("seg_id", "count"))
            .reset_index())
T_prefix["シェア_%"] = (T_prefix["図郭数"] / n_pts * 100).round(2)
T_prefix["順"] = T_prefix["図番接頭辞"].apply(
    lambda x: PREFIX_ORDER.index(x) if x in PREFIX_ORDER else 999)
T_prefix = T_prefix.sort_values("順").drop(columns="順").reset_index(drop=True)

# (4) 路線あたり図郭数 Top 15 路線
T_route_top = (gdf.groupby(["路線種", "路線名"])
               .size().reset_index(name="図郭数"))
T_route_top = T_route_top.sort_values("図郭数", ascending=False).head(15).reset_index(drop=True)

# (5) 路線あたり図郭数 Bottom 10 (= 1 図郭支線等)
T_route_bot = (gdf.groupby(["路線種", "路線名"])
               .size().reset_index(name="図郭数"))
T_route_bot = T_route_bot.sort_values("図郭数").head(10).reset_index(drop=True)

# (6) 地理クラス別
T_geo = (gdf.groupby("地理クラス")
         .agg(図郭数=("seg_id", "count"),
              路線数=("路線名", "nunique"))
         .reset_index())
T_geo["シェア_%"] = (T_geo["図郭数"] / n_pts * 100).round(1)
T_geo = T_geo.sort_values("図郭数", ascending=False).reset_index(drop=True)

# (7) 路線種 × 図番接頭辞 クロス
T_rank_prefix = (gdf.groupby(["路線種", "図番接頭辞"])
                 .size().unstack(fill_value=0))
# 列順を整える
for c in PREFIX_ORDER:
    if c not in T_rank_prefix.columns:
        T_rank_prefix[c] = 0
T_rank_prefix = T_rank_prefix[PREFIX_ORDER]
T_rank_prefix = T_rank_prefix.reindex(RANK_ORDER).reset_index()

print(f"  路線種別: 国道 {n_kokudo:,} 図 ({share_kokudo_fig}%) "
      f"/ 路線シェア {share_kokudo_route}%", flush=True)
print(f"  路線あたり図郭密度: 国道 {density_kokudo} / 主要 {density_shuyo} "
      f"/ 県道 {density_kendo}", flush=True)
print(f"  H1 (国道 路線<10% かつ 図郭>=20%): {h1_ok}", flush=True)
print(f"  H2 (密度: 国道 >= 主要 >= 県道): {h2_ok}", flush=True)
print(f"  ({time.time()-t4:.1f}s)", flush=True)


# =============================================================================
# 5. RQ2: L72 緊急輸送道路との照合
# =============================================================================
print("\n[5] RQ2 集計 — L72 重要路線優先公開", flush=True)
t5 = time.time()

# L72 4 階層 を読込み (NDJSON 風)
L72_RANK = {
    "01": "第1次 (高速・幹線国道)",
    "02": "第2次 (主要地方道・国道)",
    "03": "第3次 (一般県道・市町道)",
    "04": "補完線 (補強路線)",
}
L72_RANK_ORDER = ["01", "02", "03", "04"]
L72_COLOR = {
    "01": "#cf222e", "02": "#0969da",
    "03": "#1a7f37", "04": "#cf6f00",
}

def load_l72_lines(idx):
    p = L72_DIR / f"05_kinkyu_route_{idx}.json"
    if not p.exists():
        return []
    with open(p, "r", encoding="utf-8") as f:
        text = f.read()
    arr = json.loads("[" + text + "]")
    lines = []
    for seg in arr:
        if isinstance(seg, list) and len(seg) >= 2:
            coords = [(pt["e"], pt["d"]) for pt in seg]
            lines.append(LineString(coords))
    return lines

l72_records = []
for idx in L72_RANK_ORDER:
    lines = load_l72_lines(idx)
    for ln in lines:
        l72_records.append({"rank": idx,
                             "rank_label": L72_RANK[idx],
                             "geometry": ln})
gdf_route = gpd.GeoDataFrame(l72_records, crs="EPSG:4326").to_crs(TARGET_CRS)
gdf_route["length_km"] = gdf_route.geometry.length / 1000
n_route = len(gdf_route)
total_km_route = float(gdf_route["length_km"].sum())
print(f"  L72: {n_route} セグ / {total_km_route:.0f} km", flush=True)

# 第 1〜2 次のみ (主要骨格)
gdf_route12 = gdf_route[gdf_route["rank"].isin(["01", "02"])].copy()
n_route12 = len(gdf_route12)
total_km_route12 = float(gdf_route12["length_km"].sum())

# 第 1〜2 次 1km バッファ (union_all で 1 つの multi-polygon)
print("  L72 第1〜2次 1km バッファ計算...", flush=True)
buf12 = gdf_route12.geometry.buffer(ROUTE_BUFFER_M).union_all()
print("  L72 全階層 1km バッファ計算...", flush=True)
buf_all = gdf_route.geometry.buffer(ROUTE_BUFFER_M).union_all()

# 14,463 POINT × multi-polygon の intersects は ~2-5s で完了
def in_buf(p, b):
    return 1 if p.intersects(b) else 0

print("  POINT × バッファ判定...", flush=True)
gdf["near_l72_12"] = gdf.geometry.apply(lambda p: in_buf(p, buf12))
gdf["near_l72_all"] = gdf.geometry.apply(lambda p: in_buf(p, buf_all))

n_near12 = int(gdf["near_l72_12"].sum())
share_near12 = round(100 * n_near12 / n_pts, 1)
n_near_all = int(gdf["near_l72_all"].sum())
share_near_all = round(100 * n_near_all / n_pts, 1)
h3_ok = share_near_all >= 60.0

# 路線種別 1km 圏内率
T_l72_rank = (gdf.groupby("路線種")
              .agg(図郭数=("seg_id", "count"),
                   L72_全1km圏内=("near_l72_all", "sum"),
                   L72_第12次1km圏内=("near_l72_12", "sum"))
              .reset_index())
T_l72_rank["全1km率_%"] = (
    100 * T_l72_rank["L72_全1km圏内"] / T_l72_rank["図郭数"]).round(1)
T_l72_rank["第12次1km率_%"] = (
    100 * T_l72_rank["L72_第12次1km圏内"] / T_l72_rank["図郭数"]).round(1)
T_l72_rank["順"] = T_l72_rank["路線種"].apply(
    lambda x: RANK_ORDER.index(x) if x in RANK_ORDER else 999)
T_l72_rank = T_l72_rank.sort_values("順").drop(columns="順").reset_index(drop=True)

rate_kokudo = float(T_l72_rank.loc[T_l72_rank["路線種"] == "国道",
                                     "全1km率_%"].iloc[0])
rate_shuyo = float(T_l72_rank.loc[T_l72_rank["路線種"] == "主要地方道",
                                    "全1km率_%"].iloc[0])
rate_kendo = float(T_l72_rank.loc[T_l72_rank["路線種"] == "一般県道",
                                    "全1km率_%"].iloc[0])
h4_ok = (rate_kokudo >= rate_shuyo >= rate_kendo)

# 4 カテゴリ (第1〜2次圏内 / 第3〜4次圏内のみ / 圏外)
gdf["l72_status"] = "圏外 (>1km)"
mask12 = gdf["near_l72_12"] == 1
mask_all = gdf["near_l72_all"] == 1
gdf.loc[mask_all & ~mask12, "l72_status"] = "第3〜4次 1km 圏内のみ"
gdf.loc[mask12, "l72_status"] = "第1〜2次 1km 圏内"

T_l72_status = (gdf.groupby("l72_status")
                .agg(図郭数=("seg_id", "count"))
                .reset_index())
T_l72_status["シェア_%"] = (T_l72_status["図郭数"] / n_pts * 100).round(1)
status_order = ["第1〜2次 1km 圏内", "第3〜4次 1km 圏内のみ", "圏外 (>1km)"]
T_l72_status["順"] = T_l72_status["l72_status"].apply(
    lambda x: status_order.index(x) if x in status_order else 999)
T_l72_status = T_l72_status.sort_values("順").drop(columns="順").reset_index(drop=True)

# 各図郭の最近接 L72 距離 (km) — sindex で高速化
print("  最近接距離計算 (sindex 活用)...", flush=True)
sindex_all = gdf_route.sindex
l72_geoms_all = list(gdf_route.geometry)

def nearest_km_sindex(p):
    minx, miny, maxx, maxy = p.x - 5000, p.y - 5000, p.x + 5000, p.y + 5000
    candidates = list(sindex_all.intersection((minx, miny, maxx, maxy)))
    if not candidates:
        # 5km 圏内に何もない: 全件で計算
        candidates = list(range(len(l72_geoms_all)))
    return min(p.distance(l72_geoms_all[i]) for i in candidates) / 1000

# 14,463 件の sindex 計算 → 約 5-10 秒
gdf["dist_l72_km"] = [nearest_km_sindex(p) for p in gdf.geometry]

# 距離分布の統計
dist_q = np.percentile(gdf["dist_l72_km"], [25, 50, 75, 90])
T_dist = pd.DataFrame([
    {"統計": "最小 (km)", "値": round(gdf['dist_l72_km'].min(), 3)},
    {"統計": "25%", "値": round(dist_q[0], 3)},
    {"統計": "中央 (50%)", "値": round(dist_q[1], 3)},
    {"統計": "75%", "値": round(dist_q[2], 3)},
    {"統計": "90%", "値": round(dist_q[3], 3)},
    {"統計": "最大 (km)", "値": round(gdf['dist_l72_km'].max(), 3)},
    {"統計": "平均 (km)", "値": round(gdf['dist_l72_km'].mean(), 3)},
])

print(f"  L72 全階層 1km 圏内: {n_near_all:,}/{n_pts:,} ({share_near_all}%)",
      flush=True)
print(f"  L72 第1〜2次 1km 圏内: {n_near12:,}/{n_pts:,} ({share_near12}%)",
      flush=True)
print(f"  路線種別 全1km率: 国道 {rate_kokudo}% / 主要 {rate_shuyo}% "
      f"/ 県道 {rate_kendo}%", flush=True)
print(f"  H3 (全L72 1km >=60%): {h3_ok}", flush=True)
print(f"  H4 (国道 >= 主要 >= 県道): {h4_ok}", flush=True)
print(f"  ({time.time()-t5:.1f}s)", flush=True)


# =============================================================================
# 6. RQ3: データ公開設計 — 機械可読性評価
# =============================================================================
print("\n[6] RQ3 集計 — 公開設計評価", flush=True)
t6 = time.time()

# データセット 2 リソースの仕様比較
ZIP_BYTES = 1_147_323_651
CSV_BYTES = csv_size  # 実測値
ratio = ZIP_BYTES / CSV_BYTES  # 大小比
share_csv_pct = round(100 * CSV_BYTES / (ZIP_BYTES + CSV_BYTES), 3)
h5_ok = ratio >= 300

# 機械可読性スコア (本記事独自指標)
# 評価軸: 表構造性 / 検索性 / 大規模解析容易性 / プログラム解析容易性 / 学習者再現性
# 5 軸 × 5 段階 (1=不可, 5=容易)
T_machine = pd.DataFrame([
    {"評価軸": "表構造性 (rows × cols)",
     "ZIP (PDF集合)": "1 (PDF は構造化されない)",
     "CSV (関係資料)": "5 (14,463 行 × 9 列)",
     "備考": "PDF は OCR + 帯状図解析が必要"},
    {"評価軸": "検索性 (= 路線・図番から1件特定)",
     "ZIP (PDF集合)": "2 (ファイル名検索のみ)",
     "CSV (関係資料)": "5 (DataFrame 1 行で特定)",
     "備考": "CSV からファイル名を引くと PDF にたどり着く設計"},
    {"評価軸": "大規模解析容易性 (= 全件統計集計)",
     "ZIP (PDF集合)": "1 (1.1GB / 14,463 PDF を順次解析)",
     "CSV (関係資料)": "5 (3.4MB / pandas で即時)",
     "備考": "ZIP 内 PDF を一括テキスト化するには数時間"},
    {"評価軸": "プログラム解析 (内容アクセス)",
     "ZIP (PDF集合)": "1 (図形・幅員等は PDF 中の図のみ)",
     "CSV (関係資料)": "3 (位置 + メタ情報のみ、 内容は無し)",
     "備考": "CSV は \"目次\"、 中身は別途必要"},
    {"評価軸": "学習者再現性 (1コマ授業内)",
     "ZIP (PDF集合)": "1 (DL 1.1GB は教室回線で 30 分超)",
     "CSV (関係資料)": "5 (DL 数秒)",
     "備考": "本記事は CSV のみで完結する設計"},
])

# 路線種別の図郭密度比較 (= 「どの路線がよく公開されているか」 = 公開設計の優先付け)
T_density_compare = T_rank[["路線種", "路線数", "図郭数",
                             "路線あたり図郭密度", "図郭シェア_%",
                             "路線シェア_%"]].copy()
T_density_compare = T_density_compare.rename(
    columns={"路線あたり図郭密度": "路線あたり平均図郭数"})

# 機械可読性総合評価 (本記事独自指標)
T_overall = pd.DataFrame([
    {"指標": "総ファイル数 (PDF)", "値": f"{n_pts:,} 図郭"},
    {"指標": "総路線数", "値": f"{n_routes} 路線"},
    {"指標": "ZIP 本体サイズ", "値": f"{ZIP_BYTES/1024**3:.2f} GB"},
    {"指標": "関係資料 CSV サイズ", "値": f"{CSV_BYTES/1024**2:.2f} MB"},
    {"指標": "ZIP/CSV サイズ比", "値": f"{ratio:.0f} 倍"},
    {"指標": "CSV / 全データ容量比", "値": f"{share_csv_pct}%"},
    {"指標": "PDF 1 枚あたり平均サイズ", "値": f"{ZIP_BYTES/n_pts/1024:.1f} KB"},
    {"指標": "L72 重要路線 1km 圏内", "値": f"{share_near_all}%"},
    {"指標": "中山間山地分布率", "値": f"{share_chusankan}%"},
    {"指標": "市町判定不明", "値": f"{n_unknown} 件"},
])

print(f"  ZIP/CSV 比: {ratio:.0f} 倍", flush=True)
print(f"  CSV シェア: {share_csv_pct}% (= 機械可読層)", flush=True)
print(f"  H5 (ZIP/CSV >= 300倍): {h5_ok}", flush=True)
print(f"  ({time.time()-t6:.1f}s)", flush=True)


# =============================================================================
# 7. CSV 出力
# =============================================================================
print("\n[7] CSV 出力", flush=True)
t7 = time.time()

df_out = gdf.drop(columns=["geometry"]).copy()
df_out.to_csv(ASSETS / "L77_all_figures.csv",
              index=False, encoding="utf-8-sig")

T_rank.to_csv(ASSETS / "L77_rank_summary.csv",
              index=False, encoding="utf-8-sig")
T_office.to_csv(ASSETS / "L77_office_summary.csv",
                index=False, encoding="utf-8-sig")
T_prefix.to_csv(ASSETS / "L77_prefix_summary.csv",
                index=False, encoding="utf-8-sig")
T_route_top.to_csv(ASSETS / "L77_route_top15.csv",
                   index=False, encoding="utf-8-sig")
T_route_bot.to_csv(ASSETS / "L77_route_bottom10.csv",
                   index=False, encoding="utf-8-sig")
T_geo.to_csv(ASSETS / "L77_geo_class.csv",
             index=False, encoding="utf-8-sig")
T_rank_prefix.to_csv(ASSETS / "L77_rank_x_prefix.csv",
                     index=False, encoding="utf-8-sig")
T_l72_rank.to_csv(ASSETS / "L77_l72_by_rank.csv",
                  index=False, encoding="utf-8-sig")
T_l72_status.to_csv(ASSETS / "L77_l72_status.csv",
                    index=False, encoding="utf-8-sig")
T_dist.to_csv(ASSETS / "L77_l72_distance_stats.csv",
              index=False, encoding="utf-8-sig")
T_machine.to_csv(ASSETS / "L77_machine_readability.csv",
                 index=False, encoding="utf-8-sig")
T_density_compare.to_csv(ASSETS / "L77_density_compare.csv",
                         index=False, encoding="utf-8-sig")
T_overall.to_csv(ASSETS / "L77_overall.csv",
                 index=False, encoding="utf-8-sig")

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


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

# 県全域 表示 bbox
XMIN, YMIN = -15000, -220000
XMAX, YMAX = 125000, -90000


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 (RQ1): 路線種別 点マップ ----
print("  fig1: 路線種別 点マップ", flush=True)
fig, ax = plt.subplots(figsize=(13, 8))
admin.plot(ax=ax, color="#fff8e8", edgecolor="#888",
           linewidth=0.4, alpha=0.55)
for rank in RANK_ORDER:
    sub = gdf[gdf["路線種"] == rank]
    if len(sub) == 0:
        continue
    sub.plot(ax=ax, color=RANK_COLOR[rank], markersize=2.5,
             alpha=0.55, edgecolor="none",
             label=f"{rank} ({len(sub):,})", zorder=4)
ax.set_xlim(XMIN, XMAX)
ax.set_ylim(YMIN, YMAX)
ax.set_aspect("equal")
ax.set_title(f"図 1 (RQ1): 路線種別 道路台帳付図 図郭分布 — "
             f"全 {n_pts:,} 図郭 / {n_routes} 路線 / 3 路線種",
             fontsize=11)
ax.set_xlabel("X (m, EPSG:6671)")
ax.set_ylabel("Y (m, EPSG:6671)")
ax.legend(loc="lower left", fontsize=10, title="路線種", markerscale=4)
plt.tight_layout()
save_fig("L77_fig1_rank_map.png")


# ---- 図 2 (RQ1): 管理事務所別 choropleth ----
print("  fig2: 管理事務所別 choropleth", flush=True)
office_count_map = T_office.set_index("事務所区分")["図郭数"].to_dict()
# 事務所ごとに図郭の bbox 中心 → 注釈
fig, ax = plt.subplots(figsize=(13, 8))
admin.plot(ax=ax, color="#fff8e8", edgecolor="#888",
           linewidth=0.4, alpha=0.55)
for office in OFFICE_ORDER:
    sub = gdf[gdf["事務所区分"] == office]
    if len(sub) == 0:
        continue
    sub.plot(ax=ax, color=OFFICE_COLOR.get(office, "#888"),
             markersize=2.0, alpha=0.55, edgecolor="none",
             label=f"{office} ({len(sub):,})", zorder=4)
ax.set_xlim(XMIN, XMAX)
ax.set_ylim(YMIN, YMAX)
ax.set_aspect("equal")
ax.set_title(f"図 2 (RQ1): 管理事務所区分別 道路台帳付図 図郭分布 — "
             f"9 事務所区分 / "
             f"最多 {T_office.iloc[T_office['図郭数'].idxmax()]['事務所区分']} "
             f"({int(T_office['図郭数'].max()):,} 図郭)",
             fontsize=10.5)
ax.set_xlabel("X (m, EPSG:6671)")
ax.set_ylabel("Y (m, EPSG:6671)")
ax.legend(loc="lower left", fontsize=9, ncol=1, title="管理事務所区分",
          markerscale=4)
plt.tight_layout()
save_fig("L77_fig2_office_map.png")


# ---- 図 3 (RQ1): 路線種 × 4 軸統計 (3 panels) ----
print("  fig3: 路線種 × 4 軸統計", flush=True)
fig, axes = plt.subplots(1, 3, figsize=(17, 5.5))

# 左: 路線種別 図郭シェア vs 路線シェア
ax = axes[0]
xs = np.arange(len(RANK_ORDER))
fig_share = [float(T_rank.loc[T_rank["路線種"] == r, "図郭シェア_%"].iloc[0])
             for r in RANK_ORDER]
route_share = [float(T_rank.loc[T_rank["路線種"] == r, "路線シェア_%"].iloc[0])
               for r in RANK_ORDER]
w = 0.35
ax.bar(xs - w/2, fig_share, w, color="#0969da",
       edgecolor="#333", linewidth=0.5, label="図郭シェア (%)")
ax.bar(xs + w/2, route_share, w, color="#cf6f00",
       edgecolor="#333", linewidth=0.5, label="路線シェア (%)")
for x, fv, rv in zip(xs, fig_share, route_share):
    ax.text(x - w/2, fv + 1, f"{fv}%", ha="center", fontsize=9,
            fontweight="bold", color="#0969da")
    ax.text(x + w/2, rv + 1, f"{rv}%", ha="center", fontsize=9,
            fontweight="bold", color="#cf6f00")
ax.set_xticks(xs)
ax.set_xticklabels(RANK_ORDER, fontsize=10)
ax.set_ylabel("シェア (%)")
ax.set_ylim(0, max(max(fig_share), max(route_share)) * 1.18)
ax.set_title(f"路線種別 図郭シェア vs 路線シェア\n"
             f"国道は路線 {share_kokudo_route}% で図郭 {share_kokudo_fig}%",
             fontsize=10.5)
ax.legend(fontsize=9)
ax.grid(True, axis="y", alpha=0.3)

# 中: 路線あたり図郭密度
ax = axes[1]
densities = [float(T_rank.loc[T_rank["路線種"] == r,
                                "路線あたり図郭密度"].iloc[0])
             for r in RANK_ORDER]
cols = [RANK_COLOR[r] for r in RANK_ORDER]
ax.bar(xs, densities, color=cols, edgecolor="#333", linewidth=0.5)
for x, v in zip(xs, densities):
    ax.text(x, v + max(densities) * 0.02, f"{v}",
            ha="center", fontsize=11, fontweight="bold")
ax.set_xticks(xs)
ax.set_xticklabels(RANK_ORDER, fontsize=10)
ax.set_ylabel("路線あたり平均図郭数")
ax.set_ylim(0, max(densities) * 1.18)
ax.set_title(f"路線種別 路線あたり図郭密度\n"
             f"国道 {density_kokudo} >= 主要 {density_shuyo} "
             f">= 県道 {density_kendo}",
             fontsize=10.5)
ax.grid(True, axis="y", alpha=0.3)

# 右: 図番接頭辞分布
ax = axes[2]
prefix_xs = np.arange(len(PREFIX_ORDER))
prefix_counts = [int(T_prefix.loc[T_prefix["図番接頭辞"] == p, "図郭数"].iloc[0])
                 if p in T_prefix["図番接頭辞"].values else 0
                 for p in PREFIX_ORDER]
prefix_cols = [PREFIX_COLOR[p] for p in PREFIX_ORDER]
ax.bar(prefix_xs, prefix_counts, color=prefix_cols,
       edgecolor="#333", linewidth=0.5)
for x, v in zip(prefix_xs, prefix_counts):
    if v > 0:
        ax.text(x, v + max(prefix_counts) * 0.02,
                f"{v:,}\n({100*v/n_pts:.1f}%)",
                ha="center", fontsize=10, fontweight="bold")
ax.set_xticks(prefix_xs)
ax.set_xticklabels([p.split(" ")[0] for p in PREFIX_ORDER], fontsize=10)
ax.set_ylabel("図郭数")
ax.set_title("図番接頭辞別 図郭数分布", fontsize=10.5)
ax.grid(True, axis="y", alpha=0.3)

fig.suptitle("図 3 (RQ1): 路線種別 × シェア × 密度 × 図番 — 4 角度構造",
             fontsize=12.5, y=1.02)
plt.tight_layout()
save_fig("L77_fig3_rq1_structure.png")


# ---- 図 4 (RQ1): 路線あたり図郭数 Top 15 + 分布 ----
print("  fig4: 路線あたり図郭数 Top 15 + 分布", flush=True)
fig, axes = plt.subplots(1, 2, figsize=(15, 6.5))

# 左: Top 15 路線
ax = axes[0]
labels = [f"{r['路線名']}\n({r['路線種']})"
          for _, r in T_route_top.iterrows()]
counts = T_route_top["図郭数"].astype(int).tolist()
cols = [RANK_COLOR[r] for r in T_route_top["路線種"].tolist()]
ax.barh(np.arange(len(labels)), counts, color=cols,
        edgecolor="#333", linewidth=0.4)
for i, v in enumerate(counts):
    ax.text(v + max(counts) * 0.01, i, f"{v}",
            va="center", fontsize=9, fontweight="bold")
ax.set_yticks(np.arange(len(labels)))
ax.set_yticklabels(labels, fontsize=8.5)
ax.invert_yaxis()
ax.set_xlabel("図郭数")
ax.set_title(f"路線あたり図郭数 Top 15\n"
             f"最大: {labels[0].split(chr(10))[0]} ({counts[0]} 図郭)",
             fontsize=10.5)
ax.grid(True, axis="x", alpha=0.3)

# 右: 路線あたり図郭数 ヒストグラム + 統計
ax = axes[1]
route_counts_all = (gdf.groupby(["路線種", "路線名"])
                    .size().reset_index(name="図郭数"))
for rank in RANK_ORDER:
    sub = route_counts_all[route_counts_all["路線種"] == rank]
    ax.hist(sub["図郭数"], bins=np.arange(0, 450, 15),
            color=RANK_COLOR[rank], alpha=0.6,
            edgecolor="#333", linewidth=0.4,
            label=f"{rank} (n={len(sub)})")
ax.set_xlabel("路線あたり図郭数")
ax.set_ylabel("路線数 (頻度)")
ax.set_title(f"路線あたり図郭数 ヒストグラム\n"
             f"中央値 {int(route_counts_all['図郭数'].median())} / "
             f"平均 {route_counts_all['図郭数'].mean():.1f} / "
             f"最大 {route_counts_all['図郭数'].max()}",
             fontsize=10.5)
ax.legend(fontsize=9)
ax.grid(True, axis="y", alpha=0.3)

plt.tight_layout()
save_fig("L77_fig4_route_density.png")


# ---- 図 5 (RQ2): 道路台帳付図 + L72 重ね合わせ ----
print("  fig5: 図郭 + L72 緊急輸送道路", flush=True)
fig, ax = plt.subplots(figsize=(13, 8))
admin.plot(ax=ax, color="#fff8e8", edgecolor="#888",
           linewidth=0.4, alpha=0.55)

# L72 を細い線で背景に (補完線→3次→2次→1次の順で重ねる)
for idx in ["04", "03", "02", "01"]:
    sub = gdf_route[gdf_route["rank"] == idx]
    if len(sub) == 0:
        continue
    lw = {"01": 1.5, "02": 1.0, "03": 0.6, "04": 0.4}[idx]
    sub.plot(ax=ax, color=L72_COLOR[idx], linewidth=lw,
             alpha=0.85, zorder=3,
             label=f"L72 {L72_RANK[idx]}")

# 道路台帳付図 図郭を圏内/圏外で色分け
sub_in = gdf[gdf["near_l72_all"] == 1]
sub_out = gdf[gdf["near_l72_all"] == 0]
sub_out.plot(ax=ax, color="#cf222e", markersize=2.5,
             alpha=0.6, edgecolor="none",
             label=f"L72 圏外 ({len(sub_out):,})", zorder=4)
sub_in.plot(ax=ax, color="#1a7f37", markersize=1.5,
            alpha=0.4, edgecolor="none",
            label=f"L72 1km 圏内 ({len(sub_in):,})", zorder=5)

ax.set_xlim(XMIN, XMAX)
ax.set_ylim(YMIN, YMAX)
ax.set_aspect("equal")
ax.set_title(f"図 5 (RQ2): 道路台帳付図 図郭 vs L72 緊急輸送道路 — "
             f"全 {n_pts:,} 図郭 / L72 1km 圏内 {share_near_all}%",
             fontsize=10.5)
ax.set_xlabel("X (m, EPSG:6671)")
ax.set_ylabel("Y (m, EPSG:6671)")
ax.legend(loc="lower left", fontsize=8.5, ncol=1, markerscale=3)
plt.tight_layout()
save_fig("L77_fig5_l72_overlay.png")


# ---- 図 6 (RQ2): 路線種別 L72 1km 圏内率 + 距離分布 ----
print("  fig6: 路線種別 L72 重なり + 距離ヒスト", flush=True)
fig, axes = plt.subplots(1, 2, figsize=(15, 6))

# 左: 路線種別 L72 1km 圏内率 (全階層 vs 第1〜2次)
ax = axes[0]
xs = np.arange(len(RANK_ORDER))
all_rates = [float(T_l72_rank.loc[T_l72_rank["路線種"] == r,
                                    "全1km率_%"].iloc[0])
             for r in RANK_ORDER]
top12_rates = [float(T_l72_rank.loc[T_l72_rank["路線種"] == r,
                                       "第12次1km率_%"].iloc[0])
               for r in RANK_ORDER]
w = 0.35
ax.bar(xs - w/2, all_rates, w, color="#1a7f37",
       edgecolor="#333", linewidth=0.5, label="L72 全階層 1km")
ax.bar(xs + w/2, top12_rates, w, color="#cf222e",
       edgecolor="#333", linewidth=0.5, label="L72 第1〜2次 1km")
for x, av, tv in zip(xs, all_rates, top12_rates):
    ax.text(x - w/2, av + 1, f"{av}%", ha="center", fontsize=9,
            fontweight="bold")
    ax.text(x + w/2, tv + 1, f"{tv}%", ha="center", fontsize=9,
            fontweight="bold")
ax.set_xticks(xs)
ax.set_xticklabels(RANK_ORDER, fontsize=10)
ax.set_ylabel("L72 1km 圏内率 (%)")
ax.set_ylim(0, 105)
ax.set_title(f"路線種別 L72 1km 圏内率 (全 vs 第1〜2次)\n"
             f"国道 {rate_kokudo}% / 主要 {rate_shuyo}% / 県道 {rate_kendo}%",
             fontsize=10.5)
ax.legend(fontsize=9, loc="upper right")
ax.grid(True, axis="y", alpha=0.3)

# 右: 距離分布ヒストグラム
ax = axes[1]
for rank in RANK_ORDER:
    sub = gdf[gdf["路線種"] == rank]
    ax.hist(np.clip(sub["dist_l72_km"], 0, 5),
            bins=np.arange(0, 5.1, 0.25),
            color=RANK_COLOR[rank], alpha=0.55,
            edgecolor="#333", linewidth=0.3,
            label=f"{rank} (n={len(sub):,})")
ax.axvline(1.0, color="#cf222e", linestyle="--", linewidth=1.5,
           label="1km 閾値")
ax.set_xlabel("最近接 L72 までの距離 (km, 5km で clip)")
ax.set_ylabel("図郭数 (頻度)")
ax.set_title(f"路線種別 L72 最近接距離分布\n"
             f"中央値 {gdf['dist_l72_km'].median():.2f} km / "
             f"平均 {gdf['dist_l72_km'].mean():.2f} km",
             fontsize=10.5)
ax.legend(fontsize=9)
ax.grid(True, axis="y", alpha=0.3)

plt.tight_layout()
save_fig("L77_fig6_l72_rate_distance.png")


# ---- 図 7 (RQ3): データ容量比較 + 機械可読性レーダー ----
print("  fig7: 容量比較 + 機械可読性", flush=True)
fig, axes = plt.subplots(1, 2, figsize=(15, 6.5))

# 左: 容量比較 (対数スケール)
ax = axes[0]
labels = ["ZIP 本体\n(PDF 14,463 枚)", "関係資料 CSV\n(14,463 行)"]
sizes_mb = [ZIP_BYTES / 1024**2, CSV_BYTES / 1024**2]
cols = ["#cf222e", "#1a7f37"]
bars = ax.bar(labels, sizes_mb, color=cols,
              edgecolor="#333", linewidth=0.5, log=True)
for b, s in zip(bars, sizes_mb):
    if s >= 100:
        text = f"{s:.0f} MB\n({s/1024:.2f} GB)"
    else:
        text = f"{s:.2f} MB"
    ax.text(b.get_x() + b.get_width()/2, s * 1.15, text,
            ha="center", fontsize=11, fontweight="bold")
ax.set_ylabel("ファイルサイズ (MB, 対数軸)")
ax.set_title(f"データセット 1445 リソース容量比較\n"
             f"ZIP/CSV 比 = {ratio:.0f} 倍 / "
             f"CSV シェア {share_csv_pct}%",
             fontsize=10.5)
ax.grid(True, axis="y", alpha=0.3, which="both")

# 右: 機械可読性スコア (ヒートマップ風)
ax = axes[1]
# Score を 1-5 に変換 (T_machine の文字列から先頭数字を取得)
score_zip = []
score_csv = []
axis_labels = []
for _, row in T_machine.iterrows():
    axis_labels.append(row["評価軸"].split(" (")[0])
    z = int(re.match(r"(\d)", row["ZIP (PDF集合)"]).group(1))
    c = int(re.match(r"(\d)", row["CSV (関係資料)"]).group(1))
    score_zip.append(z)
    score_csv.append(c)

ys = np.arange(len(axis_labels))
w = 0.35
ax.barh(ys - w/2, score_zip, w, color="#cf222e",
        edgecolor="#333", linewidth=0.4, label="ZIP (PDF集合)")
ax.barh(ys + w/2, score_csv, w, color="#1a7f37",
        edgecolor="#333", linewidth=0.4, label="CSV (関係資料)")
for y, sz, sc in zip(ys, score_zip, score_csv):
    ax.text(sz + 0.05, y - w/2, f"{sz}", va="center",
            fontsize=10, fontweight="bold")
    ax.text(sc + 0.05, y + w/2, f"{sc}", va="center",
            fontsize=10, fontweight="bold")
ax.set_yticks(ys)
ax.set_yticklabels(axis_labels, fontsize=9)
ax.invert_yaxis()
ax.set_xlim(0, 5.5)
ax.set_xlabel("機械可読性スコア (1=不可 〜 5=容易)")
ax.set_title("機械可読性 5 軸評価\nCSV 平均 "
             f"{np.mean(score_csv):.1f} vs ZIP 平均 {np.mean(score_zip):.1f}",
             fontsize=10.5)
ax.legend(fontsize=9, loc="lower right")
ax.grid(True, axis="x", alpha=0.3)

plt.tight_layout()
save_fig("L77_fig7_machine_readability.png")


# ---- 図 8 (RQ3): 中山間 vs 平野・沿岸 vs 沿岸島嶼 vs 不明 + 路線種クロス ----
print("  fig8: 地理クラス × 路線種", flush=True)
fig, axes = plt.subplots(1, 2, figsize=(15, 6))

# 左: 地理クラス別 図郭数
ax = axes[0]
gc_order = ["平野・沿岸都市", "中山間山地", "沿岸島嶼", "その他/不明"]
gc_present = [g for g in gc_order if g in T_geo["地理クラス"].values]
xs = np.arange(len(gc_present))
counts_geo = [int(T_geo[T_geo["地理クラス"] == g]["図郭数"].iloc[0])
              for g in gc_present]
gc_colors = ["#0969da", "#cf6f00", "#1a7f37", "#888"]
ax.bar(xs, counts_geo, color=gc_colors[:len(gc_present)],
       edgecolor="#333", linewidth=0.5)
for x, v in zip(xs, counts_geo):
    ax.text(x, v + max(counts_geo) * 0.02,
            f"{int(v):,}\n({100*v/n_pts:.1f}%)",
            ha="center", fontsize=10, fontweight="bold")
ax.set_xticks(xs)
ax.set_xticklabels(gc_present, rotation=15, fontsize=9.5)
ax.set_ylabel("図郭数")
ax.set_title(f"地理クラス別 図郭分布\n"
             f"中山間 {share_chusankan}% — 県道路網は中山間に厚く分布",
             fontsize=10.5)
ax.grid(True, axis="y", alpha=0.3)

# 右: 路線種 × 地理クラス クロス (積み上げ)
ax = axes[1]
cross = (gdf.groupby(["路線種", "地理クラス"])
         .size().unstack(fill_value=0))
for c in gc_order:
    if c not in cross.columns:
        cross[c] = 0
cross = cross[gc_order].reindex(RANK_ORDER)
xs = np.arange(len(RANK_ORDER))
bottom = np.zeros(len(RANK_ORDER))
for j, gc in enumerate(gc_order):
    vals = cross[gc].values
    ax.bar(xs, vals, bottom=bottom, color=gc_colors[j],
           edgecolor="#333", linewidth=0.4, label=gc)
    for x, v, b_acc in zip(xs, vals, bottom):
        if v > 100:
            ax.text(x, b_acc + v/2, f"{int(v):,}",
                    ha="center", va="center", fontsize=9,
                    color="white" if j < 2 else "#222",
                    fontweight="bold")
    bottom += vals
ax.set_xticks(xs)
ax.set_xticklabels(RANK_ORDER, fontsize=10)
ax.set_ylabel("図郭数")
ax.set_title("路線種 × 地理クラス クロス積み上げ", fontsize=10.5)
ax.legend(fontsize=9, loc="upper right")
ax.grid(True, axis="y", alpha=0.3)

plt.tight_layout()
save_fig("L77_fig8_geo_class.png")


print(f"  図 8 枚生成 ({time.time()-t8:.1f}s)", flush=True)


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

T_hyp = pd.DataFrame([
    {"仮説": "H1 (RQ1, 国道優先公開)",
     "閾値": "国道 路線<10% かつ 図郭>=20%",
     "実測": f"路線 {share_kokudo_route}% / 図郭 {share_kokudo_fig}%",
     "判定": "支持" if h1_ok else "不支持"},
    {"仮説": "H2 (RQ1, 階層的図郭密度)",
     "閾値": "国道 >= 主要 >= 県道",
     "実測": f"国道 {density_kokudo} / 主要 {density_shuyo} / 県道 {density_kendo}",
     "判定": "支持" if h2_ok else "不支持"},
    {"仮説": "H3 (RQ2, L72 重なり)",
     "閾値": "L72 1km 圏内 >= 60%",
     "実測": f"{share_near_all}%",
     "判定": "支持" if h3_ok else "不支持"},
    {"仮説": "H4 (RQ2, 路線種 → L72 階層)",
     "閾値": "国道 > 主要地方道 > 一般県道",
     "実測": f"{rate_kokudo}% > {rate_shuyo}% > {rate_kendo}%"
              if h4_ok else
              f"{rate_kokudo}% / {rate_shuyo}% / {rate_kendo}%",
     "判定": "支持" if h4_ok else "不支持"},
    {"仮説": "H5 (RQ3, 機械可読の非対称)",
     "閾値": "ZIP/CSV >= 300倍",
     "実測": f"{ratio:.0f} 倍 (CSV シェア {share_csv_pct}%)",
     "判定": "支持" if h5_ok else "不支持"},
])
T_hyp.to_csv(ASSETS / "L77_hypothesis_check.csv",
             index=False, encoding="utf-8-sig")
print(T_hyp.to_string(index=False))


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


def df_to_html(df, max_rows=None):
    if max_rows is not None:
        df = df.head(max_rows)
    return df.to_html(index=False, classes="datatable",
                      border=0, escape=False, na_rep="—")


# データセット仕様表
T_dataset = pd.DataFrame([
    {"項目": "データセット ID", "値": f"DoBoX #{DATASET_ID}"},
    {"項目": "データセット名", "値": "道路台帳付図"},
    {"項目": "公開組織", "値": "広島県 道路河川管理課"},
    {"項目": "リソース数", "値": "2 (ZIP 本体 + 関係資料 CSV)"},
    {"項目": "ライセンス", "値": "クリエイティブ・コモンズ表示 (CC-BY)"},
    {"項目": "更新日 (本記事時点)", "値": "2023-11-22"},
    {"項目": "対象路線種", "値": "国道 / 主要地方道 / 一般県道 (= 県管理 3 路線種)"},
    {"項目": "対象路線数", "値": f"{n_routes} 路線"},
    {"項目": "対象図郭数 (PDF)", "値": f"{n_pts:,} 図郭"},
    {"項目": "総延長 (推定)", "値": "県管理道路約 4,000 km (路線種別公開なし)"},
    {"項目": "ファイル形式", "値": "PDF (帯状図, 1:500〜1:1,000)"},
])

# ----- セクション 1: 学習目標と問い -----
sec1 = f"""
<div class="note">
  <b>本記事は単独データセット系 Format B</b>: 1 つのデータセット
  「道路台帳付図」 を 3 つの独立した研究角度 (RQ1 / RQ2 / RQ3) で並列に
  分析する。 RQ1 でデータ構造、 RQ2 で L72 緊急輸送道路との照合、
  RQ3 で公開設計を評価する 3 段階アプローチ。
</div>

<h3>独自用語の定義 (本記事限定)</h3>
<ul>
  <li><b>道路台帳付図 (法定用語)</b>: 道路法第 28 条が道路管理者に作成・保管を
      義務付ける道路台帳の<b>別冊資料の 1 つ</b>。 各路線の道路区域・幅員・
      構造・横断構成・占用物件位置等を縮尺 1:1,000 〜 1:500 程度の<b>帯状図</b>
      として記録した法定図面。 道路法施行規則第 4 条の 2 で記載項目が定められ、
      道路区域決定 / 工事 / 占用許可 / 境界確定 等の根拠図面として用いられる。</li>
  <li><b>路線 (= 道路法上の単位)</b>: 国道・県道は<b>路線番号</b>で識別される。
      例: 「国道 433 号」 は起点 (廿日市市) から終点 (倉敷市) までの一連を
      指す<b>1 路線</b>。 県管理は<b>{n_routes} 路線</b>。</li>
  <li><b>図郭 (Map Sheet, 本記事独自用法)</b>: 1 路線の延長を区切った 1 枚分の
      地図単位。 1 図郭 = 1 PDF = 1 緯度経度点 (= 図郭中心点) として CSV に
      記録される。 県管理道路全体で<b>{n_pts:,} 図郭</b>。</li>
  <li><b>帯状図</b>: 道路台帳付図の標準形式。 路線中心線に沿って細長く描かれる
      平面図 + 横断図合成形式。 1 図郭が概ね数百メートル区間を表す。</li>
  <li><b>横断構成</b>: 道路を横切る方向の幅員・構造 (車線幅 + 路肩 + 歩道 +
      法面 + 排水溝等)。 道路台帳付図には道路規格を表す横断構成図が含まれる。</li>
  <li><b>図番接頭辞 (本記事独自分類)</b>: 道路台帳図番の先頭 1 文字
      (D / N / その他)。 N 系 = 基本図/標準帯状図、 D 系 = 詳細図/分図 (交差点
      拡大図等) という解釈が一般的だが、 県側の公式定義は本データセット内には
      含まれない。 本記事独自の集計指標。</li>
  <li><b>機械可読性 (本記事独自指標)</b>: データが<b>プログラムで自動処理可能か</b>
      の度合い。 5 軸 × 5 段階で評価: 表構造性 / 検索性 / 大規模解析容易性 /
      プログラム解析 / 学習者再現性。</li>
  <li><b>公開設計 (本記事独自概念)</b>: データ提供側がどのような<b>形式・粒度・
      補助資料</b>でデータを公開するかという設計選択。 本データセットは
      「PDF 本体 (人間可読) + CSV メタ (機械可読)」 の<b>非対称 2 層公開</b>
      を採用している。</li>
  <li><b>1km 圏内 (本記事独自閾値)</b>: 図郭中心点が L72 緊急輸送道路の
      LineString から 1km 以内に位置すること。 1km は緊急輸送道路の影響圏
      (沿線アクセス可能範囲) として一般的な閾値。</li>
  <li><b>重要路線優先公開 (本記事独自仮説)</b>: 県の道路情報公開戦略が
      「防災重要路線 (= L72 緊急輸送道路) を優先的にデジタル化・オープン化する」
      という方針を取っているという仮説。 RQ2 で量的検証。</li>
  <li><b>L72 緊急輸送道路 (背景説明)</b>: 県地域防災計画で指定された 4 階層
      ({n_route} セグ / {total_km_route:.0f} km) の災害時生命線道路。 第 1 次
      (高速・幹線国道) + 第 2 次 (主要地方道・国道) が主要骨格 = {n_route12}
      セグ / {total_km_route12:.0f} km。 RQ2 で 1km バッファ参照。</li>
  <li><b>中山間山地 (本記事独自定義)</b>: 庄原市・三次市・安芸太田町・安芸高田市・
      北広島町・神石高原町・世羅町・府中市の 8 市町。 公式分類ではないが、 地形・
      人口密度から「中山間」 と一般に呼ばれる地域。 L72 / L73 / L74 / L75 / L76
      と同じ定義を採用。</li>
  <li><b>非対称 2 層公開 (本記事独自概念)</b>: ZIP 本体 ({ZIP_BYTES/1024**3:.2f}
      GB / PDF 集合 = 人間可読) と CSV 関係資料 ({CSV_BYTES/1024**2:.2f} MB / 表
      = 機械可読) のサイズが <b>{ratio:.0f} 倍</b>異なり、 機械可読層は本体の
      <b>{share_csv_pct}%</b>に過ぎない構造。 H5 で量的検証。</li>
</ul>

<h3>研究の問い (3 RQ)</h3>
<ol>
  <li><b>RQ1 (主研究):</b> 広島県の<b>道路台帳付図のデータ構造 — 公開単位
      (路線/図郭) × 路線種 × 図番接頭辞 × 管理事務所</b>はどう描けるか?
      {n_pts:,} 図郭を 4 軸で集計し、 「県の道路情報公開の物理形状」 を初めて
      系統的に記述する。 H1 (国道 路線&lt;10% かつ 図郭&gt;=20%) / H2
      (国道 &gt;= 主要 &gt;= 県道 の図郭密度) を検証。</li>
  <li><b>RQ2 (副研究 1):</b> 道路台帳付図と<b>L72 緊急輸送道路 ({n_route} セグ /
      {total_km_route:.0f} km) との照合 — 重要路線優先公開</b>はどう描けるか?
      14,463 図郭が L72 1km 圏内かを sjoin、 「防災重要路線優先公開」 仮説を
      量的検証する。 H3 (全L72 1km &gt;=60%) / H4 (国道 &gt; 主要 &gt; 県道 の
      L72 重なり率) を検証。</li>
  <li><b>RQ3 (副研究 2):</b> <b>データ公開設計 — 学習者の機械可読性と再利用
      可能性</b>はどう評価できるか? 1.1GB ZIP / 14,463 PDF / CSV 関係資料の
      3 層構造を学習者目線で評価し、 「PDF は人間可読だが機械可読性が低い」
      「CSV 関係資料は機械可読だが PDF 中身は不可」 の<b>非対称公開構造</b>を
      量的記述する。 H5 (ZIP/CSV &gt;=300倍) を検証。</li>
</ol>

<h3>仮説 (5)</h3>
<ul>
  <li><b>H1</b> (RQ1, 国道優先公開): 路線シェア&lt;5% かつ 図郭シェア&gt;=20%。
      国道は路線あたり図郭密度が高く、 詳細図化が優先される仮説。</li>
  <li><b>H2</b> (RQ1, 階層的図郭密度): 路線あたり平均図郭数が
      国道 &gt;= 主要 &gt;= 県道。 路線種が高位ほど 1 路線あたり詳細図郭が多い
      階層構造仮説。</li>
  <li><b>H3</b> (RQ2, L72 重なり): 道路台帳付図 1km 圏内 &gt;= 65%。 県の道路情報
      公開は<b>防災重要路線を優先</b>する制度方針仮説。</li>
  <li><b>H4</b> (RQ2, 路線種 → L72 階層): 路線種別 1km 圏内率は国道 &gt; 主要
      &gt; 県道。 路線種が高位ほど L72 と地理的に重なる階層構造仮説。</li>
  <li><b>H5</b> (RQ3, 機械可読の非対称): ZIP/CSV &gt;= 300 倍。 メタデータ
      (機械可読) は本体 (人間可読 PDF) の<b>0.3% 以下</b>であり、 学習者にとって
      機械的な中身解析は事実上不可能、 という非対称公開構造仮説。</li>
</ul>

<h3>到達点</h3>
<p>本記事を読み終えると、 (1) 県管理道路 {n_routes} 路線 / {n_pts:,} 図郭の<b>
データ構造</b>を路線種・管理事務所・図番接頭辞の 3 軸で完全に俯瞰、 (2) L72
緊急輸送道路との<b>1km 重なり率 {share_near_all}%</b>を路線種別に分解し
「重要路線優先公開」 の量的証拠を獲得、 (3) ZIP {ZIP_BYTES/1024**3:.2f} GB
/ CSV {CSV_BYTES/1024**2:.2f} MB の<b>非対称 2 層公開構造</b>を機械可読性
5 軸スコアで定量化、 という 3 段階の知識が獲得できる。 これにより県の<b>道路情報
オープンデータ戦略</b>の物理形状・防災連携・公開設計思想が研究者視点で見えるよう
になる。</p>
"""


# ----- セクション 2: 使用データ -----
sec2 = f"""
<p>本研究で使う <b>1 つの dataset (2 リソース)</b> を以下の表に示す。
本データセットは「PDF 本体 (1.1GB) + 関係資料 CSV (3.4MB)」 という<b>非対称
2 層構造</b>で公開されている。 本記事では<b>関係資料 CSV のみ</b>を主データ
として用い、 PDF 本体 (ZIP) は<b>取得しない</b> (1.1GB を学習者環境で扱うのは
非現実的、 また RQ1〜RQ3 の問いは CSV のメタ情報で完結するため)。</p>

<h3>データセット仕様</h3>
{df_to_html(T_dataset)}

<h3>2 リソースの内訳</h3>
<table>
<tr><th>リソース</th><th>形式</th><th>サイズ</th><th>役割</th><th>本記事での扱い</th></tr>
<tr><td>resource {RESOURCE_ID_ZIP} (道路台帳付図_2023-11-22)</td>
    <td>ZIP (PDF 集合)</td>
    <td>{ZIP_BYTES/1024**3:.2f} GB ({n_pts:,} PDF)</td>
    <td>帯状図本体 (人間可読)</td>
    <td>取得しない (1.1GB は教材外)</td></tr>
<tr><td>resource {RESOURCE_ID_CSV} (道路台帳付図_関係資料_2023-11-22)</td>
    <td>CSV (UTF-8 BOM)</td>
    <td>{CSV_BYTES/1024**2:.2f} MB ({n_pts:,} 行 × 9 列)</td>
    <td>路線・図番・位置のメタ表 (機械可読)</td>
    <td><b>本記事の主データ</b></td></tr>
</table>

<h3>関係資料 CSV の列定義</h3>
<table>
<tr><th>列名</th><th>例</th><th>意味</th></tr>
<tr><td>路線種</td><td>国道 / 主要地方道 / 一般県道</td>
    <td>道路法上の路線分類 (3 種)</td></tr>
<tr><td>路線名</td><td>４３３号 / 三原東城線</td>
    <td>路線の正式名称 (国道は番号、 県道は地名+地名形式)</td></tr>
<tr><td>路線番号</td><td>433 / 4032</td>
    <td>路線番号 (国道は同名の数字、 県道は 4000 番台)</td></tr>
<tr><td>管理事務所名</td><td>西部建設事務所 / 東部建設事務所三原支所</td>
    <td>路線を管理する県の出先機関 (9 区分)</td></tr>
<tr><td>道路台帳図番</td><td>D251-4 / N175 / N182-1</td>
    <td>図郭の識別番号 (D=詳細図, N=基本図, 後ろは連番)</td></tr>
<tr><td>ファイル</td><td>/01西部建設事務所/01r3433一般国道４３３号/...pdf</td>
    <td>ZIP 内 PDF への相対パス</td></tr>
<tr><td>緯度</td><td>34.80880</td>
    <td>図郭中心点の WGS84 緯度</td></tr>
<tr><td>経度</td><td>132.70933</td>
    <td>図郭中心点の WGS84 経度</td></tr>
<tr><td>利用規約</td><td>https://data.hiroshima-dobox.jp/.../利用規約.pdf</td>
    <td>本データの利用規約 PDF (全行で同一)</td></tr>
</table>

<h3>形式特性の注意点</h3>
<ul>
  <li><b>1 行 = 1 図郭 = 1 PDF</b>: CSV は ZIP 内 PDF へのインデックスとして
      機能。 行数 ({n_pts:,}) と PDF 数は完全に一致。</li>
  <li><b>緯度経度は図郭中心点</b>: 帯状図は線状の路線範囲を表すが、 CSV では
      代表点 1 点だけ提供。 「沿線 N km」 の形では公開されない。</li>
  <li><b>同一座標の重複</b>: <b>363 行</b>が他行と緯度経度完全一致。 これは
      D 系 (詳細図/分図) が N 系 (基本図) と同じ場所を別解像度で記録するため。</li>
  <li><b>路線重複</b>: 国道 432 号は<b>4 つの管理事務所</b>にまたがる (尾道〜
      広島〜浜田)。 1 路線 = 必ずしも 1 事務所ではない。</li>
  <li><b>路線番号の体系</b>: 国道 = 同名の数字 (433, 432, 375 等)、 主要地方道 =
      4-66 番台、 一般県道 = 4000 番台。</li>
  <li><b>停車場線の特殊事例</b>: 一般県道のうち「広停車場線」 「大竹停車場線」 等
      は<b>1 図郭のみ</b>の極短路線 (= JR 駅前広場の県道扱い)。 県管理路線
      {n_routes} のうち約 30 路線が 1〜3 図郭。</li>
  <li><b>更新時期は 2023-11-22</b>。 道路台帳は道路法上 5 年ごとの更新義務がある
      が、 オープンデータ更新は不定期。</li>
</ul>
"""


# ----- セクション 3: ダウンロード -----
sec3 = f"""
<p>本記事の再現に必要な<b>すべて</b>を直リンクで提供する。
HTML だけ読めば学習者が完全再現できることが目標 (要件 A)。</p>

<div class="warn">
  <b>注意: ZIP 本体 ({ZIP_BYTES/1024**3:.2f} GB) は本記事ではダウンロードしない。</b>
  本記事は<b>関係資料 CSV ({CSV_BYTES/1024**2:.2f} MB) のみ</b>で完結する設計。
  PDF 本体は学習者が個別の路線について実際の帯状図を見たい場合に手動で
  入手すること (DoBoX のページから DL ボタン)。
</div>

<h3>生データ (DoBoX 1 dataset, 2 リソース)</h3>
<ul class="kv">
  <li><b>dataset {DATASET_ID}</b>:
      <a href="https://hiroshima-dobox.jp/datasets/{DATASET_ID}" target="_blank">道路台帳付図</a></li>
  <li><a href="https://hiroshima-dobox.jp/resource_download/{RESOURCE_ID_CSV}"
        target="_blank">resource {RESOURCE_ID_CSV} (関係資料 CSV) 直 DL</a>
        — 約 {CSV_BYTES/1024**2:.2f} MB (UTF-8 BOM) <b>← 本記事の主データ</b></li>
  <li><a href="https://hiroshima-dobox.jp/resources/{RESOURCE_ID_ZIP}"
        target="_blank">resource {RESOURCE_ID_ZIP} (ZIP 本体) ページ</a>
        — 約 {ZIP_BYTES/1024**3:.2f} GB / {n_pts:,} PDF (本記事では未取得)</li>
</ul>

<h3>このスクリプト本体</h3>
<ul class="kv">
  <li><a href="L77_road_register.py" download>L77_road_register.py</a>
      — 1 ファイルで完結 (8 図 + 13+ 表生成)</li>
</ul>

<h3>中間 CSV (本記事生成、 再利用可)</h3>
<ul class="kv">
  <li><a href="assets/L77_all_figures.csv" download>L77_all_figures.csv</a>
      — 全 {n_pts:,} 図郭 (基本属性 + 派生列 + L72 結合 計 約 18 列)</li>
  <li><a href="assets/L77_rank_summary.csv" download>L77_rank_summary.csv</a>
      — 路線種別 集計 (RQ1)</li>
  <li><a href="assets/L77_office_summary.csv" download>L77_office_summary.csv</a>
      — 管理事務所別 集計 (RQ1)</li>
  <li><a href="assets/L77_prefix_summary.csv" download>L77_prefix_summary.csv</a>
      — 図番接頭辞別 集計 (RQ1)</li>
  <li><a href="assets/L77_route_top15.csv" download>L77_route_top15.csv</a>
      — 路線あたり図郭数 Top 15 (RQ1)</li>
  <li><a href="assets/L77_route_bottom10.csv" download>L77_route_bottom10.csv</a>
      — 路線あたり図郭数 Bottom 10 (RQ1)</li>
  <li><a href="assets/L77_geo_class.csv" download>L77_geo_class.csv</a>
      — 地理クラス別 集計 (RQ1)</li>
  <li><a href="assets/L77_rank_x_prefix.csv" download>L77_rank_x_prefix.csv</a>
      — 路線種 × 図番接頭辞 クロス (RQ1)</li>
  <li><a href="assets/L77_l72_by_rank.csv" download>L77_l72_by_rank.csv</a>
      — 路線種別 L72 1km 圏内率 (RQ2)</li>
  <li><a href="assets/L77_l72_status.csv" download>L77_l72_status.csv</a>
      — L72 階層別 1km 圏内 (RQ2)</li>
  <li><a href="assets/L77_l72_distance_stats.csv" download>L77_l72_distance_stats.csv</a>
      — L72 最近接距離分布統計 (RQ2)</li>
  <li><a href="assets/L77_machine_readability.csv" download>L77_machine_readability.csv</a>
      — 機械可読性 5 軸評価 (RQ3)</li>
  <li><a href="assets/L77_density_compare.csv" download>L77_density_compare.csv</a>
      — 路線種別 図郭密度比較 (RQ3)</li>
  <li><a href="assets/L77_overall.csv" download>L77_overall.csv</a>
      — 全体サマリ</li>
  <li><a href="assets/L77_hypothesis_check.csv" download>L77_hypothesis_check.csv</a>
      — H1〜H5 仮説検証結果</li>
</ul>

<h3>図 (PNG, 直 DL 可)</h3>
<ul class="kv">
  <li><a href="assets/L77_fig1_rank_map.png" download>fig1 路線種別 点マップ (RQ1)</a></li>
  <li><a href="assets/L77_fig2_office_map.png" download>fig2 管理事務所別 点マップ (RQ1)</a></li>
  <li><a href="assets/L77_fig3_rq1_structure.png" download>fig3 シェア×密度×図番 (RQ1)</a></li>
  <li><a href="assets/L77_fig4_route_density.png" download>fig4 路線あたり図郭密度 (RQ1)</a></li>
  <li><a href="assets/L77_fig5_l72_overlay.png" download>fig5 図郭 + L72 重ね合わせ (RQ2)</a></li>
  <li><a href="assets/L77_fig6_l72_rate_distance.png" download>fig6 路線種別 L72 1km率 + 距離 (RQ2)</a></li>
  <li><a href="assets/L77_fig7_machine_readability.png" download>fig7 容量比較 + 機械可読性 (RQ3)</a></li>
  <li><a href="assets/L77_fig8_geo_class.png" download>fig8 地理クラス × 路線種 クロス (RQ1)</a></li>
</ul>
"""


# ----- セクション 4: RQ1 -----
sec4_code = '''
import pandas as pd
import geopandas as gpd
from shapely.geometry import Point

# (1) 関係資料 CSV を読込み (UTF-8 BOM, 14,463 行 × 9 列)
df = pd.read_csv("data/extras/L77_road_register/road_register_relations.csv",
                 encoding="utf-8-sig")
df.columns = [c.strip() for c in df.columns]

# (2) 管理事務所名 → 上位 9 区分への正規化 (本記事独自分類)
def office_top(name):
    s = str(name)
    if s.startswith("西部建設事務所東広島"): return "西部 東広島支所"
    if s.startswith("西部建設事務所安芸太田"): return "西部 安芸太田支所"
    if s.startswith("西部建設事務所廿日市"): return "西部 廿日市支所"
    if s.startswith("西部建設事務所呉"): return "西部 呉支所"
    if s.startswith("西部建設事務所"): return "西部 本所"
    if s.startswith("東部建設事務所三原"): return "東部 三原支所"
    if s.startswith("東部建設事務所"): return "東部 本所"
    if s.startswith("北部建設事務所庄原"): return "北部 庄原支所"
    if s.startswith("北部建設事務所"): return "北部 本所"
    return "その他"
df["事務所区分"] = df["管理事務所名"].apply(office_top)

# (3) 図番接頭辞 (D/N/その他) を判定 (本記事独自分類)
def fig_prefix(s):
    s = str(s).strip()
    if s.startswith("D"): return "D系 (詳細図/分図)"
    if s.startswith("N"): return "N系 (基本図/標準)"
    return "その他 (A/B/C/E/G/H/I 系)"
df["図番接頭辞"] = df["道路台帳図番"].apply(fig_prefix)

# (4) GeoDataFrame 化 + EPSG:6671 投影変換 (距離計算で正確になる)
geom = [Point(lon, lat) for lon, lat in zip(df["経度"], df["緯度"])]
gdf = gpd.GeoDataFrame(df, geometry=geom,
                        crs="EPSG:4326").to_crs("EPSG:6671")

# (5) 路線種別 4 軸集計
T_rank = (gdf.groupby("路線種")
          .agg(図郭数=("道路台帳図番", "count"),
               路線数=("路線名", "nunique"))
          .reset_index())
T_rank["路線あたり図郭密度"] = (T_rank["図郭数"] / T_rank["路線数"]).round(1)
T_rank["図郭シェア_%"] = (T_rank["図郭数"] / len(gdf) * 100).round(2)
T_rank["路線シェア_%"] = (T_rank["路線数"]
                           / gdf.groupby(["路線種","路線名"]).ngroups
                           * 100).round(2)
print(T_rank)

# (6) 管理事務所別 + 図番接頭辞別
print(gdf.groupby("事務所区分").size())
print(gdf.groupby("図番接頭辞").size())
'''

sec4 = f"""
<h3>狙い (RQ1)</h3>
<p>RQ1 では<b>「県の道路情報公開の物理形状」</b>を初めて系統的に記述する。
具体的には {n_pts:,} 図郭 / {n_routes} 路線を<b>路線種 × 管理事務所 × 図番
接頭辞 × 路線あたり図郭密度</b>の 4 軸で集計し、 「どの路線種にどれだけの
図郭があり、 どの事務所が管理し、 どの図番が多いか」 を 1 枚で俯瞰できる
ようにする。 H1 (国道 路線&lt;10% かつ 図郭&gt;=20%) は「国道は路線数では
少数派だが図郭としては優先公開される」 仮説、 H2 (国道 &gt;= 主要 &gt;= 県道
の図郭密度) は「路線種 = 整備優先度」 という階層構造を量的検証する。</p>

<h3>手法 — 4 ステップ</h3>
<ol>
  <li><b>STEP 1: CSV パース + 列名正規化</b><br>
      関係資料 CSV (UTF-8 BOM, {n_pts:,} 行 × 9 列) を <code>read_csv()</code> で
      読込み、 列名前後の空白を除去。 「路線番号」 を <code>pd.to_numeric()</code> で
      整数化。 緯度経度欠損行 (今回は 0 件) を除外。</li>
  <li><b>STEP 2: 管理事務所名を 9 区分に正規化 (本記事独自分類)</b><br>
      CSV の「管理事務所名」 列は<b>9 種類のラベル</b>を含む (西部本所 / 西部呉支所
      / 西部廿日市支所 / 西部安芸太田支所 / 西部東広島支所 / 東部本所 / 東部三原
      支所 / 北部本所 / 北部庄原支所)。 文字列先頭マッチで頑健に分類する独自関数
      <code>office_top()</code> を実装。</li>
  <li><b>STEP 3: 図番接頭辞 (D/N/その他) を判定 (本記事独自分類)</b><br>
      道路台帳図番の先頭 1 文字 (D / N / A / B / C / E / G / H / I) から
      <b>D 系 (詳細図/分図) / N 系 (基本図/標準) / その他</b>の 3 分類に集計。
      D 系 = 詳細図、 N 系 = 基本図 という解釈は本記事独自で、 公式定義は
      データセット内に含まれない。</li>
  <li><b>STEP 4: GeoDataFrame 化 + EPSG:6671 投影 + 4 軸集計</b><br>
      緯度経度 → <code>shapely.geometry.Point</code> → GeoDataFrame に変換、
      <code>to_crs("EPSG:6671")</code> で平面直角第 III 系に投影 (距離計算で
      正確になる)。 その後、 路線種・管理事務所・図番接頭辞・路線種×図番接頭辞
      の 4 集計を <code>groupby()</code> で生成。</li>
</ol>

<h3>実装</h3>
<p>狙いと方法を踏まえた実装コードは以下の通り。 列名 (路線種・路線名・路線番号・
管理事務所名・道路台帳図番・ファイル・緯度・経度) は CSV からそのまま使い、
独自分類列のみ追加する設計とした。</p>
{code(sec4_code)}

<h3>結果と読み取り</h3>

<h4>(a) 路線種別 4 軸集計</h4>
<p><b>なぜこの表か</b>: 路線種 (国道 / 主要地方道 / 一般県道) ごとの<b>図郭シェア
vs 路線シェア vs 路線あたり図郭密度</b>を一覧することで、 「公開の偏り」 を
路線数と図郭数の<b>不一致</b>として量的に把握する。</p>

{df_to_html(T_rank.drop(columns=[c for c in ["順"] if c in T_rank.columns]))}

<p><b>表から読み取れること</b>:</p>
<ul>
  <li>国道は<b>{int(T_rank.loc[T_rank['路線種']=='国道','路線数'].iloc[0])} 路線
      ({share_kokudo_route}%)</b>と少数派ながら、 図郭は<b>{n_kokudo:,} 図 ({share_kokudo_fig}%)</b>を占める。
      H1 = 国道優先公開仮説は<b>{'支持' if h1_ok else '不支持'}</b>。</li>
  <li>路線あたり図郭密度は<b>国道 {density_kokudo} ≫ 主要地方道 {density_shuyo}
      ≫ 一般県道 {density_kendo}</b> と、 路線種が高位ほど 1 路線あたり図郭が
      <b>多い</b>という階層構造が明確に見える。 H2 = 階層的図郭密度仮説は
      <b>{'支持' if h2_ok else '不支持'}</b>。</li>
  <li>一般県道は<b>{int(T_rank.loc[T_rank['路線種']=='一般県道','路線数'].iloc[0])} 路線</b>と
      最多だが、 1 路線あたり平均 {density_kendo} 図郭と<b>支線型</b>。 停車場線等の
      1 図郭路線が多数含まれることが原因 (後の Bottom 10 で詳述)。</li>
</ul>

<h4>(b) 管理事務所別 (図 1, 図 2)</h4>
<p><b>なぜこの図か</b>: 14,463 図郭の<b>地理分布</b>を路線種別 / 管理事務所別
の 2 視点で示すことで、 「どの地域・どの組織が管理しているか」 という
<b>行政区分の物理形状</b>を可視化する。 棒グラフではなく地図にする理由は、
県管理道路網が<b>沿岸都市部に集中・中山間山地に薄く広がる</b>という分布特性を
1 枚で示すため (要件 T)。</p>

{figure("assets/L77_fig1_rank_map.png",
        f"図 1: 路線種別 道路台帳付図 図郭分布 (国道 赤 / 主要地方道 青 / 一般県道 緑)")}

<p><b>図 1 から読み取れること</b>:</p>
<ul>
  <li>国道 (赤) は<b>幹線路線</b>として県内をほぼ網羅。 中国山地を東西に貫く
      国道 432・433 号、 南北を貫く 375 号などが目立つ線として浮かび上がる。</li>
  <li>主要地方道 (青) は<b>都市間連絡路線</b>として国道網の隙間を埋める形で分布。
      尾道〜世羅、 広島〜三次など。</li>
  <li>一般県道 (緑) は<b>支線・集落間連絡</b>として面的に分布し、 中山間山地の
      集落道路網を構成する。</li>
</ul>

{figure("assets/L77_fig2_office_map.png",
        f"図 2: 管理事務所区分別 道路台帳付図 図郭分布 (9 区分)")}

<p>{df_to_html(T_office)}</p>

<p><b>図 2 / 表から読み取れること</b>:</p>
<ul>
  <li>最多の管理事務所は<b>東部本所 ({int(T_office['図郭数'].max()):,} 図郭)</b>で
      福山市周辺 + 内陸部を担当。</li>
  <li>北部建設事務所庄原支所 ({int(T_office.loc[T_office['事務所区分']=='北部 庄原支所','図郭数'].iloc[0]):,} 図郭) と
      西部建設事務所安芸太田支所 ({int(T_office.loc[T_office['事務所区分']=='西部 安芸太田支所','図郭数'].iloc[0]):,} 図郭)
      は<b>中山間山地</b>を担当し、 路線数の割に図郭数が多い (= 山道は曲がりが多く
      帯状図 1 枚あたりの距離が短い)。</li>
  <li>西部建設事務所呉支所は{int(T_office.loc[T_office['事務所区分']=='西部 呉支所','図郭数'].iloc[0]):,} 図郭で
      島嶼部 (江田島・倉橋等) を含むため、 短い循環線が多い特徴を持つ。</li>
</ul>

<h4>(c) シェア × 密度 × 図番 3 角度 (図 3)</h4>
<p><b>なぜこの図か</b>: 路線種別の<b>図郭シェア vs 路線シェア</b>の<b>不一致</b>を
左パネルで一目で示し、 中パネルで<b>密度の階層</b>、 右パネルで<b>図番接頭辞の
分布</b> (D 系 vs N 系) を見せる 3 連結。 H1, H2 + 図番判定の補強根拠。</p>

{figure("assets/L77_fig3_rq1_structure.png",
        f"図 3: 路線種 × 4 軸構造 (シェア×密度×図番)")}

{df_to_html(T_prefix.drop(columns=[c for c in ['順'] if c in T_prefix.columns]))}

<p><b>図 3 / 表から読み取れること</b>:</p>
<ul>
  <li>左パネル: 国道は<b>路線シェア {share_kokudo_route}%</b>に対し<b>図郭シェア
      {share_kokudo_fig}%</b> = <b>図郭側のシェアが約 4 倍</b>。 H1 が支持される
      物理的根拠。</li>
  <li>中パネル: 路線あたり図郭密度の階層 ({density_kokudo} / {density_shuyo}
      / {density_kendo}) が明確。 H2 支持。</li>
  <li>右パネル: <b>N 系 (基本図) が {int(T_prefix.loc[T_prefix['図番接頭辞']=='N系 (基本図/標準)','図郭数'].iloc[0]):,} 図 ({float(T_prefix.loc[T_prefix['図番接頭辞']=='N系 (基本図/標準)','シェア_%'].iloc[0])}%)</b>
      で支配的。 D 系 (詳細図/分図) は{int(T_prefix.loc[T_prefix['図番接頭辞']=='D系 (詳細図/分図)','図郭数'].iloc[0]):,} 図のみ
      = 県管理道路網の大半は標準帯状図 1 枚で済み、 詳細分図は限定的。</li>
</ul>

<h4>(d) 路線あたり図郭数 分布 (図 4)</h4>
<p><b>なぜこの図か</b>: 路線あたり図郭数の<b>分布形状</b>を Top 15 ランキング +
ヒストグラムの 2 視点で示すことで、 「ロングテール vs ヘビーテール」 の判別と、
路線種別の分布形状の違い (= 整備優先度の階層) を量的に把握する。</p>

{figure("assets/L77_fig4_route_density.png",
        f"図 4: 路線あたり図郭数 Top 15 + ヒストグラム")}

<p>{df_to_html(T_route_top)}</p>

<p>{df_to_html(T_route_bot)}</p>

<p><b>図 4 / 表から読み取れること</b>:</p>
<ul>
  <li>Top 15 のうち<b>{(T_route_top['路線種']=='国道').sum()} 件が国道</b>で、
      最大は<b>国道 375 号 (424 図郭)</b>。 国道 375 号は<b>南北縦貫国道</b>で
      呉市から島根県境まで延長 200km 超を{int(T_route_top.iloc[0]['図郭数'])} 枚の
      帯状図でカバー。</li>
  <li>Bottom 10 はすべて<b>「〇〇停車場線」</b>の支線型一般県道で、 1 路線 = 1
      図郭。 これは JR 駅前広場〜駅前ロータリーの<b>数百メートル区間</b>を県道
      指定した最短路線。</li>
  <li>右ヒストグラム: 一般県道 (緑) は<b>1〜30 図郭の左側</b>に集中、 国道 (赤) は
      <b>100〜400 図郭の右側</b>に分布。 路線種ごとに分布形状が完全に異なる
      二峰性 (= 階層構造の量的可視化)。</li>
</ul>

<h4>(e) 地理クラス × 路線種 クロス (図 8)</h4>
<p><b>なぜこの図か</b>: 路線種別の図郭分布が<b>地理 (平野 / 中山間 / 沿岸島嶼)
ごとにどう違うか</b>を積み上げ棒グラフで示し、 「中山間に多い路線種 vs 平野
都市部に多い路線種」 の偏在を量的に明らかにする。</p>

{figure("assets/L77_fig8_geo_class.png",
        f"図 8: 地理クラス × 路線種 クロス積み上げ")}

<p>{df_to_html(T_geo)}</p>

<p><b>図 8 / 表から読み取れること</b>:</p>
<ul>
  <li>中山間山地は<b>{n_chusankan:,} 図郭 ({share_chusankan}%)</b>で、 平野・沿岸
      都市部 ({int(T_geo.loc[T_geo['地理クラス']=='平野・沿岸都市','図郭数'].iloc[0]):,} 図郭) に次ぐ第 2 位。
      面積では中山間が県の約半分だが、 図郭シェアでは平野部に劣る = 平野部の
      路線網の方が密。</li>
  <li>路線種の地理偏り: 国道は中山間にも比較的多く分布、 一般県道は<b>平野部の
      支線</b>として分布が偏る傾向 (右パネル)。</li>
</ul>
"""


# ----- セクション 5: RQ2 -----
sec5_code = '''
import json
import geopandas as gpd
from shapely.geometry import LineString

# (1) L72 緊急輸送道路 NDJSON 風 JSON を 4 階層分読込み
def load_l72_lines(idx):
    p = f"data/extras/L72_emergency_road/.../05_kinkyu_route_{idx}.json"
    with open(p, "r", encoding="utf-8") as f:
        text = f.read()
    arr = json.loads("[" + text + "]")  # NDJSON 風 → JSON 配列
    lines = []
    for seg in arr:
        if isinstance(seg, list) and len(seg) >= 2:
            coords = [(pt["e"], pt["d"]) for pt in seg]
            lines.append(LineString(coords))
    return lines

l72_records = []
for idx in ["01", "02", "03", "04"]:
    for ln in load_l72_lines(idx):
        l72_records.append({"rank": idx, "geometry": ln})
gdf_route = gpd.GeoDataFrame(l72_records, crs="EPSG:4326").to_crs("EPSG:6671")

# (2) 第 1〜2 次 1km バッファ + 全階層 1km バッファ
gdf_route12 = gdf_route[gdf_route["rank"].isin(["01", "02"])]
buf12 = gdf_route12.geometry.buffer(1000).union_all()
buf_all = gdf_route.geometry.buffer(1000).union_all()

# (3) 14,463 図郭 POINT × multi-polygon の intersects 判定
gdf["near_l72_12"] = gdf.geometry.apply(lambda p: 1 if p.intersects(buf12) else 0)
gdf["near_l72_all"] = gdf.geometry.apply(lambda p: 1 if p.intersects(buf_all) else 0)

# (4) 路線種別 1km 圏内率
T_l72_rank = (gdf.groupby("路線種")
              .agg(図郭数=("seg_id", "count"),
                   L72_全1km圏内=("near_l72_all", "sum"),
                   L72_第12次1km圏内=("near_l72_12", "sum"))
              .reset_index())
T_l72_rank["全1km率_%"] = (100 * T_l72_rank["L72_全1km圏内"]
                              / T_l72_rank["図郭数"]).round(1)
print(T_l72_rank)

# (5) 各図郭の最近接 L72 距離 (sindex で高速化)
sindex = gdf_route.sindex
geoms = list(gdf_route.geometry)
def nearest_km(p):
    bbox = (p.x-5000, p.y-5000, p.x+5000, p.y+5000)
    cand = list(sindex.intersection(bbox)) or list(range(len(geoms)))
    return min(p.distance(geoms[i]) for i in cand) / 1000
gdf["dist_l72_km"] = [nearest_km(p) for p in gdf.geometry]
'''

sec5 = f"""
<h3>狙い (RQ2)</h3>
<p>RQ2 では<b>「重要路線優先公開」</b>仮説を量的検証する。 県地域防災計画で
指定された<b>L72 緊急輸送道路 ({n_route} セグ / {total_km_route:.0f} km)</b>を
基準線として、 道路台帳付図 {n_pts:,} 図郭が L72 の 1km 圏内にどれだけ重なるか
を空間結合で量化する。 H3 (全L72 1km &gt;=60%) は中心仮説、 H4 (国道 &gt; 主要
&gt; 県道 の重なり率) は「路線種 = L72 重要度」 の階層構造仮説。</p>

<h3>手法 — 5 ステップ</h3>
<ol>
  <li><b>STEP 1: L72 NDJSON 風 JSON のパース</b><br>
      L72 の 4 階層 JSON は<b>厳密な JSON ではなく、 dict / array が「,」 区切りで
      並ぶ NDJSON 風</b>形式 (= JSON.parse 不可)。 テキストを <code>[</code> と
      <code>]</code> でラップしてから <code>json.loads</code> でパースする工夫が必要。</li>
  <li><b>STEP 2: LineString GeoDataFrame の構築</b><br>
      各セグメントの点列 ({{e: 経度, d: 緯度}}) を <code>shapely.LineString</code> に
      変換、 階層 ID + GeoDataFrame に統合 → <code>to_crs("EPSG:6671")</code> で
      平面直角第 III 系へ投影 (距離が m 単位で正確になる)。</li>
  <li><b>STEP 3: 1km バッファの生成</b><br>
      第 1〜2 次のみ ({n_route12} セグ) と全階層 ({n_route} セグ) について
      <code>buffer(1000)</code> を適用 → <code>union_all()</code> で 1 つの
      multi-polygon に統合。 14,463 POINT × 1 ポリゴンの intersects は
      <b>~5 秒</b>で完了する設計。</li>
  <li><b>STEP 4: 14,463 図郭 × バッファ判定</b><br>
      各 POINT に対し <code>p.intersects(buf)</code> で True/False を判定、
      0/1 列として gdf に追加。 これで「L72 1km 圏内」 フラグが完成。</li>
  <li><b>STEP 5: 最近接 L72 距離 (sindex 活用)</b><br>
      各図郭の<b>最近接 L72 セグメントまでの距離 (km)</b>を計算。 全 14,463 ×
      {n_route} セグメントの全件距離計算は ~3 分かかるため、 <code>sindex</code>
      で 5km bbox に絞ってから <code>p.distance(line)</code> を計算する高速化を
      実装 (~10 秒以内)。</li>
</ol>

<h3>実装</h3>
<p>狙いと方法を踏まえた実装コードは以下の通り。 NDJSON 風パース + バッファ
intersects + sindex 高速距離計算の 3 段構成。</p>
{code(sec5_code)}

<h3>結果と読み取り</h3>

<h4>(a) 図郭 + L72 重ね合わせマップ (図 5)</h4>
<p><b>なぜこの図か</b>: L72 の 4 階層 (赤 / 青 / 緑 / 橙) を背景線として描画し、
道路台帳付図 14,463 図郭を<b>L72 1km 圏内 (緑) vs 圏外 (赤)</b>で色分け点で
重ね合わせることで、 「重要路線優先公開」 仮説の<b>空間的真偽</b>を 1 枚で示す。
棒グラフではなく重ね合わせマップにする理由は、 「圏内・圏外の図郭がどこに
分布するか」 という地理的偏在を見るため (要件 T)。</p>

{figure("assets/L77_fig5_l72_overlay.png",
        f"図 5: 道路台帳付図 図郭 vs L72 緊急輸送道路 重ね合わせ")}

<p>{df_to_html(T_l72_status.drop(columns=[c for c in ['順'] if c in T_l72_status.columns]))}</p>

<p><b>図 5 / 表から読み取れること</b>:</p>
<ul>
  <li>L72 第 1〜2 次 1km 圏内が<b>{n_near12:,} 図郭 ({share_near12}%)</b>、
      全階層 1km 圏内が<b>{n_near_all:,} 図郭 ({share_near_all}%)</b>。
      H3 (全L72 1km &gt;=60%) は<b>{'支持' if h3_ok else '不支持'}</b>。</li>
  <li>圏外 (赤) は中山間山地の支線・島嶼部の循環線が中心 = 防災重要路線から
      外れる<b>「ローカル道路網」</b>。 これらは緊急輸送道路から距離があるため
      災害時の支援アクセスが遠い特徴を持つ。</li>
  <li>圏内 (緑) は L72 沿線に厚く分布 = 県の道路情報公開と防災網がほぼ一致。</li>
</ul>

<h4>(b) 路線種別 L72 1km 圏内率 + 距離分布 (図 6)</h4>
<p><b>なぜこの図か</b>: 路線種ごとの L72 1km 圏内率の<b>階層構造</b>を左パネルで
示し、 右パネルで<b>距離分布の路線種別差</b>をヒストグラムで補強する 2 視点。
H4 を量的に証明する中心図。</p>

{figure("assets/L77_fig6_l72_rate_distance.png",
        f"図 6: 路線種別 L72 1km 圏内率 + 最近接距離分布")}

<p>{df_to_html(T_l72_rank.drop(columns=[c for c in ['順'] if c in T_l72_rank.columns]))}</p>

<p>{df_to_html(T_dist)}</p>

<p><b>図 6 / 表から読み取れること</b>:</p>
<ul>
  <li>路線種別 L72 全階層 1km 圏内率は<b>国道 {rate_kokudo}% / 主要地方道
      {rate_shuyo}% / 一般県道 {rate_kendo}%</b>。 H4 (国道 &gt; 主要 &gt; 県道) は
      <b>{'支持' if h4_ok else '不支持'}</b>。</li>
  <li>国道は<b>ほぼ全て</b>が L72 と重なる (= 国道はほぼすべてが緊急輸送道路でも
      ある)。 主要地方道は約 {rate_shuyo}% で部分一致、 一般県道は{rate_kendo}%
      で多くが圏外。</li>
  <li>距離分布: 中央値<b>{gdf['dist_l72_km'].median():.2f} km</b>、 平均
      <b>{gdf['dist_l72_km'].mean():.2f} km</b>。 国道 (赤) は 0〜0.5km に集中、
      一般県道 (緑) は 0〜5km に幅広く分布。 路線種が L72 距離を強く規定する。</li>
  <li>距離 90% タイル = <b>{round(np.percentile(gdf['dist_l72_km'], 90), 2)} km</b>
      = 9 割の図郭は L72 から{round(np.percentile(gdf['dist_l72_km'], 90), 2)} km 以内に位置。 県道路網全体が L72
      網と<b>強く相関</b>している物理形状。</li>
</ul>
"""


# ----- セクション 6: RQ3 -----
sec6_code = '''
# RQ3: 公開設計の機械可読性 5 軸評価
import pandas as pd

ZIP_BYTES = 1_147_323_651          # ZIP 本体 (PDF 集合)
CSV_BYTES = 3_560_806              # 関係資料 CSV
ratio = ZIP_BYTES / CSV_BYTES      # = 322 倍
share_csv_pct = 100 * CSV_BYTES / (ZIP_BYTES + CSV_BYTES)  # = 0.31%

# 5 軸 × 5 段階 (1=不可, 5=容易) でリソース 2 種を比較
T_machine = pd.DataFrame([
    {"評価軸": "表構造性",        "ZIP": 1, "CSV": 5},
    {"評価軸": "検索性",         "ZIP": 2, "CSV": 5},
    {"評価軸": "大規模解析容易性", "ZIP": 1, "CSV": 5},
    {"評価軸": "プログラム解析",  "ZIP": 1, "CSV": 3},
    {"評価軸": "学習者再現性",    "ZIP": 1, "CSV": 5},
])
T_machine["差"] = T_machine["CSV"] - T_machine["ZIP"]
print(T_machine)
print(f"CSV 平均 {T_machine['CSV'].mean():.1f} vs ZIP 平均 {T_machine['ZIP'].mean():.1f}")
print(f"ZIP/CSV サイズ比 = {ratio:.0f} 倍 / CSV シェア = {share_csv_pct:.3f}%")
'''

sec6 = f"""
<h3>狙い (RQ3)</h3>
<p>RQ3 では<b>「公開設計の機械可読性評価」</b>を行う。 道路台帳付図データセットは
<b>2 つのリソース (ZIP 本体 1.1GB / CSV 関係資料 3.4MB)</b> から成る非対称構造で
公開されており、 それぞれが<b>異なる利用者層</b>に向けて設計されている。 ZIP は
人間が PDF で道路区域・幅員を確認するための<b>人間可読層</b>、 CSV は機械的に
索引・統計・空間結合するための<b>機械可読層</b>。 これを<b>5 軸 × 5 段階</b>で
評価し、 学習者にとっての再利用可能性を量化する。 H5 (ZIP/CSV &gt;=300倍) は
「機械可読層は本体の 0.3% 以下」 という非対称性の中心仮説。</p>

<h3>手法 — 3 ステップ</h3>
<ol>
  <li><b>STEP 1: リソース容量実測</b><br>
      ZIP 本体 ({ZIP_BYTES:,} bytes = {ZIP_BYTES/1024**3:.2f} GB) と関係資料 CSV
      ({CSV_BYTES:,} bytes = {CSV_BYTES/1024**2:.2f} MB) のサイズを実測比較。
      比 = <b>{ratio:.0f} 倍</b>、 CSV シェア = <b>{share_csv_pct}%</b>。</li>
  <li><b>STEP 2: 機械可読性 5 軸評価 (本記事独自指標)</b><br>
      <b>表構造性 / 検索性 / 大規模解析容易性 / プログラム解析 / 学習者再現性</b>の
      5 軸を 1 (不可) 〜 5 (容易) で評価。 PDF 集合は表構造性 1 (= 帯状図は OCR
      + 図形解析が必要)、 CSV は表構造性 5 (= pandas で即時)。 評価は本記事独自で、
      公式評価尺度ではない。</li>
  <li><b>STEP 3: スコア比較 + 公開設計の解釈</b><br>
      ZIP / CSV の平均スコアを比較し、 「PDF 集合は人間可読 / CSV は機械可読」 の
      非対称性を量的に示す。 さらに「CSV があるからこそ全 14,463 PDF を機械的に
      索引できる」 という<b>非対称 2 層公開の利点</b>も論じる。</li>
</ol>

<h3>実装</h3>
<p>狙いと方法を踏まえた実装コードは以下の通り。 容量比は実測値、 5 軸スコアは
本記事独自の評価。</p>
{code(sec6_code)}

<h3>結果と読み取り</h3>

<h4>(a) リソース容量比較 + 機械可読性 5 軸 (図 7)</h4>
<p><b>なぜこの図か</b>: 容量比 (左パネル, 対数軸) と機械可読性スコア (右パネル,
水平棒) を並置することで、 「サイズの非対称」 と「機械可読性の非対称」 が
<b>同時並行</b>している事実を 1 枚で示す。 棒グラフのみではなく対数軸 + スコア
ヒートマップという 2 種類の表現を併用する理由は、 サイズ {ratio:.0f} 倍差を
線形で見ると CSV が消えてしまうため。</p>

{figure("assets/L77_fig7_machine_readability.png",
        f"図 7: ZIP/CSV 容量比較 + 機械可読性 5 軸スコア")}

<p>{df_to_html(T_machine)}</p>

<p>{df_to_html(T_overall)}</p>

<p><b>図 7 / 表から読み取れること</b>:</p>
<ul>
  <li>ZIP 本体 ({ZIP_BYTES/1024**3:.2f} GB) は CSV ({CSV_BYTES/1024**2:.2f} MB)
      の<b>{ratio:.0f} 倍</b>。 CSV はデータセット総容量の<b>{share_csv_pct}%</b>
      に過ぎない。 H5 (ZIP/CSV &gt;=300倍) は<b>{'支持' if h5_ok else '不支持'}</b>。</li>
  <li>機械可読性スコア: CSV 平均 {np.mean(score_csv):.1f} vs ZIP 平均
      {np.mean(score_zip):.1f}。 CSV は<b>4 軸でスコア 5 (容易)</b>、 ZIP は
      <b>4 軸でスコア 1 (不可)</b>。 唯一の例外は「プログラム解析 (内容アクセス)」 で、
      CSV は位置 + メタのみ提供 = 道路の幅員・横断構成等の<b>中身は別途 PDF が
      必要</b>。</li>
  <li>学習者にとっては<b>CSV だけで RQ1, RQ2 の問いは完結</b>するが、 RQ3 の
      「PDF の中身を解析」 はサイズ・形式の二重障壁で<b>事実上不可能</b>。 これが
      <b>「非対称 2 層公開構造」</b>の現実。</li>
</ul>

<h4>(b) 公開設計の解釈 (本記事の見立て)</h4>
<p><b>なぜこの解釈か</b>: 数字だけでは見えない<b>「非対称公開の意義」</b>を、 学習者
が今後似たデータを扱う際の<b>判断基準</b>として整理する。</p>

<table>
<tr><th>視点</th><th>ZIP (PDF 本体)</th><th>CSV (関係資料)</th></tr>
<tr><td>主目的</td>
    <td>道路法上の法定図面の電子保管</td>
    <td>PDF へのインデックス (索引) + 位置情報</td></tr>
<tr><td>主想定利用者</td>
    <td>道路管理者 / 工事業者 / 占用申請者 (個別路線の現場確認)</td>
    <td>研究者 / 学習者 / 開発者 (機械的索引・統計・空間結合)</td></tr>
<tr><td>強み</td>
    <td>原本の完全情報 (図形 + 凡例 + 寸法)</td>
    <td>容量小さい + 機械可読 + 1 ファイル</td></tr>
<tr><td>弱み</td>
    <td>機械的解析不可 (1.1GB の PDF 集合)</td>
    <td>図形・幅員・横断構成等の<b>中身は無し</b></td></tr>
<tr><td>本記事の活用</td>
    <td>取得しない (教材外)</td>
    <td>RQ1〜RQ3 すべての主データ</td></tr>
</table>

<p><b>解釈から読み取れること</b>:</p>
<ul>
  <li>道路台帳付図データセットは<b>「非対称 2 層公開」</b>の典型例。 道路管理者が
      使う「現場確認用 PDF」 と、 研究者が使う「機械可読 CSV」 を<b>意図的に分離</b>
      して提供する設計思想が読み取れる。</li>
  <li>これは多くの DoBoX データセットに共通する設計パターン: <b>本体は重い形式
      (PDF / Shapefile / 大規模 ZIP) + メタは軽い CSV</b>。 学習者は<b>まず CSV
      を取得</b>し、 必要な路線だけ PDF を個別取得する 2 段階アプローチが推奨。</li>
  <li>ただし RQ3 が示すように、 CSV 単独では<b>道路の中身 (幅員・横断構成)</b>は
      解析不可。 これは PDF の OCR + 図形認識という別レイヤの研究が必要 (発展課題で
      詳述)。</li>
</ul>
"""


# ----- セクション 7: 仮説検証総合 -----
sec7 = f"""
<h3>仮説検証総合表</h3>

{df_to_html(T_hyp)}

<h3>結果の総合解釈</h3>
<p>3 RQ × 5 仮説の検証結果から、 広島県の道路情報公開戦略について以下の
<b>3 つの実証的知見</b>が得られた:</p>

<ol>
  <li><b>(RQ1 — データ構造) 国道優先公開の階層構造</b><br>
      路線種別の図郭シェア vs 路線シェアの<b>不一致</b> (国道 路線 {share_kokudo_route}%
      / 図郭 {share_kokudo_fig}%) と、 路線あたり図郭密度の<b>明確な階層</b>
      ({density_kokudo} / {density_shuyo} / {density_kendo}) は、 県の道路情報
      公開が「路線種 = 整備優先度 = 図郭密度」 という<b>3 重の階層構造</b>に
      従っている物理的証拠。 H1, H2 ともに支持。</li>
  <li><b>(RQ2 — L72 照合) 防災重要路線優先公開の量的証明</b><br>
      道路台帳付図の<b>{share_near_all}%</b>が L72 緊急輸送道路 1km 圏内に位置し、
      路線種別の重なり率は<b>国道 {rate_kokudo}% &gt; 主要 {rate_shuyo}% &gt;
      県道 {rate_kendo}%</b>と階層的。 県の道路情報オープンデータ戦略と防災計画
      は<b>事実上一体運用</b>されている制度横断的整備の物理的証拠。 H3, H4
      ともに支持される結果。</li>
  <li><b>(RQ3 — 公開設計) 非対称 2 層公開の構造</b><br>
      ZIP 本体 ({ZIP_BYTES/1024**3:.2f} GB) と CSV 関係資料 ({CSV_BYTES/1024**2:.2f}
      MB) の容量比 <b>{ratio:.0f} 倍</b>、 機械可読層は本体の<b>{share_csv_pct}%</b>
      のみ。 これは「人間可読本体 + 機械可読インデックス」 の意図的非対称設計
      で、 利用者層別に最適化された公開戦略。 H5 支持。</li>
</ol>

<h3>3 RQ を統合した「県道路情報公開」 の見立て</h3>
<p>RQ1 〜 RQ3 を統合すると、 広島県の道路情報公開戦略は<b>「3 重の選択的公開」</b>
として描ける:
(1) 路線種で選別 (国道優先で図郭密度を高く)、 (2) 防災で連携 (L72 重要路線と
連動)、 (3) 形式で分離 (人間可読 PDF + 機械可読 CSV の非対称 2 層)。 これは
単なる「道路情報のオープンデータ化」 ではなく、 <b>「行政が利用者層を意識して
階層的に設計した公開戦略」</b>であり、 オープンガバメント研究の対象として
価値がある。</p>
"""


# ----- セクション 8: 発展課題 -----
sec8 = f"""
<h3>結果から導かれる新たな問い</h3>

<h4>発展課題 1: PDF 中身の機械解析 — 横断構成の自動抽出</h4>
<p><b>結果 X</b>: RQ3 で示した通り、 関係資料 CSV は<b>位置 + メタ</b>のみで、
道路の<b>幅員・横断構成・占用物件</b>等の中身は PDF にしか記録されていない
(機械可読性スコア 1)。<br>
<b>新仮説 Y</b>: 14,463 PDF に対し<b>OCR + 帯状図の図形認識</b>を適用すれば、
県管理道路の幅員分布・路肩比率・歩道整備率等の<b>横断構成統計</b>を自動抽出
できる。 仮説: 国道は歩道整備率が一般県道より<b>有意に高い</b>。<br>
<b>課題 Z</b>: (1) 14,463 PDF を <code>pdfplumber</code> で 1 枚ずつテキスト化
+ 帯状図領域の自動検出。 (2) 図形要素の塗り分けから車線幅・歩道幅を抽出する
画像認識モデルを訓練。 (3) 県内全路線の横断構成統計を生成し、 路線種別比較
分析 + 整備格差の可視化。 サンプリングで 100 PDF だけで予備分析するのが
現実的。</p>

<h4>発展課題 2: 図郭中心点の代表性検証 — 1 図郭が表す距離の推定</h4>
<p><b>結果 X</b>: RQ1 / RQ2 では「図郭中心点」 1 点で 1 図郭を表したが、 実際は
<b>数百メートルの帯状区間</b>を 1 PDF で記録している (路線種により 200〜500m
程度)。 路線あたり図郭数 ({density_kokudo} / {density_shuyo} / {density_kendo}) と
路線延長の関係は明らかになっていない。<br>
<b>新仮説 Y</b>: 国道の路線あたり図郭密度が高い ({density_kokudo} 図郭/路線) のは
<b>路線が長い</b>からか、 <b>1 図郭あたり距離が短い</b> (= 詳細図化) からか?
仮説: 国道は<b>路線延長が長い</b>ことが主因で、 1 図郭あたり距離は路線種によらず
ほぼ一定 (~500m)。<br>
<b>課題 Z</b>: (1) 全路線の<b>連続する図郭の緯度経度間距離</b>を計算 (= 隣接
図郭間の中心距離)。 (2) 路線種別に「1 図郭あたり平均距離」 を集計。 (3) 国道
432 号 (237 図郭) など特定路線で<b>図郭順序 + 累積距離</b>を可視化し、 1 PDF
が表す典型距離を推定。 (4) 路線あたり総距離 (= 図郭数 × 1 図郭距離) の路線種
別差を統計検定。</p>

<h4>発展課題 3: 道路台帳付図の経年比較 — 整備事業の時系列追跡</h4>
<p><b>結果 X</b>: 本記事は 2023-11-22 時点の道路台帳付図データを扱った。 道路台帳
は道路法上 <b>5 年ごとの更新義務</b>があり、 整備事業 (拡幅 / 新設 / 廃止) があれ
ば次の更新で図郭が変わる。<br>
<b>新仮説 Y</b>: DoBoX が今後数年に渡って道路台帳付図を更新公開すれば、
<b>図郭追加 / 削除 / 更新</b>を時系列で追跡することで、 県の整備優先路線が
特定できる。 仮説: 整備事業は<b>L72 緊急輸送道路 + 中山間山地</b>に偏在する。<br>
<b>課題 Z</b>: (1) DoBoX が次回データセットを更新したら、 関係資料 CSV を新旧
比較 (路線×図番のキーで結合)。 (2) 追加 / 削除 / 緯度経度変更を分類集計。 (3) 整備
事業の地理偏在を市町別マップで可視化、 L72 重なり率と相関分析。 1 年〜2 年の
スパンで継続的に観測する縦断研究。</p>

<h4>発展課題 4: 路線種境界の検証 — 国道と主要地方道の重複区間</h4>
<p><b>結果 X</b>: RQ1 で国道 432 号は<b>4 つの管理事務所</b>にまたがるという
組織横断的事実を発見した。 一方、 国道と主要地方道は<b>重複指定</b> (= 同じ
道路区間が 2 つの路線種に属す) があるとされる。<br>
<b>新仮説 Y</b>: 道路台帳付図 14,463 図郭の緯度経度に<b>363 件の重複</b>がある
(検出済) のは、 D 系 (詳細図) が N 系 (基本図) と同じ場所を別解像度で記録する
ためだけでなく、 <b>路線種重複指定</b>も部分的原因か?<br>
<b>課題 Z</b>: (1) 緯度経度重複 363 行を抽出し、 路線種ペアで集計。 (2) 同一座標
で「国道+主要地方道」 の組合せが何件あるか確認。 (3) 路線重複の地理分布マップ +
路線種境界の制度的曖昧さの量的記述。</p>

<h4>発展課題 5: CSV メタの充実度評価 — 他県オープンデータとの比較</h4>
<p><b>結果 X</b>: RQ3 で広島県の道路台帳付図は<b>位置 + 路線メタ + 管理事務所</b>
の 9 列で公開されている。 他県 (例: 静岡県・岡山県) の同種データはどんな列を
公開しているか?<br>
<b>新仮説 Y</b>: 他都道府県の道路台帳オープンデータと比較すると、 広島県は
<b>機械可読層の充実度が中程度</b>で、 「幅員」 「種別 (一般/自専)」 「整備年度」
等を追加すれば機械可読性スコアが大幅に向上する余地がある。<br>
<b>課題 Z</b>: (1) 静岡県・岡山県・福岡県・東京都等の道路台帳オープンデータ
ページを調査、 公開列を比較表化。 (2) 機械可読性 5 軸を全県で再評価。 (3)
広島県に追加すべき列の<b>具体提案</b>を、 制度的可能性と技術的容易性の 2 軸で
整理。</p>
"""


# ----- セクション組み立て -----
sections = [
    ("学習目標と問い", sec1),
    ("使用データ", sec2),
    ("ダウンロード", sec3),
    ("【RQ1】データ構造研究 — 路線×事務所×図番の 4 軸", sec4),
    ("【RQ2】L72 緊急輸送道路との照合研究 — 重要路線優先公開", sec5),
    ("【RQ3】データ公開設計研究 — 機械可読性の非対称評価", sec6),
    ("仮説検証総合", sec7),
    ("発展課題", sec8),
]

html = render_lesson(
    num=77,
    title="L77 道路台帳付図 単独 3 研究例分析",
    tags=["道路", "GIS", "オープンデータ", "公開設計", "L72連携"],
    time="35〜50 分",
    level="リテラシ〜中級",
    data_label=(f'<a href="https://hiroshima-dobox.jp/datasets/{DATASET_ID}" '
                f'target="_blank">道路台帳付図 (1 dataset / 2 リソース) — '
                f'{n_pts:,} 図郭 / {n_routes} 路線</a>'),
    sections=sections,
    script_filename="L77_road_register.py",
)

OUT_HTML = LESSONS / "L77_road_register.html"
OUT_HTML.write_text(html, encoding="utf-8")
print(f"  HTML 出力: {OUT_HTML}", flush=True)
print(f"  ({time.time()-t10:.1f}s)", flush=True)


# =============================================================================
# 11. 完了
# =============================================================================
print(f"\n=== L77 完了: {time.time()-t_all:.1f}s ===", flush=True)
print(f"  CSV (中間): {len(list(ASSETS.glob('L77_*.csv')))} ファイル", flush=True)
print(f"  PNG (図):   {len(list(ASSETS.glob('L77_*.png')))} ファイル", flush=True)
print(f"  HTML:       {OUT_HTML}", flush=True)
