MarcoXL commited on
Commit
3613e77
·
1 Parent(s): 18631df

redesign UI: reuse annotation desk frontend (read-only), switch to docker SDK

Browse files
Files changed (7) hide show
  1. Dockerfile +19 -0
  2. README.md +18 -9
  3. app.py +240 -371
  4. requirements.txt +2 -0
  5. web/app.js +772 -0
  6. web/index.html +277 -0
  7. 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: gradio
7
- sdk_version: 6.27.0
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
- 图片存放在配套的 Dataset repo,由 CDN 按需分发。
22
 
23
- 建图规则见仓库里的 `GRAPH_RULES.md`。
 
 
 
 
 
 
 
 
 
 
 
 
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 只读浏览器(Gradio)。
3
 
4
- 给别人看结果用的:能翻场景、看图、看每条边每个节点的属性,但**改不了任何东西**。
5
- 没有写入接口——不是靠界面藏起来,是压根没实现。
 
6
 
7
- 数据来自 `prepare.py` 打出来的包(graph.sqlite + satellite/ + ground/ 全是包内相对路径),
8
- 所以整个目录拷到哪都能跑,不依赖原始的 52 GB 数据集。
 
 
 
 
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, Tuple
20
 
21
  import gradio as gr
22
- from PIL import Image, ImageDraw
 
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.get("SETVL_DB", "")) if os.environ.get("SETVL_DB") else None
35
- if DB is None:
36
- DB = (HERE / "graph.sqlite") if (HERE / "graph.sqlite").exists() else PACKAGE / "graph.sqlite"
37
-
38
- ZOOM, RASTER = 17, 400
39
- CITIES: List[str] = []
40
-
41
- # 与复核台一致的配色,换个地方看图不会认错
42
- COL = {
43
- "corridor_chain": (104, 200, 192),
44
- "intersection": (241, 164, 60),
45
- "bridge": (184, 162, 224),
46
- "manual": (104, 200, 192),
47
- }
48
- COL_ORPHAN = (239, 91, 77)
49
- COL_HIGHCOST = (232, 229, 218)
50
-
51
-
52
- def asset(rel: str) -> Optional[Path]:
53
- """把包内相对路径解析成本地可读文件。
54
-
55
- 数据集模式下按需从 HF CDN 拉取并缓存——27 GB 不必也不该整个下到 Space 里。
56
- 取不到就返回 None,让调用方降级(画空白底图、跳过该张街景),不要抛异常
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。Space 不会自动注入个人 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 connect() -> sqlite3.Connection:
76
- if not DB.exists():
77
- raise FileNotFoundError(
78
- f"找不到 {DB}。先跑 prepare.py 打包,或设 SETVL_DB 指向 graph.sqlite。")
79
- con = sqlite3.connect(f"file:{DB}?mode=ro", uri=True, check_same_thread=False)
80
- con.row_factory = sqlite3.Row
81
- return con
82
 
83
 
84
- CON = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
 
86
 
87
- def con() -> sqlite3.Connection:
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
- def to_px(lat: float, lon: float) -> Tuple[float, float]:
119
- x, y = mercator(lat, lon)
120
- return x - cx + RASTER / 2, y - cy + RASTER / 2
121
- return to_px
 
 
 
 
 
 
 
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
- def list_scenes(city: str, min_nodes: int, only_high_cost: bool,
138
- only_orphan: bool, query: str, page: int,
139
- per_page: int = 60) -> Tuple[List[str], int]:
 
 
140
  where, args = [], []
141
- if city and 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 query.strip():
148
  where.append("s.scene_id LIKE ?")
149
- args.append(f"%{query.strip()}%")
150
- if only_high_cost:
151
- where.append("EXISTS (SELECT 1 FROM edge e WHERE e.scene_id = s.scene_id "
152
- "AND e.high_cost_auto = 1)")
153
- if only_orphan:
154
- where.append("EXISTS (SELECT 1 FROM node n WHERE n.scene_id = s.scene_id "
155
- "AND n.is_orphan_auto = 1)")
 
 
156
  clause = (" WHERE " + " AND ".join(where)) if where else ""
157
- total = con().execute(f"SELECT COUNT(*) FROM scene s{clause}", args).fetchone()[0]
158
- page = max(0, page)
 
 
159
  rows = con().execute(
160
- f"SELECT s.scene_id, s.city, s.node_count FROM scene s{clause} "
161
- "ORDER BY s.scene_id LIMIT ? OFFSET ?", args + [per_page, page * per_page])
162
- labels = [f"{r['scene_id']} · {r['node_count']}n · {r['city']}" for r in rows]
163
- return labels, total
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
 
165
 
166
- def scene_of(label: str) -> Optional[str]:
167
- return label.split(" · ")[0] if label else None
 
 
168
 
169
 
170
- def load_scene(scene_id: str) -> Optional[Dict[str, Any]]:
171
- s = con().execute("SELECT * FROM scene WHERE scene_id = ?", (scene_id,)).fetchone()
172
- if s is None:
173
- return None
174
- nodes = [dict(r) for r in con().execute(
175
- "SELECT * FROM node WHERE scene_id = ? ORDER BY label", (scene_id,))]
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
- def ground_rel(node: Dict[str, Any]) -> Optional[str]:
319
- """从索引表查这个 pano 的街景文件名。
320
-
321
- 打包时写了一张 pano -> 文件名 的索引,因为文件名里有 alt/year/heading
322
- 三个字段是数据库里没有的,猜不出来。
323
- """
324
- row = ro_con().execute(
325
- "SELECT rel FROM ground_index WHERE panoid = ? LIMIT 1",
326
- (node["panoid"],)).fetchone()
327
- return row[0] if row else None
328
-
329
-
330
- def node_table(scene_id: str) -> str:
331
- det = load_scene(scene_id)
332
- if det is None:
333
- return ""
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
- def main() -> int:
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
- raise SystemExit(main())
 
 
 
 
 
 
 
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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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; }