Spaces:
Sleeping
Sleeping
redesign UI: reuse annotation desk frontend (read-only), switch to docker SDK
Browse files- Dockerfile +19 -0
- README.md +18 -9
- app.py +240 -371
- requirements.txt +2 -0
- web/app.js +772 -0
- web/index.html +277 -0
- web/styles.css +370 -0
Dockerfile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
COPY requirements.txt .
|
| 5 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 6 |
+
|
| 7 |
+
COPY app.py .
|
| 8 |
+
COPY web/ web/
|
| 9 |
+
COPY graph.sqlite .
|
| 10 |
+
|
| 11 |
+
# HF Spaces 用非 root 跑,缓存目录要可写——
|
| 12 |
+
# 街景图按需从 CDN 拉取后缓存在这里
|
| 13 |
+
RUN mkdir -p /app/.cache && chmod 777 /app/.cache
|
| 14 |
+
ENV HF_HOME=/app/.cache \
|
| 15 |
+
SETVL_DB=/app/graph.sqlite \
|
| 16 |
+
SETVL_PACKAGE=/app/package
|
| 17 |
+
|
| 18 |
+
EXPOSE 7860
|
| 19 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -3,21 +3,30 @@ title: SetVL Node Graph Viewer
|
|
| 3 |
emoji: 🛣️
|
| 4 |
colorFrom: gray
|
| 5 |
colorTo: yellow
|
| 6 |
-
sdk:
|
| 7 |
-
|
| 8 |
-
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
license: mit
|
| 11 |
---
|
| 12 |
|
| 13 |
# SetVL Node Graph Viewer
|
| 14 |
|
| 15 |
-
只读浏览 SetVL-480K 全库的 node graph:12,250 个场景、119,996 个节点、108,374 条边。
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
按直线连接)。
|
| 20 |
|
| 21 |
-
|
| 22 |
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
emoji: 🛣️
|
| 4 |
colorFrom: gray
|
| 5 |
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
|
|
|
| 8 |
pinned: false
|
| 9 |
license: mit
|
| 10 |
---
|
| 11 |
|
| 12 |
# SetVL Node Graph Viewer
|
| 13 |
|
| 14 |
+
只读浏览 SetVL-480K 全库的 node graph:**12,250 个场景、119,996 个节点、108,374 条边**。
|
| 15 |
|
| 16 |
+
界面与本地的标注复核台一致——三栏布局、J/K 键盘导航、卫星图/电子地图切换、
|
| 17 |
+
邻接矩阵、街景放大。区别是**去掉了全部写入**:没有判定按钮,后端也没有对应接口。
|
|
|
|
| 18 |
|
| 19 |
+
## 怎么看
|
| 20 |
|
| 21 |
+
- **左栏**:全库场景浏览。可按城市、scene_id、最少节点数筛选,
|
| 22 |
+
也可只看含孤儿 / 含高花费边 / 路网碎片化的场景。`J`/`K` 上下,`[`/`]` 翻页。
|
| 23 |
+
- **中栏**:卫星底图上叠出图结构。节点按所属走廊着色,红圈是孤儿;
|
| 24 |
+
边按来源着色——<span style="color:#68c8c0">青=走廊链</span>、
|
| 25 |
+
<span style="color:#f1a43c">橙=路口边</span>、
|
| 26 |
+
<span style="color:#b8a2e0">紫=桥接边</span>,白色加粗虚线=高花费边。
|
| 27 |
+
`B` 切换电子地图,`E` 切换沿路走线/直线。
|
| 28 |
+
- **右栏**:选中节点或边的完整属性与自动判定信号。
|
| 29 |
+
|
| 30 |
+
图片存在配套的私有 Dataset 里,由 CDN 按需分发。
|
| 31 |
+
|
| 32 |
+
建图规则见仓库的 `GRAPH_RULES.md`。
|
app.py
CHANGED
|
@@ -1,61 +1,61 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
-
"""SetVL Node Graph 只读浏览器
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
-
import argparse
|
| 14 |
import json
|
| 15 |
import math
|
| 16 |
import os
|
| 17 |
import sqlite3
|
|
|
|
| 18 |
from pathlib import Path
|
| 19 |
-
from typing import Any, Dict, List, Optional
|
| 20 |
|
| 21 |
import gradio as gr
|
| 22 |
-
from
|
|
|
|
| 23 |
|
| 24 |
HERE = Path(__file__).resolve().parent
|
| 25 |
-
|
| 26 |
-
# 图片从哪来,两种模式:
|
| 27 |
-
# 本地包 —— SETVL_PACKAGE 指向 prepare.py 产出的目录,直接读文件
|
| 28 |
-
# HF 数据集 —— SETVL_DATASET 指向 "user/repo",按需从 CDN 取,本地只留缓存
|
| 29 |
-
# 27 GB 图片放 Dataset repo、Space 里只放 93 MB 的数据库,Space 构建才快。
|
| 30 |
PACKAGE = Path(os.environ.get("SETVL_PACKAGE", HERE / "package"))
|
| 31 |
DATASET = os.environ.get("SETVL_DATASET", "").strip()
|
| 32 |
HF_TOKEN = (os.environ.get("HF_TOKEN")
|
| 33 |
or os.environ.get("HUGGING_FACE_HUB_TOKEN") or "").strip()
|
| 34 |
-
DB = Path(os.environ
|
| 35 |
-
if
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
}
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
把整个页面打挂。
|
| 58 |
-
"""
|
| 59 |
local = PACKAGE / rel
|
| 60 |
if local.is_file():
|
| 61 |
return local
|
|
@@ -63,380 +63,249 @@ def asset(rel: str) -> Optional[Path]:
|
|
| 63 |
return None
|
| 64 |
try:
|
| 65 |
from huggingface_hub import hf_hub_download
|
| 66 |
-
# 私有数据集必须带 token
|
| 67 |
-
# 要在 Space 的 Settings -> Secrets 里配 HF_TOKEN(读权限即可)。
|
| 68 |
-
# 不配的话每次取图都是 401,界面只剩空白底图。
|
| 69 |
return Path(hf_hub_download(DATASET, rel, repo_type="dataset",
|
| 70 |
token=HF_TOKEN or None))
|
| 71 |
except Exception:
|
| 72 |
return None
|
| 73 |
|
| 74 |
|
| 75 |
-
def
|
| 76 |
-
if not
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
|
| 83 |
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
|
| 87 |
-
|
| 88 |
-
global CON
|
| 89 |
-
if CON is None:
|
| 90 |
-
CON = connect()
|
| 91 |
-
return CON
|
| 92 |
|
| 93 |
|
| 94 |
-
|
|
|
|
|
|
|
| 95 |
|
| 96 |
-
def mercator(lat: float, lon: float) -> Tuple[float, float]:
|
| 97 |
-
s = 256 * (2 ** ZOOM)
|
| 98 |
-
x = (lon + 180.0) / 360.0 * s
|
| 99 |
-
sy = math.sin(math.radians(lat))
|
| 100 |
-
y = (0.5 - math.log((1 + sy) / (1 - sy)) / (4 * math.pi)) * s
|
| 101 |
-
return x, y
|
| 102 |
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
-
def tile_center(sat_rel: str) -> Optional[Tuple[float, float]]:
|
| 105 |
-
parts = Path(sat_rel).stem.split("_")
|
| 106 |
-
try:
|
| 107 |
-
return float(parts[-1]), float(parts[-2])
|
| 108 |
-
except (ValueError, IndexError):
|
| 109 |
-
return None
|
| 110 |
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
-
def projector(sat_rel: str):
|
| 113 |
-
c = tile_center(sat_rel)
|
| 114 |
-
if c is None:
|
| 115 |
-
return None
|
| 116 |
-
cx, cy = mercator(*c)
|
| 117 |
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
-
def corridor_color(cid: Optional[str]) -> Tuple[int, int, int]:
|
| 125 |
-
if not cid:
|
| 126 |
-
return (120, 130, 125)
|
| 127 |
-
h = 0
|
| 128 |
-
for ch in cid:
|
| 129 |
-
h = (h * 31 + ord(ch)) & 0xFFFFFFFF
|
| 130 |
-
import colorsys
|
| 131 |
-
r, g, b = colorsys.hls_to_rgb((h % 360) / 360.0, 0.58, 0.55)
|
| 132 |
-
return int(r * 255), int(g * 255), int(b * 255)
|
| 133 |
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
-
# --------------------------------------------------------------- 查询
|
| 136 |
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
| 140 |
where, args = [], []
|
| 141 |
-
if city
|
| 142 |
where.append("s.city = ?")
|
| 143 |
args.append(city)
|
| 144 |
if min_nodes:
|
| 145 |
where.append("s.node_count >= ?")
|
| 146 |
args.append(int(min_nodes))
|
| 147 |
-
if
|
| 148 |
where.append("s.scene_id LIKE ?")
|
| 149 |
-
args.append(f"%{
|
| 150 |
-
if
|
| 151 |
-
where.append("EXISTS (SELECT 1 FROM
|
| 152 |
-
"AND
|
| 153 |
-
if
|
| 154 |
-
where.append("EXISTS (SELECT 1 FROM
|
| 155 |
-
"AND
|
|
|
|
|
|
|
| 156 |
clause = (" WHERE " + " AND ".join(where)) if where else ""
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
| 159 |
rows = con().execute(
|
| 160 |
-
|
| 161 |
-
"
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
|
| 166 |
-
|
| 167 |
-
|
|
|
|
|
|
|
| 168 |
|
| 169 |
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
edges = [dict(r) for r in con().execute(
|
| 177 |
-
"SELECT * FROM edge WHERE scene_id = ? ORDER BY source_label, target_label",
|
| 178 |
-
(scene_id,))]
|
| 179 |
-
for n in nodes:
|
| 180 |
-
n["corridor_id"] = n["corridor_id_human"] or n["corridor_id_auto"]
|
| 181 |
-
n["corridor_name"] = (n["corridor_name_human"] or n["corridor_name_auto"]
|
| 182 |
-
or n["corridor_id"])
|
| 183 |
-
o = n["is_orphan_human"]
|
| 184 |
-
n["is_orphan"] = int(o) if o is not None else (n["is_orphan_auto"] or 0)
|
| 185 |
-
for e in edges:
|
| 186 |
-
e["origin"] = e["origin_human"] or e["origin_auto"] or "bridge"
|
| 187 |
-
hc = e["high_cost_human"]
|
| 188 |
-
e["high_cost"] = int(hc) if hc is not None else (e["high_cost_auto"] or 0)
|
| 189 |
-
e["in_graph"] = e["edge_verdict_human"] != "reject"
|
| 190 |
-
return {"scene": dict(s), "nodes": nodes, "edges": edges}
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
# --------------------------------------------------------------- 绘图
|
| 194 |
-
|
| 195 |
-
def render(scene_id: str, show_road_path: bool) -> Tuple[Optional[Image.Image], str, str]:
|
| 196 |
-
det = load_scene(scene_id)
|
| 197 |
-
if det is None:
|
| 198 |
-
return None, "未找到该场景", ""
|
| 199 |
-
sat = asset(det["scene"]["satellite_path"])
|
| 200 |
-
base = (Image.open(sat).convert("RGB") if sat
|
| 201 |
-
else Image.new("RGB", (RASTER, RASTER), (18, 22, 20)))
|
| 202 |
-
base = base.resize((RASTER * 2, RASTER * 2), Image.LANCZOS)
|
| 203 |
-
d = ImageDraw.Draw(base, "RGBA")
|
| 204 |
-
proj = projector(det["scene"]["satellite_path"])
|
| 205 |
-
if proj is None:
|
| 206 |
-
return base, "无法从瓦片名解析中心坐标", ""
|
| 207 |
-
P = {n["label"]: tuple(v * 2 for v in proj(n["lat"], n["lon"])) for n in det["nodes"]}
|
| 208 |
-
|
| 209 |
-
for e in det["edges"]:
|
| 210 |
-
a, b = P.get(e["source_label"]), P.get(e["target_label"])
|
| 211 |
-
if not a or not b:
|
| 212 |
-
continue
|
| 213 |
-
col = COL.get(e["origin"], COL["bridge"])
|
| 214 |
-
alpha = 60 if not e["in_graph"] else 255
|
| 215 |
-
if e["high_cost"]:
|
| 216 |
-
col = COL_HIGHCOST
|
| 217 |
-
path = None
|
| 218 |
-
if show_road_path and not e["high_cost"] and e["road_path_auto"]:
|
| 219 |
-
try:
|
| 220 |
-
pts = json.loads(e["road_path_auto"])
|
| 221 |
-
path = [tuple(v * 2 for v in proj(p[0], p[1])) for p in pts]
|
| 222 |
-
except (ValueError, TypeError):
|
| 223 |
-
path = None
|
| 224 |
-
if path and len(path) > 1:
|
| 225 |
-
d.line([a] + path + [b], fill=col + (alpha,), width=3, joint="curve")
|
| 226 |
-
else:
|
| 227 |
-
d.line([a, b], fill=col + (alpha,), width=3)
|
| 228 |
-
|
| 229 |
-
for n in det["nodes"]:
|
| 230 |
-
p = P[n["label"]]
|
| 231 |
-
fill = corridor_color(n["corridor_id"])
|
| 232 |
-
r = 9
|
| 233 |
-
d.ellipse([p[0] - r, p[1] - r, p[0] + r, p[1] + r], fill=fill + (255,),
|
| 234 |
-
outline=(COL_ORPHAN if n["is_orphan"] else (12, 16, 14)) + (255,),
|
| 235 |
-
width=3 if n["is_orphan"] else 2)
|
| 236 |
-
d.text((p[0] - 4, p[1] - 6), n["label"], fill=(12, 16, 14))
|
| 237 |
-
|
| 238 |
-
sc = det["scene"]
|
| 239 |
-
from collections import Counter
|
| 240 |
-
oc = Counter(e["origin"] for e in det["edges"])
|
| 241 |
-
corridors = sorted({n["corridor_name"] for n in det["nodes"] if n["corridor_name"]})
|
| 242 |
-
summary = "\n".join([
|
| 243 |
-
f"**{sc['scene_id']}**",
|
| 244 |
-
"",
|
| 245 |
-
f"- 城市:{sc['city']} 节点 {len(det['nodes'])} 边 {len(det['edges'])}",
|
| 246 |
-
f"- 边构成:走廊链 {oc.get('corridor_chain',0)} 路口 {oc.get('intersection',0)} "
|
| 247 |
-
f"桥接 {oc.get('bridge',0)} 人工 {oc.get('manual',0)}",
|
| 248 |
-
f"- 孤儿节点 {sum(n['is_orphan'] for n in det['nodes'])} "
|
| 249 |
-
f"高花费边 {sum(e['high_cost'] for e in det['edges'])} "
|
| 250 |
-
f"被拒边 {sum(0 if e['in_graph'] else 1 for e in det['edges'])}",
|
| 251 |
-
f"- 路网碎片化:{'是' if sc['net_fragmented_auto'] else '否'}",
|
| 252 |
-
"",
|
| 253 |
-
f"**走廊({len(corridors)})**:" + ("、".join(corridors[:8]) or "—")
|
| 254 |
-
+ ("…" if len(corridors) > 8 else ""),
|
| 255 |
-
])
|
| 256 |
-
|
| 257 |
-
lines = ["| 边 | 来源 | 欧氏 m | 路网 m | 绕路 | 高花费 | 状态 |",
|
| 258 |
-
"|---|---|---|---|---|---|---|"]
|
| 259 |
-
for e in det["edges"]:
|
| 260 |
-
lines.append(
|
| 261 |
-
f"| {e['source_label']}–{e['target_label']} | {e['origin']} | "
|
| 262 |
-
f"{(e['euclid_distance_m_auto'] or 0):.1f} | "
|
| 263 |
-
f"{('%.1f' % e['road_distance_m_auto']) if e['road_distance_m_auto'] else '—'} | "
|
| 264 |
-
f"{('%.2fx' % e['detour_ratio_auto']) if e['detour_ratio_auto'] else '—'} | "
|
| 265 |
-
f"{'是' if e['high_cost'] else ''} | "
|
| 266 |
-
f"{'已拒' if not e['in_graph'] else ''} |")
|
| 267 |
-
return base, summary, "\n".join(lines)
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
def node_gallery(scene_id: str) -> List[Tuple[str, str]]:
|
| 271 |
-
"""取本场景每个节点的一张街景。
|
| 272 |
-
|
| 273 |
-
数据集模式下每张图都要走一次 CDN,串行拉 38 张要等半分钟。
|
| 274 |
-
并行拉——单张仍是那个延迟,但总时长压到接近单张。
|
| 275 |
-
"""
|
| 276 |
-
det = load_scene(scene_id)
|
| 277 |
-
if det is None:
|
| 278 |
-
return []
|
| 279 |
-
city = det["scene"]["city"]
|
| 280 |
-
folder = PACKAGE / "ground" / city
|
| 281 |
-
|
| 282 |
-
def fetch(n: Dict[str, Any]) -> Optional[Tuple[str, str]]:
|
| 283 |
-
cap = (f"{n['label']} · {n['corridor_name'] or '无走廊'}"
|
| 284 |
-
+ (" · 孤儿" if n["is_orphan"] else ""))
|
| 285 |
-
if folder.is_dir():
|
| 286 |
-
hits = sorted(folder.rglob(f"{city},{n['panoid']},*.webp"))
|
| 287 |
-
if hits:
|
| 288 |
-
return (str(hits[0]), cap)
|
| 289 |
-
rel = ground_rel(n)
|
| 290 |
-
if not rel:
|
| 291 |
-
return None
|
| 292 |
-
p = asset(rel)
|
| 293 |
-
return (str(p), cap) if p else None
|
| 294 |
-
|
| 295 |
-
from concurrent.futures import ThreadPoolExecutor
|
| 296 |
-
with ThreadPoolExecutor(max_workers=12) as ex:
|
| 297 |
-
got = list(ex.map(fetch, det["nodes"]))
|
| 298 |
-
return [g for g in got if g]
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
_TLS = __import__("threading").local()
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
def ro_con() -> sqlite3.Connection:
|
| 305 |
-
"""每个线程一个只读连接。
|
| 306 |
-
|
| 307 |
-
sqlite3 的连接默认不允许跨线程用,而街景是并行拉的——共用一个连接
|
| 308 |
-
会抛 ProgrammingError。
|
| 309 |
-
"""
|
| 310 |
-
c = getattr(_TLS, "con", None)
|
| 311 |
-
if c is None:
|
| 312 |
-
c = sqlite3.connect(f"file:{DB}?mode=ro", uri=True)
|
| 313 |
-
c.row_factory = sqlite3.Row
|
| 314 |
-
_TLS.con = c
|
| 315 |
-
return c
|
| 316 |
|
| 317 |
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
lines = ["| 节点 | 走廊 | 来源 | 吸附 m | 孤儿 | 人工覆盖 |", "|---|---|---|---|---|---|"]
|
| 335 |
-
for n in det["nodes"]:
|
| 336 |
-
ov = []
|
| 337 |
-
if n["corridor_id_human"] is not None:
|
| 338 |
-
ov.append("走廊")
|
| 339 |
-
if n["is_orphan_human"] is not None:
|
| 340 |
-
ov.append("孤儿")
|
| 341 |
-
lines.append(
|
| 342 |
-
f"| {n['label']} | {n['corridor_name'] or '—'} | "
|
| 343 |
-
f"{n['corridor_source_auto'] or '—'} | "
|
| 344 |
-
f"{(n['snap_distance_m_auto'] or 0):.1f} | {'是' if n['is_orphan'] else ''} | "
|
| 345 |
-
f"{'、'.join(ov)} |")
|
| 346 |
-
return "\n".join(lines)
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
# --------------------------------------------------------------- 界面
|
| 350 |
-
|
| 351 |
-
def build() -> gr.Blocks:
|
| 352 |
-
global CITIES
|
| 353 |
-
CITIES = ["全部"] + [r[0] for r in
|
| 354 |
-
con().execute("SELECT DISTINCT city FROM scene ORDER BY city")]
|
| 355 |
-
n_scene = con().execute("SELECT COUNT(*) FROM scene").fetchone()[0]
|
| 356 |
-
n_node = con().execute("SELECT COUNT(*) FROM node").fetchone()[0]
|
| 357 |
-
n_edge = con().execute("SELECT COUNT(*) FROM edge").fetchone()[0]
|
| 358 |
-
|
| 359 |
-
with gr.Blocks(title="SetVL Node Graph Viewer", theme=gr.themes.Base()) as demo:
|
| 360 |
-
warn = ""
|
| 361 |
-
if DATASET and not HF_TOKEN and not (PACKAGE / "satellite").is_dir():
|
| 362 |
-
warn = ("\n\n> ⚠ **未检测到 HF_TOKEN**,而图片来自私有数据集 "
|
| 363 |
-
f"`{DATASET}`。请在 Space 的 Settings → Variables and secrets "
|
| 364 |
-
"里添加名为 `HF_TOKEN` 的 secret(read 权限即可),否则所有"
|
| 365 |
-
"卫星图与街景都会加载失败。")
|
| 366 |
-
gr.Markdown(
|
| 367 |
-
f"# SetVL Node Graph Viewer\n"
|
| 368 |
-
f"{n_scene:,} 个场景 · {n_node:,} 个节点 · {n_edge:,} 条边 · **只读浏览**\n\n"
|
| 369 |
-
f"节点按所属走廊着色,红圈是孤儿;边按来源着色:"
|
| 370 |
-
f"<span style='color:#68c8c0'>青=走廊链</span>、"
|
| 371 |
-
f"<span style='color:#f1a43c'>橙=路口边</span>、"
|
| 372 |
-
f"<span style='color:#b8a2e0'>紫=桥接边</span>,"
|
| 373 |
-
f"白色加粗=高花费边(直线连接)。" + warn)
|
| 374 |
-
with gr.Row():
|
| 375 |
-
with gr.Column(scale=1):
|
| 376 |
-
city = gr.Dropdown(CITIES, value="全部", label="城市")
|
| 377 |
-
query = gr.Textbox(label="scene_id 搜索", placeholder="片段即可")
|
| 378 |
-
min_nodes = gr.Slider(1, 38, value=1, step=1, label="最少节点数")
|
| 379 |
-
with gr.Row():
|
| 380 |
-
f_cost = gr.Checkbox(label="含高花费边")
|
| 381 |
-
f_orph = gr.Checkbox(label="含孤儿")
|
| 382 |
-
page = gr.Number(value=0, precision=0, label="页码(每页 60)")
|
| 383 |
-
info = gr.Markdown()
|
| 384 |
-
# 用 Dropdown + allow_custom_value:choices 是运行时才填的,
|
| 385 |
-
# Gradio 6 会拿组件初始的空 choices 去校验提交值,
|
| 386 |
-
# 结果用户一点场景就报 "not in the list of choices: []"。
|
| 387 |
-
picker = gr.Dropdown(choices=[], label="场景", interactive=True,
|
| 388 |
-
allow_custom_value=True, filterable=True)
|
| 389 |
-
with gr.Column(scale=2):
|
| 390 |
-
show_path = gr.Checkbox(value=True, label="按道路走线绘制边")
|
| 391 |
-
canvas = gr.Image(label="场景图", type="pil", height=560)
|
| 392 |
-
summary = gr.Markdown()
|
| 393 |
-
with gr.Row():
|
| 394 |
-
with gr.Column():
|
| 395 |
-
gr.Markdown("### 节点")
|
| 396 |
-
ntab = gr.Markdown()
|
| 397 |
-
with gr.Column():
|
| 398 |
-
gr.Markdown("### 边")
|
| 399 |
-
etab = gr.Markdown()
|
| 400 |
-
gallery = gr.Gallery(label="街景", columns=6, height=260)
|
| 401 |
-
|
| 402 |
-
def refresh(c, mn, hc, orp, q, pg):
|
| 403 |
-
labels, total = list_scenes(c, mn, hc, orp, q, int(pg or 0))
|
| 404 |
-
pages = max(1, -(-total // 60))
|
| 405 |
-
msg = f"命中 **{total:,}** 个场景 第 {int(pg or 0)+1} / {pages} 页"
|
| 406 |
-
return gr.update(choices=labels, value=labels[0] if labels else None), msg
|
| 407 |
-
|
| 408 |
-
def show(label, sp):
|
| 409 |
-
sid = scene_of(label)
|
| 410 |
-
if not sid:
|
| 411 |
-
return None, "", "", "", []
|
| 412 |
-
img, summ, et = render(sid, sp)
|
| 413 |
-
return img, summ, node_table(sid), et, node_gallery(sid)
|
| 414 |
-
|
| 415 |
-
for ctl in (city, min_nodes, f_cost, f_orph, query, page):
|
| 416 |
-
ctl.change(refresh, [city, min_nodes, f_cost, f_orph, query, page],
|
| 417 |
-
[picker, info])
|
| 418 |
-
picker.change(show, [picker, show_path], [canvas, summary, ntab, etab, gallery])
|
| 419 |
-
show_path.change(show, [picker, show_path], [canvas, summary, ntab, etab, gallery])
|
| 420 |
-
demo.load(refresh, [city, min_nodes, f_cost, f_orph, query, page], [picker, info])
|
| 421 |
return demo
|
| 422 |
|
| 423 |
|
| 424 |
-
|
| 425 |
-
ap = argparse.ArgumentParser(description=__doc__)
|
| 426 |
-
ap.add_argument("--package", type=Path)
|
| 427 |
-
ap.add_argument("--share", action="store_true", help="开公网临时链接(72 小时)")
|
| 428 |
-
ap.add_argument("--port", type=int, default=7860)
|
| 429 |
-
args = ap.parse_args()
|
| 430 |
-
if args.package:
|
| 431 |
-
global PACKAGE, DB
|
| 432 |
-
PACKAGE = args.package
|
| 433 |
-
DB = PACKAGE / "graph.sqlite"
|
| 434 |
-
import tempfile
|
| 435 |
-
build().launch(share=args.share, server_port=args.port,
|
| 436 |
-
allowed_paths=[str(PACKAGE), tempfile.gettempdir(),
|
| 437 |
-
os.path.expanduser("~/.cache/huggingface")])
|
| 438 |
-
return 0
|
| 439 |
|
| 440 |
|
| 441 |
if __name__ == "__main__":
|
| 442 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
+
"""SetVL Node Graph Viewer —— 只读浏览器。
|
| 3 |
|
| 4 |
+
界面直接复用本地标注台(`tool/annotation`)那套前端:同一套 topographic 配色、
|
| 5 |
+
三栏布局、J/K 键盘导航、底图切换、邻接矩阵。区别只有一个——**去掉了全部写入**:
|
| 6 |
+
没有判定按钮、没有保存、没有新增边,后端也没有对应的接口。
|
| 7 |
|
| 8 |
+
架构:FastAPI 提供静态页面与只读 API,Gradio 挂在 /gradio(HF Spaces 的
|
| 9 |
+
gradio SDK 需要一个可挂载的 Blocks,同时它也作为无 JS 时的兜底视图)。
|
| 10 |
+
|
| 11 |
+
图片来源两种,按需切换:
|
| 12 |
+
- 本地包:SETVL_PACKAGE 指向 prepare.py 的产出目录
|
| 13 |
+
- HF 数据集:SETVL_DATASET 指向 "user/repo",按需从 CDN 取并缓存
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
| 17 |
|
|
|
|
| 18 |
import json
|
| 19 |
import math
|
| 20 |
import os
|
| 21 |
import sqlite3
|
| 22 |
+
import threading
|
| 23 |
from pathlib import Path
|
| 24 |
+
from typing import Any, Dict, List, Optional
|
| 25 |
|
| 26 |
import gradio as gr
|
| 27 |
+
from fastapi import FastAPI, HTTPException, Query
|
| 28 |
+
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
| 29 |
|
| 30 |
HERE = Path(__file__).resolve().parent
|
| 31 |
+
WEB = HERE / "web"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
PACKAGE = Path(os.environ.get("SETVL_PACKAGE", HERE / "package"))
|
| 33 |
DATASET = os.environ.get("SETVL_DATASET", "").strip()
|
| 34 |
HF_TOKEN = (os.environ.get("HF_TOKEN")
|
| 35 |
or os.environ.get("HUGGING_FACE_HUB_TOKEN") or "").strip()
|
| 36 |
+
DB = (Path(os.environ["SETVL_DB"]) if os.environ.get("SETVL_DB")
|
| 37 |
+
else (HERE / "graph.sqlite" if (HERE / "graph.sqlite").exists()
|
| 38 |
+
else PACKAGE / "graph.sqlite"))
|
| 39 |
+
|
| 40 |
+
_TLS = threading.local()
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def con() -> sqlite3.Connection:
|
| 44 |
+
"""每线程一个只读连接。sqlite3 的连接不能跨线程共用。"""
|
| 45 |
+
c = getattr(_TLS, "con", None)
|
| 46 |
+
if c is None:
|
| 47 |
+
if not DB.exists():
|
| 48 |
+
raise RuntimeError(f"找不到数据库 {DB}")
|
| 49 |
+
c = sqlite3.connect(f"file:{DB}?mode=ro", uri=True)
|
| 50 |
+
c.row_factory = sqlite3.Row
|
| 51 |
+
_TLS.con = c
|
| 52 |
+
return c
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def asset_path(rel: str) -> Optional[Path]:
|
| 56 |
+
"""包内相对路径 -> 本地文件。数据集模式下按需从 CDN 拉取并缓存。"""
|
| 57 |
+
if not rel or ".." in rel:
|
| 58 |
+
return None
|
|
|
|
|
|
|
| 59 |
local = PACKAGE / rel
|
| 60 |
if local.is_file():
|
| 61 |
return local
|
|
|
|
| 63 |
return None
|
| 64 |
try:
|
| 65 |
from huggingface_hub import hf_hub_download
|
| 66 |
+
# 私有数据集必须带 token;Space 里要配 HF_TOKEN secret
|
|
|
|
|
|
|
| 67 |
return Path(hf_hub_download(DATASET, rel, repo_type="dataset",
|
| 68 |
token=HF_TOKEN or None))
|
| 69 |
except Exception:
|
| 70 |
return None
|
| 71 |
|
| 72 |
|
| 73 |
+
def _loads(raw: Optional[str]) -> Any:
|
| 74 |
+
if not raw:
|
| 75 |
+
return None
|
| 76 |
+
try:
|
| 77 |
+
return json.loads(raw)
|
| 78 |
+
except (ValueError, TypeError):
|
| 79 |
+
return None
|
| 80 |
|
| 81 |
|
| 82 |
+
def effective(nodes: List[Dict], edges: List[Dict]) -> None:
|
| 83 |
+
"""合成 _auto / _human 的生效值,与标注台一致。"""
|
| 84 |
+
for n in nodes:
|
| 85 |
+
n["corridor_id"] = n["corridor_id_human"] or n["corridor_id_auto"]
|
| 86 |
+
n["corridor_name"] = (n["corridor_name_human"] or n["corridor_name_auto"]
|
| 87 |
+
or n["corridor_id"])
|
| 88 |
+
o = n["is_orphan_human"]
|
| 89 |
+
n["is_orphan"] = int(o) if o is not None else (n["is_orphan_auto"] or 0)
|
| 90 |
+
n["corridor_overridden"] = n["corridor_id_human"] is not None
|
| 91 |
+
n["triage_signals"] = _loads(n["triage_signals_auto"]) or []
|
| 92 |
+
for e in edges:
|
| 93 |
+
e["origin"] = e["origin_human"] or e["origin_auto"] or "bridge"
|
| 94 |
+
rc = e["road_connected_human"]
|
| 95 |
+
e["road_connected"] = int(rc) if rc is not None else (e["road_connected_auto"] or 0)
|
| 96 |
+
hc = e["high_cost_human"]
|
| 97 |
+
e["high_cost"] = int(hc) if hc is not None else (e["high_cost_auto"] or 0)
|
| 98 |
+
e["cost_class"] = e["cost_class_auto"]
|
| 99 |
+
e["verdict"] = e["edge_verdict_human"]
|
| 100 |
+
e["in_graph"] = e["edge_verdict_human"] != "reject"
|
| 101 |
+
e["is_manual"] = e["origin_human"] == "manual" or e["origin_auto"] is None
|
| 102 |
+
e["origin_overridden"] = e["origin_human"] is not None
|
| 103 |
+
e["road_path"] = _loads(e["road_path_auto"])
|
| 104 |
+
e["evidence"] = _loads(e["evidence_auto"])
|
| 105 |
+
e["triage_signals"] = _loads(e["triage_signals_auto"]) or []
|
| 106 |
+
e["review_status"] = e["review_status_human"] or "unreviewed"
|
| 107 |
|
| 108 |
|
| 109 |
+
api = FastAPI(title="SetVL Node Graph Viewer")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
|
| 112 |
+
@api.get("/")
|
| 113 |
+
def index() -> FileResponse:
|
| 114 |
+
return FileResponse(WEB / "index.html")
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
+
@api.get("/app.js")
|
| 118 |
+
def appjs() -> FileResponse:
|
| 119 |
+
return FileResponse(WEB / "app.js", media_type="application/javascript")
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
+
@api.get("/styles.css")
|
| 123 |
+
def css() -> FileResponse:
|
| 124 |
+
return FileResponse(WEB / "styles.css", media_type="text/css")
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
+
@api.get("/api/bootstrap")
|
| 128 |
+
def bootstrap() -> JSONResponse:
|
| 129 |
+
c = con()
|
| 130 |
+
cities = [r[0] for r in c.execute("SELECT DISTINCT city FROM scene ORDER BY city")]
|
| 131 |
+
return JSONResponse({
|
| 132 |
+
"cities": cities,
|
| 133 |
+
"stats": stats_payload(),
|
| 134 |
+
"dataset": DATASET or "local package",
|
| 135 |
+
"read_only": True,
|
| 136 |
+
})
|
| 137 |
+
|
| 138 |
|
| 139 |
+
def stats_payload() -> Dict[str, Any]:
|
| 140 |
+
c = con()
|
| 141 |
+
q = lambda s: c.execute(s).fetchone()[0]
|
| 142 |
+
origins = {r[0] or "unknown": r[1] for r in
|
| 143 |
+
c.execute("SELECT origin_auto, COUNT(*) FROM edge GROUP BY origin_auto")}
|
| 144 |
+
return {
|
| 145 |
+
"scenes": q("SELECT COUNT(*) FROM scene"),
|
| 146 |
+
"nodes": q("SELECT COUNT(*) FROM node"),
|
| 147 |
+
"edges": q("SELECT COUNT(*) FROM edge"),
|
| 148 |
+
"total_items": q("SELECT COUNT(*) FROM node") + q("SELECT COUNT(*) FROM edge"),
|
| 149 |
+
"origins": origins,
|
| 150 |
+
"high_cost": q("SELECT COUNT(*) FROM edge WHERE high_cost_auto=1"),
|
| 151 |
+
"orphans": q("SELECT COUNT(*) FROM node WHERE is_orphan_auto=1"),
|
| 152 |
+
"fragmented": q("SELECT COUNT(*) FROM scene WHERE net_fragmented_auto=1"),
|
| 153 |
+
}
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
+
@api.get("/api/stats")
|
| 157 |
+
def stats() -> JSONResponse:
|
| 158 |
+
return JSONResponse(stats_payload())
|
| 159 |
|
|
|
|
| 160 |
|
| 161 |
+
@api.get("/api/scenes")
|
| 162 |
+
def scenes(city: str = "", min_nodes: Optional[int] = None,
|
| 163 |
+
orphan: str = "", high_cost: str = "", fragmented: str = "",
|
| 164 |
+
q: str = "", order: str = "scene_id",
|
| 165 |
+
limit: int = 60, offset: int = 0) -> JSONResponse:
|
| 166 |
where, args = [], []
|
| 167 |
+
if city:
|
| 168 |
where.append("s.city = ?")
|
| 169 |
args.append(city)
|
| 170 |
if min_nodes:
|
| 171 |
where.append("s.node_count >= ?")
|
| 172 |
args.append(int(min_nodes))
|
| 173 |
+
if q:
|
| 174 |
where.append("s.scene_id LIKE ?")
|
| 175 |
+
args.append(f"%{q}%")
|
| 176 |
+
if orphan in ("1", "true"):
|
| 177 |
+
where.append("EXISTS (SELECT 1 FROM node n WHERE n.scene_id=s.scene_id "
|
| 178 |
+
"AND n.is_orphan_auto=1)")
|
| 179 |
+
if high_cost in ("1", "true"):
|
| 180 |
+
where.append("EXISTS (SELECT 1 FROM edge e WHERE e.scene_id=s.scene_id "
|
| 181 |
+
"AND e.high_cost_auto=1)")
|
| 182 |
+
if fragmented in ("1", "true"):
|
| 183 |
+
where.append("s.net_fragmented_auto = 1")
|
| 184 |
clause = (" WHERE " + " AND ".join(where)) if where else ""
|
| 185 |
+
order_sql = {"scene_id": "s.scene_id",
|
| 186 |
+
"nodes_desc": "s.node_count DESC, s.scene_id",
|
| 187 |
+
"nodes_asc": "s.node_count ASC, s.scene_id"}.get(order, "s.scene_id")
|
| 188 |
+
limit = max(1, min(int(limit), 200))
|
| 189 |
rows = con().execute(
|
| 190 |
+
"SELECT s.scene_id, s.city, s.node_count, s.satellite_path, "
|
| 191 |
+
"s.net_fragmented_auto, "
|
| 192 |
+
"(SELECT COUNT(*) FROM edge e WHERE e.scene_id=s.scene_id) AS edge_count, "
|
| 193 |
+
"(SELECT COUNT(*) FROM node n WHERE n.scene_id=s.scene_id AND n.is_orphan_auto=1) "
|
| 194 |
+
" AS orphan_count, "
|
| 195 |
+
"(SELECT COUNT(*) FROM edge e WHERE e.scene_id=s.scene_id AND e.high_cost_auto=1) "
|
| 196 |
+
" AS high_cost_count, 0 AS revision_count "
|
| 197 |
+
f"FROM scene s{clause} ORDER BY {order_sql} LIMIT ? OFFSET ?",
|
| 198 |
+
args + [limit, max(0, int(offset))])
|
| 199 |
+
total = con().execute(f"SELECT COUNT(*) FROM scene s{clause}", args).fetchone()[0]
|
| 200 |
+
return JSONResponse({"items": [dict(r) for r in rows], "total": total,
|
| 201 |
+
"limit": limit, "offset": offset})
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
@api.get("/api/queue")
|
| 205 |
+
def queue(city: str = "", signal: str = "", kind: str = "",
|
| 206 |
+
tier: str = "", include_reviewed: str = "",
|
| 207 |
+
limit: int = 60, offset: int = 0) -> JSONResponse:
|
| 208 |
+
"""「带信号的」列表。只读版没有复核队列,这里按信号码筛条目供排查。"""
|
| 209 |
+
parts, args = [], []
|
| 210 |
+
limit = max(1, min(int(limit), 200))
|
| 211 |
+
for tbl in ("node", "edge"):
|
| 212 |
+
if kind and kind != tbl:
|
| 213 |
+
continue
|
| 214 |
+
key = "t.label" if tbl == "node" else "t.source_label || '-' || t.target_label"
|
| 215 |
+
cond = ["t.triage_signals_auto IS NOT NULL", "t.triage_signals_auto != '[]'"]
|
| 216 |
+
sub: List[Any] = []
|
| 217 |
+
if city:
|
| 218 |
+
cond.append("s.city = ?")
|
| 219 |
+
sub.append(city)
|
| 220 |
+
if signal:
|
| 221 |
+
cond.append("EXISTS (SELECT 1 FROM json_each(t.triage_signals_auto) je "
|
| 222 |
+
"WHERE json_extract(je.value,'$.code') = ?)")
|
| 223 |
+
sub.append(signal)
|
| 224 |
+
parts.append(
|
| 225 |
+
f"SELECT '{tbl}' AS kind, t.scene_id AS scene_id, s.city AS city, "
|
| 226 |
+
f"{key} AS item_key, COALESCE(t.triage_tier_auto,0) AS tier, "
|
| 227 |
+
"t.confidence_auto AS confidence, "
|
| 228 |
+
"(t.confidence_auto IS NULL) AS conf_null, "
|
| 229 |
+
"t.triage_signals_auto AS signals, 'unreviewed' AS review_status "
|
| 230 |
+
f"FROM {tbl} t JOIN scene s ON s.scene_id=t.scene_id "
|
| 231 |
+
"WHERE " + " AND ".join(cond))
|
| 232 |
+
args += sub
|
| 233 |
+
if not parts:
|
| 234 |
+
return JSONResponse({"items": [], "limit": limit, "offset": offset})
|
| 235 |
+
sql = (" UNION ALL ".join(parts) +
|
| 236 |
+
" ORDER BY conf_null ASC, confidence ASC, scene_id, item_key LIMIT ? OFFSET ?")
|
| 237 |
+
items = []
|
| 238 |
+
for r in con().execute(sql, args + [limit, max(0, int(offset))]):
|
| 239 |
+
d = dict(r)
|
| 240 |
+
d.pop("conf_null", None)
|
| 241 |
+
d["signals"] = _loads(d.pop("signals")) or []
|
| 242 |
+
items.append(d)
|
| 243 |
+
return JSONResponse({"items": items, "limit": limit, "offset": offset})
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
@api.get("/api/scene/{scene_id:path}")
|
| 247 |
+
def scene(scene_id: str) -> JSONResponse:
|
| 248 |
+
c = con()
|
| 249 |
+
s = c.execute("SELECT * FROM scene WHERE scene_id=?", (scene_id,)).fetchone()
|
| 250 |
+
if s is None:
|
| 251 |
+
raise HTTPException(404, f"无此场景 {scene_id}")
|
| 252 |
+
nodes = [dict(r) for r in c.execute(
|
| 253 |
+
"SELECT * FROM node WHERE scene_id=? ORDER BY label", (scene_id,))]
|
| 254 |
+
edges = [dict(r) for r in c.execute(
|
| 255 |
+
"SELECT * FROM edge WHERE scene_id=? ORDER BY source_label, target_label",
|
| 256 |
+
(scene_id,))]
|
| 257 |
+
effective(nodes, edges)
|
| 258 |
+
# 街景路径:打包时建的索引,运行时猜不出文件名(含 alt/year/heading)
|
| 259 |
+
for n in nodes:
|
| 260 |
+
row = c.execute("SELECT rel FROM ground_index WHERE panoid=? LIMIT 1",
|
| 261 |
+
(n["panoid"],)).fetchone()
|
| 262 |
+
n["ground_asset"] = row[0] if row else None
|
| 263 |
+
n["ground_assets"] = [row[0]] if row else []
|
| 264 |
+
return JSONResponse({"scene": dict(s), "nodes": nodes, "edges": edges,
|
| 265 |
+
"asset": {"satellite": dict(s)["satellite_path"]}})
|
| 266 |
|
| 267 |
|
| 268 |
+
@api.get("/api/revisions")
|
| 269 |
+
def revisions(table: str = "", key: str = "", limit: int = 50) -> JSONResponse:
|
| 270 |
+
# 只读包不带修订历史,返回空让前端正常渲染
|
| 271 |
+
return JSONResponse({"items": []})
|
| 272 |
|
| 273 |
|
| 274 |
+
@api.get("/asset")
|
| 275 |
+
def serve_asset(path: str = Query(...)) -> FileResponse:
|
| 276 |
+
p = asset_path(path)
|
| 277 |
+
if p is None:
|
| 278 |
+
raise HTTPException(404, "取不到该图片")
|
| 279 |
+
return FileResponse(p)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
|
| 281 |
|
| 282 |
+
# ---------------------------------------------------------------- Gradio 兜底
|
| 283 |
+
|
| 284 |
+
def _gradio_fallback() -> gr.Blocks:
|
| 285 |
+
st = None
|
| 286 |
+
try:
|
| 287 |
+
st = stats_payload()
|
| 288 |
+
except Exception:
|
| 289 |
+
pass
|
| 290 |
+
with gr.Blocks(title="SetVL Node Graph Viewer") as demo:
|
| 291 |
+
if st:
|
| 292 |
+
gr.Markdown(
|
| 293 |
+
f"# SetVL Node Graph Viewer\n\n"
|
| 294 |
+
f"{st['scenes']:,} 场景 · {st['nodes']:,} 节点 · {st['edges']:,} 边\n\n"
|
| 295 |
+
f"**[→ 打开浏览器界面](/)**")
|
| 296 |
+
else:
|
| 297 |
+
gr.Markdown("# SetVL Node Graph Viewer\n\n数据库未就绪。")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
return demo
|
| 299 |
|
| 300 |
|
| 301 |
+
app = gr.mount_gradio_app(api, _gradio_fallback(), path="/gradio")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
|
| 303 |
|
| 304 |
if __name__ == "__main__":
|
| 305 |
+
import argparse
|
| 306 |
+
import uvicorn
|
| 307 |
+
ap = argparse.ArgumentParser()
|
| 308 |
+
ap.add_argument("--port", type=int, default=7860)
|
| 309 |
+
ap.add_argument("--host", default="0.0.0.0")
|
| 310 |
+
a = ap.parse_args()
|
| 311 |
+
uvicorn.run(app, host=a.host, port=a.port, log_level="info")
|
requirements.txt
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
|
|
|
|
|
| 1 |
gradio>=5.0
|
| 2 |
pillow>=10.0
|
| 3 |
huggingface_hub>=0.25
|
|
|
|
| 1 |
+
fastapi>=0.115
|
| 2 |
+
uvicorn[standard]>=0.30
|
| 3 |
gradio>=5.0
|
| 4 |
pillow>=10.0
|
| 5 |
huggingface_hub>=0.25
|
web/app.js
ADDED
|
@@ -0,0 +1,772 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* SetVL node graph 复核台 —— 零构建步骤的原生 JS。
|
| 2 |
+
*
|
| 3 |
+
* 操作逻辑沿用上一代 pair 复核台:三栏、全键盘流、底图切换、图片放大、
|
| 4 |
+
* 队列 J/K 导航、进度条。变的是判定对象——从「一对节点的关系」变成
|
| 5 |
+
* 「节点归属哪条走廊」与「这条边该不该存在」。
|
| 6 |
+
*
|
| 7 |
+
* 快捷键按新语义重新映射:
|
| 8 |
+
* J/K 导航 1-9 选走廊 0 撤销覆盖 Y/N 边裁定
|
| 9 |
+
* S 保存 U 存疑 A 撤销 B 切底图 ? 帮助
|
| 10 |
+
*/
|
| 11 |
+
'use strict';
|
| 12 |
+
|
| 13 |
+
var S = {
|
| 14 |
+
mode: 'scenes', // 'scenes' 浏览全库 | 'queue' 复核队列
|
| 15 |
+
queue: [], filtered: [], selected: null, // {kind, scene_id, key:[...]}
|
| 16 |
+
scenes: [], sceneSel: null,
|
| 17 |
+
scene: null, offset: 0, limit: 100,
|
| 18 |
+
basemap: 'satellite', mapZoom: 17, edgeMode: 'road',
|
| 19 |
+
boot: null, pending: {}, // pending: 未保存的编辑
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
function $(id) { return document.getElementById(id); }
|
| 23 |
+
function esc(s) {
|
| 24 |
+
return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
|
| 25 |
+
return { '&': '&', '<': '<', '>': '>', '"': '"' }[c];
|
| 26 |
+
});
|
| 27 |
+
}
|
| 28 |
+
function api(path, opts) {
|
| 29 |
+
return fetch(path, opts).then(function (r) {
|
| 30 |
+
return r.json().catch(function () { return { error: 'HTTP ' + r.status }; })
|
| 31 |
+
.then(function (b) {
|
| 32 |
+
if (!r.ok) throw new Error(b.error || ('HTTP ' + r.status));
|
| 33 |
+
return b;
|
| 34 |
+
});
|
| 35 |
+
});
|
| 36 |
+
}
|
| 37 |
+
function toast(msg, isErr) {
|
| 38 |
+
var t = $('toast');
|
| 39 |
+
t.textContent = msg;
|
| 40 |
+
t.className = 'toast show' + (isErr ? ' err' : '');
|
| 41 |
+
clearTimeout(toast._t);
|
| 42 |
+
toast._t = setTimeout(function () { t.className = 'toast'; }, 2600);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/* ---------- Web Mercator:lat/lon -> 400x400 瓦片像素 -------------------
|
| 46 |
+
* 瓦片名 City_year_<lon>_<lat>.png,north-up,zoom 17,400x400。
|
| 47 |
+
* 与 benchmark 的 satellite_registration 一致。 */
|
| 48 |
+
var ZOOM = 17, RASTER = 400;
|
| 49 |
+
function mercator(lat, lon) {
|
| 50 |
+
var s = 256 * Math.pow(2, ZOOM);
|
| 51 |
+
var x = (lon + 180) / 360 * s;
|
| 52 |
+
var sy = Math.sin(lat * Math.PI / 180);
|
| 53 |
+
var y = (0.5 - Math.log((1 + sy) / (1 - sy)) / (4 * Math.PI)) * s;
|
| 54 |
+
return [x, y];
|
| 55 |
+
}
|
| 56 |
+
function tileCenter(satPath) {
|
| 57 |
+
var stem = String(satPath || '').split('/').pop().replace(/\.png$/, '');
|
| 58 |
+
var p = stem.split('_');
|
| 59 |
+
var lat = parseFloat(p[p.length - 1]), lon = parseFloat(p[p.length - 2]);
|
| 60 |
+
return (isFinite(lat) && isFinite(lon)) ? [lat, lon] : null;
|
| 61 |
+
}
|
| 62 |
+
function makeProjector(satPath) {
|
| 63 |
+
var c = tileCenter(satPath);
|
| 64 |
+
if (!c) return null;
|
| 65 |
+
var m = mercator(c[0], c[1]);
|
| 66 |
+
return function (lat, lon) {
|
| 67 |
+
var q = mercator(lat, lon);
|
| 68 |
+
return [q[0] - m[0] + RASTER / 2, q[1] - m[1] + RASTER / 2];
|
| 69 |
+
};
|
| 70 |
+
}
|
| 71 |
+
function corridorColor(cid) {
|
| 72 |
+
if (!cid) return '#66736d';
|
| 73 |
+
var h = 0;
|
| 74 |
+
for (var i = 0; i < cid.length; i++) h = (h * 31 + cid.charCodeAt(i)) >>> 0;
|
| 75 |
+
return 'hsl(' + (h % 360) + ' 58% 60%)';
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/* ---------- 队列 ------------------------------------------------------ */
|
| 79 |
+
function queueQuery() {
|
| 80 |
+
var q = new URLSearchParams();
|
| 81 |
+
if ($('cityFilter').value) q.set('city', $('cityFilter').value);
|
| 82 |
+
if ($('tierFilter').value) q.set('tier', $('tierFilter').value);
|
| 83 |
+
if ($('kindFilter').value) q.set('kind', $('kindFilter').value);
|
| 84 |
+
if ($('signalFilter').value) q.set('signal', $('signalFilter').value);
|
| 85 |
+
if ($('showReviewedSel').value) q.set('include_reviewed', '1');
|
| 86 |
+
q.set('limit', String(S.limit));
|
| 87 |
+
q.set('offset', String(S.offset));
|
| 88 |
+
return q.toString();
|
| 89 |
+
}
|
| 90 |
+
function sceneQuery() {
|
| 91 |
+
var q = new URLSearchParams();
|
| 92 |
+
var v;
|
| 93 |
+
if ((v = $('cityFilter').value)) q.set('city', v);
|
| 94 |
+
if ((v = $('orderFilter').value)) q.set('order', v);
|
| 95 |
+
if ((v = $('minNodes').value)) q.set('min_nodes', v);
|
| 96 |
+
if ((v = $('editedFilter').value)) q.set('edited', v);
|
| 97 |
+
if ($('fOrphan').checked) q.set('orphan', '1');
|
| 98 |
+
if ($('fHighCost').checked) q.set('high_cost', '1');
|
| 99 |
+
if ($('fFragmented').checked) q.set('fragmented', '1');
|
| 100 |
+
if ((v = $('searchInput').value.trim())) q.set('q', v);
|
| 101 |
+
q.set('limit', String(S.limit));
|
| 102 |
+
q.set('offset', String(S.offset));
|
| 103 |
+
return q.toString();
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
function loadScenes(keepSelection) {
|
| 107 |
+
return api('/api/scenes?' + sceneQuery()).then(function (d) {
|
| 108 |
+
S.scenes = d.items;
|
| 109 |
+
renderScenes();
|
| 110 |
+
if (!keepSelection && S.scenes.length && !S.scene) openScene(S.scenes[0].scene_id);
|
| 111 |
+
}).catch(function (e) { toast(e.message, true); });
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
function renderScenes() {
|
| 115 |
+
var list = $('queueList');
|
| 116 |
+
list.innerHTML = '';
|
| 117 |
+
if (!S.scenes.length) {
|
| 118 |
+
list.innerHTML = '<p class="empty-note">没有符合条件的场景。</p>';
|
| 119 |
+
return;
|
| 120 |
+
}
|
| 121 |
+
S.scenes.forEach(function (sc) {
|
| 122 |
+
var b = document.createElement('button');
|
| 123 |
+
b.className = 'queue-item' + (S.scene && S.scene.scene.scene_id === sc.scene_id ? ' active' : '')
|
| 124 |
+
+ (sc.revision_count ? ' reviewed' : '');
|
| 125 |
+
var badges = '';
|
| 126 |
+
if (sc.revision_count) badges += '<span class="badge edited">改过 ' + sc.revision_count + '</span>';
|
| 127 |
+
if (sc.orphan_count) badges += '<span class="badge orphan">孤儿 ' + sc.orphan_count + '</span>';
|
| 128 |
+
if (sc.high_cost_count) badges += '<span class="badge cost">$$ ' + sc.high_cost_count + '</span>';
|
| 129 |
+
if (sc.net_fragmented_auto) badges += '<span class="badge frag">S1</span>';
|
| 130 |
+
b.innerHTML =
|
| 131 |
+
'<div class="queue-top"><strong>' + esc(sc.scene_id.split(':')[1] || sc.scene_id) +
|
| 132 |
+
'</strong><span>' + sc.node_count + 'n / ' + sc.edge_count + 'e</span></div>' +
|
| 133 |
+
'<div class="queue-meta scene-item-meta">' + (badges || '<span>—</span>') + '</div>' +
|
| 134 |
+
'<div class="queue-scene">' + esc(sc.city) + '</div>';
|
| 135 |
+
b.onclick = function () { openScene(sc.scene_id); };
|
| 136 |
+
list.appendChild(b);
|
| 137 |
+
});
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
function openScene(sceneId) {
|
| 141 |
+
S.pending = {};
|
| 142 |
+
S.selected = null;
|
| 143 |
+
S.scene = null;
|
| 144 |
+
loadScene(sceneId).then(function () {
|
| 145 |
+
renderScenes();
|
| 146 |
+
// 打开场景时默认选中第一个节点,右栏才有东西可改
|
| 147 |
+
if (S.scene && S.scene.nodes.length) pickNode(S.scene.nodes[0]);
|
| 148 |
+
else renderInspector();
|
| 149 |
+
});
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
function loadList(keepSelection) {
|
| 153 |
+
return S.mode === 'queue' ? loadQueue(keepSelection) : loadScenes(keepSelection);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
function setMode(mode) {
|
| 157 |
+
S.mode = mode;
|
| 158 |
+
S.offset = 0;
|
| 159 |
+
$('modeScenes').classList.toggle('active', mode === 'scenes');
|
| 160 |
+
$('modeQueue').classList.toggle('active', mode === 'queue');
|
| 161 |
+
$('sceneFilters').hidden = mode !== 'scenes';
|
| 162 |
+
$('sceneChips').hidden = mode !== 'scenes';
|
| 163 |
+
$('queueFilters').hidden = mode !== 'queue';
|
| 164 |
+
$('panelTitle').textContent = mode === 'queue' ? '复核队列' : '场景浏览';
|
| 165 |
+
$('searchInput').placeholder = mode === 'queue' ? '场景 ID、城市或标签…' : 'scene_id 片段、城市…';
|
| 166 |
+
loadList();
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
function loadQueue(keepSelection) {
|
| 170 |
+
return api('/api/queue?' + queueQuery()).then(function (d) {
|
| 171 |
+
S.queue = d.items;
|
| 172 |
+
applySearch();
|
| 173 |
+
if (!keepSelection && S.filtered.length) selectItem(S.filtered[0]);
|
| 174 |
+
else renderList();
|
| 175 |
+
}).catch(function (e) { toast(e.message, true); });
|
| 176 |
+
}
|
| 177 |
+
function applySearch() {
|
| 178 |
+
var term = $('searchInput').value.trim().toLowerCase();
|
| 179 |
+
S.filtered = !term ? S.queue : S.queue.filter(function (i) {
|
| 180 |
+
return (i.scene_id + ' ' + i.city + ' ' + i.item_key).toLowerCase().indexOf(term) >= 0
|
| 181 |
+
|| i.signals.some(function (s) { return s.code.toLowerCase() === term; });
|
| 182 |
+
});
|
| 183 |
+
renderList();
|
| 184 |
+
}
|
| 185 |
+
function signalHtml(sigs) {
|
| 186 |
+
if (!sigs || !sigs.length) return '<b>信号缺失</b>';
|
| 187 |
+
return sigs.map(function (s) {
|
| 188 |
+
var extra = Object.keys(s).filter(function (k) { return k !== 'code' && k !== 'queued'; })
|
| 189 |
+
.map(function (k) {
|
| 190 |
+
var v = s[k];
|
| 191 |
+
return k + '=' + (typeof v === 'number' ? (+v).toFixed(2) : JSON.stringify(v));
|
| 192 |
+
}).join(' ');
|
| 193 |
+
return '<span class="sig-code">' + esc(s.code) + '</span>' + (extra ? ' ' + esc(extra) : '');
|
| 194 |
+
}).join(' · ');
|
| 195 |
+
}
|
| 196 |
+
function itemId(i) { return i.kind + '|' + i.scene_id + '|' + i.item_key; }
|
| 197 |
+
/* 左栏渲染的统一入口。
|
| 198 |
+
*
|
| 199 |
+
* 必须按模式分流:pickNode/pickEdge 里如果直接调 renderQueue(),
|
| 200 |
+
* 浏览模式下会把刚渲染好的场景列表覆盖成「队列为空」——点开一个场景后
|
| 201 |
+
* 左栏就空了,其他场景全看不见。
|
| 202 |
+
*/
|
| 203 |
+
function renderList() {
|
| 204 |
+
if (S.mode === 'queue') renderQueue();
|
| 205 |
+
else renderScenes();
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
function renderQueue() {
|
| 209 |
+
var list = $('queueList');
|
| 210 |
+
list.innerHTML = '';
|
| 211 |
+
if (!S.filtered.length) {
|
| 212 |
+
list.innerHTML = '<p class="empty-note">队列为空(可能已全部复核,或筛选过窄)。</p>';
|
| 213 |
+
return;
|
| 214 |
+
}
|
| 215 |
+
var selId = S.selected ? itemId(S.selected) : null;
|
| 216 |
+
S.filtered.forEach(function (i) {
|
| 217 |
+
var b = document.createElement('button');
|
| 218 |
+
b.className = 'queue-item'
|
| 219 |
+
+ (itemId(i) === selId ? ' active' : '')
|
| 220 |
+
+ (i.review_status === 'reviewed' ? ' reviewed' : '')
|
| 221 |
+
+ (i.review_status === 'needs_adjudication' ? ' adjudication' : '');
|
| 222 |
+
b.innerHTML =
|
| 223 |
+
'<div class="queue-top"><strong>' + (i.kind === 'node' ? '●' : '─') + ' ' +
|
| 224 |
+
esc(i.item_key) + '</strong><span>' +
|
| 225 |
+
(i.confidence == null ? '—' : (+i.confidence).toFixed(3)) + '</span></div>' +
|
| 226 |
+
'<div class="queue-meta"><span class="tier-pill tier-' + i.tier + '">T' + i.tier + '</span>' +
|
| 227 |
+
signalHtml(i.signals) + '</div>' +
|
| 228 |
+
'<div class="queue-scene">' + esc(i.scene_id) + '</div>';
|
| 229 |
+
b.onclick = function () { selectItem(i); };
|
| 230 |
+
list.appendChild(b);
|
| 231 |
+
});
|
| 232 |
+
}
|
| 233 |
+
function page(delta) {
|
| 234 |
+
var next = S.offset + delta * S.limit;
|
| 235 |
+
if (next < 0) { toast('已是第一页'); return; }
|
| 236 |
+
S.offset = next;
|
| 237 |
+
loadList(true).then(function () {
|
| 238 |
+
var n = S.mode === 'queue' ? S.filtered.length : S.scenes.length;
|
| 239 |
+
if (!n && delta > 0) { S.offset -= S.limit; loadList(true); toast('已是最后一页'); }
|
| 240 |
+
else toast('第 ' + (Math.floor(S.offset / S.limit) + 1) + ' 页');
|
| 241 |
+
});
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
function navigate(delta) {
|
| 245 |
+
if (S.mode === 'scenes') { navigateScenes(delta); return; }
|
| 246 |
+
if (!S.filtered.length) return;
|
| 247 |
+
var idx = S.selected ? S.filtered.findIndex(function (i) {
|
| 248 |
+
return itemId(i) === itemId(S.selected);
|
| 249 |
+
}) : -1;
|
| 250 |
+
var next = idx + delta;
|
| 251 |
+
if (next < 0 || next >= S.filtered.length) {
|
| 252 |
+
toast(next < 0 ? '已是第一条' : '已是最后一条');
|
| 253 |
+
return;
|
| 254 |
+
}
|
| 255 |
+
selectItem(S.filtered[next]);
|
| 256 |
+
var el = $('queueList').children[next];
|
| 257 |
+
if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
function navigateScenes(delta) {
|
| 261 |
+
if (!S.scenes.length) return;
|
| 262 |
+
var cur = S.scene ? S.scene.scene.scene_id : null;
|
| 263 |
+
var idx = S.scenes.findIndex(function (x) { return x.scene_id === cur; });
|
| 264 |
+
var next = idx + delta;
|
| 265 |
+
if (next < 0 || next >= S.scenes.length) {
|
| 266 |
+
toast(next < 0 ? '已是本页第一个' : '已是本页最后一个,按 ] 翻页');
|
| 267 |
+
return;
|
| 268 |
+
}
|
| 269 |
+
openScene(S.scenes[next].scene_id);
|
| 270 |
+
var el = $('queueList').children[next];
|
| 271 |
+
if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
function selectItem(item) {
|
| 275 |
+
S.pending = {};
|
| 276 |
+
var key = item.kind === 'node'
|
| 277 |
+
? [item.scene_id, item.item_key]
|
| 278 |
+
: [item.scene_id].concat(item.item_key.split('-'));
|
| 279 |
+
S.selected = { kind: item.kind, scene_id: item.scene_id, key: key, queue: item };
|
| 280 |
+
renderList();
|
| 281 |
+
loadScene(item.scene_id).then(renderInspector);
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
/* ---------- 场景 ------------------------------------------------------ */
|
| 285 |
+
function loadScene(sceneId) {
|
| 286 |
+
if (S.scene && S.scene.scene.scene_id === sceneId) {
|
| 287 |
+
drawScene();
|
| 288 |
+
return Promise.resolve();
|
| 289 |
+
}
|
| 290 |
+
return api('/api/scene/' + encodeURIComponent(sceneId)).then(function (d) {
|
| 291 |
+
S.scene = d;
|
| 292 |
+
drawScene();
|
| 293 |
+
}).catch(function (e) { toast(e.message, true); });
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
function sceneCorridors() {
|
| 297 |
+
var seen = {}, out = [];
|
| 298 |
+
(S.scene ? S.scene.nodes : []).forEach(function (n) {
|
| 299 |
+
var cid = n.corridor_id;
|
| 300 |
+
if (cid && !seen[cid]) {
|
| 301 |
+
seen[cid] = 1;
|
| 302 |
+
out.push({ id: cid, name: n.corridor_name || cid, color: corridorColor(cid) });
|
| 303 |
+
}
|
| 304 |
+
});
|
| 305 |
+
out.sort(function (a, b) { return a.name < b.name ? -1 : 1; });
|
| 306 |
+
return out;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
/* 给某个节点列出可选走廊。
|
| 310 |
+
*
|
| 311 |
+
* 光靠「本场景其他节点已归属的走廊」是不够的:歧义节点纠结的那条对手路
|
| 312 |
+
* 往往一个节点都没有,于是正确答案根本不在选项里,这条复核就做不了。
|
| 313 |
+
* 所以先放该节点自己的邻近候选(带距离,按远近排),再补上场景里的其它走廊。
|
| 314 |
+
*/
|
| 315 |
+
function corridorChoices(row) {
|
| 316 |
+
var out = [], seen = {};
|
| 317 |
+
(row && row.corridor_candidates ? row.corridor_candidates : []).forEach(function (c) {
|
| 318 |
+
if (seen[c.corridor_id]) return;
|
| 319 |
+
seen[c.corridor_id] = 1;
|
| 320 |
+
out.push({ id: c.corridor_id, name: c.name || c.corridor_id,
|
| 321 |
+
color: corridorColor(c.corridor_id), distance: c.distance_m });
|
| 322 |
+
});
|
| 323 |
+
sceneCorridors().forEach(function (c) {
|
| 324 |
+
if (seen[c.id]) return;
|
| 325 |
+
seen[c.id] = 1;
|
| 326 |
+
out.push({ id: c.id, name: c.name, color: c.color, distance: null });
|
| 327 |
+
});
|
| 328 |
+
return out;
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
function drawScene() {
|
| 332 |
+
if (!S.scene) return;
|
| 333 |
+
var sc = S.scene.scene;
|
| 334 |
+
$('sceneTitle').textContent = sc.scene_id.split(':')[1] || sc.scene_id;
|
| 335 |
+
$('sceneCity').textContent = sc.city;
|
| 336 |
+
$('sceneId').textContent = sc.scene_id;
|
| 337 |
+
$('mNodes').textContent = S.scene.nodes.length;
|
| 338 |
+
$('mEdges').textContent = S.scene.edges.length;
|
| 339 |
+
var cors = sceneCorridors();
|
| 340 |
+
$('mCorridors').textContent = cors.length;
|
| 341 |
+
$('mOrphans').textContent = S.scene.nodes.filter(function (n) { return n.is_orphan_auto; }).length;
|
| 342 |
+
$('satelliteImage').src = (S.scene.asset && S.scene.asset.satellite)
|
| 343 |
+
? '/asset?path=' + encodeURIComponent(S.scene.asset.satellite) : '';
|
| 344 |
+
drawGraph();
|
| 345 |
+
drawNodeChips(cors);
|
| 346 |
+
drawAdjacency();
|
| 347 |
+
drawGroundImages();
|
| 348 |
+
if (S.basemap === 'map') renderMapTiles();
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
function drawGraph() {
|
| 352 |
+
var svg = $('graphSvg');
|
| 353 |
+
svg.innerHTML = '';
|
| 354 |
+
var proj = makeProjector(S.scene.scene.satellite_path);
|
| 355 |
+
if (!proj) return;
|
| 356 |
+
var pos = {};
|
| 357 |
+
S.scene.nodes.forEach(function (n) { pos[n.label] = proj(n.lat, n.lon); });
|
| 358 |
+
var NS = 'http://www.w3.org/2000/svg';
|
| 359 |
+
function mk(tag, attrs) {
|
| 360 |
+
var el = document.createElementNS(NS, tag);
|
| 361 |
+
for (var k in attrs) el.setAttribute(k, attrs[k]);
|
| 362 |
+
return el;
|
| 363 |
+
}
|
| 364 |
+
var sel = S.selected;
|
| 365 |
+
S.scene.edges.forEach(function (e) {
|
| 366 |
+
var a = pos[e.source_label], b = pos[e.target_label];
|
| 367 |
+
if (!a || !b) return;
|
| 368 |
+
var colour = { corridor_chain: 'var(--chain)', intersection: 'var(--intersection)' }[e.origin]
|
| 369 |
+
|| 'var(--bridge)';
|
| 370 |
+
var isSel = sel && sel.kind === 'edge'
|
| 371 |
+
&& sel.key[1] === e.source_label && sel.key[2] === e.target_label;
|
| 372 |
+
var dead = !e.in_graph;
|
| 373 |
+
var costly = !!e.high_cost;
|
| 374 |
+
// 一条边只画一笔。有走线就用走线(那才是这条边的真实走向),
|
| 375 |
+
// 没有就画直线并用虚线标出「无道路可走」。
|
| 376 |
+
// 之前直线与折线同时画,全库 81% 的边两者不重合,叠出一片乱麻。
|
| 377 |
+
var el;
|
| 378 |
+
// 高花费边一律画直线:它的 road_path 已在建图阶段丢弃(绕出几十倍的
|
| 379 |
+
// 折线画在图上只会误导),真实路网距离仍在 road_distance_m 里。
|
| 380 |
+
if (!costly && S.edgeMode === 'road' && e.road_path && e.road_path.length > 1) {
|
| 381 |
+
// 折线两端落在吸附点上,不在节点上——补两段短接头,否则边看着是断的
|
| 382 |
+
var pts = [a].concat(e.road_path.map(function (p) { return proj(p[0], p[1]); })).concat([b]);
|
| 383 |
+
el = mk('polyline', {
|
| 384 |
+
points: pts.map(function (p) { return p.join(','); }).join(' '),
|
| 385 |
+
fill: 'none', stroke: isSel ? '#fff' : colour,
|
| 386 |
+
'stroke-width': isSel ? 3 : 1.7, opacity: dead ? .22 : 1,
|
| 387 |
+
'stroke-linejoin': 'round', 'stroke-linecap': 'round',
|
| 388 |
+
style: 'cursor:pointer',
|
| 389 |
+
});
|
| 390 |
+
} else {
|
| 391 |
+
el = mk('line', {
|
| 392 |
+
x1: a[0], y1: a[1], x2: b[0], y2: b[1],
|
| 393 |
+
stroke: isSel ? '#fff' : colour,
|
| 394 |
+
'stroke-width': isSel ? 3 : (costly ? 2.2 : 1.7),
|
| 395 |
+
opacity: dead ? .22 : 1,
|
| 396 |
+
'stroke-dasharray': costly ? '6 4' : '',
|
| 397 |
+
style: 'cursor:pointer',
|
| 398 |
+
});
|
| 399 |
+
}
|
| 400 |
+
el.onclick = function () { pickEdge(e); };
|
| 401 |
+
svg.appendChild(el);
|
| 402 |
+
});
|
| 403 |
+
S.scene.nodes.forEach(function (n) {
|
| 404 |
+
var p = pos[n.label];
|
| 405 |
+
if (!p) return;
|
| 406 |
+
var isSel = sel && sel.kind === 'node' && sel.key[1] === n.label;
|
| 407 |
+
var g = mk('g', { style: 'cursor:pointer' });
|
| 408 |
+
g.appendChild(mk('circle', {
|
| 409 |
+
cx: p[0], cy: p[1], r: isSel ? 6.5 : 5,
|
| 410 |
+
fill: corridorColor(n.corridor_id),
|
| 411 |
+
stroke: isSel ? '#fff' : (n.is_orphan_auto ? 'var(--anchor)' : '#0b0e0d'),
|
| 412 |
+
'stroke-width': isSel ? 2.4 : (n.is_orphan_auto ? 2.2 : 1.2),
|
| 413 |
+
}));
|
| 414 |
+
var t = mk('text', {
|
| 415 |
+
x: p[0], y: p[1], fill: '#0b0e0d',
|
| 416 |
+
style: 'font:800 6px var(--mono);text-anchor:middle;dominant-baseline:central;pointer-events:none',
|
| 417 |
+
});
|
| 418 |
+
t.textContent = n.label;
|
| 419 |
+
g.appendChild(t);
|
| 420 |
+
g.onclick = function () { pickNode(n); };
|
| 421 |
+
svg.appendChild(g);
|
| 422 |
+
});
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
function drawNodeChips(cors) {
|
| 426 |
+
var wrap = $('nodeThumbnails');
|
| 427 |
+
wrap.innerHTML = '';
|
| 428 |
+
S.scene.nodes.forEach(function (n) {
|
| 429 |
+
var b = document.createElement('button');
|
| 430 |
+
var isSel = S.selected && S.selected.kind === 'node' && S.selected.key[1] === n.label;
|
| 431 |
+
b.className = 'node-chip' + (isSel ? ' active' : '') + (n.is_orphan_auto ? ' orphan' : '');
|
| 432 |
+
b.textContent = n.label;
|
| 433 |
+
b.title = (n.corridor_name_auto || '(无走廊)') + (n.corridor_overridden ? ' · 人工覆盖' : '');
|
| 434 |
+
b.onclick = function () { pickNode(n); };
|
| 435 |
+
wrap.appendChild(b);
|
| 436 |
+
});
|
| 437 |
+
var leg = $('corridorLegend');
|
| 438 |
+
leg.innerHTML = cors.slice(0, 6).map(function (c) {
|
| 439 |
+
return '<div><i style="background:' + c.color + '"></i>' + esc(c.name) + '</div>';
|
| 440 |
+
}).join('') || '<div>(无走廊)</div>';
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
function drawAdjacency() {
|
| 444 |
+
var labels = S.scene.nodes.map(function (n) { return n.label; });
|
| 445 |
+
var idx = {};
|
| 446 |
+
labels.forEach(function (l, i) { idx[l] = i; });
|
| 447 |
+
var cell = {};
|
| 448 |
+
var counts = { corridor_chain: 0, intersection: 0, bridge: 0 };
|
| 449 |
+
S.scene.edges.forEach(function (e) {
|
| 450 |
+
cell[e.source_label + '|' + e.target_label] = e;
|
| 451 |
+
cell[e.target_label + '|' + e.source_label] = e;
|
| 452 |
+
if (counts[e.origin_auto] != null) counts[e.origin_auto]++;
|
| 453 |
+
});
|
| 454 |
+
$('gChain').textContent = counts.corridor_chain;
|
| 455 |
+
$('gInter').textContent = counts.intersection;
|
| 456 |
+
$('gBridge').textContent = counts.bridge;
|
| 457 |
+
|
| 458 |
+
var sel = S.selected;
|
| 459 |
+
var html = '<tr><th></th>' + labels.map(function (l) { return '<th>' + l + '</th>'; }).join('') + '</tr>';
|
| 460 |
+
labels.forEach(function (r) {
|
| 461 |
+
html += '<tr><th>' + r + '</th>';
|
| 462 |
+
labels.forEach(function (c) {
|
| 463 |
+
if (r === c) { html += '<td class="diag"></td>'; return; }
|
| 464 |
+
var e = cell[r + '|' + c];
|
| 465 |
+
if (!e) {
|
| 466 |
+
// 空格子也可点——那是「自动化没给出这条边,但人认为该有」的入口
|
| 467 |
+
html += '<td></td>';
|
| 468 |
+
return;
|
| 469 |
+
}
|
| 470 |
+
var isSel = sel && sel.kind === 'edge'
|
| 471 |
+
&& sel.key[1] === e.source_label && sel.key[2] === e.target_label;
|
| 472 |
+
var cls = 'edge e-' + (e.origin || 'bridge') + (isSel ? ' sel' : '') +
|
| 473 |
+
(e.in_graph ? '' : ' rejected');
|
| 474 |
+
html += '<td class="' + cls + '" data-e="' + e.source_label + '-' +
|
| 475 |
+
e.target_label + '">' + (e.in_graph ? '·' : '×') + '</td>';
|
| 476 |
+
});
|
| 477 |
+
html += '</tr>';
|
| 478 |
+
});
|
| 479 |
+
var tbl = $('adjacencyTable');
|
| 480 |
+
tbl.innerHTML = html;
|
| 481 |
+
Array.prototype.forEach.call(tbl.querySelectorAll('td.addable'), function (td) {
|
| 482 |
+
td.onclick = function () {
|
| 483 |
+
toast('只读浏览,不能新增边');
|
| 484 |
+
};
|
| 485 |
+
});
|
| 486 |
+
Array.prototype.forEach.call(tbl.querySelectorAll('td.edge'), function (td) {
|
| 487 |
+
td.onclick = function () {
|
| 488 |
+
var k = td.getAttribute('data-e').split('-');
|
| 489 |
+
var e = S.scene.edges.find(function (x) {
|
| 490 |
+
return x.source_label === k[0] && x.target_label === k[1];
|
| 491 |
+
});
|
| 492 |
+
if (e) pickEdge(e);
|
| 493 |
+
};
|
| 494 |
+
});
|
| 495 |
+
|
| 496 |
+
var listEl = $('edgeList');
|
| 497 |
+
listEl.innerHTML = '';
|
| 498 |
+
S.scene.edges.forEach(function (e) {
|
| 499 |
+
var d = document.createElement('div');
|
| 500 |
+
var isSel = sel && sel.kind === 'edge'
|
| 501 |
+
&& sel.key[1] === e.source_label && sel.key[2] === e.target_label;
|
| 502 |
+
d.className = 'edge-row' + (isSel ? ' sel' : '');
|
| 503 |
+
var colour = { corridor_chain: 'var(--chain)', intersection: 'var(--intersection)' }[e.origin]
|
| 504 |
+
|| 'var(--bridge)';
|
| 505 |
+
if (!e.in_graph) d.classList.add('rejected');
|
| 506 |
+
d.innerHTML = '<i style="background:' + colour + '"></i>' +
|
| 507 |
+
esc(e.source_label) + '–' + esc(e.target_label) +
|
| 508 |
+
(e.is_manual ? ' <span style="color:var(--cyan)">M</span>' : '') +
|
| 509 |
+
(e.in_graph ? '' : ' <span style="color:var(--no)">已拒</span>') +
|
| 510 |
+
(e.high_cost ? ' <span class="cost-tag" title="' + esc(e.cost_class || '') +
|
| 511 |
+
'">$$</span>' : '') +
|
| 512 |
+
'<span class="len">' + (e.euclid_distance_m == null ? '—' : (+e.euclid_distance_m).toFixed(0) + ' m') + '</span>';
|
| 513 |
+
d.onclick = function () { pickEdge(e); };
|
| 514 |
+
listEl.appendChild(d);
|
| 515 |
+
});
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
function groundUrl(panoid) {
|
| 519 |
+
// 街景文件名带朝向,这里只用节点坐标做占位;真实图由 /asset 白名单服务。
|
| 520 |
+
return null;
|
| 521 |
+
}
|
| 522 |
+
function drawGroundImages() {
|
| 523 |
+
var sel = S.selected;
|
| 524 |
+
if (!sel || !S.scene) return;
|
| 525 |
+
function fill(role, label) {
|
| 526 |
+
var n = S.scene.nodes.find(function (x) { return x.label === label; });
|
| 527 |
+
$(role + 'Badge').textContent = label || '—';
|
| 528 |
+
$(role + 'Caption').textContent = n
|
| 529 |
+
? (n.lat.toFixed(6) + ', ' + n.lon.toFixed(6) + ' · ' + (n.corridor_name_auto || '无走廊'))
|
| 530 |
+
: '—';
|
| 531 |
+
var img = $(role + 'Image');
|
| 532 |
+
if (n && n.ground_asset) img.src = '/asset?path=' + encodeURIComponent(n.ground_asset);
|
| 533 |
+
else { img.removeAttribute('src'); }
|
| 534 |
+
}
|
| 535 |
+
if (sel.kind === 'node') {
|
| 536 |
+
$('anchorKind').textContent = 'Node';
|
| 537 |
+
$('targetCard').style.display = 'none';
|
| 538 |
+
fill('anchor', sel.key[1]);
|
| 539 |
+
} else {
|
| 540 |
+
$('anchorKind').textContent = 'Source';
|
| 541 |
+
$('targetCard').style.display = '';
|
| 542 |
+
fill('anchor', sel.key[1]);
|
| 543 |
+
fill('target', sel.key[2]);
|
| 544 |
+
}
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
function pickNode(n) {
|
| 548 |
+
S.pending = {};
|
| 549 |
+
S.selected = { kind: 'node', scene_id: S.scene.scene.scene_id,
|
| 550 |
+
key: [S.scene.scene.scene_id, n.label] };
|
| 551 |
+
drawScene();
|
| 552 |
+
renderInspector();
|
| 553 |
+
renderList();
|
| 554 |
+
}
|
| 555 |
+
function pickEdge(e) {
|
| 556 |
+
S.pending = {};
|
| 557 |
+
S.selected = { kind: 'edge', scene_id: S.scene.scene.scene_id,
|
| 558 |
+
key: [S.scene.scene.scene_id, e.source_label, e.target_label] };
|
| 559 |
+
drawScene();
|
| 560 |
+
renderInspector();
|
| 561 |
+
renderList();
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
/* ---------- 判定面板 -------------------------------------------------- */
|
| 565 |
+
function currentRow() {
|
| 566 |
+
if (!S.selected || !S.scene) return null;
|
| 567 |
+
if (S.selected.kind === 'node') {
|
| 568 |
+
return S.scene.nodes.find(function (n) { return n.label === S.selected.key[1]; });
|
| 569 |
+
}
|
| 570 |
+
return S.scene.edges.find(function (e) {
|
| 571 |
+
return e.source_label === S.selected.key[1] && e.target_label === S.selected.key[2];
|
| 572 |
+
});
|
| 573 |
+
}
|
| 574 |
+
function kv(pairs) {
|
| 575 |
+
return '<dl class="kv">' + pairs.map(function (p) {
|
| 576 |
+
return '<dt>' + esc(p[0]) + '</dt><dd>' + esc(p[1] == null ? 'null' : p[1]) + '</dd>';
|
| 577 |
+
}).join('') + '</dl>';
|
| 578 |
+
}
|
| 579 |
+
function renderInspector() {
|
| 580 |
+
var body = $('inspectorBody');
|
| 581 |
+
var row = currentRow();
|
| 582 |
+
if (!row) {
|
| 583 |
+
body.innerHTML = '<p class="empty-note">在图上点一个节点或一条边查看属性。</p>';
|
| 584 |
+
$('itemId').textContent = '—';
|
| 585 |
+
return;
|
| 586 |
+
}
|
| 587 |
+
var isNode = S.selected.kind === 'node';
|
| 588 |
+
$('decisionTitle').textContent = isNode ? '节点属性' : '边属性';
|
| 589 |
+
$('itemId').textContent = isNode ? row.label : (row.source_label + '→' + row.target_label);
|
| 590 |
+
var conf = row.confidence_auto;
|
| 591 |
+
var html = '';
|
| 592 |
+
|
| 593 |
+
html += '<section class="signal-block"><div class="block-title">' +
|
| 594 |
+
'<span>自动判定信号</span></div>';
|
| 595 |
+
var sigs = row.triage_signals || [];
|
| 596 |
+
html += sigs.length ? sigs.map(function (sg) {
|
| 597 |
+
var extra = Object.keys(sg).filter(function (k) { return k !== 'code'; })
|
| 598 |
+
.map(function (k) {
|
| 599 |
+
var v = sg[k];
|
| 600 |
+
return k + '=' + (typeof v === 'number' ? (+v).toFixed(3) : JSON.stringify(v));
|
| 601 |
+
}).join(' ');
|
| 602 |
+
return '<div class="signal-row"><b>' + esc(sg.code) + '</b><em>' + esc(extra) + '</em></div>';
|
| 603 |
+
}).join('') : '<div class="signal-row"><em>无信号(高置信)</em></div>';
|
| 604 |
+
html += '<div class="conf-meter"><span style="width:' +
|
| 605 |
+
Math.round((conf == null ? 0 : conf) * 100) + '%"></span></div>';
|
| 606 |
+
html += '<div style="margin-top:5px;color:var(--faint);font:600 9px var(--mono)">CONFIDENCE ' +
|
| 607 |
+
(conf == null ? '—' : (+conf).toFixed(4)) + '</div></section>';
|
| 608 |
+
|
| 609 |
+
if (isNode) {
|
| 610 |
+
html += kv([
|
| 611 |
+
['panoid', row.panoid],
|
| 612 |
+
['lat / lon', row.lat.toFixed(6) + ', ' + row.lon.toFixed(6)],
|
| 613 |
+
['走廊', row.corridor_name || '—'],
|
| 614 |
+
['走廊来源', row.corridor_source_auto],
|
| 615 |
+
['吸附距离 m', row.snap_distance_m_auto],
|
| 616 |
+
['孤儿', row.is_orphan ? '是' : '否'],
|
| 617 |
+
['人工覆盖', row.corridor_overridden ? '走廊已改' : '—'],
|
| 618 |
+
]).replace('<dl', '<dl style="padding:11px 14px"');
|
| 619 |
+
} else {
|
| 620 |
+
html += kv([
|
| 621 |
+
['来源', row.origin],
|
| 622 |
+
['欧氏距离 m', row.euclid_distance_m_auto],
|
| 623 |
+
['路网距离 m', row.road_distance_m_auto],
|
| 624 |
+
['绕路比', row.detour_ratio_auto],
|
| 625 |
+
['有道路可通', row.road_connected ? '是' : '否'],
|
| 626 |
+
['高花费边', row.high_cost ? ('是 · ' + (row.cost_class || '')) : '否'],
|
| 627 |
+
['走线点数', row.road_path ? row.road_path.length : '—'],
|
| 628 |
+
['证据', row.evidence ? JSON.stringify(row.evidence) : '—'],
|
| 629 |
+
['人工裁定', row.verdict || '—'],
|
| 630 |
+
]).replace('<dl', '<dl style="padding:11px 14px"');
|
| 631 |
+
}
|
| 632 |
+
html += '<section class="field-block"><div class="block-title">' +
|
| 633 |
+
'<span>只读浏览</span></div><p style="margin:0;color:var(--faint);font-size:11px">' +
|
| 634 |
+
'这是结果浏览器,不提供修改。标注与复核在本地的 tool/annotation 里做。</p></section>';
|
| 635 |
+
body.innerHTML = html;
|
| 636 |
+
drawGroundImages();
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
|
| 640 |
+
|
| 641 |
+
|
| 642 |
+
|
| 643 |
+
|
| 644 |
+
function setEdgeMode(mode) {
|
| 645 |
+
S.edgeMode = mode;
|
| 646 |
+
$('edgeRoad').classList.toggle('active', mode === 'road');
|
| 647 |
+
$('edgeStraight').classList.toggle('active', mode === 'straight');
|
| 648 |
+
if (S.scene) drawGraph();
|
| 649 |
+
}
|
| 650 |
+
|
| 651 |
+
/* ---------- 底图:OSM 电子地图瓦片 ------------------------------------ */
|
| 652 |
+
function setBasemap(mode) {
|
| 653 |
+
S.basemap = mode;
|
| 654 |
+
$('satelliteStage').classList.toggle('map-mode', mode === 'map');
|
| 655 |
+
$('baseSat').classList.toggle('active', mode === 'satellite');
|
| 656 |
+
$('baseMap').classList.toggle('active', mode === 'map');
|
| 657 |
+
if (mode === 'map') renderMapTiles();
|
| 658 |
+
}
|
| 659 |
+
function renderMapTiles() {
|
| 660 |
+
if (!S.scene) return;
|
| 661 |
+
var c = tileCenter(S.scene.scene.satellite_path);
|
| 662 |
+
if (!c) return;
|
| 663 |
+
var layer = $('mapTileLayer');
|
| 664 |
+
layer.innerHTML = '';
|
| 665 |
+
var z = S.mapZoom;
|
| 666 |
+
var s = 256 * Math.pow(2, z);
|
| 667 |
+
var x = (c[1] + 180) / 360 * s;
|
| 668 |
+
var sy = Math.sin(c[0] * Math.PI / 180);
|
| 669 |
+
var y = (0.5 - Math.log((1 + sy) / (1 - sy)) / (4 * Math.PI)) * s;
|
| 670 |
+
var tx = Math.floor(x / 256), ty = Math.floor(y / 256);
|
| 671 |
+
var ox = x - tx * 256, oy = y - ty * 256;
|
| 672 |
+
var box = $('satelliteStage').getBoundingClientRect();
|
| 673 |
+
var cx = box.width / 2, cy = box.height / 2;
|
| 674 |
+
for (var dx = -2; dx <= 2; dx++) {
|
| 675 |
+
for (var dy = -2; dy <= 2; dy++) {
|
| 676 |
+
var img = document.createElement('img');
|
| 677 |
+
img.src = 'https://tile.openstreetmap.org/' + z + '/' + (tx + dx) + '/' + (ty + dy) + '.png';
|
| 678 |
+
img.loading = 'lazy';
|
| 679 |
+
img.style.left = (cx - ox + dx * 256) + 'px';
|
| 680 |
+
img.style.top = (cy - oy + dy * 256) + 'px';
|
| 681 |
+
layer.appendChild(img);
|
| 682 |
+
}
|
| 683 |
+
}
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
/* ---------- 统计与进度 ------------------------------------------------ */
|
| 687 |
+
function refreshStats() {
|
| 688 |
+
return api('/api/stats').then(function (st) {
|
| 689 |
+
S.boot = st;
|
| 690 |
+
$('statScenes').textContent = st.scenes.toLocaleString();
|
| 691 |
+
$('statItems').textContent = st.nodes.toLocaleString() + ' 节点';
|
| 692 |
+
$('statQueue').textContent = st.edges.toLocaleString() + ' 边';
|
| 693 |
+
var o = st.origins || {};
|
| 694 |
+
var chain = o.corridor_chain || 0, inter = o.intersection || 0, bridge = o.bridge || 0;
|
| 695 |
+
var tot = chain + inter + bridge || 1;
|
| 696 |
+
$('countChain').textContent = chain.toLocaleString();
|
| 697 |
+
$('countInter').textContent = inter.toLocaleString();
|
| 698 |
+
$('countBridge').textContent = bridge.toLocaleString();
|
| 699 |
+
$('countCost').textContent = (st.high_cost || 0).toLocaleString();
|
| 700 |
+
$('progressLabel').textContent =
|
| 701 |
+
'走廊链 ' + Math.round(chain / tot * 100) + '%';
|
| 702 |
+
// 进度条在这里表示「边有多少来自真实道路走廊」,而不是复核进度
|
| 703 |
+
$('progressBar').style.width = (chain / tot * 100) + '%';
|
| 704 |
+
if ($('datasetLabel')) $('datasetLabel').textContent = '';
|
| 705 |
+
}).catch(function () {});
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
/* ---------- 启动与键盘 ------------------------------------------------ */
|
| 709 |
+
function boot() {
|
| 710 |
+
api('/api/bootstrap').then(function (b) {
|
| 711 |
+
var sel = $('cityFilter');
|
| 712 |
+
b.cities.forEach(function (c) {
|
| 713 |
+
var o = document.createElement('option');
|
| 714 |
+
o.value = o.textContent = c;
|
| 715 |
+
sel.appendChild(o);
|
| 716 |
+
});
|
| 717 |
+
return refreshStats();
|
| 718 |
+
}).then(function () { setMode('scenes'); });
|
| 719 |
+
|
| 720 |
+
['cityFilter', 'orderFilter', 'minNodes', 'editedFilter',
|
| 721 |
+
'fOrphan', 'fHighCost', 'fFragmented',
|
| 722 |
+
'tierFilter', 'kindFilter', 'signalFilter', 'showReviewedSel'].forEach(function (id) {
|
| 723 |
+
var el = $(id);
|
| 724 |
+
if (el) el.onchange = function () { S.offset = 0; loadList(); };
|
| 725 |
+
});
|
| 726 |
+
$('modeScenes').onclick = function () { setMode('scenes'); };
|
| 727 |
+
$('modeQueue').onclick = function () { setMode('queue'); };
|
| 728 |
+
$('pagePrev').onclick = function () { page(-1); };
|
| 729 |
+
$('pageNext').onclick = function () { page(1); };
|
| 730 |
+
$('searchInput').oninput = function () {
|
| 731 |
+
clearTimeout(boot._t);
|
| 732 |
+
boot._t = setTimeout(function () {
|
| 733 |
+
if (S.mode === 'queue') applySearch();
|
| 734 |
+
else { S.offset = 0; loadScenes(true); }
|
| 735 |
+
}, 250);
|
| 736 |
+
};
|
| 737 |
+
$('previousButton').onclick = function () { navigate(-1); };
|
| 738 |
+
$('nextButton').onclick = function () { navigate(1); };
|
| 739 |
+
$('edgeRoad').onclick = function () { setEdgeMode('road'); };
|
| 740 |
+
$('edgeStraight').onclick = function () { setEdgeMode('straight'); };
|
| 741 |
+
$('baseSat').onclick = function () { setBasemap('satellite'); };
|
| 742 |
+
$('baseMap').onclick = function () { setBasemap('map'); };
|
| 743 |
+
$('mapZoomIn').onclick = function () { S.mapZoom = Math.min(19, S.mapZoom + 1); renderMapTiles(); };
|
| 744 |
+
$('mapZoomOut').onclick = function () { S.mapZoom = Math.max(13, S.mapZoom - 1); renderMapTiles(); };
|
| 745 |
+
$('helpButton').onclick = function () { $('helpDialog').showModal(); };
|
| 746 |
+
$('helpClose').onclick = function () { $('helpDialog').close(); };
|
| 747 |
+
$('lightbox').onclick = function () { this.classList.remove('open'); };
|
| 748 |
+
Array.prototype.forEach.call(document.querySelectorAll('.ground-stage'), function (b) {
|
| 749 |
+
b.onclick = function () {
|
| 750 |
+
var img = b.querySelector('img');
|
| 751 |
+
if (!img || !img.getAttribute('src')) return;
|
| 752 |
+
$('lightboxImage').src = img.src;
|
| 753 |
+
$('lightbox').classList.add('open');
|
| 754 |
+
};
|
| 755 |
+
});
|
| 756 |
+
|
| 757 |
+
document.addEventListener('keydown', function (ev) {
|
| 758 |
+
var tag = (ev.target.tagName || '').toLowerCase();
|
| 759 |
+
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
| 760 |
+
if (ev.metaKey || ev.ctrlKey || ev.altKey) return;
|
| 761 |
+
var k = ev.key.toLowerCase();
|
| 762 |
+
if (k === '?') { $('helpDialog').showModal(); ev.preventDefault(); return; }
|
| 763 |
+
if (k === 'j') { navigate(1); ev.preventDefault(); }
|
| 764 |
+
else if (k === 'k') { navigate(-1); ev.preventDefault(); }
|
| 765 |
+
else if (k === 'b') { setBasemap(S.basemap === 'map' ? 'satellite' : 'map'); ev.preventDefault(); }
|
| 766 |
+
else if (k === '[') { page(-1); ev.preventDefault(); }
|
| 767 |
+
else if (k === ']') { page(1); ev.preventDefault(); }
|
| 768 |
+
else if (k === 'q') { setMode(S.mode === 'queue' ? 'scenes' : 'queue'); ev.preventDefault(); }
|
| 769 |
+
else if (k === 'e') { setEdgeMode(S.edgeMode === 'road' ? 'straight' : 'road'); ev.preventDefault(); }
|
| 770 |
+
});
|
| 771 |
+
}
|
| 772 |
+
document.addEventListener('DOMContentLoaded', boot);
|
web/index.html
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>SetVL Node Graph Viewer</title>
|
| 7 |
+
<link rel="stylesheet" href="styles.css">
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div class="topographic-grid" aria-hidden="true"></div>
|
| 11 |
+
|
| 12 |
+
<header class="topbar">
|
| 13 |
+
<div class="brand">
|
| 14 |
+
<div class="brand-mark" aria-hidden="true">SG</div>
|
| 15 |
+
<div>
|
| 16 |
+
<h1>Node Graph Viewer</h1>
|
| 17 |
+
<p>SetVL · 只读浏览</p>
|
| 18 |
+
</div>
|
| 19 |
+
</div>
|
| 20 |
+
<div class="run-status">
|
| 21 |
+
<div><span>Scenes</span><strong id="statScenes">—</strong></div>
|
| 22 |
+
<div><span>Items</span><strong id="statItems">—</strong></div>
|
| 23 |
+
<div><span>Queue T1+2</span><strong id="statQueue">—</strong></div>
|
| 24 |
+
</div>
|
| 25 |
+
<div class="reviewer-field">
|
| 26 |
+
<label>Dataset</label>
|
| 27 |
+
<span id="datasetLabel" style="font:600 11px var(--mono);color:var(--cyan)">—</span>
|
| 28 |
+
</div>
|
| 29 |
+
<button class="icon-button" id="helpButton" title="帮助与快捷键" aria-label="帮助与快捷键">?</button>
|
| 30 |
+
</header>
|
| 31 |
+
|
| 32 |
+
<section class="progress-strip" aria-label="Graph composition">
|
| 33 |
+
<div class="progress-copy">
|
| 34 |
+
<span>Edges by origin</span>
|
| 35 |
+
<strong id="progressLabel">—</strong>
|
| 36 |
+
</div>
|
| 37 |
+
<div class="progress-track"><span id="progressBar"></span></div>
|
| 38 |
+
<div class="progress-counters">
|
| 39 |
+
<div><b id="countChain">—</b> 走廊链</div>
|
| 40 |
+
<div><b id="countInter">—</b> 路口</div>
|
| 41 |
+
<div><b id="countBridge">—</b> 桥接</div>
|
| 42 |
+
<div><b id="countCost">—</b> 高花费</div>
|
| 43 |
+
</div>
|
| 44 |
+
</section>
|
| 45 |
+
|
| 46 |
+
<main class="app-shell">
|
| 47 |
+
<!-- 01 队列 --------------------------------------------------------->
|
| 48 |
+
<aside class="queue-panel">
|
| 49 |
+
<div class="panel-heading">
|
| 50 |
+
<p class="section-index">01 / BROWSE</p>
|
| 51 |
+
<h2 id="panelTitle">场景浏览</h2>
|
| 52 |
+
</div>
|
| 53 |
+
<div class="mode-switch" role="group" aria-label="列表模式">
|
| 54 |
+
<button type="button" id="modeScenes" class="active">全部场景</button>
|
| 55 |
+
<button type="button" id="modeQueue">带信号的</button>
|
| 56 |
+
</div>
|
| 57 |
+
<div class="filter-stack">
|
| 58 |
+
<div class="search-wrap">
|
| 59 |
+
<input id="searchInput" type="search" placeholder="scene_id 片段、城市…">
|
| 60 |
+
</div>
|
| 61 |
+
<div class="filter-grid" id="sceneFilters">
|
| 62 |
+
<label><span>City</span>
|
| 63 |
+
<select id="cityFilter"><option value="">All cities</option></select>
|
| 64 |
+
</label>
|
| 65 |
+
<label><span>Sort</span>
|
| 66 |
+
<select id="orderFilter">
|
| 67 |
+
<option value="scene_id">按 ID</option>
|
| 68 |
+
<option value="nodes_desc">节点多→少</option>
|
| 69 |
+
<option value="nodes_asc">节点少→多</option>
|
| 70 |
+
</select>
|
| 71 |
+
</label>
|
| 72 |
+
<label><span>Min nodes</span>
|
| 73 |
+
<input id="minNodes" type="number" min="1" max="38" placeholder="—">
|
| 74 |
+
</label>
|
| 75 |
+
<label><span>状态</span>
|
| 76 |
+
<select id="editedFilter">
|
| 77 |
+
<option value="">全部</option>
|
| 78 |
+
<option value="1">已人工改过</option>
|
| 79 |
+
<option value="0">未改动</option>
|
| 80 |
+
</select>
|
| 81 |
+
</label>
|
| 82 |
+
</div>
|
| 83 |
+
<div class="chip-row" id="sceneChips">
|
| 84 |
+
<label class="chip"><input id="fOrphan" type="checkbox"> 含孤儿</label>
|
| 85 |
+
<label class="chip"><input id="fHighCost" type="checkbox"> 含高花费边</label>
|
| 86 |
+
<label class="chip"><input id="fFragmented" type="checkbox"> 路网碎片化</label>
|
| 87 |
+
</div>
|
| 88 |
+
<div class="filter-grid" id="queueFilters" hidden>
|
| 89 |
+
<label><span>Kind</span>
|
| 90 |
+
<select id="kindFilter">
|
| 91 |
+
<option value="">节点 + 边</option>
|
| 92 |
+
<option value="node">只看节点</option>
|
| 93 |
+
<option value="edge">只看边</option>
|
| 94 |
+
</select>
|
| 95 |
+
</label>
|
| 96 |
+
<label><span>Tier</span>
|
| 97 |
+
<select id="tierFilter">
|
| 98 |
+
<option value="">全部</option>
|
| 99 |
+
<option value="1">Tier 1 必须</option>
|
| 100 |
+
<option value="2">Tier 2 优先</option>
|
| 101 |
+
<option value="3">Tier 3 抽查</option>
|
| 102 |
+
</select>
|
| 103 |
+
</label>
|
| 104 |
+
<label><span>Signal</span>
|
| 105 |
+
<select id="signalFilter">
|
| 106 |
+
<option value="">全部信号</option>
|
| 107 |
+
<option value="A1">A1 孤儿</option>
|
| 108 |
+
<option value="A2">A2 吸附偏远</option>
|
| 109 |
+
<option value="A3">A3 无路名</option>
|
| 110 |
+
<option value="A4">A4 归属歧义</option>
|
| 111 |
+
<option value="E1">E1 无道路通路</option>
|
| 112 |
+
<option value="E2">E2 绕路超限</option>
|
| 113 |
+
<option value="E3">E3 绕路偏大</option>
|
| 114 |
+
<option value="E4">E4 选边并列</option>
|
| 115 |
+
</select>
|
| 116 |
+
</label>
|
| 117 |
+
<label><span>已复核</span>
|
| 118 |
+
<select id="showReviewedSel">
|
| 119 |
+
<option value="">不含</option>
|
| 120 |
+
<option value="1">含已复核</option>
|
| 121 |
+
</select>
|
| 122 |
+
</label>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
<div class="queue-list" id="queueList" role="listbox" aria-label="Review queue"></div>
|
| 126 |
+
<div class="queue-footer">
|
| 127 |
+
<button class="quiet-button" id="pagePrev">←</button>
|
| 128 |
+
<button class="quiet-button" id="previousButton"><kbd>K</kbd> Prev</button>
|
| 129 |
+
<button class="quiet-button" id="nextButton">Next <kbd>J</kbd></button>
|
| 130 |
+
<button class="quiet-button" id="pageNext">→</button>
|
| 131 |
+
</div>
|
| 132 |
+
</aside>
|
| 133 |
+
|
| 134 |
+
<!-- 02 证据 --------------------------------------------------------->
|
| 135 |
+
<section class="evidence-panel">
|
| 136 |
+
<div class="scene-header">
|
| 137 |
+
<div>
|
| 138 |
+
<div class="scene-title-row">
|
| 139 |
+
<h2 id="sceneTitle">未选择</h2>
|
| 140 |
+
<span class="scene-city" id="sceneCity">—</span>
|
| 141 |
+
</div>
|
| 142 |
+
<p class="scene-id" id="sceneId">—</p>
|
| 143 |
+
</div>
|
| 144 |
+
<div class="scene-metrics">
|
| 145 |
+
<div><span>Nodes</span><strong id="mNodes">—</strong></div>
|
| 146 |
+
<div><span>Edges</span><strong id="mEdges">—</strong></div>
|
| 147 |
+
<div><span>Corridors</span><strong id="mCorridors">—</strong></div>
|
| 148 |
+
<div><span>Orphans</span><strong id="mOrphans">—</strong></div>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
<div class="evidence-grid">
|
| 153 |
+
<article class="image-card">
|
| 154 |
+
<div class="image-card-head">
|
| 155 |
+
<span class="image-kind">Scene Graph</span>
|
| 156 |
+
<div class="map-card-tools">
|
| 157 |
+
<div class="basemap-switch" role="group" aria-label="边的画法">
|
| 158 |
+
<button type="button" id="edgeRoad" class="active" title="沿道路走线">ROAD</button>
|
| 159 |
+
<button type="button" id="edgeStraight" title="直线连接">LINE</button>
|
| 160 |
+
</div>
|
| 161 |
+
<div class="basemap-switch" role="group" aria-label="底图切换">
|
| 162 |
+
<button type="button" id="baseSat" class="active">SAT</button>
|
| 163 |
+
<button type="button" id="baseMap">MAP</button>
|
| 164 |
+
</div>
|
| 165 |
+
<div class="map-controls" aria-label="电子地图缩放">
|
| 166 |
+
<button type="button" id="mapZoomIn" title="放大">+</button>
|
| 167 |
+
<button type="button" id="mapZoomOut" title="缩小">−</button>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
<div class="image-stage satellite-stage" id="satelliteStage">
|
| 172 |
+
<img id="satelliteImage" alt="satellite">
|
| 173 |
+
<div class="street-map" id="streetMap" aria-hidden="true">
|
| 174 |
+
<div class="map-tile-layer" id="mapTileLayer"></div>
|
| 175 |
+
</div>
|
| 176 |
+
<svg class="graph-svg" id="graphSvg" viewBox="0 0 400 400" preserveAspectRatio="none"></svg>
|
| 177 |
+
</div>
|
| 178 |
+
<div class="image-caption">
|
| 179 |
+
<span class="legend">
|
| 180 |
+
<span><i class="lg-chain"></i>走廊链</span>
|
| 181 |
+
<span><i class="lg-inter"></i>路口边</span>
|
| 182 |
+
<span><i class="lg-bridge"></i>桥接边</span>
|
| 183 |
+
<span><i class="lg-road" style="border-top:3px dashed var(--paper);background:none"></i>高花费边</span>
|
| 184 |
+
<span><i class="lg-orphan"></i>孤儿</span>
|
| 185 |
+
</span>
|
| 186 |
+
</div>
|
| 187 |
+
</article>
|
| 188 |
+
|
| 189 |
+
<div class="ground-stack">
|
| 190 |
+
<article class="image-card ground-card">
|
| 191 |
+
<div class="image-card-head compact">
|
| 192 |
+
<div class="node-label anchor-label" id="anchorBadge">—</div>
|
| 193 |
+
<span class="image-kind" id="anchorKind">Node</span>
|
| 194 |
+
</div>
|
| 195 |
+
<button type="button" class="image-stage ground-stage" data-role="anchor" aria-label="放大街景">
|
| 196 |
+
<img id="anchorImage" alt="ground view">
|
| 197 |
+
</button>
|
| 198 |
+
<div class="image-caption mono" id="anchorCaption">—</div>
|
| 199 |
+
</article>
|
| 200 |
+
<article class="image-card ground-card" id="targetCard">
|
| 201 |
+
<div class="image-card-head compact">
|
| 202 |
+
<div class="node-label target-label" id="targetBadge">—</div>
|
| 203 |
+
<span class="image-kind">Target</span>
|
| 204 |
+
</div>
|
| 205 |
+
<button type="button" class="image-stage ground-stage" data-role="target" aria-label="放大街景">
|
| 206 |
+
<img id="targetImage" alt="ground view">
|
| 207 |
+
</button>
|
| 208 |
+
<div class="image-caption mono" id="targetCaption">—</div>
|
| 209 |
+
</article>
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
|
| 213 |
+
<div class="context-rail">
|
| 214 |
+
<div>
|
| 215 |
+
<span class="image-kind">Scene Nodes</span>
|
| 216 |
+
<p>点击可切换到该节点</p>
|
| 217 |
+
</div>
|
| 218 |
+
<div class="node-thumbnails" id="nodeThumbnails"></div>
|
| 219 |
+
<div class="corridor-legend" id="corridorLegend"></div>
|
| 220 |
+
</div>
|
| 221 |
+
|
| 222 |
+
<section class="graph-card" aria-labelledby="graphTitle">
|
| 223 |
+
<div class="graph-head">
|
| 224 |
+
<div>
|
| 225 |
+
<p class="section-index">SCENE ADJACENCY</p>
|
| 226 |
+
<h3 id="graphTitle">节点邻接矩阵</h3>
|
| 227 |
+
</div>
|
| 228 |
+
<div class="graph-counts">
|
| 229 |
+
<span><b id="gChain">—</b> chain</span>
|
| 230 |
+
<span><b id="gInter">—</b> inter</span>
|
| 231 |
+
<span><b id="gBridge">—</b> bridge</span>
|
| 232 |
+
</div>
|
| 233 |
+
</div>
|
| 234 |
+
<p class="graph-rule">每一格是一条无向边,颜色对应它的来源。点击格子或右侧列表可直接检查该条边。</p>
|
| 235 |
+
<div class="graph-body">
|
| 236 |
+
<div><table class="adjacency-table" id="adjacencyTable"></table></div>
|
| 237 |
+
<div class="graph-edge-list" id="edgeList"></div>
|
| 238 |
+
</div>
|
| 239 |
+
</section>
|
| 240 |
+
</section>
|
| 241 |
+
|
| 242 |
+
<!-- 03 判定 --------------------------------------------------------->
|
| 243 |
+
<aside class="inspector-panel">
|
| 244 |
+
<div class="panel-heading inspector-heading">
|
| 245 |
+
<div>
|
| 246 |
+
<p class="section-index">03 / PROPERTIES</p>
|
| 247 |
+
<h2 id="decisionTitle">属性</h2>
|
| 248 |
+
</div>
|
| 249 |
+
<span class="relation-id" id="itemId">—</span>
|
| 250 |
+
</div>
|
| 251 |
+
<div id="inspectorBody"><p class="empty-note">从左侧队列选一条,或在图上点节点/边。</p></div>
|
| 252 |
+
</aside>
|
| 253 |
+
</main>
|
| 254 |
+
|
| 255 |
+
<dialog id="helpDialog">
|
| 256 |
+
<div class="dialog-head">
|
| 257 |
+
<h3>快捷键</h3>
|
| 258 |
+
<button class="icon-button" id="helpClose" aria-label="关闭">×</button>
|
| 259 |
+
</div>
|
| 260 |
+
<div class="dialog-body">
|
| 261 |
+
<div class="shortcut-grid">
|
| 262 |
+
<kbd>J</kbd><span>队列下一条</span>
|
| 263 |
+
<kbd>K</kbd><span>队列上一条</span>
|
| 264 |
+
<kbd>B</kbd><span>切换卫星图 / 电子地图</span>
|
| 265 |
+
<kbd>E</kbd><span>切换边的画法:沿路走线 / 直线</span>
|
| 266 |
+
<kbd>[</kbd> <kbd>]</kbd><span>列表上一页 / 下一页</span>
|
| 267 |
+
<kbd>Q</kbd><span>切换「全部场景」/「复核队列」</span>
|
| 268 |
+
<kbd>?</kbd><span>打开本帮助</span>
|
| 269 |
+
</div>
|
| 270 |
+
</div>
|
| 271 |
+
</dialog>
|
| 272 |
+
|
| 273 |
+
<div class="lightbox" id="lightbox"><img id="lightboxImage" alt=""></div>
|
| 274 |
+
<div id="toast" class="toast"></div>
|
| 275 |
+
<script src="app.js"></script>
|
| 276 |
+
</body>
|
| 277 |
+
</html>
|
web/styles.css
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* SetVL node graph 复核台
|
| 2 |
+
*
|
| 3 |
+
* 视觉系统沿用上一代 pair 复核台:同一套 topographic/asphalt 配色、
|
| 4 |
+
* DIN Condensed 标题、三栏骨架。变的只是数据模型——从「一对节点的关系」
|
| 5 |
+
* 变成「一张图上的节点与边」。
|
| 6 |
+
*/
|
| 7 |
+
:root {
|
| 8 |
+
--ink: #0d1211;
|
| 9 |
+
--asphalt: #141a18;
|
| 10 |
+
--panel: #1a211f;
|
| 11 |
+
--panel-raised: #202926;
|
| 12 |
+
--line: #34403c;
|
| 13 |
+
--line-soft: #29332f;
|
| 14 |
+
--paper: #e8e5da;
|
| 15 |
+
--muted: #9aa69f;
|
| 16 |
+
--faint: #66736d;
|
| 17 |
+
--signal: #f1a43c;
|
| 18 |
+
--signal-dark: #a86112;
|
| 19 |
+
--cyan: #68c8c0;
|
| 20 |
+
--yes: #70c79b;
|
| 21 |
+
--no: #e27667;
|
| 22 |
+
--uncertain: #d0b87b;
|
| 23 |
+
--anchor: #ef5b4d;
|
| 24 |
+
--target: #3c8ee8;
|
| 25 |
+
/* 边的三种来源,沿用旧版的语义配色逻辑 */
|
| 26 |
+
--chain: #68c8c0;
|
| 27 |
+
--intersection: #f1a43c;
|
| 28 |
+
--bridge: #b8a2e0;
|
| 29 |
+
--road: #70c79b;
|
| 30 |
+
--radius: 4px;
|
| 31 |
+
--shadow: 0 24px 70px rgba(0, 0, 0, .28);
|
| 32 |
+
--display: "DIN Condensed", "Avenir Next Condensed", "Franklin Gothic Condensed", sans-serif;
|
| 33 |
+
--body: "Avenir Next", "Helvetica Neue", sans-serif;
|
| 34 |
+
--mono: "SFMono-Regular", "Roboto Mono", "Courier New", monospace;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
* { box-sizing: border-box; }
|
| 38 |
+
html, body { height: 100%; }
|
| 39 |
+
body {
|
| 40 |
+
margin: 0; overflow: hidden; color: var(--paper);
|
| 41 |
+
background: radial-gradient(circle at 74% 8%, rgba(87,126,111,.15), transparent 28rem), var(--ink);
|
| 42 |
+
font-family: var(--body); font-size: 14px;
|
| 43 |
+
}
|
| 44 |
+
button, input, select, textarea { font: inherit; }
|
| 45 |
+
button { color: inherit; }
|
| 46 |
+
|
| 47 |
+
.topographic-grid {
|
| 48 |
+
position: fixed; inset: 0; pointer-events: none; opacity: .24;
|
| 49 |
+
background-image:
|
| 50 |
+
linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px),
|
| 51 |
+
linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px);
|
| 52 |
+
background-size: 42px 42px;
|
| 53 |
+
mask-image: linear-gradient(to bottom, black, transparent 72%);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
/* ---------------------------------------------------------------- 顶栏 */
|
| 57 |
+
.topbar {
|
| 58 |
+
height: 70px; display: grid;
|
| 59 |
+
grid-template-columns: minmax(340px, 1fr) auto minmax(250px, 320px) 38px;
|
| 60 |
+
align-items: center; gap: 18px; padding: 0 20px;
|
| 61 |
+
border-bottom: 1px solid var(--line); background: rgba(20,26,24,.96);
|
| 62 |
+
position: relative; z-index: 5;
|
| 63 |
+
}
|
| 64 |
+
.brand { display: flex; align-items: center; gap: 13px; }
|
| 65 |
+
.brand-mark {
|
| 66 |
+
width: 34px; height: 34px; display: grid; place-items: center;
|
| 67 |
+
border: 1px solid var(--signal); color: var(--signal);
|
| 68 |
+
font: 800 15px var(--mono);
|
| 69 |
+
}
|
| 70 |
+
.brand h1 { margin: 0; font: 600 19px/1 var(--display); letter-spacing: .06em; text-transform: uppercase; }
|
| 71 |
+
.brand p { margin: 3px 0 0; color: var(--faint); font: 600 9px var(--mono); letter-spacing: .14em; text-transform: uppercase; }
|
| 72 |
+
.run-status { display: flex; gap: 20px; }
|
| 73 |
+
.run-status div { text-align: right; }
|
| 74 |
+
.run-status span { display: block; color: var(--faint); font: 600 9px var(--mono); letter-spacing: .12em; text-transform: uppercase; }
|
| 75 |
+
.run-status strong { font: 600 17px var(--display); letter-spacing: .04em; }
|
| 76 |
+
.reviewer-field { display: flex; flex-direction: column; gap: 4px; }
|
| 77 |
+
.reviewer-field label { color: var(--faint); font: 600 9px var(--mono); letter-spacing: .12em; text-transform: uppercase; }
|
| 78 |
+
.reviewer-field input {
|
| 79 |
+
background: var(--ink); border: 1px solid var(--line); color: var(--paper);
|
| 80 |
+
padding: 6px 9px; border-radius: var(--radius); font: 600 12px var(--mono);
|
| 81 |
+
}
|
| 82 |
+
.reviewer-field input:focus { outline: none; border-color: var(--signal); }
|
| 83 |
+
.icon-button {
|
| 84 |
+
width: 30px; height: 30px; border: 1px solid var(--line); background: transparent;
|
| 85 |
+
color: var(--muted); cursor: pointer; border-radius: var(--radius); font: 700 14px var(--mono);
|
| 86 |
+
}
|
| 87 |
+
.icon-button:hover { border-color: var(--signal); color: var(--signal); }
|
| 88 |
+
|
| 89 |
+
/* ---------------------------------------------------------------- 进度条 */
|
| 90 |
+
.progress-strip {
|
| 91 |
+
display: grid; grid-template-columns: 210px minmax(180px, 1fr) auto;
|
| 92 |
+
align-items: center; gap: 16px; padding: 9px 20px;
|
| 93 |
+
border-bottom: 1px solid var(--line); background: rgba(16,21,19,.9);
|
| 94 |
+
}
|
| 95 |
+
.progress-copy span { color: var(--faint); font: 600 9px var(--mono); letter-spacing: .12em; text-transform: uppercase; }
|
| 96 |
+
.progress-copy strong { display: block; font: 600 15px var(--display); letter-spacing: .04em; }
|
| 97 |
+
.progress-track { height: 3px; background: var(--line-soft); overflow: hidden; }
|
| 98 |
+
.progress-track span { display: block; height: 100%; width: 0; background: linear-gradient(90deg, var(--signal), #ffd38b); transition: width .45s ease; }
|
| 99 |
+
.progress-counters { display: flex; gap: 14px; color: var(--faint); font: 600 10px var(--mono); text-transform: uppercase; }
|
| 100 |
+
.progress-counters b { color: var(--paper); }
|
| 101 |
+
|
| 102 |
+
/* ---------------------------------------------------------------- 三栏 */
|
| 103 |
+
.app-shell {
|
| 104 |
+
height: calc(100vh - 70px - 41px);
|
| 105 |
+
display: grid; grid-template-columns: 294px minmax(600px, 1fr) 350px;
|
| 106 |
+
position: relative; z-index: 2;
|
| 107 |
+
}
|
| 108 |
+
.queue-panel, .inspector-panel, .evidence-panel { min-height: 0; }
|
| 109 |
+
.queue-panel, .inspector-panel { background: rgba(20,26,24,.96); }
|
| 110 |
+
.queue-panel { border-right: 1px solid var(--line); display: flex; flex-direction: column; }
|
| 111 |
+
.inspector-panel { border-left: 1px solid var(--line); overflow-y: auto; }
|
| 112 |
+
.evidence-panel { overflow-y: auto; padding: 16px 18px; display: flex; flex-direction: column; gap: 12px; }
|
| 113 |
+
|
| 114 |
+
.panel-heading { padding: 14px 16px 11px; border-bottom: 1px solid var(--line); }
|
| 115 |
+
.panel-heading h2 { margin: 2px 0 0; font: 600 16px var(--display); letter-spacing: .05em; text-transform: uppercase; }
|
| 116 |
+
.section-index { margin: 0; color: var(--signal); font: 700 9px var(--mono); letter-spacing: .16em; }
|
| 117 |
+
.inspector-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; }
|
| 118 |
+
.relation-id { color: var(--muted); font: 700 12px var(--mono); }
|
| 119 |
+
|
| 120 |
+
/* ---------------------------------------------------------------- 队列 */
|
| 121 |
+
.mode-switch { display: flex; border-bottom: 1px solid var(--line); }
|
| 122 |
+
.mode-switch button {
|
| 123 |
+
flex: 1; background: transparent; border: 0; padding: 8px; cursor: pointer;
|
| 124 |
+
color: var(--faint); font: 700 10px var(--mono); letter-spacing: .08em;
|
| 125 |
+
border-bottom: 2px solid transparent;
|
| 126 |
+
}
|
| 127 |
+
.mode-switch button.active { color: var(--signal); border-bottom-color: var(--signal); }
|
| 128 |
+
.chip-row { display: flex; gap: 6px; flex-wrap: wrap; }
|
| 129 |
+
.chip {
|
| 130 |
+
display: flex; align-items: center; gap: 4px; padding: 3px 7px; cursor: pointer;
|
| 131 |
+
border: 1px solid var(--line); color: var(--faint); font: 600 9px var(--mono);
|
| 132 |
+
}
|
| 133 |
+
.chip:hover { border-color: var(--muted); }
|
| 134 |
+
.filter-grid input[type=number] {
|
| 135 |
+
background: var(--ink); border: 1px solid var(--line); color: var(--paper);
|
| 136 |
+
padding: 5px 6px; border-radius: var(--radius); font: 500 11px var(--body); width: 100%;
|
| 137 |
+
}
|
| 138 |
+
.scene-item-meta { display: flex; gap: 8px; flex-wrap: wrap; }
|
| 139 |
+
.badge { padding: 0 4px; font: 700 9px var(--mono); border: 1px solid; }
|
| 140 |
+
.badge.edited { color: var(--yes); border-color: var(--yes); }
|
| 141 |
+
.badge.orphan { color: var(--anchor); border-color: var(--anchor); }
|
| 142 |
+
.badge.cost { color: var(--uncertain); border-color: var(--uncertain); }
|
| 143 |
+
.badge.frag { color: var(--no); border-color: var(--no); }
|
| 144 |
+
|
| 145 |
+
.filter-stack { padding: 11px 14px; border-bottom: 1px solid var(--line); display: flex; flex-direction: column; gap: 8px; }
|
| 146 |
+
.search-wrap { position: relative; }
|
| 147 |
+
.search-wrap input {
|
| 148 |
+
width: 100%; background: var(--ink); border: 1px solid var(--line); color: var(--paper);
|
| 149 |
+
padding: 7px 9px; border-radius: var(--radius); font: 500 12px var(--body);
|
| 150 |
+
}
|
| 151 |
+
.search-wrap input:focus { outline: none; border-color: var(--signal); }
|
| 152 |
+
.filter-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
|
| 153 |
+
.filter-grid label { display: flex; flex-direction: column; gap: 3px; }
|
| 154 |
+
.filter-grid span { color: var(--faint); font: 600 8px var(--mono); letter-spacing: .1em; text-transform: uppercase; }
|
| 155 |
+
.filter-grid select {
|
| 156 |
+
background: var(--ink); border: 1px solid var(--line); color: var(--paper);
|
| 157 |
+
padding: 5px 6px; border-radius: var(--radius); font: 500 11px var(--body);
|
| 158 |
+
}
|
| 159 |
+
.filter-grid select:focus { outline: none; border-color: var(--signal); }
|
| 160 |
+
.queue-list { flex: 1; overflow-y: auto; }
|
| 161 |
+
.queue-item {
|
| 162 |
+
width: 100%; text-align: left; border: 0; border-left: 2px solid transparent;
|
| 163 |
+
border-bottom: 1px solid var(--line-soft); background: transparent;
|
| 164 |
+
padding: 10px 13px; cursor: pointer; position: relative; display: block;
|
| 165 |
+
}
|
| 166 |
+
.queue-item:hover { background: rgba(255,255,255,.025); }
|
| 167 |
+
.queue-item.active { background: rgba(241,164,60,.075); border-left-color: var(--signal); }
|
| 168 |
+
.queue-item.reviewed::after { content: ""; position: absolute; right: 9px; top: 11px; width: 6px; height: 6px; border-radius: 50%; background: var(--yes); }
|
| 169 |
+
.queue-item.adjudication::after { content: ""; position: absolute; right: 9px; top: 11px; width: 6px; height: 6px; border-radius: 50%; background: var(--no); }
|
| 170 |
+
.queue-top { display: flex; align-items: baseline; gap: 8px; }
|
| 171 |
+
.queue-top strong { color: var(--paper); font: 650 12px var(--mono); }
|
| 172 |
+
.queue-top span { color: var(--muted); font-size: 11px; margin-left: auto; }
|
| 173 |
+
.queue-meta { margin-top: 7px; display: flex; align-items: center; gap: 5px; color: var(--faint); font: 600 9px var(--mono); flex-wrap: wrap; }
|
| 174 |
+
.tier-pill { padding: 1px 5px; color: var(--ink); font: 800 9px var(--mono); }
|
| 175 |
+
.tier-1 { background: var(--no); } .tier-2 { background: var(--signal); } .tier-3 { background: var(--uncertain); }
|
| 176 |
+
.sig-code { color: var(--signal); font-weight: 800; }
|
| 177 |
+
.queue-scene { margin-top: 4px; color: #56635c; font: 500 9px var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 178 |
+
.queue-footer { padding: 9px 13px; border-top: 1px solid var(--line); display: flex; gap: 7px; }
|
| 179 |
+
.quiet-button {
|
| 180 |
+
flex: 1; background: transparent; border: 1px solid var(--line); color: var(--muted);
|
| 181 |
+
padding: 6px; cursor: pointer; border-radius: var(--radius); font: 600 10px var(--mono);
|
| 182 |
+
}
|
| 183 |
+
.quiet-button:hover:not(:disabled) { border-color: var(--signal); color: var(--signal); }
|
| 184 |
+
.quiet-button:disabled { opacity: .35; cursor: default; }
|
| 185 |
+
kbd {
|
| 186 |
+
display: inline-block; padding: 0 4px; margin: 0 2px; border: 1px solid var(--line);
|
| 187 |
+
border-bottom-width: 2px; border-radius: 3px; background: var(--ink);
|
| 188 |
+
font: 700 9px var(--mono); color: var(--muted);
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
/* ---------------------------------------------------------------- 证据区 */
|
| 192 |
+
.scene-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
| 193 |
+
.scene-title-row { display: flex; align-items: baseline; gap: 10px; }
|
| 194 |
+
.scene-title-row h2 { margin: 0; font: 600 20px var(--display); letter-spacing: .04em; }
|
| 195 |
+
.scene-city { color: var(--cyan); font: 700 10px var(--mono); letter-spacing: .1em; text-transform: uppercase; }
|
| 196 |
+
.scene-id { margin: 3px 0 0; color: var(--faint); font: 500 10px var(--mono); }
|
| 197 |
+
.scene-metrics { display: flex; gap: 16px; }
|
| 198 |
+
.scene-metrics div { text-align: right; }
|
| 199 |
+
.scene-metrics span { display: block; color: var(--faint); font: 600 8px var(--mono); letter-spacing: .1em; text-transform: uppercase; }
|
| 200 |
+
.scene-metrics strong { font: 600 15px var(--display); }
|
| 201 |
+
|
| 202 |
+
.evidence-grid { display: grid; grid-template-columns: minmax(320px, 1fr) minmax(260px, 380px); gap: 14px; align-items: start; }
|
| 203 |
+
.image-card { border: 1px solid var(--line); background: var(--panel); }
|
| 204 |
+
.image-card-head { display: flex; align-items: center; gap: 9px; padding: 8px 11px; border-bottom: 1px solid var(--line-soft); }
|
| 205 |
+
.image-card-head.compact { padding: 6px 9px; }
|
| 206 |
+
.image-kind { color: var(--faint); font: 700 9px var(--mono); letter-spacing: .14em; text-transform: uppercase; }
|
| 207 |
+
.map-card-tools { margin-left: auto; display: flex; align-items: center; gap: 9px; }
|
| 208 |
+
.basemap-switch { display: flex; border: 1px solid var(--line); }
|
| 209 |
+
.basemap-switch button {
|
| 210 |
+
background: transparent; border: 0; padding: 3px 9px; cursor: pointer;
|
| 211 |
+
color: var(--faint); font: 700 9px var(--mono); letter-spacing: .08em;
|
| 212 |
+
}
|
| 213 |
+
.basemap-switch button.active { background: var(--signal); color: var(--ink); }
|
| 214 |
+
.legend { display: flex; gap: 9px; }
|
| 215 |
+
.legend i { display: inline-block; width: 12px; height: 2px; margin-right: 3px; vertical-align: middle; }
|
| 216 |
+
.legend span { color: var(--faint); font: 600 8px var(--mono); white-space: nowrap; }
|
| 217 |
+
.lg-chain { background: var(--chain); } .lg-inter { background: var(--intersection); }
|
| 218 |
+
.lg-bridge { background: var(--bridge); } .lg-road { background: var(--road); }
|
| 219 |
+
.lg-orphan { width: 7px !important; height: 7px !important; border-radius: 50%; background: var(--anchor); }
|
| 220 |
+
|
| 221 |
+
.image-stage { display: block; width: 100%; padding: 0; border: 0; background: #0b0e0d; position: relative; overflow: hidden; }
|
| 222 |
+
.image-stage > img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
| 223 |
+
.satellite-stage { aspect-ratio: 1/1; cursor: crosshair; }
|
| 224 |
+
.ground-stage { aspect-ratio: 16/8.3; cursor: zoom-in; }
|
| 225 |
+
.ground-stage:hover > img { filter: brightness(1.06); }
|
| 226 |
+
.graph-svg { position: absolute; inset: 0; width: 100%; height: 100%; }
|
| 227 |
+
.street-map { display: none; position: absolute; inset: 0; z-index: 1; background: #d7d5cb; }
|
| 228 |
+
.satellite-stage.map-mode .street-map { display: block; }
|
| 229 |
+
.map-tile-layer { position: absolute; inset: 0; }
|
| 230 |
+
.map-tile-layer img { position: absolute; width: 256px; height: 256px; }
|
| 231 |
+
.map-controls { display: flex; border: 1px solid var(--line); }
|
| 232 |
+
.map-controls button { background: transparent; border: 0; width: 22px; cursor: pointer; color: var(--muted); font: 700 12px var(--mono); }
|
| 233 |
+
.map-controls button:hover { color: var(--signal); }
|
| 234 |
+
.image-caption { min-height: 34px; padding: 8px 11px; display: flex; justify-content: space-between; gap: 10px; color: var(--faint); border-top: 1px solid var(--line-soft); font: 600 9px var(--mono); }
|
| 235 |
+
.image-caption.mono { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
| 236 |
+
|
| 237 |
+
.ground-stack { display: grid; grid-template-rows: auto auto; gap: 12px; }
|
| 238 |
+
.node-label { width: 29px; height: 29px; display: grid; place-items: center; font: 800 14px var(--mono); border: 1px solid; }
|
| 239 |
+
.anchor-label { color: var(--anchor); border-color: var(--anchor); background: rgba(239,91,77,.1); }
|
| 240 |
+
.target-label { color: var(--target); border-color: var(--target); background: rgba(60,142,232,.1); }
|
| 241 |
+
|
| 242 |
+
.context-rail {
|
| 243 |
+
min-height: 70px; display: grid; grid-template-columns: 160px 1fr auto;
|
| 244 |
+
align-items: center; gap: 14px; padding: 10px 12px;
|
| 245 |
+
border: 1px solid var(--line); background: var(--panel);
|
| 246 |
+
}
|
| 247 |
+
.context-rail span.image-kind { display: block; }
|
| 248 |
+
.context-rail p { margin: 3px 0 0; color: var(--faint); font-size: 10px; }
|
| 249 |
+
.node-thumbnails { display: flex; gap: 5px; flex-wrap: wrap; }
|
| 250 |
+
.node-chip {
|
| 251 |
+
width: 26px; height: 26px; display: grid; place-items: center; cursor: pointer;
|
| 252 |
+
border: 1px solid var(--line); background: var(--ink); color: var(--muted);
|
| 253 |
+
font: 700 11px var(--mono);
|
| 254 |
+
}
|
| 255 |
+
.node-chip:hover { border-color: var(--signal); }
|
| 256 |
+
.node-chip.active { border-color: var(--signal); color: var(--signal); background: rgba(241,164,60,.12); }
|
| 257 |
+
.node-chip.orphan { border-color: var(--anchor); color: var(--anchor); }
|
| 258 |
+
.node-chip.reviewed::after { content: "·"; position: absolute; }
|
| 259 |
+
.corridor-legend { display: flex; flex-direction: column; gap: 3px; max-height: 62px; overflow-y: auto; }
|
| 260 |
+
.corridor-legend div { display: flex; align-items: center; gap: 5px; color: var(--muted); font: 600 9px var(--mono); white-space: nowrap; }
|
| 261 |
+
.corridor-legend i { width: 8px; height: 8px; border-radius: 50%; flex: none; }
|
| 262 |
+
|
| 263 |
+
/* ---------------------------------------------------------------- 邻接矩阵 */
|
| 264 |
+
.graph-card { border: 1px solid var(--line); background: var(--panel); }
|
| 265 |
+
.graph-head { display: flex; align-items: center; gap: 14px; padding: 10px 12px; border-bottom: 1px solid var(--line-soft); }
|
| 266 |
+
.graph-head h3 { margin: 2px 0 0; font: 600 15px var(--display); letter-spacing: .04em; text-transform: uppercase; }
|
| 267 |
+
.graph-counts { margin-left: auto; display: flex; gap: 12px; color: var(--faint); font: 600 9px var(--mono); text-transform: uppercase; }
|
| 268 |
+
.graph-counts b { color: var(--paper); }
|
| 269 |
+
.graph-rule { margin: 0; padding: 8px 12px; color: var(--faint); font-size: 10px; border-bottom: 1px solid var(--line-soft); }
|
| 270 |
+
.graph-body { display: grid; grid-template-columns: auto 1fr; gap: 12px; padding: 11px 12px; align-items: start; }
|
| 271 |
+
.adjacency-table { border-collapse: collapse; font: 700 10px var(--mono); }
|
| 272 |
+
.adjacency-table th, .adjacency-table td {
|
| 273 |
+
width: 21px; height: 21px; text-align: center; border: 1px solid var(--line-soft); color: var(--faint);
|
| 274 |
+
}
|
| 275 |
+
.adjacency-table td.edge { cursor: pointer; }
|
| 276 |
+
.adjacency-table td.e-chain { background: rgba(104,200,192,.3); color: var(--paper); }
|
| 277 |
+
.adjacency-table td.e-intersection { background: rgba(241,164,60,.3); color: var(--paper); }
|
| 278 |
+
.adjacency-table td.e-bridge { background: rgba(184,162,224,.3); color: var(--paper); }
|
| 279 |
+
.adjacency-table td.diag { background: var(--line-soft); }
|
| 280 |
+
.adjacency-table td.addable { cursor: cell; color: #3d4a45; }
|
| 281 |
+
.adjacency-table td.addable:hover { background: rgba(104,200,192,.18); color: var(--cyan); }
|
| 282 |
+
.adjacency-table td.rejected { opacity: .4; text-decoration: line-through; }
|
| 283 |
+
.edge-row.rejected { opacity: .45; }
|
| 284 |
+
.cost-tag { color: var(--uncertain); font-weight: 800; letter-spacing: -1px; }
|
| 285 |
+
.adjacency-table td.sel { outline: 2px solid var(--signal); outline-offset: -2px; }
|
| 286 |
+
.graph-edge-list { max-height: 190px; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
|
| 287 |
+
.edge-row {
|
| 288 |
+
display: flex; align-items: center; gap: 7px; padding: 3px 6px; cursor: pointer;
|
| 289 |
+
border: 1px solid transparent; font: 600 10px var(--mono); color: var(--muted);
|
| 290 |
+
}
|
| 291 |
+
.edge-row:hover { background: rgba(255,255,255,.03); }
|
| 292 |
+
.edge-row.sel { border-color: var(--signal); color: var(--paper); }
|
| 293 |
+
.edge-row i { width: 10px; height: 2px; flex: none; }
|
| 294 |
+
.edge-row .len { margin-left: auto; color: var(--faint); }
|
| 295 |
+
|
| 296 |
+
/* ---------------------------------------------------------------- 判定区 */
|
| 297 |
+
.signal-block { padding: 12px 14px; border-bottom: 1px solid var(--line); }
|
| 298 |
+
.block-title { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 8px; }
|
| 299 |
+
.block-title span { color: var(--faint); font: 700 9px var(--mono); letter-spacing: .12em; text-transform: uppercase; }
|
| 300 |
+
.text-button { background: none; border: 0; color: var(--signal); cursor: pointer; font: 600 10px var(--mono); padding: 0; }
|
| 301 |
+
.signal-row { display: flex; align-items: baseline; gap: 8px; padding: 4px 0; border-bottom: 1px solid var(--line-soft); }
|
| 302 |
+
.signal-row:last-child { border-bottom: 0; }
|
| 303 |
+
.signal-row b { color: var(--no); font: 800 11px var(--mono); }
|
| 304 |
+
.signal-row em { color: var(--muted); font-style: normal; font: 600 10px var(--mono); }
|
| 305 |
+
.conf-meter { height: 3px; background: var(--line-soft); margin-top: 8px; }
|
| 306 |
+
.conf-meter span { display: block; height: 100%; background: var(--signal); }
|
| 307 |
+
|
| 308 |
+
.field-block { padding: 12px 14px; border-bottom: 1px solid var(--line); }
|
| 309 |
+
.choice-row { display: flex; gap: 6px; margin-top: 6px; }
|
| 310 |
+
.choice {
|
| 311 |
+
flex: 1; padding: 7px 4px; cursor: pointer; text-align: center;
|
| 312 |
+
border: 1px solid var(--line); background: var(--ink); color: var(--muted);
|
| 313 |
+
font: 700 10px var(--mono); letter-spacing: .05em;
|
| 314 |
+
}
|
| 315 |
+
.choice:hover { border-color: var(--muted); }
|
| 316 |
+
.choice.on-yes { border-color: var(--yes); color: var(--yes); background: rgba(112,199,155,.12); }
|
| 317 |
+
.choice.on-no { border-color: var(--no); color: var(--no); background: rgba(226,118,103,.12); }
|
| 318 |
+
.choice.on-uncertain { border-color: var(--uncertain); color: var(--uncertain); background: rgba(208,184,123,.12); }
|
| 319 |
+
.corridor-picker { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; max-height: 190px; overflow-y: auto; }
|
| 320 |
+
.corridor-option {
|
| 321 |
+
display: flex; align-items: center; gap: 7px; padding: 6px 8px; cursor: pointer;
|
| 322 |
+
border: 1px solid var(--line); background: var(--ink); color: var(--muted);
|
| 323 |
+
font: 600 11px var(--mono); text-align: left;
|
| 324 |
+
}
|
| 325 |
+
.corridor-option:hover { border-color: var(--muted); }
|
| 326 |
+
.corridor-option.active { border-color: var(--signal); color: var(--paper); background: rgba(241,164,60,.1); }
|
| 327 |
+
.corridor-option i { width: 9px; height: 9px; border-radius: 50%; flex: none; }
|
| 328 |
+
.corridor-option kbd { margin-left: auto; }
|
| 329 |
+
.corridor-option .auto-tag { color: var(--cyan); font-size: 9px; }
|
| 330 |
+
.corridor-option span:first-of-type { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 331 |
+
.corridor-option .dist-tag { color: var(--faint); font-size: 9px; flex: none; }
|
| 332 |
+
.notes-field { width: 100%; margin-top: 6px; background: var(--ink); border: 1px solid var(--line); color: var(--paper); padding: 7px 9px; border-radius: var(--radius); resize: vertical; min-height: 52px; font: 500 11px var(--body); }
|
| 333 |
+
.notes-field:focus { outline: none; border-color: var(--signal); }
|
| 334 |
+
.commit-row { padding: 12px 14px; display: flex; flex-direction: column; gap: 7px; }
|
| 335 |
+
.primary-button {
|
| 336 |
+
padding: 9px; cursor: pointer; border: 1px solid var(--signal); background: var(--signal);
|
| 337 |
+
color: var(--ink); font: 800 11px var(--mono); letter-spacing: .08em; text-transform: uppercase;
|
| 338 |
+
}
|
| 339 |
+
.primary-button:hover { background: #ffc069; }
|
| 340 |
+
.ghost-button {
|
| 341 |
+
padding: 8px; cursor: pointer; border: 1px solid var(--line); background: transparent;
|
| 342 |
+
color: var(--muted); font: 700 10px var(--mono); letter-spacing: .06em; text-transform: uppercase;
|
| 343 |
+
}
|
| 344 |
+
.ghost-button:hover { border-color: var(--uncertain); color: var(--uncertain); }
|
| 345 |
+
.kv { display: grid; grid-template-columns: auto 1fr; gap: 3px 10px; font: 600 10px var(--mono); }
|
| 346 |
+
.kv dt { color: var(--faint); }
|
| 347 |
+
.kv dd { margin: 0; color: var(--muted); word-break: break-all; }
|
| 348 |
+
.revision-list { list-style: none; margin: 6px 0 0; padding: 0; max-height: 150px; overflow-y: auto; }
|
| 349 |
+
.revision-list li { padding: 3px 0; border-bottom: 1px solid var(--line-soft); color: var(--faint); font: 500 9px var(--mono); word-break: break-all; }
|
| 350 |
+
|
| 351 |
+
/* ---------------------------------------------------------------- 浮层 */
|
| 352 |
+
dialog { border: 1px solid var(--line); background: var(--panel); color: var(--paper); padding: 0; box-shadow: var(--shadow); }
|
| 353 |
+
dialog::backdrop { background: rgba(6,9,8,.72); }
|
| 354 |
+
.dialog-head { display: flex; justify-content: space-between; align-items: center; padding: 13px 16px; border-bottom: 1px solid var(--line); }
|
| 355 |
+
.dialog-head h3 { margin: 0; font: 600 16px var(--display); letter-spacing: .05em; text-transform: uppercase; }
|
| 356 |
+
.dialog-body { padding: 14px 16px; max-width: 520px; }
|
| 357 |
+
.shortcut-grid { display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; align-items: baseline; font-size: 12px; }
|
| 358 |
+
.shortcut-grid span { color: var(--muted); }
|
| 359 |
+
.lightbox { position: fixed; inset: 0; z-index: 50; display: none; place-items: center; background: rgba(6,9,8,.9); cursor: zoom-out; }
|
| 360 |
+
.lightbox.open { display: grid; }
|
| 361 |
+
.lightbox img { max-width: 92vw; max-height: 92vh; border: 1px solid var(--line); }
|
| 362 |
+
.toast {
|
| 363 |
+
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 60;
|
| 364 |
+
padding: 9px 16px; border: 1px solid var(--line); background: var(--panel-raised);
|
| 365 |
+
color: var(--paper); font: 600 11px var(--mono); opacity: 0; pointer-events: none;
|
| 366 |
+
transition: opacity .2s ease;
|
| 367 |
+
}
|
| 368 |
+
.toast.show { opacity: 1; }
|
| 369 |
+
.toast.err { border-color: var(--no); color: var(--no); }
|
| 370 |
+
.empty-note { color: var(--faint); font-size: 12px; padding: 16px; }
|