#!/usr/bin/env python3
"""Build an interactive HTML dashboard from extracted tender entity tables."""

from __future__ import annotations

import json
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any

from openpyxl import load_workbook


ROOT = Path(__file__).resolve().parents[1]
SOURCE_XLSX = ROOT / "docs" / "deliverables" / "20260617-raw-excel-entity-tables" / "原始Excel招投标实体抽取表.xlsx"
OUT_DIR = ROOT / "docs" / "deliverables" / "20260617-entity-map-dashboard"
OUT_HTML = OUT_DIR / "招投标实体地图看板.html"


PROVINCE_META = {
    "北京": ("北京市", "110000"),
    "天津": ("天津市", "120000"),
    "河北": ("河北省", "130000"),
    "山西": ("山西省", "140000"),
    "内蒙古": ("内蒙古自治区", "150000"),
    "辽宁": ("辽宁省", "210000"),
    "吉林": ("吉林省", "220000"),
    "黑龙江": ("黑龙江省", "230000"),
    "上海": ("上海市", "310000"),
    "江苏": ("江苏省", "320000"),
    "浙江": ("浙江省", "330000"),
    "安徽": ("安徽省", "340000"),
    "福建": ("福建省", "350000"),
    "江西": ("江西省", "360000"),
    "山东": ("山东省", "370000"),
    "河南": ("河南省", "410000"),
    "湖北": ("湖北省", "420000"),
    "湖南": ("湖南省", "430000"),
    "广东": ("广东省", "440000"),
    "广西": ("广西壮族自治区", "450000"),
    "海南": ("海南省", "460000"),
    "重庆": ("重庆市", "500000"),
    "四川": ("四川省", "510000"),
    "贵州": ("贵州省", "520000"),
    "云南": ("云南省", "530000"),
    "西藏": ("西藏自治区", "540000"),
    "陕西": ("陕西省", "610000"),
    "甘肃": ("甘肃省", "620000"),
    "青海": ("青海省", "630000"),
    "宁夏": ("宁夏回族自治区", "640000"),
    "新疆": ("新疆维吾尔自治区", "650000"),
    "台湾": ("台湾省", "710000"),
    "香港": ("香港特别行政区", "810000"),
    "澳门": ("澳门特别行政区", "820000"),
}
MUNICIPALITIES = {"北京", "天津", "上海", "重庆"}


def clean(value: Any) -> str:
    if value is None:
        return ""
    text = str(value).strip()
    return "" if text in {"--", "None", "nan"} else text


def as_float(value: Any) -> float:
    if isinstance(value, (int, float)):
        return float(value)
    text = clean(value).replace(",", "")
    if not text:
        return 0.0
    try:
        return float(text)
    except ValueError:
        return 0.0


def read_sheet(sheet_name: str) -> list[dict[str, Any]]:
    wb = load_workbook(SOURCE_XLSX, read_only=True, data_only=True)
    ws = wb[sheet_name]
    headers = [clean(cell.value) for cell in ws[2]]
    rows = []
    for row in ws.iter_rows(min_row=3, values_only=True):
        item = {headers[idx]: row[idx] for idx in range(len(headers)) if headers[idx]}
        if any(clean(value) for value in item.values()):
            rows.append(item)
    wb.close()
    return rows


def province_display(raw: str) -> str:
    return PROVINCE_META.get(raw, (raw, ""))[0]


def city_display(raw: str) -> str:
    if not raw:
        return ""
    if raw.endswith(("市", "州", "盟", "地区", "自治州")):
        return raw
    return f"{raw}市"


def normalize_project(row: dict[str, Any]) -> dict[str, Any]:
    province = clean(row.get("发布省份"))
    city = clean(row.get("发布市级"))
    district = clean(row.get("发布区级"))
    amount_wan = as_float(row.get("中标金额_万元"))
    return {
        "projectId": clean(row.get("项目ID")),
        "title": clean(row.get("项目名称")),
        "keyword": clean(row.get("关键词")),
        "projectNo": clean(row.get("项目编号")),
        "date": clean(row.get("信息发布时间")),
        "month": clean(row.get("月份")),
        "province": province,
        "provinceName": province_display(province),
        "provinceCode": PROVINCE_META.get(province, ("", ""))[1],
        "city": city,
        "cityName": city_display(city),
        "district": district,
        "stage": clean(row.get("中标阶段")),
        "amountWan": amount_wan,
        "buyer": clean(row.get("招标公司")),
        "supplier": clean(row.get("投标公司/中标单位")),
        "demandType": clean(row.get("需求类型")),
        "clientType": clean(row.get("客户类型")),
        "durationDays": row.get("合同工期天数") or "",
        "url": clean(row.get("官网查看地址")),
    }


def aggregate(rows: list[dict[str, Any]], key: str) -> list[dict[str, Any]]:
    groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for row in rows:
        name = clean(row.get(key))
        if name:
            groups[name].append(row)
    output = []
    for name, items in groups.items():
        amount = sum(row["amountWan"] for row in items)
        output.append(
            {
                "name": name,
                "projectCount": len(items),
                "amountWan": round(amount, 4),
                "avgAmountWan": round(amount / len(items), 4) if items else 0,
            }
        )
    return sorted(output, key=lambda item: (item["amountWan"], item["projectCount"]), reverse=True)


def build_payload() -> dict[str, Any]:
    projects = [normalize_project(row) for row in read_sheet("招投标项目表")]
    companies = read_sheet("招标公司表")
    suppliers = read_sheet("投标公司表")
    products = read_sheet("投标产品表")
    qualifications = read_sheet("投标公司资质表")

    province_rows = aggregate(projects, "provinceName")
    demand_counter = Counter(row["demandType"] or "未识别" for row in projects)
    client_counter = Counter(row["clientType"] or "未识别" for row in projects)
    supplier_amount: dict[str, float] = defaultdict(float)
    supplier_count: Counter[str] = Counter()
    for row in projects:
        if row["supplier"]:
            supplier_amount[row["supplier"]] += row["amountWan"]
            supplier_count[row["supplier"]] += 1
    top_suppliers = sorted(
        [
            {"name": name, "amountWan": round(amount, 4), "projectCount": supplier_count[name]}
            for name, amount in supplier_amount.items()
        ],
        key=lambda item: (item["amountWan"], item["projectCount"]),
        reverse=True,
    )[:20]
    return {
        "meta": {
            "source": str(SOURCE_XLSX.relative_to(ROOT)),
            "projectCount": len(projects),
            "buyerCount": len(companies),
            "supplierCount": len(suppliers),
            "productCount": len(products),
            "qualificationTodoCount": len(qualifications),
            "totalAmountWan": round(sum(row["amountWan"] for row in projects), 4),
        },
        "provinceMeta": {
            raw: {"name": display, "code": code}
            for raw, (display, code) in PROVINCE_META.items()
        },
        "municipalities": sorted(MUNICIPALITIES),
        "projects": projects,
        "provinceSummary": province_rows,
        "demandSummary": [{"name": k, "value": v} for k, v in demand_counter.most_common()],
        "clientSummary": [{"name": k, "value": v} for k, v in client_counter.most_common()],
        "topSuppliers": top_suppliers,
    }


def html_escape_json(payload: dict[str, Any]) -> str:
    return json.dumps(payload, ensure_ascii=False).replace("</", "<\\/")


def build_html(payload: dict[str, Any]) -> str:
    data_json = html_escape_json(payload)
    return f"""<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>招投标实体地图看板</title>
  <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
  <style>
    :root {{
      --page:#f4f6f8;
      --panel:#ffffff;
      --ink:#17212b;
      --muted:#5d6b7b;
      --line:#d9e3ec;
      --accent:#1f6f8b;
      --accent-soft:#e5f3f6;
      --good:#2f8f6b;
      --warn:#b86b2d;
    }}
    * {{ box-sizing:border-box; }}
    body {{
      margin:0;
      background:var(--page);
      color:var(--ink);
      font-family:"Microsoft YaHei","PingFang SC",Arial,sans-serif;
      line-height:1.55;
    }}
    .shell {{ max-width:1500px; margin:0 auto; padding:28px 24px 42px; }}
    header {{ display:grid; grid-template-columns:minmax(0, 1fr) auto; gap:20px; align-items:end; margin-bottom:20px; }}
    h1 {{ margin:0 0 8px; font-size:28px; line-height:1.2; letter-spacing:0; }}
    .subtitle {{ margin:0; color:var(--muted); font-size:14px; }}
    .source {{ color:var(--muted); font-size:12px; text-align:right; max-width:560px; }}
    .kpis {{ display:grid; grid-template-columns:repeat(5, minmax(150px, 1fr)); gap:12px; margin:16px 0 18px; }}
    .kpi {{ background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:14px 16px; }}
    .kpi-label {{ color:var(--muted); font-size:12px; margin-bottom:4px; }}
    .kpi-value {{ font-size:24px; font-weight:700; letter-spacing:0; }}
    .grid {{ display:grid; grid-template-columns:minmax(600px, 1.45fr) minmax(360px, .8fr); gap:16px; align-items:start; }}
    .panel {{ background:var(--panel); border:1px solid var(--line); border-radius:8px; min-width:0; }}
    .panel-head {{ display:flex; align-items:center; justify-content:space-between; gap:12px; border-bottom:1px solid var(--line); padding:14px 16px; }}
    .panel-title {{ font-weight:700; font-size:15px; }}
    .panel-subtitle {{ color:var(--muted); font-size:12px; margin-top:2px; }}
    .panel-body {{ padding:14px 16px; }}
    #mapChart {{ height:620px; width:100%; }}
    .toolbar {{ display:flex; gap:8px; align-items:center; flex-wrap:wrap; }}
    button, select, input {{
      font:inherit;
      border:1px solid var(--line);
      background:#fff;
      color:var(--ink);
      border-radius:6px;
      min-height:32px;
      padding:6px 10px;
    }}
    button {{ cursor:pointer; }}
    button.primary {{ background:var(--accent); color:#fff; border-color:var(--accent); }}
    button:disabled {{ color:#9aa7b4; cursor:not-allowed; background:#f4f6f8; }}
    .detail-metrics {{ display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:14px; }}
    .detail-card {{ border:1px solid var(--line); border-radius:8px; padding:12px; background:#fbfcfd; }}
    .detail-card b {{ display:block; font-size:20px; margin-top:4px; }}
    .list {{ display:grid; gap:8px; max-height:400px; overflow:auto; padding-right:4px; }}
    .project {{ border:1px solid var(--line); border-radius:8px; padding:10px 12px; background:#fff; }}
    .project-title {{ font-weight:700; font-size:13px; margin-bottom:6px; }}
    .project-meta {{ color:var(--muted); font-size:12px; display:flex; gap:8px; flex-wrap:wrap; }}
    .project a {{ color:var(--accent); text-decoration:none; }}
    .charts {{ display:grid; grid-template-columns:repeat(3, minmax(260px, 1fr)); gap:16px; margin-top:16px; }}
    .chart {{ height:330px; }}
    table {{ width:100%; border-collapse:collapse; font-size:13px; }}
    th, td {{ border-bottom:1px solid var(--line); padding:9px 8px; text-align:left; vertical-align:top; }}
    th {{ color:#14384a; background:var(--accent-soft); font-weight:700; position:sticky; top:0; }}
    .table-wrap {{ max-height:370px; overflow:auto; }}
    .note {{ color:var(--muted); font-size:12px; margin-top:8px; }}
    .badge {{ display:inline-flex; align-items:center; border-radius:999px; padding:2px 8px; background:var(--accent-soft); color:#14546b; font-size:12px; }}
    .status {{ min-height:18px; color:var(--muted); font-size:12px; }}
    @media (max-width:1100px) {{
      header, .grid {{ grid-template-columns:1fr; }}
      .source {{ text-align:left; }}
      .kpis {{ grid-template-columns:repeat(2, minmax(150px, 1fr)); }}
      .charts {{ grid-template-columns:1fr; }}
      #mapChart {{ height:480px; }}
    }}
  </style>
</head>
<body>
  <div class="shell">
    <header>
      <div>
        <h1>招投标实体地图看板</h1>
        <p class="subtitle">把原始 Excel 抽取出的项目、招标公司、投标公司、产品需求和来源链接作为页面内置数据库，按地区进行地图钻取和报表联动。</p>
      </div>
      <div class="source" id="sourceText"></div>
    </header>

    <section class="kpis" id="kpis"></section>

    <section class="grid">
      <div class="panel">
        <div class="panel-head">
          <div>
            <div class="panel-title">地区金额地图</div>
            <div class="panel-subtitle" id="mapSubtitle">全国项目金额与项目数分布</div>
          </div>
          <div class="toolbar">
            <span class="badge" id="levelBadge">全国</span>
            <button id="backBtn" disabled>返回全国</button>
            <select id="metricSelect">
              <option value="amountWan">按金额</option>
              <option value="projectCount">按项目数</option>
            </select>
          </div>
        </div>
        <div id="mapChart"></div>
        <div class="panel-body status" id="mapStatus">地图加载中。</div>
      </div>

      <aside class="panel">
        <div class="panel-head">
          <div>
            <div class="panel-title" id="detailTitle">全国汇总</div>
            <div class="panel-subtitle">点击地图区域后，右侧报表自动切换到该区域。</div>
          </div>
        </div>
        <div class="panel-body">
          <div class="detail-metrics">
            <div class="detail-card"><span>项目数</span><b id="detailCount">-</b></div>
            <div class="detail-card"><span>金额合计</span><b id="detailAmount">-</b></div>
          </div>
          <div class="panel-title">项目明细</div>
          <div class="note">默认显示金额最高的 30 个项目。</div>
          <div class="list" id="projectList"></div>
        </div>
      </aside>
    </section>

    <section class="charts">
      <div class="panel">
        <div class="panel-head"><div><div class="panel-title">地区排行</div><div class="panel-subtitle">随地图层级切换</div></div></div>
        <div class="chart" id="regionChart"></div>
      </div>
      <div class="panel">
        <div class="panel-head"><div><div class="panel-title">中标单位排行</div><div class="panel-subtitle">按披露金额</div></div></div>
        <div class="chart" id="supplierChart"></div>
      </div>
      <div class="panel">
        <div class="panel-head"><div><div class="panel-title">需求类型分布</div><div class="panel-subtitle">项目标题与关键词分类</div></div></div>
        <div class="chart" id="demandChart"></div>
      </div>
    </section>

    <section class="panel" style="margin-top:16px;">
      <div class="panel-head">
        <div>
          <div class="panel-title">当前区域报表详情</div>
          <div class="panel-subtitle">区域、项目数、金额、主要招标公司和中标单位。</div>
        </div>
      </div>
      <div class="panel-body table-wrap">
        <table>
          <thead><tr><th>区域</th><th>项目数</th><th>金额合计</th><th>主要招标公司</th><th>主要中标单位</th></tr></thead>
          <tbody id="regionTable"></tbody>
        </table>
      </div>
    </section>
  </div>

  <script id="dashboard-data" type="application/json">{data_json}</script>
  <script>
    const payload = JSON.parse(document.getElementById('dashboard-data').textContent);
    const projects = payload.projects;
    const provinceMeta = payload.provinceMeta;
    const municipalities = new Set(payload.municipalities);
    const mapChart = echarts.init(document.getElementById('mapChart'));
    const regionChart = echarts.init(document.getElementById('regionChart'));
    const supplierChart = echarts.init(document.getElementById('supplierChart'));
    const demandChart = echarts.init(document.getElementById('demandChart'));
    const fmt = new Intl.NumberFormat('zh-CN', {{ maximumFractionDigits: 1 }});
    let current = {{ level: 'china', provinceRaw: '', mapName: 'china' }};

    function amountLabel(value) {{
      if (!value) return '0 万';
      if (value >= 10000) return `${{fmt.format(value / 10000)}} 亿`;
      return `${{fmt.format(value)}} 万`;
    }}

    function compact(value) {{ return fmt.format(value || 0); }}

    function provinceRawByDisplay(display) {{
      for (const [raw, meta] of Object.entries(provinceMeta)) {{
        if (meta.name === display || raw === display) return raw;
      }}
      return display.replace(/省|市|自治区|壮族|回族|维吾尔|特别行政区/g, '');
    }}

    function cityName(raw) {{
      if (!raw) return '';
      if (raw.endsWith('市') || raw.endsWith('州') || raw.endsWith('盟') || raw.endsWith('地区') || raw.endsWith('自治州')) return raw;
      return `${{raw}}市`;
    }}

    function filteredProjects() {{
      if (current.level === 'china') return projects;
      return projects.filter(row => row.province === current.provinceRaw);
    }}

    function aggregateRows(rows, level) {{
      const groups = new Map();
      for (const row of rows) {{
        let name = row.provinceName || row.province || '未识别';
        if (level === 'province') {{
          if (municipalities.has(row.province)) {{
            name = row.district || row.cityName || row.provinceName || '未识别';
          }} else {{
            name = row.cityName || row.district || row.provinceName || '未识别';
          }}
        }}
        if (!groups.has(name)) groups.set(name, {{ name, projectCount: 0, amountWan: 0, buyers: new Map(), suppliers: new Map() }});
        const item = groups.get(name);
        item.projectCount += 1;
        item.amountWan += Number(row.amountWan || 0);
        if (row.buyer) item.buyers.set(row.buyer, (item.buyers.get(row.buyer) || 0) + 1);
        if (row.supplier) item.suppliers.set(row.supplier, (item.suppliers.get(row.supplier) || 0) + 1);
      }}
      return Array.from(groups.values())
        .map(item => ({{
          ...item,
          amountWan: Number(item.amountWan.toFixed(4)),
          topBuyers: Array.from(item.buyers.entries()).sort((a,b) => b[1]-a[1]).slice(0,4).map(x => x[0]).join('、'),
          topSuppliers: Array.from(item.suppliers.entries()).sort((a,b) => b[1]-a[1]).slice(0,4).map(x => x[0]).join('、')
        }}))
        .sort((a,b) => b.amountWan - a.amountWan || b.projectCount - a.projectCount);
    }}

    function renderKpis() {{
      const meta = payload.meta;
      const kpis = [
        ['项目数', meta.projectCount],
        ['金额合计', amountLabel(meta.totalAmountWan)],
        ['招标公司', meta.buyerCount],
        ['中标/投标公司', meta.supplierCount],
        ['资质待补抓', meta.qualificationTodoCount]
      ];
      document.getElementById('kpis').innerHTML = kpis.map(([label, value]) => `
        <div class="kpi"><div class="kpi-label">${{label}}</div><div class="kpi-value">${{value}}</div></div>
      `).join('');
      document.getElementById('sourceText').textContent = `数据源：${{meta.source}}；Excel sheets 已嵌入本 HTML。`;
    }}

    function renderDetail() {{
      const rows = filteredProjects();
      const amount = rows.reduce((sum, row) => sum + Number(row.amountWan || 0), 0);
      const title = current.level === 'china' ? '全国汇总' : `${{current.provinceRaw}}明细`;
      document.getElementById('detailTitle').textContent = title;
      document.getElementById('detailCount').textContent = compact(rows.length);
      document.getElementById('detailAmount').textContent = amountLabel(amount);
      const list = rows.slice().sort((a,b) => b.amountWan - a.amountWan).slice(0, 30);
      document.getElementById('projectList').innerHTML = list.map(row => `
        <div class="project">
          <div class="project-title">${{escapeHtml(row.title || '未命名项目')}}</div>
          <div class="project-meta">
            <span>${{escapeHtml(row.province || '')}}${{row.city ? ' / ' + escapeHtml(row.city) : ''}}</span>
            <span>${{amountLabel(row.amountWan)}}</span>
            <span>${{escapeHtml(row.supplier || '未披露中标单位')}}</span>
            ${{row.url ? `<a href="${{escapeAttr(row.url)}}" target="_blank" rel="noreferrer">来源</a>` : ''}}
          </div>
        </div>
      `).join('');
    }}

    function renderRegionTable(summary) {{
      document.getElementById('regionTable').innerHTML = summary.slice(0, 80).map(row => `
        <tr>
          <td>${{escapeHtml(row.name)}}</td>
          <td>${{compact(row.projectCount)}}</td>
          <td>${{amountLabel(row.amountWan)}}</td>
          <td>${{escapeHtml(row.topBuyers || '-')}}</td>
          <td>${{escapeHtml(row.topSuppliers || '-')}}</td>
        </tr>
      `).join('');
    }}

    function renderRegionChart(summary) {{
      const rows = summary.slice(0, 12).reverse();
      const metric = document.getElementById('metricSelect').value;
      regionChart.setOption({{
        grid: {{ left: 86, right: 20, top: 20, bottom: 24 }},
        tooltip: {{ trigger: 'axis' }},
        xAxis: {{ type: 'value', axisLabel: {{ color: '#5d6b7b' }} }},
        yAxis: {{ type: 'category', data: rows.map(r => r.name), axisLabel: {{ color: '#17212b' }} }},
        series: [{{ type: 'bar', data: rows.map(r => metric === 'amountWan' ? r.amountWan : r.projectCount), itemStyle: {{ color: '#1f6f8b', borderRadius: [0, 4, 4, 0] }} }}]
      }});
    }}

    function renderSupplierChart() {{
      const rows = payload.topSuppliers.slice(0, 10).reverse();
      supplierChart.setOption({{
        grid: {{ left: 112, right: 20, top: 20, bottom: 24 }},
        tooltip: {{ trigger: 'axis', formatter: params => `${{params[0].name}}<br/>${{amountLabel(params[0].value)}}` }},
        xAxis: {{ type: 'value', axisLabel: {{ color: '#5d6b7b' }} }},
        yAxis: {{ type: 'category', data: rows.map(r => r.name), axisLabel: {{ color: '#17212b', width: 100, overflow: 'truncate' }} }},
        series: [{{ type: 'bar', data: rows.map(r => r.amountWan), itemStyle: {{ color: '#2f8f6b', borderRadius: [0, 4, 4, 0] }} }}]
      }});
    }}

    function renderDemandChart() {{
      demandChart.setOption({{
        tooltip: {{ trigger: 'item' }},
        legend: {{ bottom: 0, type: 'scroll' }},
        series: [{{
          type: 'pie',
          radius: ['42%', '68%'],
          center: ['50%', '44%'],
          data: payload.demandSummary,
          label: {{ formatter: '{{b}}\\n{{c}}' }}
        }}]
      }});
    }}

    async function loadGeoJson(code) {{
      const url = `https://geo.datav.aliyun.com/areas_v3/bound/${{code}}_full.json`;
      const response = await fetch(url);
      if (!response.ok) throw new Error(`GeoJSON HTTP ${{response.status}}`);
      return response.json();
    }}

    async function renderMap() {{
      const metric = document.getElementById('metricSelect').value;
      const rows = filteredProjects();
      const summary = aggregateRows(rows, current.level);
      renderRegionChart(summary);
      renderRegionTable(summary);
      renderDetail();
      const maxValue = Math.max(...summary.map(row => metric === 'amountWan' ? row.amountWan : row.projectCount), 1);
      const mapData = summary.map(row => ({{
        name: row.name,
        value: metric === 'amountWan' ? row.amountWan : row.projectCount,
        projectCount: row.projectCount,
        amountWan: row.amountWan
      }}));

      const mapName = current.mapName;
      const status = document.getElementById('mapStatus');
      try {{
        const code = current.level === 'china' ? '100000' : provinceMeta[current.provinceRaw]?.code;
        const geo = await loadGeoJson(code);
        echarts.registerMap(mapName, geo);
        status.textContent = current.level === 'china'
          ? '点击省份可下钻到省内市/区金额。'
          : '当前为省级下钻视图；直辖市优先展示区级金额。';
        mapChart.setOption({{
          tooltip: {{
            trigger: 'item',
            formatter: item => {{
              const data = item.data || {{}};
              return `${{item.name}}<br/>项目数：${{compact(data.projectCount || 0)}}<br/>金额：${{amountLabel(data.amountWan || 0)}}`;
            }}
          }},
          visualMap: {{
            min: 0,
            max: maxValue,
            left: 18,
            bottom: 24,
            calculable: true,
            text: metric === 'amountWan' ? ['金额高', '金额低'] : ['项目多', '项目少'],
            inRange: {{ color: ['#e9f3f6', '#91c9d4', '#1f6f8b'] }},
            textStyle: {{ color: '#5d6b7b' }}
          }},
          series: [{{
            type: 'map',
            map: mapName,
            roam: true,
            zoom: current.level === 'china' ? 1.12 : 1,
            data: mapData,
            selectedMode: false,
            label: {{ show: false }},
            emphasis: {{ label: {{ show: true, color: '#17212b' }}, itemStyle: {{ areaColor: '#ffd08a' }} }},
            itemStyle: {{ borderColor: '#ffffff', borderWidth: 1, areaColor: '#edf2f5' }}
          }}]
        }}, true);
      }} catch (err) {{
        status.textContent = `地图底图加载失败，已切换为排行图。原因：${{err.message}}`;
        const fallback = summary.slice(0, 20).reverse();
        mapChart.setOption({{
          grid: {{ left: 96, right: 34, top: 34, bottom: 34 }},
          tooltip: {{ trigger: 'axis' }},
          xAxis: {{ type: 'value' }},
          yAxis: {{ type: 'category', data: fallback.map(r => r.name) }},
          series: [{{ type: 'bar', data: fallback.map(r => metric === 'amountWan' ? r.amountWan : r.projectCount), itemStyle: {{ color: '#1f6f8b' }} }}]
        }}, true);
      }}
      document.getElementById('levelBadge').textContent = current.level === 'china' ? '全国' : current.provinceRaw;
      document.getElementById('backBtn').disabled = current.level === 'china';
      document.getElementById('mapSubtitle').textContent = current.level === 'china'
        ? '全国项目金额与项目数分布'
        : `${{current.provinceRaw}}省内市/区金额与项目数分布`;
    }}

    mapChart.on('click', params => {{
      if (current.level !== 'china') return;
      const raw = provinceRawByDisplay(params.name);
      if (!provinceMeta[raw]) return;
      current = {{ level: 'province', provinceRaw: raw, mapName: `province-${{provinceMeta[raw].code}}` }};
      renderMap();
    }});

    document.getElementById('backBtn').addEventListener('click', () => {{
      current = {{ level: 'china', provinceRaw: '', mapName: 'china' }};
      renderMap();
    }});
    document.getElementById('metricSelect').addEventListener('change', renderMap);
    window.addEventListener('resize', () => {{
      mapChart.resize();
      regionChart.resize();
      supplierChart.resize();
      demandChart.resize();
    }});

    function escapeHtml(value) {{
      return String(value ?? '').replace(/[&<>"']/g, ch => ({{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}}[ch]));
    }}
    function escapeAttr(value) {{ return escapeHtml(value); }}

    renderKpis();
    renderSupplierChart();
    renderDemandChart();
    renderMap();
  </script>
</body>
</html>"""


def main() -> None:
    OUT_DIR.mkdir(parents=True, exist_ok=True)
    payload = build_payload()
    OUT_HTML.write_text(build_html(payload), encoding="utf-8")
    print(OUT_HTML)
    print(json.dumps(payload["meta"], ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()
