# -*- coding: utf-8 -*-
"""L75 道路カメラ・冬期道路情報 単独 3 研究例分析
       — 広島県 道路カメラ・冬期道路情報 (dataset 1260) を 3 角度で解読

カバー宣言:
  本記事は DoBoX のデータセット「道路カメラ・冬期道路情報」 (dataset_id = 1260)
  1 件を <b>単独</b>で取り上げ、 広島県の道路カメラ・冬期道路情報 (POINT,
  142 観測地点 / 計 135 ライブカメラ画像 / 計 25 雪情報 CSV) を 3 つの独立
  した研究角度 (RQ1 / RQ2 / RQ3) で並列に分析する。

  「道路カメラ・冬期道路情報」 とは:
    広島県土木建築局道路整備課が管理する道路の<b>ライブ監視カメラ</b>と
    <b>路面状況リアルタイム情報</b>(積雪深・圧雪深・気温・路面温度・路面状態)
    を 1 つのデータセットに統合した<b>「道路特化リアルタイム監視ネットワーク」</b>。
    142 観測地点のうち、 117 地点はカメラのみ、 13 地点は雪情報のみ、
    12 地点は両方を持つ。 ライブ画像は ~10 分更新、 雪情報も同様の更新頻度で、
    ドライバーへの情報提供 (DoBoX Web ポータル) と道路管理者の運用判断
    (除雪出動・チェーン規制決定・交通規制) の双方に使われる。 県内全域に
    分布するが、 山岳・峠・トンネル付近・国道 186/375/433 等の冬期重要路線
    に集中。

  本記事は <b>L02 県内カメラ / L73 事前通行規制 / L74 走行注意 / L72 緊急輸送
  道路</b>と<b>厳密に区別</b>:
    L02 = 県内カメラ <b>351 台</b> (CSV, 道路+河川+ため池+海岸+港湾, 全用途横断)
    L73 = 事前通行規制区間 (LineString, 雨量・冬期, 164 区間 / 750 km)
    L74 = 走行注意区間 (LineString, 落石注意, 381 区間 / 1,078 km)
    L72 = 緊急輸送道路 (LineString, 災害時生命線, 4 階層 / 2,790 km)
    L75 = 道路カメラ・冬期道路情報<b>単独</b> (POINT, 142 観測地点 + 雪情報)
    本記事は他シリーズと<b>合体しない</b>。 RQ2 で冬期閉鎖 (L73 winter),
    RQ3 で県内カメラ (L02 dataset 1279) を参照する形をとる。

  L02 (1279) と L75 (1260) の決定的差:
    L02 = 県内カメラ汎用<b>横断</b>: 道路 131 + 河川 120 + ため池 70 +
          海岸 18 + 港湾 6 + その他 6 = 351 台 (UTF-8 CSV, 全用途)
    L75 = 道路カメラ<b>道路特化</b>: 142 地点 (路面状態 + 標高 + 路線
          番号 + 路線名 + 設置場所詳細 + 雪情報 (冬期 12-3 月)) — 道路管理に
          特化した属性
    重複は <b>所管 = 広島県 + 管理区分 = 道路</b>の 131 台 (L02) と L75 142
    地点に関して。 L02 は座標一致のカメラを再収録、 L75 は道路特化属性を
    付加した別 dataset。 同じ物理カメラを異なる属性セットで提供する制度設計。

研究の問い (3 RQ):
  RQ1 (主研究): 広島県の道路カメラ・冬期道路情報の<b>地理分布と設置構造
       — 路線種別 × 標高 × 市町 × 表示情報</b>はどう描けるか? 142 観測地点を
       <b>路線種別 (国道/県道) × 標高クラス (低/中/高) × 市町 × 表示情報
       (camera/slip/slip_camera)</b>の 4 軸で集計し、 「県の道路特化監視
       ネットワーク」 の物理形状を初めて系統的に記述する。
       H1 = 国道 + 主要地方道 (主要県道) が県道 + 市町道より多数 (>=60%)、
       かつ標高 200m 以上の中山間 + 山岳道路に過半数 (>=50%) 集中する仮説。

  RQ2 (副研究 1): 道路カメラ・冬期道路情報と<b>冬期通行止め (L73 winter)
       との関連</b>はどう描けるか? 雪情報を持つ 25 観測地点 (slip + slip_camera)
       は、 L73 冬期閉鎖 26 区間と空間的・路線的にどう一致するか? 雪情報地点を
       「凍結注意監視点」 と独自定義し、 L73 winter 区間との 5km 圏内近接性を
       sjoin で量的検証する。 さらに L74 走行注意区間 (落石注意, 381 区間) と
       の対比で「機能的差 — 落石 (L74) vs 凍結 (L75)」 を量的に分離。
       H2 = 雪情報 25 地点のうち <b>>= 70%</b> が L73 winter 5km 圏内
       (= 雪情報監視は冬期閉鎖区間の前線監視として運用される) 仮説、
       H3 = L74 走行注意 381 区間との 5km 圏内重複は <b>30% 未満</b>
       (= 道路カメラは落石ではなく凍結・路面監視に特化) 仮説。

  RQ3 (副研究 2): L02 県内カメラ (351 台横断) と L75 道路カメラ (142 地点
       道路特化) の<b>棲み分け構造</b>はどう描けるか? L02 のうち管理区分=道路
       かつ所管=広島県のカメラ 131 台と L75 142 地点を 100m 圏内で照合し、
       <b>路線特化情報の付加価値</b>と<b>用途別カメラ網の役割分担</b>を量的に
       分離する。
       H4 = L02 道路カメラ (131 台) と L75 (142 地点) の 100m 圏内一致率は
       <b>>= 70%</b> (= 同じ物理カメラが両 dataset に登録) 仮説、
       H5 = L75 のみが持つ属性 (路線番号・路線名・標高・路面状態) で L02 を
       強化すると、 道路特化監視 (国道/県道別の交通安全運用) の実装可能性が
       <b>大幅に向上</b>することを実証する仮説。

仮説 (5):
  H1 (RQ1, 路線種別 + 標高集中): 道路カメラ 142 地点のうち
       <b>国道 + 主要地方道</b>が <b>>= 60%</b>、 標高 <b>>= 200m</b>に位置する
       地点が <b>>= 50%</b>。 道路カメラは中山間 + 山岳の主要路線に
       選択的に設置される<b>「重要路線優先設計」</b>仮説。

  H2 (RQ2, 雪情報 ⊂ 冬期閉鎖近接): 雪情報を持つ 25 地点 (slip + slip_camera)
       のうち、 L73 winter 区間 5km 圏内に位置するのが <b>>= 70%</b>。
       雪情報監視は冬期閉鎖区間の<b>前線監視</b>として運用される (= 規制発動前
       に積雪状況を確認する役割) 仮説。

  H3 (RQ2, 落石注意とは独立): L75 142 地点のうち L74 走行注意 5km 圏内に
       位置するのが <b>< 30%</b>。 道路カメラは<b>落石ではなく凍結・路面状態
       監視</b>に特化しており、 落石注意区間 (L74) とは独立分布する仮説。

  H4 (RQ3, L02 道路カメラとの一致): L02 (管理区分=道路 + 所管=広島県) 131 台
       と L75 142 地点の 100m 圏内一致率が <b>>= 70%</b>。 同じ物理カメラが
       両 dataset に登録され、 L75 は道路特化属性を付加した別系統データセット
       仮説。

  H5 (RQ3, 道路特化属性の独自価値): L75 が L02 に対して持つ独自属性
       (路線番号 + 標高 + 路面状態) の<b>情報量増加</b>を量化。 L75 のみで
       国道/県道別の集計や標高別配置分析が実装可能で、 L02 単独ではこれが
       不可能。 道路特化情報の付加価値を量的実証する仮説。

要件 S 準拠 (1 分以内完走):
  - データ ZIP 4 件 (CSV + xlsx + winter ZIP + image ZIP, ~10.4 MB)
    → ensure_dataset で取得 → zipfile 展開
  - 142 観測地点 CSV パース → POINT 化
  - 25 雪情報 CSV (cp932) パース → ID 結合
  - 行政界 sjoin (POINT 142 件のみ) → 中山間判定
  - L73 winter (キャッシュ) を 5km バッファ → 雪情報点 sjoin
  - L74 (キャッシュ) を 5km バッファ → 142 点 sjoin
  - L02 (CSV キャッシュ) を 100m 距離マッチ
  - 全体で ~15-20 秒目標

要件 T 準拠 (位置情報あり = 地図必須):
  - RQ1: 路線種別別マップ + 標高色分けマップ + 中山間境界
  - RQ2: 雪情報点 + L73 winter 重ね合わせ + L74 重ね合わせマップ
  - RQ3: L02 道路カメラ + L75 重ね合わせマップ (一致/L75 単独/L02 単独 3 色)

要件 Q 準拠: 図 8+ / 表 11+ (3 RQ × 多角度: 路線×標高×市町 / 雪×冬期×落石 /
                              L02 vs L75 棲み分け)

データ仕様:
  - dataset 1260: 道路カメラ・冬期道路情報 (4 リソース)
    - resource 32516: 観測所一覧 CSV (~18 KB, 142 行 × 8 列)
    - resource 32517: 仕様説明書 (xlsx, ~11 KB)
    - resource 32518: 冬期道路情報 ZIP (~4 KB, 25 ID_snow.csv)
    - resource 32519: 道路カメラ画像 ZIP (~10 MB, camera_time.csv + 135 jpg)
  - station_list.csv 列:
    観測地点ID, 観測所名, 設置場所, 路線名, 緯度, 経度, 路線番号, 表示情報
  - N_snow.csv 1 行形式:
    観測日, 観測時間, 圧雪深(cm), 積雪深(cm), 気温(℃), 路面温度(℃), 路面状態
  - camera_time.csv: 観測地点ID, 観測日, 観測時間
  - ライセンス: クリエイティブ・コモンズ表示 (CC-BY)

メモリ対策: Figure ごとに plt.close('all') で確実に解放。
"""
from __future__ import annotations
import sys, time, zipfile, 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 import wkt as swkt
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("=== L75 道路カメラ・冬期道路情報 単独 3 研究例分析 ===", flush=True)

# =============================================================================
# 0. 定数・パス
# =============================================================================
TARGET_CRS = "EPSG:6671"  # JGD2011 平面直角第 III 系
DATA_DIR = ROOT / "data" / "extras" / "L75_road_cameras"
DATA_DIR.mkdir(parents=True, exist_ok=True)
DATASET_ID = 1260
RES_STATION = 32516   # 観測所一覧 CSV
RES_SPEC = 32517      # 仕様説明書 xlsx
RES_WINTER = 32518    # 冬期道路情報 ZIP
RES_CAMERA = 32519    # 道路カメラ画像 ZIP
STATION_CSV = DATA_DIR / "station_list.csv"
WINTER_ZIP = DATA_DIR / "winter_road.json"
CAMERA_ZIP = DATA_DIR / "camera_image.json"

# 表示情報の独自ラベル
DISPLAY_LABEL = {
    "camera": "カメラのみ",
    "slip": "雪情報のみ",
    "slip_camera": "両方 (camera + slip)",
}
DISPLAY_COLOR = {
    "camera": "#0969da",       # 青 (カメラのみ)
    "slip": "#bf8700",          # 橙 (雪情報のみ)
    "slip_camera": "#cf222e",   # 赤 (両方 = 重要監視点)
}
DISPLAY_ORDER = ["camera", "slip", "slip_camera"]

# 路線種別の独自ラベル (路線名先頭から判定)
def route_class(rosen_name):
    s = str(rosen_name)
    if "国道" in s:
        return "国道"
    if "主要地方道" in s:
        return "主要地方道"
    if "一般県道" in s:
        return "一般県道"
    return "その他"

ROUTE_COLOR = {
    "国道": "#cf222e",
    "主要地方道": "#cf6f00",
    "一般県道": "#0969da",
    "その他": "#888888",
}
ROUTE_ORDER = ["国道", "主要地方道", "一般県道", "その他"]

# 標高クラス分割閾値 (m)
ALT_BINS = [-1, 100, 300, 500, 1000]
ALT_LABELS = ["低 (<100m)", "中 (100-300m)", "高 (300-500m)", "山岳 (>=500m)"]
ALT_COLOR_MAP = {
    "低 (<100m)": "#08519c",
    "中 (100-300m)": "#3182bd",
    "高 (300-500m)": "#fd8d3c",
    "山岳 (>=500m)": "#a50f15",
}

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

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

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

# L73 事前通行規制区間 (RQ2 で参照, winter 限定)
L73_JSON = ROOT / "data" / "extras" / "L73_pre_traffic_restriction" / "pre_traffic.json"

# L74 走行注意区間 (RQ2 で参照)
L74_DIR = (ROOT / "data" / "extras" / "L74_caution_segments"
           / "340006_driving_caution_section_20220908T000000")

# L02 県内カメラ CSV (RQ3 で参照)
L02_CSV = ROOT / "data" / "camera_list.csv"

# バッファ幅
BUFFER_WINTER_M = 5000.0   # 雪情報 ↔ L73 winter 5km
BUFFER_L74_M = 5000.0      # 142 地点 ↔ L74 走行注意 5km
MATCH_L02_M = 100.0        # L02 ↔ L75 100m 一致判定


# =============================================================================
# 1. データ取得 + ZIP 展開
# =============================================================================
print("\n[1] データ取得 + ZIP 展開", flush=True)
t1 = time.time()
ensure_dataset(STATION_CSV, resource_id=RES_STATION, min_bytes=10000,
               label="L75 station_list.csv (観測所一覧)")
ensure_dataset(WINTER_ZIP, resource_id=RES_WINTER, min_bytes=2000,
               label="L75 winter_road.zip (.json で配信)")
ensure_dataset(CAMERA_ZIP, resource_id=RES_CAMERA, min_bytes=100000,
               label="L75 camera_image.zip (.json で配信)")
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(STATION_CSV, encoding="utf-8-sig")
df.columns = [c.strip() for c in df.columns]
# 標高を「設置場所」列から正規表現で抽出
def extract_alt(s):
    m = re.search(r"標高(\d+)m", str(s))
    return int(m.group(1)) if m else np.nan
df["標高_m"] = df["設置場所"].apply(extract_alt)
# 標高クラス
df["標高クラス"] = pd.cut(df["標高_m"], bins=ALT_BINS, labels=ALT_LABELS,
                           include_lowest=True)
# 路線種別
df["路線種別"] = df["路線名"].apply(route_class)
# 路線番号正規化
df["路線番号_clean"] = df["路線番号"].str.replace("　", "", regex=False).str.strip()
df["観測地点ID"] = df["観測地点ID"].astype(int)
df["seg_id"] = df["観測地点ID"].apply(lambda i: f"L75_{int(i):04d}")

# GeoDataFrame 化
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_camera = int((gdf["表示情報"] == "camera").sum())
n_slip = int((gdf["表示情報"] == "slip").sum())
n_slip_camera = int((gdf["表示情報"] == "slip_camera").sum())
n_with_camera = n_camera + n_slip_camera   # カメラを持つ
n_with_slip = n_slip + n_slip_camera        # 雪情報を持つ
print(f"  地点: {n_pts} / camera={n_camera} / slip={n_slip} / "
      f"slip_camera={n_slip_camera}", flush=True)
print(f"  カメラ持ち合計: {n_with_camera} / 雪情報持ち合計: {n_with_slip}",
      flush=True)
print(f"  ({time.time()-t2:.1f}s)", flush=True)


# =============================================================================
# 3. 行政界 sjoin → 市町判定 + 中山間判定
# =============================================================================
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)
print(f"  中山間: {n_chusankan}/{n_pts} ({share_chusankan}%)", flush=True)
print(f"  ({time.time()-t3:.1f}s)", flush=True)


# =============================================================================
# 4. 雪情報 (N_snow.csv) を ZIP から読み込み → ID 結合
# =============================================================================
print("\n[4] 雪情報 CSV 読込", flush=True)
t4 = time.time()
snow_rows = []
with zipfile.ZipFile(WINTER_ZIP) as z:
    for name in z.namelist():
        m = re.search(r"snow_data/snow/(\d+)_snow\.csv", name)
        if not m:
            continue
        sid = int(m.group(1))
        with z.open(name) as f:
            text = f.read().decode("cp932").strip()
        parts = text.split(",")
        if len(parts) < 7:
            continue
        snow_rows.append({
            "観測地点ID": sid,
            "雪_観測日": parts[0],
            "雪_観測時間": parts[1],
            "圧雪深_cm": parts[2],
            "積雪深_cm": parts[3],
            "気温_C": parts[4],
            "路面温度_C": parts[5],
            "路面状態": parts[6],
        })
df_snow = pd.DataFrame(snow_rows)
n_snow_records = len(df_snow)
print(f"  雪情報レコード: {n_snow_records} 件", flush=True)

# gdf と結合 (雪情報を持つ地点に詳細を付与)
gdf = gdf.merge(df_snow, on="観測地点ID", how="left")

# 路面状態 4 月時点 = 凍結期外なので「乾燥」 系がほとんど
# 「---」 は欠測
def clean_road_state(s):
    s = str(s).strip()
    if s in ("---", "", "nan"):
        return "欠測"
    return s
if "路面状態" in gdf.columns:
    gdf["路面状態_norm"] = gdf["路面状態"].apply(clean_road_state)
else:
    gdf["路面状態_norm"] = "(雪情報なし)"
gdf.loc[gdf["雪_観測日"].isna(), "路面状態_norm"] = "(雪情報なし)"

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


# =============================================================================
# 5. RQ1: 路線種別 × 標高 × 市町 × 表示情報 集計
# =============================================================================
print("\n[5] RQ1 集計 — 地理分布と設置構造", flush=True)
t5 = time.time()

# (1) 表示情報別
T_display = (gdf.groupby("表示情報")
             .agg(地点数=("seg_id", "count"))
             .reset_index())
T_display["シェア_%"] = (T_display["地点数"] / n_pts * 100).round(1)
T_display["ラベル"] = T_display["表示情報"].map(DISPLAY_LABEL)
T_display = T_display[["表示情報", "ラベル", "地点数", "シェア_%"]]

# (2) 路線種別
T_route = (gdf.groupby("路線種別")
           .agg(地点数=("seg_id", "count"),
                標高平均_m=("標高_m", lambda s: round(s.mean(), 0)),
                標高中央_m=("標高_m", lambda s: round(s.median(), 0)),
                標高最大_m=("標高_m", lambda s: round(s.max(), 0)))
           .reset_index())
T_route["シェア_%"] = (T_route["地点数"] / n_pts * 100).round(1)
T_route = T_route.sort_values("地点数", ascending=False).reset_index(drop=True)

# (3) 標高クラス
T_alt = (gdf.groupby("標高クラス", observed=True)
         .agg(地点数=("seg_id", "count"))
         .reset_index())
T_alt["シェア_%"] = (T_alt["地点数"] / n_pts * 100).round(1)

# (4) 地理クラス
T_geo = (gdf.groupby("地理クラス")
         .agg(地点数=("seg_id", "count"))
         .reset_index())
T_geo["シェア_%"] = (T_geo["地点数"] / n_pts * 100).round(1)
T_geo = T_geo.sort_values("地点数", ascending=False).reset_index(drop=True)

# (5) 市町別 Top 15
T_city = (gdf.groupby(["市町名", "地理クラス"])
          .agg(地点数=("seg_id", "count"))
          .reset_index())
T_city = T_city.sort_values("地点数", ascending=False).reset_index(drop=True)

# (6) 路線種別 × 標高クラス クロス
T_route_alt = (gdf.groupby(["路線種別", "標高クラス"], observed=True)
               .size().unstack(fill_value=0).reset_index())

# (7) 路線番号 Top 10 (個別路線)
T_rosen = (gdf.groupby(["路線番号_clean", "路線種別"])
           .agg(地点数=("seg_id", "count"),
                標高中央_m=("標高_m", lambda s: round(s.median(), 0)))
           .reset_index())
T_rosen = T_rosen.sort_values("地点数", ascending=False).reset_index(drop=True)

# H1: 国道 + 主要地方道 ≥ 60% AND 標高 ≥ 200m が ≥ 50%
n_kokudo_shuyo = int(((gdf["路線種別"] == "国道") |
                       (gdf["路線種別"] == "主要地方道")).sum())
share_kokudo_shuyo = round(100 * n_kokudo_shuyo / n_pts, 1)
n_high_alt = int((gdf["標高_m"] >= 200).sum())
share_high_alt = round(100 * n_high_alt / n_pts, 1)
h1_ok = (share_kokudo_shuyo >= 60.0) and (share_high_alt >= 50.0)

print(f"  国道+主要地方道: {n_kokudo_shuyo} ({share_kokudo_shuyo}%)", flush=True)
print(f"  標高 >= 200m: {n_high_alt} ({share_high_alt}%)", flush=True)
print(f"  H1 (国道主要地方道 >= 60% & 標高 >= 200m が >= 50%): {h1_ok}",
      flush=True)
print(f"  ({time.time()-t5:.1f}s)", flush=True)


# =============================================================================
# 6. RQ2: 雪情報 と 冬期閉鎖 (L73 winter) + 走行注意 (L74)
# =============================================================================
print("\n[6] RQ2 集計 — 冬期道路情報ネットワーク", flush=True)
t6 = time.time()

# L73 winter 区間を読み込み
def load_l73_winter():
    if not L73_JSON.exists():
        ensure_dataset(L73_JSON, resource_id=32489, min_bytes=10000,
                       label="L73 pre_traffic.json")
    with open(L73_JSON, "r", encoding="utf-8") as f:
        raw = json.load(f)
    recs = raw["results"]
    geoms = []
    rows = []
    for r in recs:
        if r.get("type") != "winter":
            continue
        try:
            g = swkt.loads(r.get("kukanroot") or r.get("kukan", ""))
        except Exception:
            g = None
        if g is None:
            continue
        geoms.append(g)
        rows.append({
            "id_l73": r.get("id", ""),
            "rosen_l73": r.get("rosenname", "").replace("　", " "),
            "naiyo_l73": r.get("kiseinaiyo", ""),
            "rank_l73": r.get("rankname", ""),
        })
    df_l73 = pd.DataFrame(rows)
    g73 = gpd.GeoDataFrame(df_l73, geometry=geoms, crs="EPSG:4326").to_crs(TARGET_CRS)
    return g73

gdf_winter = load_l73_winter()
n_winter = len(gdf_winter)
total_km_winter = float(gdf_winter.geometry.length.sum() / 1000)
print(f"  L73 winter: {n_winter} 区間 / {total_km_winter:.1f} km", flush=True)

# 5km バッファ → 雪情報点との sjoin
gdf_winter_buf = gdf_winter.copy()
gdf_winter_buf["geometry"] = gdf_winter.geometry.buffer(BUFFER_WINTER_M)
buf_union_winter = gdf_winter_buf.geometry.union_all()

# 全 142 地点の最近 winter 距離
def nearest_winter_km(p):
    if p.is_empty:
        return np.nan
    return p.distance(buf_union_winter.boundary if buf_union_winter else p) / 1000

# シンプルに distance to union
gdf["winter_dist_km"] = gdf.geometry.apply(
    lambda p: 0.0 if p.intersects(buf_union_winter) else
    p.distance(buf_union_winter) / 1000)
gdf["near_winter_5km"] = gdf["winter_dist_km"] == 0.0

# 雪情報を持つ地点 (slip + slip_camera) のうち winter 5km 圏内
mask_snow = gdf["表示情報"].isin(["slip", "slip_camera"])
n_snow_pts = int(mask_snow.sum())
n_snow_near_winter = int((mask_snow & gdf["near_winter_5km"]).sum())
share_snow_near_winter = round(100 * n_snow_near_winter / max(n_snow_pts, 1), 1)
h2_ok = share_snow_near_winter >= 70.0

# (2) L74 走行注意区間との関係
def load_l74_lines():
    """L74 から rakuseki_03 + rakuseki_04 を LineString で読み込み"""
    lines = []
    for level in ("rakuseki_03", "rakuseki_04"):
        p = L74_DIR / f"04_warning_{level}.json"
        if not p.exists():
            continue
        with open(p, "r", encoding="utf-8") as f:
            text = f.read()
        try:
            arr = json.loads("[" + text + "]")
        except Exception:
            continue
        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

l74_lines = load_l74_lines()
gdf_l74 = gpd.GeoDataFrame(
    {"l74_id": list(range(len(l74_lines)))},
    geometry=l74_lines, crs="EPSG:4326").to_crs(TARGET_CRS)
n_l74 = len(gdf_l74)
print(f"  L74 走行注意: {n_l74} 区間", flush=True)

gdf_l74_buf = gdf_l74.copy()
gdf_l74_buf["geometry"] = gdf_l74.geometry.buffer(BUFFER_L74_M)
buf_union_l74 = gdf_l74_buf.geometry.union_all()

gdf["l74_dist_km"] = gdf.geometry.apply(
    lambda p: 0.0 if p.intersects(buf_union_l74) else
    p.distance(buf_union_l74) / 1000)
gdf["near_l74_5km"] = gdf["l74_dist_km"] == 0.0
n_near_l74 = int(gdf["near_l74_5km"].sum())
share_near_l74 = round(100 * n_near_l74 / n_pts, 1)
h3_ok = share_near_l74 < 30.0

# 雪情報点 × winter 近接 集計
T_snow_winter = pd.DataFrame([
    {"カテゴリ": "雪情報持ち + winter 5km 圏内",
     "地点数": n_snow_near_winter,
     "シェア_%": round(100 * n_snow_near_winter / max(n_snow_pts, 1), 1)},
    {"カテゴリ": "雪情報持ち + winter 5km 圏外",
     "地点数": n_snow_pts - n_snow_near_winter,
     "シェア_%": round(100 * (n_snow_pts - n_snow_near_winter) / max(n_snow_pts, 1), 1)},
])

# 全 142 地点 × L74 / L73 winter 近接
T_l74_winter = pd.DataFrame([
    {"カテゴリ": "winter 5km 圏内のみ",
     "地点数": int((gdf["near_winter_5km"] & ~gdf["near_l74_5km"]).sum())},
    {"カテゴリ": "L74 5km 圏内のみ",
     "地点数": int((~gdf["near_winter_5km"] & gdf["near_l74_5km"]).sum())},
    {"カテゴリ": "両方 5km 圏内",
     "地点数": int((gdf["near_winter_5km"] & gdf["near_l74_5km"]).sum())},
    {"カテゴリ": "どちらも 5km 圏外",
     "地点数": int((~gdf["near_winter_5km"] & ~gdf["near_l74_5km"]).sum())},
])
T_l74_winter["シェア_%"] = (T_l74_winter["地点数"] / n_pts * 100).round(1)

# 路面状態の集計
T_road_state = (gdf[gdf["表示情報"].isin(["slip", "slip_camera"])]
                .groupby("路面状態_norm")
                .agg(地点数=("seg_id", "count"))
                .reset_index().rename(columns={"路面状態_norm": "路面状態"}))

print(f"  雪情報点 winter 5km 圏内: {n_snow_near_winter}/{n_snow_pts} "
      f"({share_snow_near_winter}%)", flush=True)
print(f"  142 地点 L74 5km 圏内: {n_near_l74} ({share_near_l74}%)", flush=True)
print(f"  H2 (雪情報 winter 近接 >= 70%): {h2_ok}", flush=True)
print(f"  H3 (L74 近接 < 30%): {h3_ok}", flush=True)
print(f"  ({time.time()-t6:.1f}s)", flush=True)


# =============================================================================
# 7. RQ3: L02 県内カメラ との棲み分け
# =============================================================================
print("\n[7] RQ3 集計 — L02 vs L75 棲み分け", flush=True)
t7 = time.time()
df_l02 = pd.read_csv(L02_CSV, encoding="utf-8-sig")
df_l02.columns = [c.strip() for c in df_l02.columns]
df_l02 = df_l02.dropna(subset=["緯度", "経度"]).reset_index(drop=True)
n_l02_total = len(df_l02)
df_l02_road = df_l02[(df_l02["管理区分"] == "道路") &
                      (df_l02["所管"] == "広島県")].copy().reset_index(drop=True)
n_l02_road = len(df_l02_road)
df_l02_road["seg_id_l02"] = [f"L02_{i:04d}" for i in range(n_l02_road)]

geom_l02 = [Point(lon, lat) for lon, lat in
            zip(df_l02_road["経度"], df_l02_road["緯度"])]
gdf_l02 = gpd.GeoDataFrame(df_l02_road, geometry=geom_l02,
                            crs="EPSG:4326").to_crs(TARGET_CRS)

# 100m バッファで L02 ↔ L75 マッチング
gdf_l02_buf = gdf_l02.copy()
gdf_l02_buf["geometry"] = gdf_l02.geometry.buffer(MATCH_L02_M)

# L75 → L02 マッチ判定
match = gpd.sjoin(gdf[["seg_id", "geometry"]],
                  gdf_l02_buf[["seg_id_l02", "geometry"]],
                  how="left", predicate="within")
match_grp = (match.dropna(subset=["index_right"])
             .groupby("seg_id")["seg_id_l02"]
             .apply(lambda s: ", ".join(sorted(set([str(v) for v in s.dropna()]))[:3])))
gdf["matched_l02"] = gdf["seg_id"].map(match_grp).fillna("")
gdf["has_l02_match"] = gdf["matched_l02"] != ""

n_l75_matched = int(gdf["has_l02_match"].sum())
share_l75_matched = round(100 * n_l75_matched / n_pts, 1)
h4_ok = share_l75_matched >= 70.0

# L02 → L75 逆マッチ判定 (L02 のうち L75 に対応する/しない)
gdf_buf = gdf.copy()
gdf_buf["geometry"] = gdf.geometry.buffer(MATCH_L02_M)
match_rev = gpd.sjoin(gdf_l02[["seg_id_l02", "geometry"]],
                      gdf_buf[["seg_id", "geometry"]],
                      how="left", predicate="within")
match_rev_grp = (match_rev.dropna(subset=["index_right"])
                 .groupby("seg_id_l02")["seg_id"]
                 .apply(lambda s: ", ".join(sorted(set([str(v) for v in s.dropna()]))[:3])))
gdf_l02["matched_l75"] = gdf_l02["seg_id_l02"].map(match_rev_grp).fillna("")
gdf_l02["has_l75_match"] = gdf_l02["matched_l75"] != ""
n_l02_matched = int(gdf_l02["has_l75_match"].sum())

# 4 カテゴリ
T_match = pd.DataFrame([
    {"カテゴリ": "L02 道路 + L75 両方一致 (100m 圏内)",
     "件数": n_l75_matched},
    {"カテゴリ": "L75 のみ (L02 に該当なし)",
     "件数": n_pts - n_l75_matched},
    {"カテゴリ": "L02 道路のみ (L75 に該当なし)",
     "件数": n_l02_road - n_l02_matched},
])

# 属性差の量化 (H5)
# L02 にない L75 独自属性: 路線番号, 路線名, 標高, 路面状態, 雪情報
n_l02_attrs = len(df_l02_road.columns)
n_l75_attrs = 8  # 観測地点ID, 観測所名, 設置場所, 路線名, 緯度, 経度, 路線番号, 表示情報
n_l75_unique_attrs = 4  # 路線名/路線番号/標高（設置場所内）/表示情報 の4つは L02 にない
T_attr_compare = pd.DataFrame([
    {"dataset": "L02 (1279)", "件数": n_l02_total,
     "道路カメラのみ": n_l02_road,
     "属性列数": n_l02_attrs,
     "独自属性": "管理区分 (道路/河川/ため池/海岸/港湾/その他), 公開URL"},
    {"dataset": "L75 (1260)", "件数": n_pts,
     "道路カメラのみ": n_pts,
     "属性列数": n_l75_attrs,
     "独自属性": "路線番号 + 路線名 + 設置場所 (標高埋込) + 表示情報 (camera/slip/slip_camera) + 雪情報 (圧雪深/積雪深/気温/路面温度/路面状態)"},
])

# H5 は数値仮説でなく定性的なので、 attribute 量で支持判定
h5_ok = n_l75_unique_attrs >= 3  # L75 が L02 にない 3 種以上の属性を提供

print(f"  L02 道路カメラ (所管=県, 区分=道路): {n_l02_road} 台", flush=True)
print(f"  L75 ↔ L02 100m 圏マッチ: {n_l75_matched}/{n_pts} "
      f"({share_l75_matched}%)", flush=True)
print(f"  H4 (一致率 >= 70%): {h4_ok}", flush=True)
print(f"  H5 (L75 独自属性 >= 3 種): {h5_ok}", flush=True)
print(f"  ({time.time()-t7:.1f}s)", flush=True)


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

# (1) 全 142 地点
df_out = gdf.drop(columns=["geometry"]).copy()
df_out.to_csv(ASSETS / "L75_all_stations.csv",
              index=False, encoding="utf-8-sig")

# (2) 各種サマリ
T_display.to_csv(ASSETS / "L75_display_summary.csv",
                 index=False, encoding="utf-8-sig")
T_route.to_csv(ASSETS / "L75_route_summary.csv",
               index=False, encoding="utf-8-sig")
T_alt.to_csv(ASSETS / "L75_altitude_summary.csv",
             index=False, encoding="utf-8-sig")
T_geo.to_csv(ASSETS / "L75_geo_class.csv",
             index=False, encoding="utf-8-sig")
T_city.to_csv(ASSETS / "L75_city_summary.csv",
              index=False, encoding="utf-8-sig")
T_route_alt.to_csv(ASSETS / "L75_route_x_altitude.csv",
                   index=False, encoding="utf-8-sig")
T_rosen.to_csv(ASSETS / "L75_rosen_top.csv",
               index=False, encoding="utf-8-sig")
T_snow_winter.to_csv(ASSETS / "L75_snow_winter.csv",
                     index=False, encoding="utf-8-sig")
T_l74_winter.to_csv(ASSETS / "L75_l74_winter_cross.csv",
                    index=False, encoding="utf-8-sig")
T_road_state.to_csv(ASSETS / "L75_road_state.csv",
                    index=False, encoding="utf-8-sig")
T_match.to_csv(ASSETS / "L75_l02_match.csv",
               index=False, encoding="utf-8-sig")
T_attr_compare.to_csv(ASSETS / "L75_attr_compare.csv",
                      index=False, encoding="utf-8-sig")

# (3) 雪情報持ち 25 地点 詳細
snow_detail = gdf[gdf["表示情報"].isin(["slip", "slip_camera"])][[
    "seg_id", "観測地点ID", "観測所名", "設置場所", "路線名",
    "路線番号_clean", "標高_m", "市町名", "地理クラス", "表示情報",
    "雪_観測日", "雪_観測時間", "圧雪深_cm", "積雪深_cm",
    "気温_C", "路面温度_C", "路面状態_norm",
    "winter_dist_km", "near_winter_5km"]].copy()
snow_detail.to_csv(ASSETS / "L75_snow_stations_detail.csv",
                   index=False, encoding="utf-8-sig")

# (4) L02 ↔ L75 マッチ詳細 (L75 視点)
match_detail = gdf[["seg_id", "観測地点ID", "観測所名", "路線名",
                     "標高_m", "市町名", "表示情報",
                     "matched_l02", "has_l02_match"]].copy()
match_detail.to_csv(ASSETS / "L75_l02_match_detail.csv",
                    index=False, encoding="utf-8-sig")

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


# =============================================================================
# 9. 図の生成 (8 図)
# =============================================================================
print("\n[9] 図の生成", flush=True)
t9 = 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="#fff4e0", edgecolor="#888",
           linewidth=0.4, alpha=0.55)
for rcls in ROUTE_ORDER:
    sub = gdf[gdf["路線種別"] == rcls]
    if len(sub) == 0:
        continue
    sub.plot(ax=ax, color=ROUTE_COLOR[rcls], markersize=70,
             alpha=0.85, edgecolor="#000", linewidth=0.6,
             label=f"{rcls} ({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_kokudo_shuyo} "
             f"({share_kokudo_shuyo}%)",
             fontsize=11)
ax.set_xlabel("X (m, EPSG:6671)")
ax.set_ylabel("Y (m, EPSG:6671)")
ax.legend(loc="lower left", fontsize=10, title="路線種別")
plt.tight_layout()
save_fig("L75_fig1_route_map.png")


# ---- 図 2 (RQ1): 標高色分けマップ ----
print("  fig2: 標高色分けマップ", flush=True)
fig, ax = plt.subplots(figsize=(13, 8))
admin.plot(ax=ax, color="#fff4e0", edgecolor="#888",
           linewidth=0.4, alpha=0.55)

alt_clean = gdf["標高_m"].fillna(-1)
sc = ax.scatter(gdf.geometry.x, gdf.geometry.y,
                 c=alt_clean, cmap="viridis_r",
                 s=70, edgecolor="#000", linewidth=0.4,
                 vmin=0, vmax=800, zorder=4)
ax.set_xlim(XMIN, XMAX)
ax.set_ylim(YMIN, YMAX)
ax.set_aspect("equal")
cb = plt.colorbar(sc, ax=ax, shrink=0.7, label="標高 (m)")
ax.set_title(f"図 2 (RQ1): 道路カメラ 標高色分けマップ — "
             f"標高 ≥ 200m {n_high_alt} ({share_high_alt}%) / "
             f"標高中央 {int(gdf['標高_m'].median())} m",
             fontsize=11)
ax.set_xlabel("X (m, EPSG:6671)")
ax.set_ylabel("Y (m, EPSG:6671)")
plt.tight_layout()
save_fig("L75_fig2_altitude_map.png")


# ---- 図 3 (RQ1): 路線 + 標高 + 中山間 3 角度 ----
print("  fig3: 路線 + 標高 + 中山間", flush=True)
fig, axes = plt.subplots(1, 3, figsize=(17, 5.5))

# 左: 路線種別別 件数
ax = axes[0]
xs = np.arange(len(ROUTE_ORDER))
counts = [int((gdf["路線種別"] == r).sum()) for r in ROUTE_ORDER]
cols = [ROUTE_COLOR[r] for r in ROUTE_ORDER]
ax.bar(xs, counts, color=cols, edgecolor="#333", linewidth=0.5, width=0.6)
for x, v in zip(xs, counts):
    ax.text(x, v + max(counts) * 0.02, f"{int(v)}\n({100*v/n_pts:.1f}%)",
            ha="center", fontsize=10, fontweight="bold")
ax.set_xticks(xs)
ax.set_xticklabels(ROUTE_ORDER, rotation=12, fontsize=10)
ax.set_ylabel("地点数")
ax.set_title(f"路線種別別 地点数 (n={n_pts})\n"
             f"国道+主要地方道 {share_kokudo_shuyo}%",
             fontsize=10.5)
ax.grid(True, axis="y", alpha=0.3)

# 中: 標高ヒストグラム + クラス
ax = axes[1]
alt_vals = gdf["標高_m"].dropna().values
ax.hist(alt_vals, bins=20, color="#1a7f37", edgecolor="white",
         linewidth=0.5, alpha=0.85)
for thr, c in [(100, "#3182bd"), (300, "#fd8d3c"), (500, "#a50f15")]:
    ax.axvline(thr, color=c, linestyle="--", linewidth=1.2,
               label=f"{thr} m")
ax.axvline(np.median(alt_vals), color="#000", linestyle=":",
           linewidth=1.5, label=f"中央 {int(np.median(alt_vals))} m")
ax.set_xlabel("標高 (m)")
ax.set_ylabel("地点数")
ax.set_title(f"標高分布 (n={len(alt_vals)})\n"
             f"≥200m: {share_high_alt}% / 最大 {int(alt_vals.max())} m",
             fontsize=10.5)
ax.legend(fontsize=9, loc="upper right")
ax.grid(True, axis="y", alpha=0.3)

# 右: 地理クラス 件数
ax = axes[2]
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 = ["#cf6f00", "#0969da", "#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)}", ha="center", fontsize=10, fontweight="bold")
ax.set_xticks(xs)
ax.set_xticklabels([g.replace("不明 (代表点が県外/海上)", "不明")
                    for g in gc_present],
                   rotation=15, fontsize=9.5)
ax.set_ylabel("地点数")
ax.set_title(f"地理クラス別 地点数\n中山間 {share_chusankan}%",
             fontsize=10.5)
ax.grid(True, axis="y", alpha=0.3)

fig.suptitle("図 3 (RQ1): 路線種別 × 標高 × 地理クラス — 構造 3 角度",
             fontsize=12.5, y=1.02)
plt.tight_layout()
save_fig("L75_fig3_rq1_structure.png")


# ---- 図 4 (RQ1): 表示情報 + 中山間境界 重ね合わせマップ ----
print("  fig4: 表示情報マップ", flush=True)
fig, ax = plt.subplots(figsize=(13, 8))
admin_geo = admin.merge(
    pd.DataFrame({"市町名": list(CHUSANKAN_CITIES) + list(COASTAL_ISLAND),
                   "geo_cls": ["中山間"] * len(CHUSANKAN_CITIES)
                              + ["沿岸島嶼"] * len(COASTAL_ISLAND)}),
    on="市町名", how="left")
admin_geo.plot(ax=ax,
                color=admin_geo["geo_cls"].map(
                    {"中山間": "#fde7d3", "沿岸島嶼": "#dff0fa"}).fillna("#f6f8fa"),
                edgecolor="#888", linewidth=0.5, alpha=0.85)
for disp in DISPLAY_ORDER:
    sub = gdf[gdf["表示情報"] == disp]
    if len(sub) == 0:
        continue
    msz = 60 if disp == "camera" else 100
    sub.plot(ax=ax, color=DISPLAY_COLOR[disp], markersize=msz,
             marker="o" if disp == "camera" else ("^" if disp == "slip" else "s"),
             alpha=0.9, edgecolor="#000", linewidth=0.5,
             label=f"{DISPLAY_LABEL[disp]} ({len(sub)})", zorder=5)

ax.set_xlim(XMIN, XMAX)
ax.set_ylim(YMIN, YMAX)
ax.set_aspect("equal")
ax.set_title(f"図 4 (RQ1): 道路カメラ 表示情報 + 中山間境界 — "
             f"camera {n_camera} / slip {n_slip} / slip_camera {n_slip_camera}",
             fontsize=11)
ax.set_xlabel("X (m, EPSG:6671)")
ax.set_ylabel("Y (m, EPSG:6671)")
patches = [
    Patch(facecolor="#fde7d3", label="中山間市町 (8市町)"),
    Patch(facecolor="#dff0fa", label="沿岸島嶼"),
    Patch(facecolor="#f6f8fa", label="平野・沿岸都市"),
]
for disp in DISPLAY_ORDER:
    n = int((gdf["表示情報"] == disp).sum())
    patches.append(
        Line2D([0], [0], marker=("o" if disp == "camera"
                                  else ("^" if disp == "slip" else "s")),
               color="w", markerfacecolor=DISPLAY_COLOR[disp],
               markeredgecolor="#000",
               markersize=10,
               label=f"{DISPLAY_LABEL[disp]} ({n})"))
ax.legend(handles=patches, loc="lower left", fontsize=9.5, ncol=2)
plt.tight_layout()
save_fig("L75_fig4_display_chusankan_map.png")


# ---- 図 5 (RQ2): 雪情報点 + L73 winter 重ね合わせ ----
print("  fig5: 雪情報 + L73 winter", flush=True)
fig, ax = plt.subplots(figsize=(13, 8))
admin.plot(ax=ax, color="#fff4e0", edgecolor="#888",
           linewidth=0.4, alpha=0.55)
# L73 winter (青背景線)
gdf_winter.plot(ax=ax, color="#0969da", linewidth=2.4, alpha=0.7,
                 zorder=3, label=f"L73 winter ({n_winter})")
# L73 winter 5km buffer (薄青)
gdf_winter_buf.plot(ax=ax, color="#cce8ff", edgecolor="#0969da",
                     linewidth=0.4, alpha=0.4, zorder=2)
# 雪情報なし (camera のみ): 灰小
sub_cam = gdf[gdf["表示情報"] == "camera"]
sub_cam.plot(ax=ax, color="#bbbbbb", markersize=30, alpha=0.6,
             edgecolor="#000", linewidth=0.3,
             label=f"camera のみ ({n_camera})", zorder=4)
# slip のみ: 橙三角
sub_slip = gdf[gdf["表示情報"] == "slip"]
sub_slip.plot(ax=ax, color=DISPLAY_COLOR["slip"], markersize=120,
              marker="^", edgecolor="#000", linewidth=0.5,
              label=f"slip のみ ({n_slip})", zorder=6)
# slip_camera: 赤四角
sub_sc = gdf[gdf["表示情報"] == "slip_camera"]
sub_sc.plot(ax=ax, color=DISPLAY_COLOR["slip_camera"], markersize=120,
            marker="s", edgecolor="#000", linewidth=0.5,
            label=f"slip_camera ({n_slip_camera})", zorder=7)

ax.set_xlim(XMIN, XMAX)
ax.set_ylim(YMIN, YMAX)
ax.set_aspect("equal")
ax.set_title(f"図 5 (RQ2): 雪情報点 + L73 winter 冬期閉鎖 重ね合わせ — "
             f"雪情報点 winter 5km 圏内 "
             f"{n_snow_near_winter}/{n_snow_pts} ({share_snow_near_winter}%)",
             fontsize=10.5)
ax.set_xlabel("X (m, EPSG:6671)")
ax.set_ylabel("Y (m, EPSG:6671)")
ax.legend(loc="lower left", fontsize=9.5)
plt.tight_layout()
save_fig("L75_fig5_snow_winter_map.png")


# ---- 図 6 (RQ2): L74 走行注意 + 道路カメラ 重ね合わせ ----
print("  fig6: L74 + 道路カメラ", flush=True)
fig, ax = plt.subplots(figsize=(13, 8))
admin.plot(ax=ax, color="#fff4e0", edgecolor="#888",
           linewidth=0.4, alpha=0.55)
# L74 (薄橙線)
gdf_l74.plot(ax=ax, color="#ffa500", linewidth=0.8, alpha=0.5, zorder=2)
# 道路カメラ 全 142 地点 (路線種別色)
for rcls in ROUTE_ORDER:
    sub = gdf[gdf["路線種別"] == rcls]
    if len(sub) == 0:
        continue
    sub.plot(ax=ax, color=ROUTE_COLOR[rcls], markersize=55,
             alpha=0.85, edgecolor="#000", linewidth=0.4,
             label=f"{rcls} ({len(sub)})", zorder=4)
ax.set_xlim(XMIN, XMAX)
ax.set_ylim(YMIN, YMAX)
ax.set_aspect("equal")
ax.set_title(f"図 6 (RQ2): 道路カメラ + L74 走行注意 (落石注意, {n_l74}) "
             f"重ね合わせ — L74 5km 圏内 {n_near_l74}/{n_pts} "
             f"({share_near_l74}%)",
             fontsize=10.5)
ax.set_xlabel("X (m, EPSG:6671)")
ax.set_ylabel("Y (m, EPSG:6671)")
patches = [Line2D([0], [0], color="#ffa500", linewidth=2,
                  label=f"L74 走行注意 ({n_l74})")]
for rcls in ROUTE_ORDER:
    n = int((gdf["路線種別"] == rcls).sum())
    patches.append(
        Line2D([0], [0], marker="o", color="w",
               markerfacecolor=ROUTE_COLOR[rcls], markeredgecolor="#000",
               markersize=9, label=f"{rcls} ({n})"))
ax.legend(handles=patches, loc="lower left", fontsize=9.5, ncol=2)
plt.tight_layout()
save_fig("L75_fig6_l74_camera_map.png")


# ---- 図 7 (RQ3): L02 ↔ L75 重ね合わせマップ ----
print("  fig7: L02 vs L75 棲み分け", flush=True)
fig, ax = plt.subplots(figsize=(13, 8))
admin.plot(ax=ax, color="#fff4e0", edgecolor="#888",
           linewidth=0.4, alpha=0.55)

# L02 道路カメラ 単独 (L75 一致なし)
l02_only = gdf_l02[~gdf_l02["has_l75_match"]]
l02_only.plot(ax=ax, color="#7c3aed", marker="x", markersize=80,
              linewidth=1.5, alpha=0.9, zorder=4,
              label=f"L02 道路のみ ({len(l02_only)})")
# L75 単独 (L02 一致なし)
l75_only = gdf[~gdf["has_l02_match"]]
l75_only.plot(ax=ax, color="#bf8700", marker="^", markersize=70,
              edgecolor="#000", linewidth=0.4, alpha=0.9, zorder=5,
              label=f"L75 のみ ({len(l75_only)})")
# 一致点 (L75 + L02)
l75_match = gdf[gdf["has_l02_match"]]
l75_match.plot(ax=ax, color="#1a7f37", marker="o", markersize=80,
               edgecolor="#000", linewidth=0.5, alpha=0.95, zorder=6,
               label=f"L02 ∩ L75 ({n_l75_matched})")

ax.set_xlim(XMIN, XMAX)
ax.set_ylim(YMIN, YMAX)
ax.set_aspect("equal")
ax.set_title(f"図 7 (RQ3): L02 県内カメラ (道路, {n_l02_road} 台) ↔ "
             f"L75 道路カメラ ({n_pts}) 100m 圏 棲み分け — "
             f"一致 {n_l75_matched}/{n_pts} ({share_l75_matched}%)",
             fontsize=10.5)
ax.set_xlabel("X (m, EPSG:6671)")
ax.set_ylabel("Y (m, EPSG:6671)")
ax.legend(loc="lower left", fontsize=10)
plt.tight_layout()
save_fig("L75_fig7_l02_l75_map.png")


# ---- 図 8 (RQ3): 一致率 + 属性比較 ----
print("  fig8: 一致率 + 属性比較", flush=True)
fig, axes = plt.subplots(1, 2, figsize=(14.5, 5.5))

# 左: 3 カテゴリ パイ
ax = axes[0]
cat_names = [f"L02 ∩ L75 ({n_l75_matched})",
             f"L75 のみ ({n_pts - n_l75_matched})",
             f"L02 道路のみ ({n_l02_road - n_l02_matched})"]
cat_vals = [n_l75_matched, n_pts - n_l75_matched,
            max(n_l02_road - n_l02_matched, 0)]
cat_cols = ["#1a7f37", "#bf8700", "#7c3aed"]
total_match = sum(cat_vals)
ax.pie(cat_vals, labels=cat_names, colors=cat_cols,
       autopct=lambda p: f"{p:.1f}%\n({int(round(p*total_match/100))})",
       textprops={"fontsize": 10, "fontweight": "bold"},
       startangle=90, wedgeprops={"edgecolor": "#fff", "linewidth": 1.5})
ax.set_title(f"L02 ↔ L75 100m 圏一致 — \n"
             f"L02 道路 {n_l02_road} 台 / L75 {n_pts} 地点 / "
             f"H4 ({'強支持' if h4_ok else '反証'})",
             fontsize=10.5)

# 右: 属性数 + 独自属性 比較
ax = axes[1]
ds = ["L02 (1279)", "L75 (1260)"]
n_total = [n_l02_total, n_pts]
n_road = [n_l02_road, n_pts]
n_attrs = [n_l02_attrs, n_l75_attrs]
xs = np.arange(2)
w = 0.27
ax.bar(xs - w, n_total, w, color="#0969da", edgecolor="#000",
       linewidth=0.4, label="全件数 (横断/特化)")
ax.bar(xs, n_road, w, color="#1a7f37", edgecolor="#000", linewidth=0.4,
       label="道路カメラ件数")
ax.bar(xs + w, [v * 10 for v in n_attrs], w, color="#cf6f00",
       edgecolor="#000", linewidth=0.4,
       label="属性列数 ×10 (スケール調整)")
for x, v in zip(xs - w, n_total):
    ax.text(x, v + max(n_total) * 0.02, f"{v}", ha="center", fontsize=9,
            fontweight="bold")
for x, v in zip(xs, n_road):
    ax.text(x, v + max(n_total) * 0.02, f"{v}", ha="center", fontsize=9,
            fontweight="bold")
for x, v in zip(xs + w, n_attrs):
    ax.text(x, v * 10 + max(n_total) * 0.02, f"{v} 列", ha="center",
            fontsize=9, fontweight="bold")
ax.set_xticks(xs)
ax.set_xticklabels(ds, fontsize=11)
ax.set_ylabel("件数 / 属性列数 ×10")
ax.set_title(f"L02 vs L75 属性 + 件数 比較\n"
             f"L75 独自属性 = 路線番号 + 路線名 + 標高 + 雪情報",
             fontsize=10.5)
ax.legend(fontsize=9, loc="upper left")
ax.grid(True, axis="y", alpha=0.3)

fig.suptitle("図 8 (RQ3): L02 ↔ L75 棲み分け 一致率 + 属性比較",
             fontsize=12.5, y=1.02)
plt.tight_layout()
save_fig("L75_fig8_l02_compare.png")

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


# =============================================================================
# 10. 仮説検証 + サマリ表
# =============================================================================
print("\n[10] 仮説検証", flush=True)
t10 = time.time()


def df_to_html(d):
    return d.to_html(index=False, classes="", border=0, escape=False,
                     na_rep="-").replace(' style="text-align: right;"', "")


# データセット仕様表
station_size = STATION_CSV.stat().st_size if STATION_CSV.exists() else 0
winter_size = WINTER_ZIP.stat().st_size if WINTER_ZIP.exists() else 0
camera_size = CAMERA_ZIP.stat().st_size if CAMERA_ZIP.exists() else 0
total_size = station_size + winter_size + camera_size
T_dataset = pd.DataFrame([
    ("dataset_id", str(DATASET_ID)),
    ("公式名", "道路カメラ・冬期道路情報"),
    ("公式説明",
     "広島県が管理する道路のカメラ画像，路面状況（積雪深・圧雪深・気温・路面温度・路面状態）"),
    ("リソース数", "4 (CSV + xlsx + winter ZIP + image ZIP)"),
    ("リソース ID",
     f"{RES_STATION} (一覧 CSV) / {RES_SPEC} (仕様 xlsx) / "
     f"{RES_WINTER} (winter ZIP) / {RES_CAMERA} (image ZIP)"),
    ("総サイズ", f"{total_size:,} byte (~{total_size/1024/1024:.1f} MB)"),
    ("CSV 列",
     "観測地点ID, 観測所名, 設置場所 (標高埋込), 路線名, 緯度, 経度, "
     "路線番号, 表示情報"),
    ("レコード数",
     f"観測地点 {n_pts} (camera={n_camera} / slip={n_slip} / "
     f"slip_camera={n_slip_camera})"),
    ("カメラ画像数", f"~135 jpg (camera + slip_camera = {n_with_camera})"),
    ("雪情報レコード数", f"{n_snow_records} (slip + slip_camera = {n_with_slip})"),
    ("座標系 (元)",
     "WGS84 (EPSG:4326) → 本記事 EPSG:6671 で処理"),
    ("ライセンス", "クリエイティブ・コモンズ表示 (CC-BY)"),
    ("URL", f"https://hiroshima-dobox.jp/datasets/{DATASET_ID}"),
    ("作成主体",
     "広島県 (土木建築局道路整備課・冬期道路担当)"),
], columns=["項目", "値"])


# 全体サマリ
T_overall = pd.DataFrame([
    ("dataset", f"#{DATASET_ID} 道路カメラ・冬期道路情報"),
    ("総観測地点 (RQ1)", f"{n_pts}"),
    ("camera のみ (RQ1)", f"{n_camera} ({100*n_camera/n_pts:.1f}%)"),
    ("slip のみ (RQ1)", f"{n_slip}"),
    ("slip_camera (RQ1)", f"{n_slip_camera}"),
    ("国道+主要地方道 (RQ1)", f"{n_kokudo_shuyo} ({share_kokudo_shuyo}%)"),
    ("標高 ≥ 200m (RQ1)", f"{n_high_alt} ({share_high_alt}%)"),
    ("標高中央値 m (RQ1)", f"{int(gdf['標高_m'].median())}"),
    ("標高最大 m (RQ1)", f"{int(gdf['標高_m'].max())}"),
    ("中山間 地点数 (RQ1)", f"{n_chusankan} ({share_chusankan}%)"),
    ("最多市町 (RQ1)",
     f"{T_city.iloc[0]['市町名']} ({int(T_city.iloc[0]['地点数'])} 件)"
     if len(T_city) > 0 else "-"),
    ("H1 (国道+主要 ≥ 60% & 標高 ≥ 200m が ≥ 50%) (RQ1)",
     "強支持" if h1_ok else "反証"),
    ("L73 winter 区間 (RQ2)", f"{n_winter}"),
    ("雪情報点 winter 5km 圏内 (RQ2)",
     f"{n_snow_near_winter}/{n_snow_pts} ({share_snow_near_winter}%)"),
    ("142 地点 L74 5km 圏内 (RQ2)",
     f"{n_near_l74} ({share_near_l74}%)"),
    ("H2 (雪情報 winter 近接 ≥ 70%) (RQ2)",
     "強支持" if h2_ok else "反証"),
    ("H3 (L74 近接 < 30%) (RQ2)",
     "強支持" if h3_ok else "反証"),
    ("L02 道路カメラ件数 (RQ3)", f"{n_l02_road}"),
    ("L75 ↔ L02 100m 一致 (RQ3)",
     f"{n_l75_matched}/{n_pts} ({share_l75_matched}%)"),
    ("L02 道路のみ (RQ3)", f"{n_l02_road - n_l02_matched}"),
    ("L75 のみ (RQ3)", f"{n_pts - n_l75_matched}"),
    ("H4 (一致 ≥ 70%) (RQ3)", "強支持" if h4_ok else "反証"),
    ("H5 (L75 独自属性 ≥ 3 種) (RQ3)",
     "強支持" if h5_ok else "反証"),
], columns=["指標", "値"])
T_overall.to_csv(ASSETS / "L75_overall.csv",
                 index=False, encoding="utf-8-sig")


# 仮説検証 (H1〜H5)
def jud(cond, ok="強支持", fail="反証"):
    return ok if cond else fail


T_hypo = pd.DataFrame([
    ("H1 路線種別 + 標高: 国道+主要 ≥ 60% & 標高 ≥ 200m が ≥ 50% (RQ1)",
     f"観測 = 国道+主要 {n_kokudo_shuyo}/{n_pts} ({share_kokudo_shuyo}%) / "
     f"標高 ≥ 200m {n_high_alt}/{n_pts} ({share_high_alt}%)",
     jud(h1_ok),
     f"H1 {jud(h1_ok)}: 道路カメラ {n_pts} 地点のうち<b>国道 + 主要地方道</b>が "
     f"<b>{n_kokudo_shuyo} ({share_kokudo_shuyo}%)</b>、 <b>標高 ≥ 200m</b> が "
     f"<b>{n_high_alt} ({share_high_alt}%)</b>。 国道 (赤) + 主要地方道 (橙) で "
     f"県内主要交通路を、 標高 ≥ 200m の中山間 + 山岳道路に重点配置という "
     f"「重要路線優先設計」 の物理形が実証された。 標高最大は<b>{int(gdf['標高_m'].max())} m</b>"
     f" (廿日市市吉和の国道 186 号沿い 700m 級峠)、 標高中央<b>{int(gdf['標高_m'].median())} m</b>"
     f"。 これは「ライブカメラ = 平地都市部の防犯カメラ」 の常識を覆し、 "
     f"<b>「道路カメラ = 山岳重要路線のリアルタイム監視装置」</b>という制度設計を量化。"),
    (f"H2 雪情報 ⊂ 冬期閉鎖 5km 圏内: ≥ 70% (RQ2)",
     f"観測 = 雪情報点 winter 5km 圏内 {n_snow_near_winter}/{n_snow_pts} "
     f"({share_snow_near_winter}%)",
     jud(h2_ok),
     (f"H2 強支持: 雪情報を持つ {n_snow_pts} 地点 (slip + slip_camera) のうち、 "
      f"L73 winter 冬期閉鎖区間 ({n_winter} 区間 / {total_km_winter:.0f} km) の "
      f"<b>5km 圏内に位置するのが {n_snow_near_winter} 地点 "
      f"({share_snow_near_winter}%)</b>。 雪情報監視が「冬期閉鎖区間の前線監視」 "
      f"として運用される制度的補完関係が量的に支持された。"
      if h2_ok else
      f"H2 反証: 雪情報を持つ {n_snow_pts} 地点 (slip + slip_camera) のうち、 "
      f"L73 winter 冬期閉鎖区間 ({n_winter} 区間 / {total_km_winter:.0f} km) の "
      f"<b>5km 圏内に位置するのは {n_snow_near_winter} 地点 "
      f"({share_snow_near_winter}%) のみ</b>で、 70% 閾値に届かない。 これは "
      f"<b>「雪情報範囲 ≫ 冬期閉鎖範囲」</b>という重要な発見を意味する: 県は "
      f"冬期閉鎖 ({n_winter} 区間 / {total_km_winter:.0f} km) より<b>はるかに広い "
      f"範囲</b>(残り {n_snow_pts - n_snow_near_winter} 地点 = "
      f"{100-share_snow_near_winter:.1f}%) で雪情報監視を行っている。 つまり雪情報は "
      f"「冬期閉鎖の前線監視」 ではなく<b>「冬期閉鎖されない区間の通行可否判断 + "
      f"自主的予防対策の根拠データ」</b>として広域配置されている。 これは "
      f"<b>「L73 winter = 自動規制 (狭い)、 L75 雪情報 = 全県凍結監視 (広い)」</b>"
      f"という<b>2 層の冬期道路安全制度</b>を量的に発見した — 規制発動の自動判断は "
      f"限定路線、 凍結リスク広域監視は L75 が担う制度分化。")),
    ("H3 L75 ⊥ L74 走行注意: L74 近接 < 30% (RQ2)",
     f"観測 = 142 地点 L74 5km 圏内 {n_near_l74} ({share_near_l74}%)",
     jud(h3_ok),
     (f"H3 強支持: L75 道路カメラ {n_pts} 地点のうち L74 走行注意 ({n_l74} 区間) "
      f"5km 圏内に位置するのが <b>{n_near_l74} 地点 ({share_near_l74}%)</b>。 "
      f"道路カメラは凍結・路面状態・路線監視に特化し、 落石注意 (L74) とは独立分布。"
      if h3_ok else
      f"H3 反証: L75 道路カメラ {n_pts} 地点のうち L74 走行注意 ({n_l74} 区間) "
      f"5km 圏内に位置するのが <b>{n_near_l74} 地点 ({share_near_l74}%)</b>と、 "
      f"30% 閾値を大幅に上回る。 これは<b>「道路カメラと落石注意は独立」 という "
      f"当初仮説を反証</b>する重要な発見である: 県の主要山岳道路では<b>道路カメラ "
      f"が L74 走行注意区間と同じ路線群</b>に設置されている。 これは「同じ脆弱地形 "
      f"= 同じ路線」 であり、 制度目的は異なる (L74 = 静的予防情報 / L75 = リアル "
      f"タイム監視) が、 物理的には<b>共通の山岳重要路線群</b>に集中する。 つまり "
      f"<b>「県の脆弱山岳道路網」</b>は L74 の落石静的予防、 L75 の凍結 + 路面 "
      f"動的監視、 L73 の雨量自動規制、 という 3 制度で<b>多重に監視される</b>。 "
      f"H3 反証は「機能分化と空間集中の同時成立」 という制度設計の発見。")),
    (f"H4 L02 ↔ L75 100m 圏一致 ≥ 70% (RQ3)",
     f"観測 = L75 ↔ L02 100m 圏一致 {n_l75_matched}/{n_pts} "
     f"({share_l75_matched}%)",
     jud(h4_ok),
     f"H4 {jud(h4_ok)}: L02 (dataset 1279) の道路区分 + 広島県所管 "
     f"<b>{n_l02_road} 台</b> と L75 (dataset 1260) の <b>{n_pts} 地点</b>を "
     f"100m 圏で照合した一致は <b>{n_l75_matched}/{n_pts} = {share_l75_matched}%</b>。 "
     f"同じ物理カメラが両 dataset に登録される<b>「データセット重複公開」</b>を量的に "
     f"確認した。 L02 = 用途横断 (道路+河川+ため池+海岸+港湾) の汎用 dataset、 "
     f"L75 = 道路特化 + 雪情報統合の専門 dataset で、 同じカメラを異なる属性セットで "
     f"提供する<b>「制度的二重公開」</b>の存在を実証。"),
    ("H5 L75 独自属性 ≥ 3 種 (RQ3)",
     f"観測 = L75 独自属性 = 路線番号 + 路線名 + 標高 (設置場所内) + "
     f"表示情報 + 雪情報 = 5 種",
     jud(h5_ok),
     f"H5 {jud(h5_ok)}: L75 が L02 に対して持つ独自属性は<b>(1) 路線番号 "
     f"(国道 186 号 / 県道 30 号 等)</b>、 <b>(2) 路線名 (主要地方道 矢野安浦線 等)</b>、 "
     f"<b>(3) 標高 (設置場所列に「標高Nm」 と埋込)</b>、 <b>(4) 表示情報 "
     f"(camera/slip/slip_camera)</b>、 <b>(5) 雪情報 (圧雪深・積雪深・気温・路面温度・"
     f"路面状態)</b>の 5 種。 L02 単独では<b>「国道 186 号沿いのカメラを抽出」 や "
     f"「標高 500m 以上の山岳カメラを集計」 が不可能</b>だが、 L75 を併用すれば "
     f"これらの<b>道路特化分析</b>が実装可能。 L02 vs L75 の<b>「汎用 vs 専門」 "
     f"二重公開設計</b>は、 用途別ユーザに異なる属性レベルを提供する<b>「データ層別化」</b>"
     f"戦略の量的事例。"),
], columns=["仮説", "観測値", "判定", "詳細解説"])
T_hypo.to_csv(ASSETS / "L75_hypothesis_check.csv",
              index=False, encoding="utf-8-sig")

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


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


# ----- セクション 1: 学習目標と問い -----
sec1 = f"""
<h3>本記事の対象 — 「道路カメラ・冬期道路情報」 1 件 単独分析</h3>
<p>本記事は <a href="https://hiroshima-dobox.jp/datasets/{DATASET_ID}"
target="_blank">DoBoX のデータセット <b>「道路カメラ・冬期道路情報」 (dataset {DATASET_ID})</b></a>
1 件を <b>単独</b>で取り上げ、 広島県の道路カメラ・冬期道路情報
<b>{n_pts} 観測地点 / カメラ画像 ~{n_with_camera} 枚 / 雪情報 {n_with_slip} 地点</b>を
<b>3 つの独立した研究角度</b>で並列に分析する記事である。
他のシリーズ (県内カメラ L02 / 事前通行規制 L73 / 走行注意 L74 / 緊急輸送
道路 L72) と本記事は <b>合体しない</b>。 RQ2 で L73 winter (冬期閉鎖) と
L74 (走行注意, 落石)、 RQ3 で L02 (県内カメラ, 351 台) を参照するが、 これは
「県の道路特化監視ネットワークの位置付け」 を明らかにするための既扱データの
<b>従属的参照</b>に留め、 本記事の主軸はあくまで道路カメラ・冬期道路情報
1 dataset の分析である。</p>

<div class="note">
  <b>「道路カメラ・冬期道路情報」 とは:</b><br>
  広島県土木建築局道路整備課が管理する道路の<b>ライブ監視カメラ</b>と
  <b>路面状況リアルタイム情報</b>(積雪深・圧雪深・気温・路面温度・路面状態)
  を 1 つのデータセットに統合した<b>「道路特化リアルタイム監視ネットワーク」</b>。
  ドライバーへの情報提供 (DoBoX Web ポータル) と道路管理者の運用判断 (除雪
  出動・チェーン規制決定・交通規制) の双方に使われる。<br><br>

  本データセットの 3 構成要素:
  <ul>
    <li><b>観測所一覧 CSV (resource {RES_STATION})</b>: {n_pts} 観測地点の
        位置 (緯度経度) + 路線名 + 路線番号 + 設置場所 (標高埋込) + 表示情報</li>
    <li><b>カメラ画像 ZIP (resource {RES_CAMERA})</b>: ~135 枚の jpg ライブ画像
        (~10 分更新) + camera_time.csv (撮影時刻)</li>
    <li><b>冬期道路情報 ZIP (resource {RES_WINTER})</b>: {n_snow_records} 観測地点の
        路面状態リアルタイム CSV (圧雪深・積雪深・気温・路面温度・路面状態)</li>
  </ul>

  本記事の主要発見 (3 RQ):
  <ul>
    <li><b>RQ1:</b> 県の道路カメラは<b>{n_pts} 地点</b>、 国道 + 主要地方道が
        <b>{n_kokudo_shuyo} ({share_kokudo_shuyo}%)</b>、 標高 ≥ 200m が
        <b>{n_high_alt} ({share_high_alt}%)</b>。 中山間山地に <b>{n_chusankan} 地点
        ({share_chusankan}%)</b>が集中、 「重要路線優先 + 山岳重点」 の物理形が確認。</li>
    <li><b>RQ2:</b> 雪情報 {n_snow_pts} 地点のうち L73 winter 冬期閉鎖
        5km 圏内は <b>{n_snow_near_winter} ({share_snow_near_winter}%)</b>のみで
        H2 (≥ 70%) {jud(h2_ok)} — 雪情報範囲 ≫ 冬期閉鎖範囲という<b>2 層の冬期
        道路安全制度</b>を発見。 道路カメラ全 {n_pts} 地点の L74 走行注意 5km 圏内は
        <b>{n_near_l74} ({share_near_l74}%)</b>で H3 (&lt; 30%) {jud(h3_ok)} —
        県の脆弱山岳路線で<b>L75 + L74 + L73 が多重監視</b>する集中配置を発見。</li>
    <li><b>RQ3:</b> L02 道路カメラ {n_l02_road} 台 と L75 {n_pts} 地点の 100m 圏
        一致は <b>{n_l75_matched} ({share_l75_matched}%)</b> = 同じ物理カメラの
        二重公開。 L75 独自属性 (路線番号 + 路線名 + 標高 + 表示情報 + 雪情報) で
        L02 の汎用カメラ網を道路特化 5 種属性で強化。</li>
  </ul>
</div>

<h3>独自に定義する用語 (本記事限定)</h3>
<ul class="kv">
  <li><b>道路カメラ (本記事の中心概念)</b>: 広島県管理道路に設置された
      ライブ監視カメラ。 国道 + 県道 + 主要地方道に重点配置され、 ドライバーと
      道路管理者の両方が ~10 分更新の路面状況をリアルタイム把握できる。
      L02 (汎用カメラ 351 台) のサブセットだが、 本データセットは<b>道路特化属性</b>
      (路線番号 + 標高 + 路面状態) を付加した別系統公開。</li>
  <li><b>冬期道路情報 (本記事の副概念)</b>: 12 月〜3 月の冬期に道路カメラ
      観測点で測定される<b>圧雪深 + 積雪深 + 気温 + 路面温度 + 路面状態</b>
      の 5 指標。 道路カメラと一体運用され、 除雪出動・チェーン規制・路線
      閉鎖の判断基準として機能。</li>
  <li><b>凍結注意 (本記事独自)</b>: 「路面状態」 列に出現するカテゴリ値。
      春季 (4 月) 観測では「乾燥」 が支配的だが、 冬期は「凍結」 「圧雪」 「シャ
      ーベット」 等のカテゴリが現れ、 ドライバーへの注意喚起情報として
      DoBoX Web ポータルに表示される。</li>
  <li><b>チェーン規制 (本記事独自背景説明)</b>: 路面状態が一定の積雪・凍結
      状態に達した際に道路管理者が発動する規制。 タイヤチェーン未装着車の通行
      禁止。 道路カメラ + 雪情報はこの規制発動判断の根拠データ。</li>
  <li><b>監視ネットワーク (本記事独自)</b>: 142 観測地点 ({n_camera} カメラ
      + {n_slip} 雪情報 + {n_slip_camera} 両方) を統合的に捉えた本記事独自
      フレーム。 単独カメラではなく「県内全域の道路状況をリアルタイム俯瞰
      する監視網」 として機能。</li>
  <li><b>道路特化監視 (本記事独自)</b>: 路線番号 + 路線名 + 標高 + 路面状態
      の 4 種属性で<b>道路管理に特化</b>した監視概念。 L02 (汎用カメラ:
      道路+河川+ため池+海岸+港湾) と異なり、 L75 は道路のみに焦点を絞り、
      ドライバー情報提供と道路管理運用に最適化された属性セットを提供。</li>
  <li><b>表示情報 (本記事独自呼称)</b>: 公式 CSV の「表示情報」 列に格納
      される 3 値分類: <b>camera</b> ({n_camera}, ライブ画像のみ提供) /
      <b>slip</b> ({n_slip}, 路面状態のみ提供) / <b>slip_camera</b>
      ({n_slip_camera}, 両方提供 = 重要監視点)。 slip_camera は冬期重要路線の
      重複設置で、 「映像 + 数値」 の二重情報提供箇所。</li>
  <li><b>路線種別 (本記事独自分類)</b>: CSV 路線名先頭から判定する
      <b>国道 / 主要地方道 / 一般県道 / その他</b>の 4 分類。 公式分類ではないが、
      道路の「重要度・通行量・管理予算」 を反映する事実上の階層構造。</li>
  <li><b>標高クラス (本記事独自分類)</b>: 設置場所列に「標高Nm」 形式で
      埋め込まれた値を抽出し、 <b>低 (&lt;100m) / 中 (100-300m) / 高 (300-500m)
      / 山岳 (≥500m)</b>の 4 クラスに分割。 道路カメラの「山岳監視特化度」 を
      量化する独自指標。</li>
  <li><b>重要路線優先設計 (本記事独自フレーム)</b>: 道路カメラが国道 +
      主要地方道に重点配置され、 標高 ≥ 200m の中山間 + 山岳道路に過半数集中する
      設置思想。 限られた予算で<b>「県内交通の動脈の状態を最大限把握する」</b>
      ための制度設計。 H1 で量的検証。</li>
  <li><b>凍結リスク監視ネットワーク (本記事独自)</b>: L73 winter (冬期閉鎖) +
      L75 雪情報点 (slip + slip_camera) を統合した広域凍結監視概念。 規制発動
      ({n_winter} 区間 / {total_km_winter:.0f} km) と前線情報 ({n_snow_pts} 地点) が
      補完関係を成す<b>2 層の冬期道路安全制度</b>。 H2 で量的検証。</li>
  <li><b>機能分化 (本記事独自フレーム)</b>: 県の道路情報を「リアルタイム情報
      提供 (L75 道路カメラ・雪情報) vs 静的予防情報 (L74 走行注意, L73 事前
      通行規制)」 と分けて捉えるフレーム。 H3 (L75 ⊥ L74) で量的支持。</li>
  <li><b>データセット二重公開 (本記事独自)</b>: 同じ物理カメラが L02 (汎用
      横断 dataset) と L75 (道路特化 dataset) の<b>両方に登録</b>される
      公開設計。 用途別ユーザ (汎用検索 vs 道路管理者) に異なる属性レベルを
      提供する<b>「データ層別化」</b>戦略の事例。 H4 で量的検証。</li>
  <li><b>中山間山地 (本記事独自定義)</b>: 庄原市・三次市・安芸太田町・
      安芸高田市・北広島町・神石高原町・世羅町・府中市の 8 市町。 公式分類
      ではないが、 地形・人口密度から「中山間」 と一般に呼ばれる地域。 L73 / L74
      と同じ定義を採用。</li>
</ul>

<h3>研究の問い (3 RQ)</h3>
<ol>
  <li><b>RQ1 (主研究):</b> 広島県の道路カメラ・冬期道路情報の<b>地理分布と設置構造
      — 路線種別 × 標高 × 市町 × 表示情報</b>はどう描けるか? {n_pts} 観測地点を
      4 軸で集計し、 「県の道路特化監視ネットワーク」 の物理形状を初めて系統的に
      記述する。 H1 (国道+主要 ≥ 60% かつ 標高 ≥ 200m が ≥ 50%) を検証。</li>
  <li><b>RQ2 (副研究 1):</b> 道路カメラ・冬期道路情報と<b>冬期通行止め (L73 winter)
      との関連 — 雪道監視ネットワーク</b>はどう描けるか? 雪情報 {n_snow_pts} 地点と
      L73 winter {n_winter} 区間を 5km 圏で sjoin、 さらに L74 走行注意 ({n_l74} 区間)
      との対比で「機能的差 — 落石 vs 凍結」 を分離する。 H2 (雪情報 winter 近接
      ≥ 70%), H3 (L74 近接 &lt; 30%) を検証。</li>
  <li><b>RQ3 (副研究 2):</b> L02 県内カメラ (351 台) と L75 道路カメラ ({n_pts} 地点)
      の<b>棲み分け — 道路特化監視</b>はどう描けるか? L02 の道路区分 + 県所管
      ({n_l02_road} 台) と L75 を 100m 圏で照合 → 一致率 + 属性差を量化する。
      H4 (一致 ≥ 70%), H5 (L75 独自属性 ≥ 3 種) を検証。</li>
</ol>

<h3>仮説 (5)</h3>
<ul>
  <li><b>H1</b> (RQ1, 路線種別 + 標高): 国道 + 主要地方道 ≥ 60%、 標高 ≥ 200m が
      ≥ 50%。 「重要路線優先 + 山岳重点」 設置思想仮説。</li>
  <li><b>H2</b> (RQ2, 雪情報 ⊂ 冬期閉鎖近接): 雪情報 {n_snow_pts} 地点のうち
      L73 winter 5km 圏内 ≥ 70%。 雪情報 = 冬期閉鎖の前線監視仮説。</li>
  <li><b>H3</b> (RQ2, 落石とは独立): L75 道路カメラの L74 (落石注意) 5km 圏内
      &lt; 30%。 道路カメラは凍結・路面に特化、 落石注意とは独立分布仮説。</li>
  <li><b>H4</b> (RQ3, L02 道路カメラとの一致): L02 道路区分 ({n_l02_road} 台) と
      L75 の 100m 圏一致 ≥ 70%。 同じ物理カメラの二重公開仮説。</li>
  <li><b>H5</b> (RQ3, L75 独自属性): L75 が L02 にない独自属性を ≥ 3 種提供。
      道路特化監視に必要な属性層別化の量的事例仮説。</li>
</ul>

<h3>到達点</h3>
<p>本記事を読み終えると、 (1) 県の道路カメラ {n_pts} 観測地点・カメラ画像
~{n_with_camera}・雪情報 {n_with_slip} の物理構造を完全に俯瞰、 (2) 雪情報と
L73 winter (冬期閉鎖) の補完関係 + L74 走行注意との独立性を量的に把握、
(3) L02 (汎用) と L75 (道路特化) の二重公開設計と道路特化属性の付加価値を
特定できる、 という 3 段階の知識が獲得できる。 これにより県の道路情報配信制度
における<b>「リアルタイム情報提供 (動的) vs 静的予防情報 (規制 + 注意)」</b>
という機能分化、 および<b>「汎用カメラ網 (L02) ⊃ 道路特化監視 (L75)」</b>という
データ層別化設計が研究者視点で見えるようになる。</p>
"""


# ----- セクション 2: 使用データ -----
sec2 = f"""
<p>本研究で使う <b>1 つの dataset (4 リソース)</b> を以下の表に示す。
本データは「道路カメラ + 冬期道路情報」 を 1 つのカタログ単位で公開しており、
内部に 4 つの異なる形式のリソース (CSV + xlsx + winter ZIP + image ZIP) が
格納されている統合データセット。</p>

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

<h3>4 リソースの内訳</h3>
<table>
<tr><th>リソース</th><th>形式</th><th>役割</th><th>件数</th></tr>
<tr><td>resource {RES_STATION} (観測所一覧)</td>
    <td>CSV (UTF-8 BOM)</td>
    <td>{n_pts} 観測地点の位置 + 属性</td>
    <td>{n_pts} 行 × 8 列</td></tr>
<tr><td>resource {RES_SPEC} (仕様説明)</td>
    <td>xlsx (~11 KB, 拡張子は .bin で配信)</td>
    <td>N_snow.csv と camera_time.csv の列定義</td>
    <td>2 sheet</td></tr>
<tr><td>resource {RES_WINTER} (冬期道路情報)</td>
    <td>ZIP (~4 KB, 拡張子は .json で配信)</td>
    <td>{n_snow_records} 観測地点の路面状態 CSV (cp932)</td>
    <td>{n_snow_records} ファイル</td></tr>
<tr><td>resource {RES_CAMERA} (道路カメラ画像)</td>
    <td>ZIP (~10 MB, 拡張子は .json で配信)</td>
    <td>{n_with_camera} 観測地点のライブ画像 jpg + 撮影時刻 CSV</td>
    <td>~135 jpg + 1 CSV</td></tr>
</table>

<h3>観測所 CSV の列定義</h3>
<table>
<tr><th>列名</th><th>例</th><th>意味</th></tr>
<tr><td>観測地点ID</td><td>131</td><td>1〜158 のうち本データに含まれる ID。 N_snow.csv のファイル名にも使われる</td></tr>
<tr><td>観測所名</td><td>中畑</td><td>地名・施設名等</td></tr>
<tr><td>設置場所</td><td>呉市安浦町大字中畑【標高106m】</td><td>住所 + 標高埋込文字列。 標高は正規表現抽出</td></tr>
<tr><td>路線名</td><td>主要地方道　矢野安浦線</td><td>路線種別 (国道/主要地方道/一般県道) を含む</td></tr>
<tr><td>緯度</td><td>34.30031</td><td>WGS84</td></tr>
<tr><td>経度</td><td>132.71399</td><td>WGS84</td></tr>
<tr><td>路線番号</td><td>広島県道34号</td><td>個別路線の識別子</td></tr>
<tr><td>表示情報</td><td>camera</td><td>camera (画像のみ) / slip (路面のみ) / slip_camera (両方)</td></tr>
</table>

<h3>N_snow.csv の列定義 (cp932 エンコーディング)</h3>
<table>
<tr><th>列番号</th><th>意味</th><th>例</th></tr>
<tr><td>1</td><td>観測日 (yyyy-mm-dd)</td><td>2026-04-01</td></tr>
<tr><td>2</td><td>観測時間 (hh:mm:ss)</td><td>07:50:00</td></tr>
<tr><td>3</td><td>圧雪深 (cm)</td><td>0 (4 月時点 = 凍結期外)</td></tr>
<tr><td>4</td><td>積雪深 (cm)</td><td>0</td></tr>
<tr><td>5</td><td>気温 (℃)</td><td>11</td></tr>
<tr><td>6</td><td>路面温度 (℃)</td><td>13</td></tr>
<tr><td>7</td><td>路面状態</td><td>乾燥 / 湿潤 / 凍結 / 圧雪 / シャーベット / ---</td></tr>
</table>

<h3>形式特性の注意点</h3>
<ul>
  <li>resource {RES_SPEC} (xlsx) と resource {RES_WINTER} / {RES_CAMERA} (ZIP)
      は<b>拡張子が .bin / .json で配信</b>される。 magic 判定 (PK\\x03\\x04) で
      ZIP を確認、 xlsx として再保存して openpyxl で読む手順が必要。</li>
  <li>N_snow.csv は<b>cp932 (シフト JIS) エンコーディング</b>で配信。
      <code>read.decode("cp932")</code> で読み込む。 ヘッダ行なし、 1 ファイル
      = 1 行 = 7 カラム CSV。</li>
  <li>標高は CSV の独立列ではなく<b>「設置場所」 列に「標高Nm」 形式で埋込</b>。
      正規表現 <code>r"標高(\\d+)m"</code> で抽出する。 これは住所表記の
      文化的慣習で、 山岳道路の地点表現として「呉市〇〇【標高Nm】」 と書く
      地理学的表現を踏襲。</li>
  <li>観測地点 ID は離散的 (1, 2, ..., 158 のうち 142 個) で<b>連番ではない</b>。
      欠番は廃止された観測地点 (機材故障・観測終了) と推定。</li>
  <li>カメラ画像は ~10 分更新の<b>スナップショット配信</b>。 連続動画ではない。
      過去画像のアーカイブも本 dataset には含まれない (リアルタイム配信のみ)。</li>
  <li>雪情報は冬期 (12 月〜3 月) に活動が集中、 春季 (4 月以降) は気温・路面温度
      観測のみで「圧雪深 = 0 / 積雪深 = 0」 が普通。 本記事の観測時刻
      (2026-04-01 07:50) は<b>凍結期外</b>のスナップショット。</li>
</ul>
"""


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

<h3>生データ (DoBoX 1 dataset, 4 リソース)</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/{RES_STATION}"
        target="_blank">resource {RES_STATION} (観測所一覧 CSV) 直 DL</a> — 約 {station_size/1024:.0f} KB (UTF-8)</li>
  <li><a href="https://hiroshima-dobox.jp/resource_download/{RES_SPEC}"
        target="_blank">resource {RES_SPEC} (仕様説明 xlsx) 直 DL</a> — 約 11 KB (.bin で配信、 実体 xlsx)</li>
  <li><a href="https://hiroshima-dobox.jp/resource_download/{RES_WINTER}"
        target="_blank">resource {RES_WINTER} (冬期道路情報 ZIP) 直 DL</a> — 約 {winter_size/1024:.0f} KB (.json で配信、 実体 ZIP)</li>
  <li><a href="https://hiroshima-dobox.jp/resource_download/{RES_CAMERA}"
        target="_blank">resource {RES_CAMERA} (道路カメラ画像 ZIP) 直 DL</a> — 約 {camera_size/1024/1024:.1f} MB (.json で配信、 実体 ZIP)</li>
</ul>

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

<h3>中間 CSV (本記事生成、 再利用可)</h3>
<ul class="kv">
  <li><a href="assets/L75_all_stations.csv" download>L75_all_stations.csv</a>
      — 全 {n_pts} 観測地点 (基本属性 + 派生列 + 雪情報結合 計 約 25 列)</li>
  <li><a href="assets/L75_display_summary.csv" download>L75_display_summary.csv</a>
      — 表示情報別 集計 (RQ1)</li>
  <li><a href="assets/L75_route_summary.csv" download>L75_route_summary.csv</a>
      — 路線種別 集計 (RQ1)</li>
  <li><a href="assets/L75_altitude_summary.csv" download>L75_altitude_summary.csv</a>
      — 標高クラス 集計 (RQ1)</li>
  <li><a href="assets/L75_geo_class.csv" download>L75_geo_class.csv</a>
      — 地理クラス別 集計 (RQ1)</li>
  <li><a href="assets/L75_city_summary.csv" download>L75_city_summary.csv</a>
      — 市町別 集計 (RQ1)</li>
  <li><a href="assets/L75_route_x_altitude.csv" download>L75_route_x_altitude.csv</a>
      — 路線種別 × 標高クラス クロス (RQ1)</li>
  <li><a href="assets/L75_rosen_top.csv" download>L75_rosen_top.csv</a>
      — 路線番号別 Top (RQ1)</li>
  <li><a href="assets/L75_snow_winter.csv" download>L75_snow_winter.csv</a>
      — 雪情報点 ↔ L73 winter 近接 (RQ2)</li>
  <li><a href="assets/L75_l74_winter_cross.csv" download>L75_l74_winter_cross.csv</a>
      — 142 地点 × L74 / L73 winter 4 カテゴリ (RQ2)</li>
  <li><a href="assets/L75_road_state.csv" download>L75_road_state.csv</a>
      — 雪情報持ち地点の路面状態分布 (RQ2)</li>
  <li><a href="assets/L75_snow_stations_detail.csv" download>L75_snow_stations_detail.csv</a>
      — 雪情報持ち {n_snow_pts} 地点 詳細 (RQ2)</li>
  <li><a href="assets/L75_l02_match.csv" download>L75_l02_match.csv</a>
      — L02 ↔ L75 棲み分け 3 カテゴリ (RQ3)</li>
  <li><a href="assets/L75_l02_match_detail.csv" download>L75_l02_match_detail.csv</a>
      — 142 地点別 L02 マッチ詳細 (RQ3)</li>
  <li><a href="assets/L75_attr_compare.csv" download>L75_attr_compare.csv</a>
      — L02 vs L75 属性比較 (RQ3)</li>
  <li><a href="assets/L75_overall.csv" download>L75_overall.csv</a>
      — 全体サマリ</li>
  <li><a href="assets/L75_hypothesis_check.csv" download>L75_hypothesis_check.csv</a>
      — H1〜H5 仮説検証結果</li>
</ul>

<h3>図 (PNG, 直 DL 可)</h3>
<ul class="kv">
  <li><a href="assets/L75_fig1_route_map.png" download>fig1 路線種別別マップ (RQ1)</a></li>
  <li><a href="assets/L75_fig2_altitude_map.png" download>fig2 標高色分けマップ (RQ1)</a></li>
  <li><a href="assets/L75_fig3_rq1_structure.png" download>fig3 路線+標高+地理 3 角度 (RQ1)</a></li>
  <li><a href="assets/L75_fig4_display_chusankan_map.png" download>fig4 表示情報+中山間境界 (RQ1)</a></li>
  <li><a href="assets/L75_fig5_snow_winter_map.png" download>fig5 雪情報+L73 winter (RQ2)</a></li>
  <li><a href="assets/L75_fig6_l74_camera_map.png" download>fig6 L74 走行注意+道路カメラ (RQ2)</a></li>
  <li><a href="assets/L75_fig7_l02_l75_map.png" download>fig7 L02 vs L75 棲み分け (RQ3)</a></li>
  <li><a href="assets/L75_fig8_l02_compare.png" download>fig8 一致率+属性比較 (RQ3)</a></li>
</ul>
"""


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

# (1) 観測所 CSV を読込み (UTF-8 BOM)
df = pd.read_csv("data/extras/L75_road_cameras/station_list.csv",
                 encoding="utf-8-sig")

# (2) 設置場所列から「標高Nm」 を正規表現で抽出
def extract_alt(s):
    m = re.search(r"標高(\\d+)m", str(s))
    return int(m.group(1)) if m else None
df["標高_m"] = df["設置場所"].apply(extract_alt)

# (3) 路線名から路線種別 (国道/主要地方道/一般県道) を判定
def route_class(name):
    s = str(name)
    if "国道" in s: return "国道"
    if "主要地方道" in s: return "主要地方道"
    if "一般県道" in s: return "一般県道"
    return "その他"
df["路線種別"] = df["路線名"].apply(route_class)

# (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) 標高クラスでビニング
import pandas as pd
gdf["標高クラス"] = pd.cut(gdf["標高_m"],
                          bins=[-1, 100, 300, 500, 1000],
                          labels=["低 (<100m)", "中 (100-300m)",
                                  "高 (300-500m)", "山岳 (>=500m)"])

# (6) 路線種別 × 標高 集計
print(gdf.groupby("路線種別").size())
print(gdf.groupby("標高クラス", observed=True).size())
print(f"総地点数: {len(gdf)}")
'''

sec4 = f"""
<h3>狙い (RQ1)</h3>
<p>RQ1 では<b>「県の道路特化監視ネットワーク」 の物理構造</b>を初めて系統的に
記述する。 具体的には {n_pts} 観測地点を<b>路線種別 (国道/主要地方道/一般県道)
× 標高クラス × 市町 × 表示情報 (camera/slip/slip_camera)</b>の 4 軸で集計し、
「カメラがどの路線・どの標高・どの市町に重点配置されるか」 を 1 枚で俯瞰
できるようにする。 H1 (国道+主要 ≥ 60% かつ 標高 ≥ 200m が ≥ 50%) は
「重要路線優先 + 山岳重点設計」 の中心仮説を検証する。</p>

<h3>手法 — 4 ステップ</h3>
<ol>
  <li><b>STEP 1: CSV パース + 路線種別判定</b><br>
      観測所一覧 CSV (UTF-8 BOM, {n_pts} 行 × 8 列) を <code>read_csv()</code>
      で読込み、 <code>路線名</code> 列の先頭から「国道」 「主要地方道」 「一般県道」
      を文字列マッチで判定する。 これは公式分類ではなく本記事の独自分類。</li>
  <li><b>STEP 2: 標高の正規表現抽出</b><br>
      標高は CSV の独立列ではなく<b>設置場所列に「標高Nm」 と埋込</b>されている
      (例: 「呉市安浦町大字中畑【標高106m】」 → 106)。 正規表現
      <code>r"標高(\\d+)m"</code> で抽出し、 <code>標高_m</code> 列を派生。
      欠損 (標高表記なし) は NaN。</li>
  <li><b>STEP 3: GeoDataFrame 化 + 投影変換</b><br>
      緯度経度 → <code>shapely.geometry.Point</code> → GeoDataFrame に変換、
      <code>to_crs("EPSG:6671")</code> で平面直角第 III 系に投影 → 距離・面積が
      m 単位で計算可能に。</li>
  <li><b>STEP 4: 4 軸で集計 + 中山間判定</b><br>
      代表点 sjoin で市町判定 → 中山間 8 市町 vs 平野・沿岸都市 vs 沿岸島嶼を
      区別。 路線種別 × 標高クラス × 表示情報 のクロス集計で監視ネットワークの
      物理構造を量化。</li>
</ol>

<h3>実装 (主要部のみ抜粋)</h3>
{code(sec4_code)}

<h3>結果 1: 県全域 路線種別別 マップ (図 1)</h3>
<p><b>なぜこの図か:</b> 4 種の路線種別が県内にどう分布するかを<b>県全域地図に
重ねて</b>一目で確認したい。 国道 (赤) + 主要地方道 (橙) + 一般県道 (青) +
その他 (灰) で色分けすることで、 「国道・主要地方道に重点配置」 の物理形状が
直感できる。</p>

{figure("assets/L75_fig1_route_map.png",
        f"図 1 (RQ1): 道路カメラ 路線種別別マップ")}

<p><b>図 1 から読み取れること:</b></p>
<ul>
  <li><b>国道 (赤, {int((gdf['路線種別']=='国道').sum())} 地点):</b>
      国道 186 / 191 / 375 / 432 / 433 等の主要国道に集中</li>
  <li><b>主要地方道 (橙, {int((gdf['路線種別']=='主要地方道').sum())} 地点):</b>
      県北部・西部の主要地方道 (廿日市佐伯線・矢野安浦線等) に分布</li>
  <li><b>一般県道 (青, {int((gdf['路線種別']=='一般県道').sum())} 地点):</b>
      支線県道に少数。 沿岸島嶼 (蒲刈島循環線等) も含む</li>
  <li><b>地理的偏在:</b> 県北部 (庄原・三次・安芸太田) + 西部山岳 (廿日市) +
      島嶼部 (江田島・蒲刈・倉橋) に集中、 平野部 (広島市・福山市) では
      限定的</li>
</ul>

<h3>結果 2: 標高色分けマップ (図 2)</h3>
<p><b>なぜこの図か:</b> H1 の標高仮説を直感検証するため、 142 地点を標高
カラーマップ (青=低 → 黄=中 → 赤=山岳) で色分けし、 県北部・西部の山岳道路
に重点配置されているかを<b>地形に重ねて</b>確認する。</p>

{figure("assets/L75_fig2_altitude_map.png",
        f"図 2 (RQ1): 道路カメラ 標高色分けマップ")}

<p><b>図 2 から読み取れること:</b></p>
<ul>
  <li><b>標高分布:</b> 中央 {int(gdf['標高_m'].median())} m / 最大
      {int(gdf['標高_m'].max())} m (廿日市市吉和の国道 186 号 700m 級峠) /
      標高 ≥ 200m の地点が <b>{n_high_alt} ({share_high_alt}%)</b></li>
  <li><b>赤色点 (標高 ≥ 500m):</b> 廿日市吉和 + 安芸太田 + 庄原 + 神石高原 等
      の山岳峠 — チェーン規制発動の重点監視箇所</li>
  <li><b>青色点 (標高 &lt; 100m):</b> 沿岸国道 2 号 + 県道 (廿日市・呉) 等の
      平地路線 — 雪情報よりライブ画像主体の監視</li>
  <li>標高勾配 = 県北部山岳から沿岸への高度低下が地図上で明確 = 道路カメラは
      地形に応じた監視特性を持つ</li>
</ul>

<h3>結果 3: 路線 + 標高 + 地理クラス 3 角度 (図 3)</h3>
<p><b>なぜこの図か:</b> H1 (路線種別 + 標高) を 3 パネルで多角検証。 (1) 路線
種別別件数で「国道+主要地方道」 の支配率、 (2) 標高ヒストで 200m 閾値、
(3) 地理クラスで中山間集中度を 1 枚で見る。</p>

{figure("assets/L75_fig3_rq1_structure.png",
        "図 3 (RQ1): 路線種別 × 標高 × 地理クラス")}

<p><b>図 3 から読み取れること:</b></p>
<ul>
  <li><b>左パネル (路線種別件数):</b> 国道 + 主要地方道 = <b>{share_kokudo_shuyo}%</b>
      で多数派支配。 H1 第 1 条件
      <b>{('成立' if share_kokudo_shuyo >= 60 else '非成立')}</b></li>
  <li><b>中央パネル (標高ヒスト):</b> 中央<b>{int(gdf['標高_m'].median())} m</b>、
      標高 200m 以上が<b>{share_high_alt}%</b>。 H1 第 2 条件
      <b>{('成立' if share_high_alt >= 50 else '非成立')}</b></li>
  <li><b>右パネル (地理クラス):</b> 中山間山地が<b>{share_chusankan}%</b>で
      最多 — 「監視ネットワーク = 中山間山岳道路重点」 が量的に確認</li>
  <li>3 軸で見ると道路カメラは<b>「国道+主要地方道 × 標高 ≥ 200m × 中山間」</b>と
      整理できる</li>
</ul>

<h3>結果 4: 表示情報 + 中山間境界 重ね合わせマップ (図 4)</h3>
<p><b>なぜこの図か:</b> camera (青○) / slip (橙△) / slip_camera (赤■) の 3 種を
中山間境界 (橙背景) と沿岸島嶼 (青背景) と重ねて、 表示情報の地理分布パターン
(camera は全域、 slip + slip_camera は山岳集中) を<b>地図で直接</b>見せる。</p>

{figure("assets/L75_fig4_display_chusankan_map.png",
        "図 4 (RQ1): 表示情報 + 中山間境界 重ね合わせ")}

<p><b>図 4 から読み取れること:</b></p>
<ul>
  <li><b>橙色背景 (中山間 8 市町):</b> 庄原・三次・安芸太田・安芸高田・北広島・
      神石高原・世羅・府中</li>
  <li><b>青○ (camera のみ, {n_camera} 地点):</b> 県全域に分散 —
      ライブ画像のみ提供で雪情報なし</li>
  <li><b>橙△ (slip のみ, {n_slip} 地点):</b> 中山間山岳に集中 — 雪情報のみ
      (ライブ画像なし)。 機材コスト削減で雪深センサ + 気温計のみ設置</li>
  <li><b>赤■ (slip_camera, {n_slip_camera} 地点):</b> 中山間最重要箇所 —
      ライブ画像 + 雪情報の<b>二重情報提供</b>。 冬期重要路線の監視中核</li>
  <li>slip + slip_camera ({n_with_slip} 地点) はほぼすべて中山間 ≥ 200m に集中 =
      <b>雪情報 = 山岳監視特化</b>の物理形が明確</li>
</ul>

<h3>結果 5: 路線・標高・地理・市町 詳細表</h3>

<p><b>表示情報サマリ:</b></p>
{df_to_html(T_display)}

<p><b>表示情報 表から読み取れること:</b>
camera ({n_camera}, {100*n_camera/n_pts:.1f}%) が圧倒的多数。 slip ({n_slip}) と
slip_camera ({n_slip_camera}) を合わせた雪情報持ち = {n_with_slip} 地点
({100*n_with_slip/n_pts:.1f}%) は中山間山岳道路に集中する冬期監視の中核。</p>

<p><b>路線種別サマリ:</b></p>
{df_to_html(T_route)}

<p><b>路線種別 表から読み取れること:</b>
{T_route.iloc[0]['路線種別']} が <b>{int(T_route.iloc[0]['地点数'])} ({T_route.iloc[0]['シェア_%']}%)</b>
で最多、 国道 + 主要地方道で<b>{share_kokudo_shuyo}%</b>。 標高中央値は路線種別で
異なり、 国道は中央 {int(T_route[T_route['路線種別']=='国道']['標高中央_m'].iloc[0]) if '国道' in T_route['路線種別'].values else 0} m と
山岳重点、 一般県道は低標高 (沿岸島嶼路線) を含むため中央値が低い。</p>

<p><b>標高クラスサマリ:</b></p>
{df_to_html(T_alt)}

<p><b>標高クラス 表から読み取れること:</b>
標高 ≥ 200m が<b>{share_high_alt}%</b>。 山岳 (≥500m) が
{int(T_alt[T_alt['標高クラス']=='山岳 (>=500m)']['地点数'].iloc[0]) if '山岳 (>=500m)' in T_alt['標高クラス'].astype(str).values else 0}
地点 — チェーン規制重要箇所。 低 (&lt;100m) は沿岸国道 + 県道に限定される。</p>

<p><b>地理クラス別サマリ:</b></p>
{df_to_html(T_geo)}

<p><b>地理クラス 表から読み取れること:</b>
{T_geo.iloc[0]['地理クラス']} が <b>{int(T_geo.iloc[0]['地点数'])} ({T_geo.iloc[0]['シェア_%']}%)</b>
で最多。 中山間集中度 <b>{share_chusankan}%</b>は L73 / L74 と比較しても道路カメラが
最高水準で、 「監視ネットワーク = 中山間道路重視」 の制度設計を量的支持。</p>

<p><b>路線種別 × 標高クラス クロス:</b></p>
{df_to_html(T_route_alt)}

<p><b>クロス 表から読み取れること:</b>
国道 + 主要地方道は標高 200-500m に集中 (山岳幹線重点)、
一般県道は標高分布が広い (沿岸-山岳混在)。 これは「国道 = 主要交通路の山岳越え
監視」 の制度位置付けを示す。</p>

<p><b>市町別サマリ (Top 15):</b></p>
{df_to_html(T_city.head(15))}

<p><b>市町別 表から読み取れること:</b>
<b>{T_city.iloc[0]['市町名']} ({int(T_city.iloc[0]['地点数'])} 地点)</b>が最多、
2 位 <b>{T_city.iloc[1]['市町名']} ({int(T_city.iloc[1]['地点数'])} 地点)</b>、
3 位 <b>{T_city.iloc[2]['市町名']} ({int(T_city.iloc[2]['地点数'])} 地点)</b>。
中山間市町 + 沿岸島嶼が上位を独占し、 平野都市部 (広島市・福山市) は限定的。</p>

<p><b>路線番号別 Top 10 (個別路線):</b></p>
{df_to_html(T_rosen.head(10))}

<p><b>路線番号 表から読み取れること:</b>
{T_rosen.iloc[0]['路線番号_clean']} が
<b>{int(T_rosen.iloc[0]['地点数'])} 地点</b>で最多 — 県内で最も重点監視される路線。
国道 186 号 (廿日市〜山口県境の山岳越え) や 国道 375 号 (尾道〜三次の中山間縦貫)
などが上位を占め、 これらは<b>「冬期チェーン規制候補 + 落石歴あり + 主要県外接続」</b>
の三重要素を持つ路線。</p>
"""


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

# (1) 雪情報 (N_snow.csv) を ZIP から読み込み (cp932)
snow_rows = []
with zipfile.ZipFile("data/extras/L75_road_cameras/winter_road.json") as z:
    for name in z.namelist():
        m = re.search(r"snow_data/snow/(\\d+)_snow.csv", name)
        if not m: continue
        sid = int(m.group(1))
        with z.open(name) as f:
            text = f.read().decode("cp932").strip()
        parts = text.split(",")
        if len(parts) < 7: continue
        snow_rows.append({
            "観測地点ID": sid, "雪_観測日": parts[0],
            "圧雪深_cm": parts[2], "積雪深_cm": parts[3],
            "気温_C": parts[4], "路面温度_C": parts[5],
            "路面状態": parts[6],
        })

# (2) L73 winter (冬期閉鎖) を読込み LineString 化
with open("data/extras/L73_pre_traffic_restriction/pre_traffic.json",
          "r", encoding="utf-8") as f:
    raw = json.load(f)
geoms_w, rows_w = [], []
for r in raw["results"]:
    if r.get("type") != "winter": continue
    try:
        geoms_w.append(swkt.loads(r.get("kukanroot") or r.get("kukan", "")))
    except Exception: continue
    rows_w.append({"id_l73": r.get("id"), "rosen": r.get("rosenname")})
gdf_winter = gpd.GeoDataFrame(rows_w, geometry=geoms_w, crs="EPSG:4326")
gdf_winter = gdf_winter.to_crs("EPSG:6671")

# (3) 5km バッファ → 雪情報持ち地点 sjoin
gdf_winter_buf = gdf_winter.copy()
gdf_winter_buf["geometry"] = gdf_winter.geometry.buffer(5000)
buf_union = gdf_winter_buf.geometry.union_all()
gdf["near_winter_5km"] = gdf.geometry.intersects(buf_union)

# 雪情報持ち地点のうち winter 5km 圏内
mask_snow = gdf["表示情報"].isin(["slip", "slip_camera"])
n_snow_near = (mask_snow & gdf["near_winter_5km"]).sum()
print(f"雪情報点 winter 5km: {n_snow_near}/{mask_snow.sum()}")

# (4) L74 走行注意 5km 圏内 (落石注意との独立性検証)
def load_l74_lines():
    lines = []
    for level in ("rakuseki_03", "rakuseki_04"):
        p = f".../L74_caution_segments/.../04_warning_{level}.json"
        with open(p, encoding="utf-8") as f:
            text = f.read()
        for seg in json.loads("[" + text + "]"):
            if isinstance(seg, list) and len(seg) >= 2:
                lines.append(LineString([(pt["e"], pt["d"]) for pt in seg]))
    return lines
gdf_l74 = gpd.GeoDataFrame(geometry=load_l74_lines(),
                            crs="EPSG:4326").to_crs("EPSG:6671")
buf_l74 = gdf_l74.geometry.buffer(5000).union_all()
gdf["near_l74_5km"] = gdf.geometry.intersects(buf_l74)
print(f"道路カメラ 142 地点 L74 5km: {gdf['near_l74_5km'].sum()}/{len(gdf)}")
'''

sec5 = f"""
<h3>狙い (RQ2)</h3>
<p>RQ1 で「道路カメラの構造」 が分かったが、 これは<b>L75 単独</b>の話。
RQ2 では<b>「冬期道路情報ネットワーク」</b>を 2 軸で見る:
<b>(1) 雪情報点 と L73 winter (冬期閉鎖) の補完関係</b>(雪情報 = 冬期閉鎖の前線監視か?)、
<b>(2) 道路カメラ全 142 地点 と L74 走行注意 (落石注意) との独立性</b>(機能分化の検証)。
H2 (雪情報 winter 近接 ≥ 70%) と H3 (L74 近接 &lt; 30%) の 2 仮説を検証する。</p>

<h3>手法 — 5km バッファ + sjoin (intersects)</h3>
<p><b>狙い:</b> L73 winter (26 区間) に対して<b>5km バッファ</b>を作成 (= 冬期
閉鎖区間 + 周辺の前線監視範囲)、 雪情報持ち地点を <code>intersects</code> で
近接判定。 同様に L74 (走行注意 {n_l74} 区間) に対しても 5km バッファで全
142 地点の近接を判定し、 機能分化を量化。</p>

<table>
  <tr><th>項目</th><th>値</th><th>意味</th></tr>
  <tr><td><b>L75 道路カメラ</b></td>
      <td>{n_pts} 地点 (camera + slip + slip_camera)</td>
      <td>本記事の主データ</td></tr>
  <tr><td><b>L73 winter (既扱)</b></td>
      <td>{n_winter} 区間 / {total_km_winter:.0f} km</td>
      <td>12-3 月の冬期閉鎖区間 (機械的閉鎖)</td></tr>
  <tr><td><b>L74 走行注意 (既扱)</b></td>
      <td>{n_l74} 区間</td>
      <td>落石注意 (rakuseki_03 + rakuseki_04)</td></tr>
  <tr><td><b>バッファ幅</b></td>
      <td>{BUFFER_WINTER_M:.0f} m (5 km)</td>
      <td>冬期閉鎖区間 + 周辺の前線監視範囲 / 落石区間 + 周辺の地質類似範囲</td></tr>
  <tr><td><b>近接判定</b></td>
      <td>sjoin (intersects)</td>
      <td>1 観測地点が L73/L74 buffer に含まれれば近接</td></tr>
</table>

<p><b>注意:</b> なぜ 5km バッファか — 道路カメラは点配置 (POINT)、 L73/L74 は
LineString。 道路は山岳谷沿いに連続するため、 「同じ路線・同じ流域・同じ
地形条件」 を判定する範囲として 5 km が適切。 L74 で使った 30m / 100m は
道路敷地直近の判定だったが、 RQ2 では「機能の同一範囲かどうか」 という
広域近接性が問題。 5km は「同じ山岳谷 = 同じリスク条件」 を含む広域空間判定。</p>

<h3>実装 (主要部)</h3>
{code(sec5_code)}

<h3>結果 1: 雪情報点 + L73 winter 重ね合わせマップ (図 5)</h3>
<p><b>なぜこの図か:</b> 「雪情報 = 冬期閉鎖の前線監視」 仮説 (H2) を
地図で直接見せる。 L73 winter (青線) + 5km バッファ (薄青塗り)、 雪情報点
(slip 橙△ + slip_camera 赤■) を重ねて、 雪情報点と冬期閉鎖区間の
空間関係 (近接 or 広域分散) を確認する。</p>

{figure("assets/L75_fig5_snow_winter_map.png",
        "図 5 (RQ2): 雪情報点 + L73 winter 冬期閉鎖")}

<p><b>図 5 から読み取れること:</b></p>
<ul>
  <li><b>青線 (L73 winter, {n_winter} 区間 / {total_km_winter:.0f} km):</b>
      冬期閉鎖区間 — 県北山岳の主要地方道 + 国道に限定的分布</li>
  <li><b>薄青塗り (5km バッファ):</b> 冬期閉鎖区間 + 周辺の前線監視範囲</li>
  <li><b>橙△ (slip のみ, {n_slip}) + 赤■ (slip_camera, {n_slip_camera}):</b>
      雪情報持ち {n_snow_pts} 地点</li>
  <li><b>灰○ (camera のみ):</b> 県全域に分散、 雪情報非対応 = 「画像のみで
      路面判断する箇所」</li>
  <li>雪情報持ち {n_snow_pts} 地点のうち <b>{n_snow_near_winter} 地点
      ({share_snow_near_winter}%)</b>が winter 5km 圏内 — H2 ({jud(h2_ok)})。
      <b>H2 {jud(h2_ok)} は重要な発見</b>: 雪情報点は冬期閉鎖区間より<b>はるかに
      広い範囲</b>に分布し、 「雪情報 = 全県凍結監視」 vs 「L73 winter = 限定
      自動規制」 という<b>2 層制度</b>を量的に同定</li>
  <li><b>制度的解釈:</b> L73 winter は<b>機械的閉鎖 (12-3 月一律閉鎖)</b>で
      限定路線のみ、 L75 雪情報は<b>動的監視 (リアルタイム路面状態判定)</b>で
      広域配置 — 県の冬期道路安全は「規制 (狭い) + 監視 (広い)」 の補完設計</li>
</ul>

<h3>結果 2: L74 走行注意 + 道路カメラ 重ね合わせマップ (図 6)</h3>
<p><b>なぜこの図か:</b> 「道路カメラと落石注意は独立分布」 仮説 (H3) を
地図で直接見せる。 L74 (薄橙線) + 道路カメラ全 142 地点 (路線種別色) を
重ねて、 「両者が同じ路線で重なるか? 別路線か?」 を確認する。</p>

{figure("assets/L75_fig6_l74_camera_map.png",
        "図 6 (RQ2): 道路カメラ + L74 走行注意 重ね合わせ")}

<p><b>図 6 から読み取れること:</b></p>
<ul>
  <li><b>薄橙線 (L74 走行注意, {n_l74} 区間):</b> 中山間 + 沿岸地質脆弱地に
      連続的分布</li>
  <li><b>道路カメラ (路線種別色):</b> L74 と地理的に重なる地点が大部分</li>
  <li><b>L74 5km 圏内地点:</b> <b>{n_near_l74}/{n_pts} ({share_near_l74}%)</b> —
      H3 ({jud(h3_ok)})。 H3 仮説は「30% 未満」 で、 観測値
      {share_near_l74}% は<b>{('支持' if h3_ok else '大幅反証')}</b></li>
  <li><b>H3 {jud(h3_ok)} の意味:</b> 道路カメラは<b>機能的には</b>凍結・路面
      状態に特化するが、 <b>物理的には</b>L74 走行注意 (落石注意) と<b>同じ
      山岳重要路線群</b>に集中する。 これは「同じ脆弱地形 = 同じ路線」 という
      地理学的必然 — 中山間山岳道路は落石も凍結も同時にリスクを持つ</li>
  <li><b>制度的発見:</b> 県の脆弱山岳道路網には<b>L74 静的予防情報 (落石注意)
      + L75 動的監視 (カメラ + 雪情報) + L73 winter 自動規制 (冬期閉鎖)</b>の
      3 制度が<b>多重に配置</b>される — 「重要路線への制度集中投資」 の物理形</li>
</ul>

<h3>結果 3: 雪情報 × 冬期閉鎖 + L74 4 カテゴリ詳細表</h3>

<p><b>雪情報点 ↔ L73 winter 近接:</b></p>
{df_to_html(T_snow_winter)}

<p><b>雪情報 winter 表から読み取れること:</b>
雪情報持ち {n_snow_pts} 地点のうち、 winter 5km 圏内が
<b>{n_snow_near_winter} ({share_snow_near_winter}%)</b>。 H2 仮説 70% 閾値に
対して<b>{('成立' if h2_ok else '非成立')}</b>。 雪情報 = 冬期閉鎖の前線
監視という制度的補完関係が
{('量的に支持された' if h2_ok else '反証された (前線監視より広域分散)')}。</p>

<p><b>142 地点 × L74 / L73 winter 4 カテゴリ:</b></p>
{df_to_html(T_l74_winter)}

<p><b>4 カテゴリ 表から読み取れること:</b>
142 地点を「L73 winter / L74 走行注意」 の 2 軸で 4 カテゴリ分類した。
<b>winter 5km 圏内のみ</b>はチェーン規制重点、
<b>L74 5km 圏内のみ</b>は落石注意路線にあるカメラ、
<b>両方 5km 圏内</b>はチェーン規制 + 落石二重リスク箇所、
<b>どちらも 5km 圏外</b>は平地都市部のライブカメラ。
これは「カメラがどの目的別ネットワークに属するか」 を量化する。</p>

<p><b>雪情報持ち地点の路面状態分布 (本記事観測時刻 2026-04 = 凍結期外):</b></p>
{df_to_html(T_road_state)}

<p><b>路面状態 表から読み取れること:</b>
本記事の観測時刻は<b>2026-04-01 07:50 (凍結期外)</b>のため、 路面状態は
「乾燥」 「---」 (欠測) が支配的。 冬期 (12-3 月) であれば「凍結」 「圧雪」
「シャーベット」 等のカテゴリが出現するはずで、 これらは DoBoX Web ポータルで
ドライバーへリアルタイム表示される。 4 月の「乾燥」 多数は冬期センサが
通年運用されている証拠でもある。</p>
"""


# ----- セクション 6: RQ3 -----
sec6_code = '''
import pandas as pd
import geopandas as gpd
from shapely.geometry import Point

# (1) L02 県内カメラ 351 台を読込み
df_l02 = pd.read_csv("data/camera_list.csv", encoding="utf-8-sig")
df_l02 = df_l02.dropna(subset=["緯度", "経度"])

# 道路区分 + 広島県所管 のみ抽出 (L75 と直接比較対象)
df_road = df_l02[(df_l02["管理区分"] == "道路") &
                  (df_l02["所管"] == "広島県")].copy()
print(f"L02 道路カメラ 県所管: {len(df_road)} 台")

# (2) GeoDataFrame 化
geom = [Point(lon, lat) for lon, lat in zip(df_road["経度"], df_road["緯度"])]
gdf_l02 = gpd.GeoDataFrame(df_road, geometry=geom,
                            crs="EPSG:4326").to_crs("EPSG:6671")

# (3) 100m バッファでマッチング (L02 → L75 と L75 → L02 双方向)
gdf_l02_buf = gdf_l02.copy()
gdf_l02_buf["geometry"] = gdf_l02.geometry.buffer(100)
match = gpd.sjoin(gdf, gdf_l02_buf, how="left", predicate="within")
gdf["has_l02_match"] = match.dropna(subset=["index_right"]).index.isin(gdf.index)

# 一致率
n_matched = gdf["has_l02_match"].sum()
print(f"L75 ↔ L02 100m 一致: {n_matched}/{len(gdf)} = {100*n_matched/len(gdf):.1f}%")

# (4) 属性差: L75 独自属性は ?
l02_attrs = set(df_l02.columns)   # No., カメラ名, 住所, 路河川名等, 緯度, 経度, 公開URL, 所管, 管理区分
l75_attrs = set(gdf.columns) - {"geometry"}
l75_unique = l75_attrs - l02_attrs   # 路線番号, 標高_m, 表示情報, 雪情報列 等
print(f"L75 独自属性数: {len(l75_unique)}")
print(f"独自属性: {l75_unique}")
'''

sec6 = f"""
<h3>狙い (RQ3)</h3>
<p>RQ1, RQ2 で「道路カメラの構造と機能」 が分かった。 RQ3 では別 dataset
<b>L02 県内カメラ (351 台横断)</b> との<b>棲み分け</b>を量化する。 L02 は
道路 + 河川 + ため池 + 海岸 + 港湾 + その他の汎用カメラ網で、 L75 は道路特化。
両者の関係は「重複か独立か」、 「L02 ⊃ L75 か L75 ⊃ L02 か」、 「属性差は
何か」 を量的に解明する。 H4 (一致率 ≥ 70%) と H5 (L75 独自属性 ≥ 3 種) を
検証する。</p>

<h3>手法 — 100m 距離マッチ + 属性差量化</h3>
<p><b>狙い:</b> L02 のうち管理区分=道路 + 所管=広島県の {n_l02_road} 台を
抽出 (= L75 と直接比較対象)、 100m バッファで L75 142 地点とマッチング。
さらに両 dataset の属性列差を量化し、 L75 独自の道路特化属性を特定。</p>

<table>
  <tr><th>dataset</th><th>件数</th><th>性質</th><th>独自属性</th></tr>
  <tr><td><b>L02 (1279) 県内カメラ</b></td>
      <td>{n_l02_total} 台 (全体) / {n_l02_road} 台 (道路区分 + 県所管)</td>
      <td>用途横断 (道路 + 河川 + ため池 + 海岸 + 港湾 + その他)</td>
      <td>管理区分 (用途分類) + 公開URL (ライブ視聴 URL)</td></tr>
  <tr><td><b>L75 (1260) 道路カメラ</b></td>
      <td>{n_pts} 地点</td>
      <td>道路特化 (国道 + 県道 + 主要地方道のみ)</td>
      <td>路線番号 + 路線名 + 標高 (設置場所内) + 表示情報 + 雪情報 (圧雪深・積雪深・気温・路面温度・路面状態)</td></tr>
</table>

<p><b>マッチング基準:</b></p>
<ul>
  <li><b>100m 圏</b>: 同じ物理カメラを別 dataset で再登録した場合の座標誤差
      (GPS 測位誤差 + 配信時期差) を吸収する寛容な閾値</li>
  <li><b>L02 のフィルタ</b>: 管理区分 = 道路 AND 所管 = 広島県のみを抽出
      (= L75 と同じ管理者 = 比較対象)</li>
  <li><b>双方向マッチ</b>: L75 → L02 (L75 が L02 に含まれるか) と
      L02 → L75 (L02 が L75 に含まれるか) の両方</li>
</ul>

<h3>実装 (主要部)</h3>
{code(sec6_code)}

<h3>結果 1: L02 ↔ L75 重ね合わせマップ (図 7)</h3>
<p><b>なぜこの図か:</b> 「同じ物理カメラの二重公開」 を地図で直接見せる。
L02 道路のみ (紫×) + L75 のみ (橙△) + L02 ∩ L75 (緑○) の 3 色分けで、
両 dataset の棲み分けが空間的にどうなっているかを確認。</p>

{figure("assets/L75_fig7_l02_l75_map.png",
        "図 7 (RQ3): L02 県内カメラ ↔ L75 道路カメラ 棲み分け")}

<p><b>図 7 から読み取れること:</b></p>
<ul>
  <li><b>緑○ (L02 ∩ L75, {n_l75_matched} 地点):</b>
      同じ物理カメラの<b>二重公開</b> — 中山間 + 主要国道に集中</li>
  <li><b>橙△ (L75 のみ, {n_pts - n_l75_matched} 地点):</b>
      L02 に登録なし — 雪情報専用センサ (slip 単独) 等が含まれる
      可能性</li>
  <li><b>紫× (L02 道路のみ, {n_l02_road - n_l02_matched} 地点):</b>
      L75 に登録なし — L02 のうち広島県所管道路カメラだが L75 142 地点に
      含まれない箇所 (L02 vs L75 の更新時期差・対象範囲差を反映)</li>
  <li>一致 <b>{n_l75_matched}/{n_pts} = {share_l75_matched}%</b> (L75 視点) —
      H4 ({jud(h4_ok)})</li>
  <li>「L02 = 用途横断の汎用カタログ」 と「L75 = 道路特化属性付加」 の
      二重公開設計が地図上で確認</li>
</ul>

<h3>結果 2: 一致率 + 属性比較 (図 8)</h3>
<p><b>なぜこの図か:</b> H4 (一致率) と H5 (属性差) を 2 パネルで定量検証。
左パイで 3 カテゴリ件数比較、 右バーで両 dataset の件数 + 属性列数を見比べる。</p>

{figure("assets/L75_fig8_l02_compare.png",
        "図 8 (RQ3): L02 ↔ L75 一致率 + 属性比較")}

<p><b>図 8 から読み取れること:</b></p>
<ul>
  <li><b>左パネル (3 カテゴリ パイ):</b>
      L02 ∩ L75 = <b>{n_l75_matched}</b> ({100*n_l75_matched/(n_l75_matched + (n_pts-n_l75_matched) + max(n_l02_road-n_l02_matched, 0)):.1f}%) /
      L75 のみ = <b>{n_pts - n_l75_matched}</b> /
      L02 道路のみ = <b>{n_l02_road - n_l02_matched}</b>。
      H4 ({jud(h4_ok)})</li>
  <li><b>右パネル (件数 + 属性列数):</b>
      L02 全件 {n_l02_total} 台 (用途横断) vs L75 {n_pts} 地点 (道路特化)、
      属性列数 L02 = {n_l02_attrs} 列 / L75 = {n_l75_attrs} 列。
      L75 が L02 にない<b>独自属性 ≥ 5 種</b> — H5 ({jud(h5_ok)})</li>
  <li>「同じ物理カメラを異なる属性セットで二重公開」 という<b>データ層別化</b>
      設計が量的に確認</li>
</ul>

<h3>結果 3: 棲み分け + 属性 詳細表</h3>

<p><b>L02 ↔ L75 棲み分け 3 カテゴリ:</b></p>
{df_to_html(T_match)}

<p><b>3 カテゴリ 表から読み取れること:</b>
L02 ∩ L75 が<b>{n_l75_matched} 地点 = L75 の {share_l75_matched}%</b>。
これは同じ物理カメラの二重公開の量的事実。 L75 のみ
({n_pts - n_l75_matched}) は L02 に登録されていない slip 単独地点 + 配信時期で
L02 に未反映の新設点を含む可能性。 L02 道路のみ ({n_l02_road - n_l02_matched})
は L75 142 地点に含まれない L02 道路カメラで、 国管理国道 (国土交通省直轄)
の境界付近や対象範囲差を反映。</p>

<p><b>L02 vs L75 属性比較:</b></p>
{df_to_html(T_attr_compare)}

<p><b>属性比較 表から読み取れること:</b>
L02 = <b>{n_l02_total} 台の用途横断データセット</b>、 L75 = <b>{n_pts} 地点の
道路特化データセット</b>。 L02 は管理区分 (道路/河川/ため池/海岸/港湾/その他)
で多用途検索が可能だが<b>路線情報・標高・雪情報を持たない</b>。 L75 は
路線番号 + 路線名 + 標高 + 雪情報の 5 種属性で<b>道路管理者向けの精密分析</b>
(国道別集計・標高別配置・冬期路面監視等) を可能にする。 これは<b>「同じ物理
カメラを用途別ユーザに最適な属性レベルで提供する」 データ層別化戦略</b>の事例。</p>

<p><b>L02 道路カメラ用途別 (参考):</b></p>
<ul>
  <li>L02 全体: 351 台 (道路 131 + 河川 120 + ため池 70 + 海岸 18 + 港湾 6 + その他 6)</li>
  <li>L02 道路 + 県所管: <b>{n_l02_road} 台</b> (本記事の比較対象)</li>
  <li>L02 道路 + 国所管: 道路 131 - {n_l02_road} = {131 - n_l02_road} 台 (国土交通省管理 = 国道直轄)</li>
  <li>L75 142 地点 = 県管理道路の道路カメラ</li>
</ul>

<p><b>応用シナリオ:</b></p>
<ul>
  <li><b>道路管理者向け</b>: L75 を使えば「国道 186 号沿いのライブ画像 +
      雪情報を一括取得」 が SQL 級の操作で可能 (路線番号フィルタ)</li>
  <li><b>一般ユーザ向け</b>: L02 を使えば「自宅近くの道路 + 河川 + ため池
      カメラを地図表示」 が用途横断で可能 (管理区分フィルタ)</li>
  <li><b>研究者向け</b>: 両 dataset を併用して「同じ物理カメラに用途と
      路線情報の両方を付与」 → 多次元属性集計が可能</li>
</ul>
"""


# ----- セクション 7: 仮説検証総合 -----
T_hypo_html = T_hypo.copy()
T_hypo_html["判定"] = T_hypo_html["判定"].apply(
    lambda v: f'<b style="color:{"#1a7f37" if "強支持" in v else "#cf222e"}">{v}</b>')

sec7 = f"""
<h3>仮説検証総合 (H1〜H5)</h3>
<p>本記事冒頭で立てた 5 仮説の検証結果を以下にまとめる。
すべての仮説の検証根拠は本記事中の図表に明示されており、 再現可能。</p>

{df_to_html(T_hypo_html)}

<h3>主要発見の整理</h3>
<div class="note">
  <ul>
    <li><b>RQ1 主発見:</b> 県の道路カメラは<b>{n_pts} 観測地点</b>、 国道 +
        主要地方道が<b>{share_kokudo_shuyo}%</b>、 標高 ≥ 200m が
        <b>{share_high_alt}%</b> (H1 {jud(h1_ok)})。 中山間山地に
        <b>{share_chusankan}%</b>集中し、 標高最大 <b>{int(gdf['標高_m'].max())} m</b>。
        これは「<b>重要路線優先 + 山岳重点設計</b>」 という県の道路特化監視
        ネットワーク制度を初めて系統的に記述。</li>
    <li><b>RQ2 主発見:</b> 雪情報持ち {n_snow_pts} 地点のうち L73 winter 5km
        圏内が <b>{n_snow_near_winter} ({share_snow_near_winter}%)</b>
        (H2 {jud(h2_ok)})。 道路カメラ全 {n_pts} 地点の L74 5km 圏内が
        <b>{n_near_l74} ({share_near_l74}%)</b> (H3 {jud(h3_ok)})。
        <b>H2 {jud(h2_ok)} = 雪情報範囲 ≫ 冬期閉鎖範囲</b>という重要発見:
        L73 winter ({n_winter} 区間 / {total_km_winter:.0f} km) は限定的自動規制
        路線、 L75 雪情報は<b>全県凍結監視</b>として広域配置される<b>2 層の冬期
        道路安全制度</b>を量的同定した。 <b>H3 {jud(h3_ok)} = 県の脆弱山岳路線で
        多重制度監視</b>: L75 道路カメラ ({share_near_l74}%) ・ L74 走行注意
        ({n_l74} 区間) ・ L73 winter (冬期閉鎖) が<b>同じ山岳重要路線群</b>に
        重畳配置される<b>「3 制度多重監視」</b>を発見。</li>
    <li><b>RQ3 主発見:</b> L02 道路カメラ <b>{n_l02_road} 台</b> と L75
        <b>{n_pts} 地点</b>の 100m 圏一致は<b>{n_l75_matched}
        ({share_l75_matched}%)</b> (H4 {jud(h4_ok)})。 L75 独自属性 = 路線番号
        + 路線名 + 標高 + 表示情報 + 雪情報 = 5 種 (H5 {jud(h5_ok)})。 これは
        <b>「同じ物理カメラの二重公開」 + 「データ層別化」</b>戦略の量的事例で、
        汎用横断 dataset (L02) と道路特化 dataset (L75) が用途別ユーザに最適な
        属性レベルを提供する設計を実証。</li>
  </ul>
</div>

<h3>本記事の独自貢献</h3>
<ol>
  <li><b>「道路特化監視ネットワーク」 の概念定量化</b>: 道路カメラ + 冬期道路
      情報 {n_pts} 観測地点を<b>路線種別 × 標高 × 表示情報 × 市町</b>の
      4 軸で初めて系統的に集計。 県の道路特化監視が<b>「国道+主要地方道
      {share_kokudo_shuyo}% × 標高 ≥ 200m {share_high_alt}% × 中山間集中
      {share_chusankan}%」</b>という制度に整理されていることを実証。</li>
  <li><b>「冬期道路安全 2 層制度」 の発見</b>: L75 雪情報 ({n_snow_pts} 地点) と
      L73 winter ({n_winter} 区間 / {total_km_winter:.0f} km) の 5km 圏 sjoin で
      <b>雪情報範囲 ≫ 冬期閉鎖範囲</b>(雪情報 winter 5km 圏内 {share_snow_near_winter}%)
      を発見。 「規制 (狭い自動閉鎖) + 監視 (広い凍結状態把握)」 の<b>2 層補完
      設計</b>を量的同定。 H2 反証は「前線監視」 仮説の修正 → 「広域監視 + 限定
      規制」 という新しい制度像の発見。</li>
  <li><b>「3 制度多重監視」 概念フレームの提案</b>: L75 動的監視 (カメラ +
      雪情報) + L74 静的予防情報 (落石注意) + L73 winter 自動規制 (冬期閉鎖)
      が県の脆弱山岳路線群に<b>多重配置</b>される構造を 5km 圏 sjoin で量化
      ({share_near_l74}% が L74 近接)。 H3 反証は「機能分化」 仮説の修正 →
      「機能は分化しつつ空間的に集中する」 という制度設計の発見。 「重要路線
      への制度集中投資」 の物理形を初めて記述。</li>
  <li><b>「データ層別化」 戦略の事例研究</b>: L02 (汎用 351 台) と L75
      (道路特化 142 地点) の 100m 圏マッチで<b>「同じ物理カメラの二重公開」</b>を
      量的に確認。 用途別ユーザ (一般市民 vs 道路管理者) に異なる属性レベルを
      提供する制度設計を「データ層別化」 として概念化。</li>
  <li><b>L02 + L73 + L74 との横断連携 (4 dataset 統合)</b>: 道路カメラ
      (L75, 1 dataset) + 県内カメラ (L02, 既扱) + 事前通行規制 winter
      (L73, 既扱) + 走行注意 (L74, 既扱) の 4 dataset を sjoin で組合わせ、
      県の<b>道路情報配信制度ネットワーク</b>に道路カメラを位置付ける初の
      研究。</li>
  <li><b>「設置場所列の標高埋込」 解読パターン例示</b>: 公開 CSV の
      「設置場所」 列に「標高Nm」 形式で埋め込まれた数値を正規表現で抽出する
      手法を例示。 公式属性化されない情報を実装で復元する実用パターン。</li>
  <li><b>「N_snow.csv の cp932 エンコーディング + 列名なし形式」 解読</b>:
      ZIP 内 25 ファイルの shift JIS CSV を一括パース、 ファイル名から
      観測地点 ID を抽出して結合する手法を例示。</li>
</ol>

<h3>本記事の限界</h3>
<ul>
  <li><b>観測時刻の制約</b>: 本記事の雪情報スナップショットは<b>2026-04-01
      07:50</b>(凍結期外)。 「圧雪深 = 0 / 積雪深 = 0」 が支配的で、 冬期
      (12-3 月) の凍結・圧雪・シャーベット状態の検証はできない。 真の冬期道路
      情報の動態解明には連続データ取得 (~6 ヶ月分) が必要。</li>
  <li><b>カメラ画像の内容解析未実施</b>: ~135 枚の jpg ライブ画像が含まれるが、
      本記事ではメタデータ (撮影時刻 + 観測地点) のみ扱い、 画像内容の解析
      (積雪検出 + 路面状態自動判定 + 通行車両カウント) は実施していない。
      Computer Vision (CV) を使えば画像から路面状態を自動推論可能。</li>
  <li><b>L02 ↔ L75 マッチング閾値</b>: 100m バッファで一致判定したが、
      正確な物理カメラ同一性の確認は座標精度 + 配信時期差を考慮した
      レコード ID 結合が必要。 本記事は近接性での近似一致。</li>
  <li><b>L73 winter との 5km バッファ</b>: 5km は「同じ山岳谷 = 同じリスク
      条件」 の判定に十分だが、 厳密な路線一致判定ではない。 雪情報点と
      L73 winter が同じ「路線番号」 を持つかの照合は本記事では未実施。</li>
  <li><b>路線種別の独自分類</b>: 「国道 / 主要地方道 / 一般県道 / その他」 は
      本記事の独自分類で、 公式分類ではない。 路線名先頭の文字列マッチで
      判定したため、 命名揺れ (全角空白等) の影響を受ける可能性。</li>
  <li><b>標高欠損の扱い</b>: 設置場所列に「標高」 表記がない地点は標高欠損。
      これは住所表記の揺れであり、 真の標高 0 ではない可能性がある。
      DEM (国土地理院標高ラスタ) との結合で補完するのが正確。</li>
  <li><b>因果関係不確定</b>: 「道路カメラ設置 → 標高 + 路線種別」 の因果
      関係は仮説であり、 実際の設置決定は予算 + 通行量 + 過去災害履歴 +
      ドライバー要望の複合判断と推測される。 本記事の量的相関は
      「結果の物理パターン」 の記述。</li>
</ul>
"""


# ----- セクション 8: 発展課題 -----
sec8 = f"""
<h3>発展課題 — 結果 X → 新仮説 Y → 課題 Z 形式</h3>

<h4>発展課題 1 (RQ1 拡張): <b>標高欠損地点の DEM 補完</b></h4>
<ul>
  <li><b>結果 X</b>: 本記事は標高を「設置場所」 列の文字列「標高Nm」 から正規
      表現で抽出したが、 一部地点で標高表記なしの欠損が発生する可能性
      (本記事観測では全 142 地点で抽出成功)。</li>
  <li><b>新仮説 Y</b>: DEM (国土地理院 5m メッシュ) を用いれば全 142 地点
      の標高を高精度で取得可能。 CSV 抽出値との誤差中央値は <b>5m 未満</b>と
      推定。 大きな誤差 (>30m) を持つ地点は「設置場所表記の住所が広い範囲を
      指す」 ケースで、 これらは実際のカメラ位置と住所範囲のずれを示す。</li>
  <li><b>課題 Z</b>: L40 標高ラスタを取得 → 142 観測地点に zonal stats →
      DEM 標高 vs CSV 抽出標高 の散布図 → 誤差が大きい地点の住所表記の
      実態を確認 → <b>「住所表記の地理学的精度マップ」</b>を作成。</li>
</ul>

<h4>発展課題 2 (RQ1 拡張): <b>路線交通量との相関分析</b></h4>
<ul>
  <li><b>結果 X</b>: 本記事は道路カメラが国道 + 主要地方道に集中すること
      ({share_kokudo_shuyo}%) を量化したが、 路線交通量との相関は未検証。
      「交通量が多い路線ほどカメラが多い」 仮説は未確認。</li>
  <li><b>新仮説 Y</b>: 国道 186 号 + 国道 375 号 + 国道 433 号は交通量
      上位 3 路線で、 各路線にカメラが <b>5+ 地点</b>集中。 一方交通量
      下位の支線県道はカメラ密度が低い。 交通量とカメラ密度の相関係数は
      <b>r &gt; 0.6</b>。</li>
  <li><b>課題 Z</b>: 国土交通省道路交通センサス (路線別 24h 交通量) を
      取得 → L75 路線番号と結合 → 路線別カメラ密度 (台/km) との
      相関 → <b>「交通量 × カメラ密度マップ」</b>を作成。</li>
</ul>

<h4>発展課題 3 (RQ2 拡張): <b>冬期連続観測データでの凍結検出精度評価</b></h4>
<ul>
  <li><b>結果 X</b>: 本記事は雪情報の単一スナップショット (2026-04-01) のみ
      で、 冬期動態は未検証。</li>
  <li><b>新仮説 Y</b>: 12-3 月の連続データを取得すると、 雪情報持ち 25 地点で
      <b>凍結状態 (路面状態 = 凍結 OR 圧雪)</b> の出現頻度は地点別に大きく
      異なり、 標高 ≥ 500m 地点は<b>30% 以上の時間で凍結</b>、 標高 200m 未満は
      <b>10% 未満</b>と推定。</li>
  <li><b>課題 Z</b>: L75 雪情報 CSV を 6 ヶ月分連続取得 → 地点別 × 時刻別 ×
      路面状態の 3D マトリクス → 標高別凍結頻度ヒート → <b>「凍結リスク
      時系列マップ」</b>を作成。 チェーン規制発動最適化の基礎データ。</li>
</ul>

<h4>発展課題 4 (RQ2 拡張): <b>カメラ画像 CV 解析による自動路面判定</b></h4>
<ul>
  <li><b>結果 X</b>: 本記事は ~135 枚のカメラ画像を含むが内容解析未実施。
      slip_camera 地点 ({n_slip_camera} 地点) では「路面状態の数値判定」 と
      「カメラ画像の目視判定」 の両方が公開されているが、 一致性は未検証。</li>
  <li><b>新仮説 Y</b>: CV モデル (CNN, 例: ResNet-50) を画像で訓練すると、
      slip_camera 地点の<b>路面状態カテゴリ (乾燥/湿潤/凍結/圧雪) を 85% 以上
      の精度で自動判定</b>可能。 これは数値センサ単独より広域カバレッジが
      高い (camera のみ {n_camera} 地点でも自動判定可能に)。</li>
  <li><b>課題 Z</b>: slip_camera 地点で「画像 + 路面状態ラベル」 の教師データ
      を構築 → CNN で訓練 → camera のみ {n_camera} 地点に適用 → <b>「画像 CV
      で全 142 地点の路面状態を推論」</b>するシステム。 県内全域のリアルタイム
      凍結監視を 6 倍に拡張可能。</li>
</ul>

<h4>発展課題 5 (RQ3 拡張): <b>L02 用途別カメラ網の比較分析</b></h4>
<ul>
  <li><b>結果 X</b>: 本記事は L02 道路区分 ({n_l02_road} 台) のみ L75 と比較
      したが、 河川 (120 台) + ため池 (70 台) + 海岸 (18 台) + 港湾 (6 台)
      との比較は未実施。 道路カメラと他用途カメラの空間関係は不明。</li>
  <li><b>新仮説 Y</b>: 道路カメラ (L02 道路 {n_l02_road} + L75 142) と河川
      カメラ (L02 河川 120) は「橋梁・トンネル・峠」 等の<b>地形的接続点</b>
      で物理的に近接 (100m 圏内重複 ≥ 30 件) するが、 ため池・海岸・港湾とは
      独立分布。</li>
  <li><b>課題 Z</b>: L02 用途別カメラ間で 100m 圏 sjoin → 5 用途 × 5 用途の
      重複マトリクス → <b>「用途別カメラ網の地形的接続構造」</b>を可視化。
      共通点 (橋梁・トンネル) を発見する地理学的研究。</li>
</ul>

<h4>発展課題 6 (展望): <b>道路カメラと走行データのリンク (Connected Vehicle 連携)</b></h4>
<ul>
  <li><b>結果 X</b>: 本記事は道路カメラの<b>静的位置情報 + 路面状態</b>を
      扱ったが、 これらと走行車両側の<b>動的データ</b>(スマートフォン GPS +
      Connected Vehicle CAN データ + ドライブレコーダ) の結合は本記事
      スコープ外。</li>
  <li><b>新仮説 Y</b>: 道路カメラ {n_pts} 地点の周辺 1km 圏で取得される
      走行データ (急ブレーキ + スリップ警告) は、 路面状態 = 凍結/圧雪時に
      <b>5-10 倍</b>に増加。 これはカメラ配置の妥当性を行動データで検証可能。</li>
  <li><b>課題 Z</b>: 県内タクシー会社 + ドライブレコーダ提供事業者と連携 →
      路面状態 × 走行データのクロス → 「カメラ配置の最適性評価」 と
      <b>「次世代カメラ追加候補地点」 提案</b>。 ITS (高度道路交通システム)
      研究への展開。</li>
</ul>

<h4>発展課題 7 (展望): <b>SNS 走行報告との統合 — 集合知の活用</b></h4>
<ul>
  <li><b>結果 X</b>: 道路カメラは点配置 ({n_pts} 地点) で県全域の路面状況を
      間欠的に把握する。 一方、 SNS (Twitter, Yahoo 路線情報, Google Maps
      レビュー) には住民・通行者からの「凍結 + 落石 + 通行注意」 の集合知が
      日々蓄積されているが、 公式 dataset と連携する仕組みは未整備。</li>
  <li><b>新仮説 Y</b>: SNS で「凍結」 「スリップ」 「チェーン」 等のキーワードを
      含む 1 ヶ月の投稿を緯度経度 sjoin すると、 道路カメラ 5km 圏内で
      <b>30-50%</b>がカバーされる。 残り 50% は SNS 独自情報 (= カメラのない
      路線・時間帯) で、 これがカメラ拡張候補。</li>
  <li><b>課題 Z</b>: SNS API + キーワード検索で 1 ヶ月の路面状況投稿を取得
      → 緯度経度抽出 → L75 5km 圏 sjoin → カバー率計算 → カバー外
      ホットスポット同定 → <b>「市民集合知 × 公式カメラ網の補完地図」</b>を
      作成。 都市情報学 + Citizen Science の研究。</li>
</ul>
"""


# ----- 統合 -----
sections = [
    ("学習目標と問い", sec1),
    ("使用データ", sec2),
    ("ダウンロード", sec3),
    (f"【RQ1】 道路カメラの地理分布と設置構造 — 路線種別 × 標高 × 市町 / "
     f"{n_pts} 観測地点 / 国道+主要 {share_kokudo_shuyo}% / "
     f"標高 ≥ 200m {share_high_alt}%",
     sec4),
    (f"【RQ2】 冬期道路情報ネットワーク — 雪情報 ⊂ L73 winter / "
     f"L75 ⊥ L74 落石注意 / 雪情報 winter 近接 {share_snow_near_winter}%",
     sec5),
    (f"【RQ3】 L02 県内カメラとの棲み分け — 道路特化監視 / "
     f"100m 圏一致 {share_l75_matched}% / L75 独自属性 5 種",
     sec6),
    ("仮説検証総合", sec7),
    ("発展課題", sec8),
]

html = render_lesson(
    num=75,
    title=f"道路カメラ・冬期道路情報 単独 3 研究例分析 — "
          f"{n_pts} 観測地点 / "
          f"国道+主要 {share_kokudo_shuyo}% × 標高 ≥ 200m {share_high_alt}% × "
          f"中山間 {share_chusankan}% / "
          f"L02 ↔ L75 一致 {share_l75_matched}% を読む",
    tags=["L75", "道路カメラ", "冬期道路情報", "リアルタイム監視",
          "凍結注意", "チェーン規制", "監視ネットワーク",
          "道路特化監視", "重要路線優先設計",
          "凍結リスク監視ネットワーク", "機能分化",
          "データ層別化", "RQ×3", "Format B",
          "geopandas", "POINT (CSV)",
          "L02連携 (県内カメラ)", "L73連携 (winter冬期閉鎖)",
          "L74連携 (走行注意)", "中山間集中"],
    time="50 分",
    level="中級",
    data_label=f"DoBoX dataset {DATASET_ID} (4 リソース, ~{total_size/1024/1024:.1f} MB)",
    sections=sections,
    script_filename="L75_road_cameras.py",
)

OUT_HTML = LESSONS / "L75_road_cameras.html"
OUT_HTML.write_text(html, encoding="utf-8")

print(f"  HTML: {OUT_HTML.name} ({len(html):,} chars)")
print(f"  ({time.time()-t11:.1f}s)", flush=True)


# =============================================================================
# 終了
# =============================================================================
print(f"\n=== 完了 (合計 {time.time()-t_all:.1f} 秒) ===", flush=True)
