MarcoXL commited on
Commit
cdff6a6
·
1 Parent(s): ffe24e3

Upload folder using huggingface_hub

Browse files
Files changed (3) hide show
  1. web/app.js +325 -30
  2. web/index.html +106 -81
  3. web/styles.css +167 -63
web/app.js CHANGED
@@ -127,11 +127,15 @@ function renderScenes() {
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
  });
@@ -158,13 +162,74 @@ function setMode(mode) {
158
  $('modeScenes').classList.toggle('active', mode === 'scenes');
159
  $('modeQueue').classList.toggle('active', mode === 'queue');
160
  $('sceneFilters').hidden = mode !== 'scenes';
161
- $('sceneChips').hidden = mode !== 'scenes';
162
  $('queueFilters').hidden = mode !== 'queue';
163
  $('panelTitle').textContent = mode === 'queue' ? '复核队列' : '场景浏览';
164
  $('searchInput').placeholder = mode === 'queue' ? '场景 ID、城市或标签…' : 'scene_id 片段、城市…';
 
165
  loadList();
166
  }
167
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  function loadQueue(keepSelection) {
169
  return api('/api/queue?' + queueQuery()).then(function (d) {
170
  S.queue = d.items;
@@ -202,6 +267,11 @@ function itemId(i) { return i.kind + '|' + i.scene_id + '|' + i.item_key; }
202
  function renderList() {
203
  if (S.mode === 'queue') renderQueue();
204
  else renderScenes();
 
 
 
 
 
205
  }
206
 
207
  function renderQueue() {
@@ -346,6 +416,116 @@ function drawScene() {
346
  if (S.basemap === 'map') renderMapTiles();
347
  }
348
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  function drawGraph() {
350
  var svg = $('graphSvg');
351
  svg.innerHTML = '';
@@ -360,26 +540,39 @@ function drawGraph() {
360
  return el;
361
  }
362
  var sel = S.selected;
363
- S.scene.edges.forEach(function (e) {
 
 
364
  var a = pos[e.source_label], b = pos[e.target_label];
365
- if (!a || !b) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  var colour = { corridor_chain: 'var(--chain)', intersection: 'var(--intersection)' }[e.origin]
367
  || 'var(--bridge)';
368
  var isSel = sel && sel.kind === 'edge'
369
  && sel.key[1] === e.source_label && sel.key[2] === e.target_label;
370
  var dead = !e.in_graph;
371
- var costly = !!e.high_cost;
372
- // 一条边只画一笔。有走线就用走线(那才是这条边的真实走向),
373
- // 没有就画直线并用虚线标出「无道路可走」。
374
- // 之前直线与折线同时画,全库 81% 的边两者不重合,叠出一片乱麻。
375
  var el;
376
- // 高花费边一律画直线:它的 road_path 已在建图阶段丢弃(绕出几十倍的
377
- // 折线画在图上只会误导),真实路网距离仍在 road_distance_m 里。
378
- if (!costly && S.edgeMode === 'road' && e.road_path && e.road_path.length > 1) {
379
- // 折线两端落在吸附点上,不在节点上——补两段短接头,否则边看着是断的
380
- var pts = [a].concat(e.road_path.map(function (p) { return proj(p[0], p[1]); })).concat([b]);
381
  el = mk('polyline', {
382
- points: pts.map(function (p) { return p.join(','); }).join(' '),
383
  fill: 'none', stroke: isSel ? '#fff' : colour,
384
  'stroke-width': isSel ? 3 : 1.7, opacity: dead ? .22 : 1,
385
  'stroke-linejoin': 'round', 'stroke-linecap': 'round',
@@ -387,17 +580,24 @@ function drawGraph() {
387
  });
388
  } else {
389
  el = mk('line', {
390
- x1: a[0], y1: a[1], x2: b[0], y2: b[1],
391
  stroke: isSel ? '#fff' : colour,
392
- 'stroke-width': isSel ? 3 : (costly ? 2.2 : 1.7),
393
  opacity: dead ? .22 : 1,
394
- 'stroke-dasharray': costly ? '6 4' : '',
395
  style: 'cursor:pointer',
396
  });
397
  }
 
 
398
  el.onclick = function () { pickEdge(e); };
 
 
 
 
399
  svg.appendChild(el);
400
  });
 
401
  S.scene.nodes.forEach(function (n) {
402
  var p = pos[n.label];
403
  if (!p) return;
@@ -418,6 +618,79 @@ function drawGraph() {
418
  g.onclick = function () { pickNode(n); };
419
  svg.appendChild(g);
420
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
  }
422
 
423
  function drawNodeChips(cors) {
@@ -552,7 +825,7 @@ function drawGroundImages() {
552
  b.className = k === i ? 'active' : '';
553
  b.setAttribute('role', 'tab');
554
  b.setAttribute('aria-selected', k === i ? 'true' : 'false');
555
- b.onclick = function () { groundView[role] = k; drawGroundImages(); };
556
  strip.appendChild(b);
557
  });
558
  }
@@ -580,6 +853,7 @@ function cycleGround(step) {
580
  if (len > 1) groundView[pair[0]] = ((groundView[pair[0]] + step) % len + len) % len;
581
  });
582
  drawGroundImages();
 
583
  }
584
 
585
  function pickNode(n) {
@@ -692,6 +966,12 @@ function setBasemap(mode) {
692
  $('baseMap').classList.toggle('active', mode === 'map');
693
  if (mode === 'map') renderMapTiles();
694
  }
 
 
 
 
 
 
695
  function renderMapTiles() {
696
  if (!S.scene) return;
697
  var c = tileCenter(S.scene.scene.satellite_path);
@@ -703,17 +983,27 @@ function renderMapTiles() {
703
  var x = (c[1] + 180) / 360 * s;
704
  var sy = Math.sin(c[0] * Math.PI / 180);
705
  var y = (0.5 - Math.log((1 + sy) / (1 - sy)) / (4 * Math.PI)) * s;
 
 
 
 
 
 
706
  var tx = Math.floor(x / 256), ty = Math.floor(y / 256);
707
- var ox = x - tx * 256, oy = y - ty * 256;
708
- var box = $('satelliteStage').getBoundingClientRect();
709
- var cx = box.width / 2, cy = box.height / 2;
710
- for (var dx = -2; dx <= 2; dx++) {
711
- for (var dy = -2; dy <= 2; dy++) {
 
712
  var img = document.createElement('img');
713
- img.src = 'https://tile.openstreetmap.org/' + z + '/' + (tx + dx) + '/' + (ty + dy) + '.png';
714
  img.loading = 'lazy';
715
- img.style.left = (cx - ox + dx * 256) + 'px';
716
- img.style.top = (cy - oy + dy * 256) + 'px';
 
 
 
717
  layer.appendChild(img);
718
  }
719
  }
@@ -757,8 +1047,13 @@ function boot() {
757
  'fOrphan', 'fHighCost', 'fFragmented',
758
  'tierFilter', 'kindFilter', 'signalFilter', 'showReviewedSel'].forEach(function (id) {
759
  var el = $(id);
760
- if (el) el.onchange = function () { S.offset = 0; loadList(); };
761
  });
 
 
 
 
 
762
  $('modeScenes').onclick = function () { setMode('scenes'); };
763
  $('modeQueue').onclick = function () { setMode('queue'); };
764
  $('pagePrev').onclick = function () { page(-1); };
 
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
+ // 三行压成两行:城市并进第一行(原来单独占一行只为显示一个词),
131
+ // 没有徽章时整条 meta 行不渲染,而不是留一个占位的破折号。
132
+ var name = sc.scene_id.split(':')[1] || sc.scene_id;
133
+ b.title = sc.scene_id;
134
  b.innerHTML =
135
+ '<div class="queue-top"><strong>' + esc(name) + '</strong>' +
136
+ '<span>' + sc.node_count + 'n/' + sc.edge_count + 'e</span></div>' +
137
+ '<div class="queue-sub"><em>' + esc(sc.city) + '</em>' +
138
+ (badges ? '<span class="queue-badges">' + badges + '</span>' : '') + '</div>';
139
  b.onclick = function () { openScene(sc.scene_id); };
140
  list.appendChild(b);
141
  });
 
162
  $('modeScenes').classList.toggle('active', mode === 'scenes');
163
  $('modeQueue').classList.toggle('active', mode === 'queue');
164
  $('sceneFilters').hidden = mode !== 'scenes';
 
165
  $('queueFilters').hidden = mode !== 'queue';
166
  $('panelTitle').textContent = mode === 'queue' ? '复核队列' : '场景浏览';
167
  $('searchInput').placeholder = mode === 'queue' ? '场景 ID、城市或标签…' : 'scene_id 片段、城市…';
168
+ syncFilterChrome();
169
  loadList();
170
  }
171
 
172
+ /* ---------- 筛选抽屉与 token ------------------------------------------
173
+ * 抽屉里的控件收起后就看不见了,所以已激活的项必须在外面留痕:
174
+ * 一个计数徽标 + 一排可点掉的 token。否则「筛了但忘了筛过」是必然的。
175
+ */
176
+ var FILTERS = [
177
+ { id: 'minNodes', label: '最少节点', mode: 'scenes', type: 'value' },
178
+ { id: 'editedFilter', label: '状态', mode: 'scenes', type: 'select' },
179
+ { id: 'fOrphan', label: '含孤儿', mode: 'scenes', type: 'check' },
180
+ { id: 'fHighCost', label: '含高花费边', mode: 'scenes', type: 'check' },
181
+ { id: 'fFragmented', label: '路网碎片化', mode: 'scenes', type: 'check' },
182
+ { id: 'kindFilter', label: 'Kind', mode: 'queue', type: 'select' },
183
+ { id: 'tierFilter', label: 'Tier', mode: 'queue', type: 'select' },
184
+ { id: 'signalFilter', label: 'Signal', mode: 'queue', type: 'select' },
185
+ { id: 'showReviewedSel', label: '已复核', mode: 'queue', type: 'select' },
186
+ ];
187
+
188
+ function activeFilters() {
189
+ return FILTERS.filter(function (f) {
190
+ if (f.mode !== S.mode) return false;
191
+ var el = $(f.id);
192
+ if (!el) return false;
193
+ return f.type === 'check' ? el.checked : !!String(el.value || '').trim();
194
+ });
195
+ }
196
+
197
+ function clearFilter(f) {
198
+ var el = $(f.id);
199
+ if (!el) return;
200
+ if (f.type === 'check') el.checked = false; else el.value = '';
201
+ }
202
+
203
+ function syncFilterChrome() {
204
+ var act = activeFilters();
205
+ var badge = $('filterCount');
206
+ badge.textContent = act.length;
207
+ badge.hidden = act.length === 0;
208
+
209
+ var row = $('activeTokens');
210
+ row.innerHTML = '';
211
+ row.hidden = act.length === 0;
212
+ act.forEach(function (f) {
213
+ var el = $(f.id);
214
+ var shown = f.type === 'check' ? ''
215
+ : (el.tagName === 'SELECT' ? el.options[el.selectedIndex].textContent : el.value);
216
+ var b = document.createElement('button');
217
+ b.type = 'button';
218
+ b.className = 'token';
219
+ b.title = '点击取消该筛选';
220
+ b.innerHTML = esc(f.label) + (shown ? ' <b>' + esc(shown) + '</b>' : '') + ' <i>×</i>';
221
+ b.onclick = function () { clearFilter(f); S.offset = 0; syncFilterChrome(); loadList(); };
222
+ row.appendChild(b);
223
+ });
224
+ }
225
+
226
+ function toggleDrawer(force) {
227
+ var btn = $('filterToggle'), box = $('filterDrawer');
228
+ var open = force != null ? force : box.hidden;
229
+ box.hidden = !open;
230
+ btn.setAttribute('aria-expanded', open ? 'true' : 'false');
231
+ }
232
+
233
  function loadQueue(keepSelection) {
234
  return api('/api/queue?' + queueQuery()).then(function (d) {
235
  S.queue = d.items;
 
267
  function renderList() {
268
  if (S.mode === 'queue') renderQueue();
269
  else renderScenes();
270
+ var n = (S.mode === 'queue' ? S.filtered : S.scenes).length;
271
+ var pg = Math.floor(S.offset / S.limit) + 1;
272
+ $('listCount').textContent = n
273
+ ? (n + ' 条 · 第 ' + pg + ' 页 · 序号 ' + (S.offset + 1) + '–' + (S.offset + n))
274
+ : '无结果';
275
  }
276
 
277
  function renderQueue() {
 
416
  if (S.basemap === 'map') renderMapTiles();
417
  }
418
 
419
+ /* ---------- 重叠边的分离 ----------------------------------------------
420
+ * 同一条街上常常叠着好几条边:桥接边从 A 直连到 B,而 A-B 之间的走廊链
421
+ * 正好也沿着同一条路;不同走廊被合并成同一几何时也会完全重合。叠在一起
422
+ * 后只有最后画的那条可见,颜色就"丢"了一种。
423
+ *
424
+ * 判定用真实几何而不是哈希:沿边 A 采样若干点,量它们到边 B 折线的距离,
425
+ * 若绝大部分点都落在 COINCIDE_TOL 之内,就认为两条边视觉上压在一起。
426
+ * (早先的版本按「中点+角度」分桶,结果在共享节点处首尾相接的相邻边被
427
+ * 误判成重叠,全库 83% 的边都被挪开——那不是重叠,是正常的链式连接。)
428
+ *
429
+ * 同组的边各自沿法线偏移 (i - (n-1)/2) * SEG_SEP,并排显示。
430
+ */
431
+ var SEG_SEP = 3.2; // 相邻两条重合边的间距(raster 单位,400 = 全宽)
432
+ var COINCIDE_TOL = 2.5; // 视为"压在一起"的垂直距离
433
+ var COINCIDE_FRAC = 0.75; // 采样点落在容差内的比例门槛
434
+
435
+ function distToSegment(p, a, b) {
436
+ var dx = b[0] - a[0], dy = b[1] - a[1];
437
+ var L2 = dx * dx + dy * dy;
438
+ var t = L2 ? ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / L2 : 0;
439
+ t = t < 0 ? 0 : (t > 1 ? 1 : t);
440
+ return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
441
+ }
442
+ function distToPolyline(p, pts) {
443
+ var best = Infinity;
444
+ for (var i = 0; i + 1 < pts.length; i++) {
445
+ var d = distToSegment(p, pts[i], pts[i + 1]);
446
+ if (d < best) best = d;
447
+ }
448
+ return best;
449
+ }
450
+ /* 沿折线等距取 n 个点(含两端) */
451
+ function sampleAlong(pts, n) {
452
+ var segs = [], total = 0;
453
+ for (var i = 0; i + 1 < pts.length; i++) {
454
+ var L = Math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]);
455
+ segs.push(L); total += L;
456
+ }
457
+ if (!total) return [pts[0]];
458
+ var out = [], step = total / (n - 1);
459
+ for (var k = 0; k < n; k++) {
460
+ var want = Math.min(k * step, total), acc = 0, j = 0;
461
+ while (j < segs.length && acc + segs[j] < want) { acc += segs[j]; j++; }
462
+ var t = segs[j] ? (want - acc) / segs[j] : 0;
463
+ out.push([pts[j][0] + (pts[j + 1][0] - pts[j][0]) * t,
464
+ pts[j][1] + (pts[j + 1][1] - pts[j][1]) * t]);
465
+ }
466
+ return out;
467
+ }
468
+ function coincideFraction(a, b) {
469
+ var pts = sampleAlong(a, 12);
470
+ var hit = 0;
471
+ pts.forEach(function (p) { if (distToPolyline(p, b) <= COINCIDE_TOL) hit++; });
472
+ return hit / pts.length;
473
+ }
474
+
475
+ /* 返回 edgeIndex -> 偏移量。只有真正重合的边才会拿到非零值。 */
476
+ function planOffsets(polys) {
477
+ var n = polys.length;
478
+ var parent = [];
479
+ for (var i = 0; i < n; i++) parent.push(i);
480
+ function find(x) { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; }
481
+ function join(x, y) { var a = find(x), b = find(y); if (a !== b) parent[b] = a; }
482
+
483
+ for (var i = 0; i < n; i++) {
484
+ if (!polys[i] || polys[i].length < 2) continue;
485
+ for (var j = i + 1; j < n; j++) {
486
+ if (!polys[j] || polys[j].length < 2) continue;
487
+ // 一条短边完全压在长边上时,只有短→长这个方向比例高,取 max
488
+ var f = Math.max(coincideFraction(polys[i], polys[j]),
489
+ coincideFraction(polys[j], polys[i]));
490
+ if (f >= COINCIDE_FRAC) join(i, j);
491
+ }
492
+ }
493
+ var groups = {};
494
+ for (var k = 0; k < n; k++) {
495
+ if (!polys[k] || polys[k].length < 2) continue;
496
+ var r = find(k);
497
+ (groups[r] || (groups[r] = [])).push(k);
498
+ }
499
+ var shift = {};
500
+ Object.keys(groups).forEach(function (g) {
501
+ var list = groups[g];
502
+ if (list.length < 2) return; // 不重合的一条都不动
503
+ list.sort(function (a, b) { return a - b; }); // 稳定:按边的绘制序
504
+ list.forEach(function (ei, idx) {
505
+ shift[ei] = (idx - (list.length - 1) / 2) * SEG_SEP;
506
+ });
507
+ });
508
+ return shift;
509
+ }
510
+
511
+ /* 按法线方向平移一条折线。每段单独算法线,拐角处取相邻两段的平均,
512
+ 这样折线偏移后不会在拐点裂开。 */
513
+ function offsetPolyline(pts, dist) {
514
+ if (!dist || pts.length < 2) return pts;
515
+ var norms = [];
516
+ for (var i = 0; i + 1 < pts.length; i++) {
517
+ var dx = pts[i + 1][0] - pts[i][0], dy = pts[i + 1][1] - pts[i][1];
518
+ var L = Math.hypot(dx, dy) || 1;
519
+ norms.push([-dy / L, dx / L]);
520
+ }
521
+ return pts.map(function (p, i) {
522
+ var a = norms[Math.max(0, i - 1)], b = norms[Math.min(norms.length - 1, i)];
523
+ var nx = (a[0] + b[0]) / 2, ny = (a[1] + b[1]) / 2;
524
+ var L = Math.hypot(nx, ny) || 1;
525
+ return [p[0] + nx / L * dist, p[1] + ny / L * dist];
526
+ });
527
+ }
528
+
529
  function drawGraph() {
530
  var svg = $('graphSvg');
531
  svg.innerHTML = '';
 
540
  return el;
541
  }
542
  var sel = S.selected;
543
+
544
+ // 先把每条边的几何算出来,再统一规划重叠偏移,最后才画。
545
+ var geo = S.scene.edges.map(function (e) {
546
  var a = pos[e.source_label], b = pos[e.target_label];
547
+ if (!a || !b) return null;
548
+ // 高花费边一律画直线:它的 road_path 已在建图阶段丢弃(绕出几十倍的
549
+ // 折线画在图上只会误导),真实路网距离仍在 road_distance_m 里。
550
+ var costly = !!e.high_cost;
551
+ if (!costly && S.edgeMode === 'road' && e.road_path && e.road_path.length > 1) {
552
+ // 折线两端落在吸附点上,不在节点上——补两段短接头,否则边看着是断的
553
+ return { e: e, costly: costly, road: true,
554
+ pts: [a].concat(e.road_path.map(function (q) { return proj(q[0], q[1]); })).concat([b]) };
555
+ }
556
+ return { e: e, costly: costly, road: false, pts: [a, b] };
557
+ });
558
+ var rank = planOffsets(geo.map(function (g) { return g ? g.pts : []; }));
559
+
560
+ geo.forEach(function (g, ei) {
561
+ if (!g) return;
562
+ var e = g.e;
563
  var colour = { corridor_chain: 'var(--chain)', intersection: 'var(--intersection)' }[e.origin]
564
  || 'var(--bridge)';
565
  var isSel = sel && sel.kind === 'edge'
566
  && sel.key[1] === e.source_label && sel.key[2] === e.target_label;
567
  var dead = !e.in_graph;
568
+
569
+ var shift = rank[ei] || 0;
570
+ var pts = offsetPolyline(g.pts, shift);
571
+
572
  var el;
573
+ if (g.road) {
 
 
 
 
574
  el = mk('polyline', {
575
+ points: pts.map(function (q) { return q[0].toFixed(2) + ',' + q[1].toFixed(2); }).join(' '),
576
  fill: 'none', stroke: isSel ? '#fff' : colour,
577
  'stroke-width': isSel ? 3 : 1.7, opacity: dead ? .22 : 1,
578
  'stroke-linejoin': 'round', 'stroke-linecap': 'round',
 
580
  });
581
  } else {
582
  el = mk('line', {
583
+ x1: pts[0][0], y1: pts[0][1], x2: pts[1][0], y2: pts[1][1],
584
  stroke: isSel ? '#fff' : colour,
585
+ 'stroke-width': isSel ? 3 : (g.costly ? 2.2 : 1.7),
586
  opacity: dead ? .22 : 1,
587
+ 'stroke-dasharray': g.costly ? '6 4' : '',
588
  style: 'cursor:pointer',
589
  });
590
  }
591
+ // 被挪开的边加一层深色描边,和邻线之间留出视觉缝隙
592
+ if (shift) el.setAttribute('paint-order', 'stroke');
593
  el.onclick = function () { pickEdge(e); };
594
+ var title = mk('title', {});
595
+ title.textContent = e.source_label + '–' + e.target_label + ' · ' + (e.origin || '')
596
+ + (shift ? '(与其它边重合,已错开显示)' : '');
597
+ el.appendChild(title);
598
  svg.appendChild(el);
599
  });
600
+
601
  S.scene.nodes.forEach(function (n) {
602
  var p = pos[n.label];
603
  if (!p) return;
 
618
  g.onclick = function () { pickNode(n); };
619
  svg.appendChild(g);
620
  });
621
+
622
+ drawHeadingArrows(svg, mk, pos);
623
+ }
624
+
625
+ /* ---------- 朝向箭头 ----------------------------------------------------
626
+ * 街景有 4 个朝向,但光看图片说不出它朝哪边。给当前选中的节点(选中一条边
627
+ * 时则是两端)在图上画出这 4 个方向,正在看的那个用信号色加粗——这样
628
+ * 「图片里的这条路」和「图上的这条边」能直接对上。
629
+ * heading 以正北为 0、顺时针增大;SVG 的 y 轴向下,所以
630
+ * dx = sin(θ), dy = -cos(θ)。
631
+ */
632
+ function drawHeadingArrows(svg, mk, pos) {
633
+ var sel = S.selected;
634
+ if (!sel) return;
635
+ var roles = sel.kind === 'node'
636
+ ? [['anchor', sel.key[1]]]
637
+ : [['anchor', sel.key[1]], ['target', sel.key[2]]];
638
+
639
+ roles.forEach(function (pair) {
640
+ var n = S.scene.nodes.find(function (x) { return x.label === pair[1]; });
641
+ if (!n || !n.ground_headings || !n.ground_headings.length) return;
642
+ var p = pos[n.label];
643
+ if (!p) return;
644
+ var active = groundView[pair[0]] || 0;
645
+
646
+ n.ground_headings.forEach(function (h, k) {
647
+ if (h == null) return;
648
+ var th = (Number(h) % 360 + 360) % 360 * Math.PI / 180;
649
+ var dx = Math.sin(th), dy = -Math.cos(th);
650
+ var on = k === active;
651
+ var r0 = 7.5, r1 = on ? 22 : 14; // 起点让开节点圆点
652
+ var x0 = p[0] + dx * r0, y0 = p[1] + dy * r0;
653
+ var x1 = p[0] + dx * r1, y1 = p[1] + dy * r1;
654
+ // 底图可能是深色卫星图,也可能是浅色电子地图。所以���个箭头都先铺
655
+ // 一层深色描边再画本体,两种底图上都读得出来——只靠半透明白色,
656
+ // 在 OSM 的浅底上基本看不见。
657
+ var col = on ? 'var(--signal)' : '#f2f5ef';
658
+ var halo = 'rgba(8,12,11,.85)';
659
+ var lw = on ? 2 : 1.3;
660
+
661
+ var gg = mk('g', { style: 'cursor:pointer' });
662
+ gg.appendChild(mk('line', {
663
+ x1: x0, y1: y0, x2: x1, y2: y1, stroke: halo,
664
+ 'stroke-width': lw + 1.6, 'stroke-linecap': 'round',
665
+ }));
666
+ gg.appendChild(mk('line', {
667
+ x1: x0, y1: y0, x2: x1, y2: y1, stroke: col,
668
+ 'stroke-width': lw, 'stroke-linecap': 'round',
669
+ }));
670
+ // 箭头:沿朝向的等腰三角形,深色描边 + 本体填充
671
+ var hw = on ? 3.4 : 2.4, hl = on ? 6 : 4.2;
672
+ var px = -dy, py = dx; // 法线
673
+ gg.appendChild(mk('polygon', {
674
+ points: [
675
+ (x1 + dx * hl) + ',' + (y1 + dy * hl),
676
+ (x1 + px * hw) + ',' + (y1 + py * hw),
677
+ (x1 - px * hw) + ',' + (y1 - py * hw),
678
+ ].join(' '),
679
+ fill: col, stroke: halo, 'stroke-width': 1.1,
680
+ 'stroke-linejoin': 'round', 'paint-order': 'stroke',
681
+ }));
682
+ var t = mk('title', {});
683
+ t.textContent = headingLabel(h) + (on ? '(当前街景)' : '(点击切到该朝向)');
684
+ gg.appendChild(t);
685
+ gg.onclick = function (ev) {
686
+ ev.stopPropagation();
687
+ groundView[pair[0]] = k;
688
+ drawGroundImages();
689
+ drawGraph();
690
+ };
691
+ svg.appendChild(gg);
692
+ });
693
+ });
694
  }
695
 
696
  function drawNodeChips(cors) {
 
825
  b.className = k === i ? 'active' : '';
826
  b.setAttribute('role', 'tab');
827
  b.setAttribute('aria-selected', k === i ? 'true' : 'false');
828
+ b.onclick = function () { groundView[role] = k; drawGroundImages(); drawGraph(); };
829
  strip.appendChild(b);
830
  });
831
  }
 
853
  if (len > 1) groundView[pair[0]] = ((groundView[pair[0]] + step) % len + len) % len;
854
  });
855
  drawGroundImages();
856
+ drawGraph(); // 图上的朝向箭头跟着换高亮
857
  }
858
 
859
  function pickNode(n) {
 
966
  $('baseMap').classList.toggle('active', mode === 'map');
967
  if (mode === 'map') renderMapTiles();
968
  }
969
+ /* 瓦片按 raster 百分比定位,和 SVG 的 400x400 viewBox 共用同一套坐标。
970
+ *
971
+ * 旧版把瓦片按固定 256 CSS px 摆在 stage 里,而 SVG 把 400 个 raster 单位
972
+ * 拉伸到 stage 的实际宽度(通常 500+ px)。两套尺度不一致,图和底图对不上;
973
+ * 窗口一改大小错得更多。现在换算成百分比,缩放与 zoom 都自动跟随。
974
+ */
975
  function renderMapTiles() {
976
  if (!S.scene) return;
977
  var c = tileCenter(S.scene.scene.satellite_path);
 
983
  var x = (c[1] + 180) / 360 * s;
984
  var sy = Math.sin(c[0] * Math.PI / 180);
985
  var y = (0.5 - Math.log((1 + sy) / (1 - sy)) / (4 * Math.PI)) * s;
986
+
987
+ // z 级的 1 像素 = 2^(z-ZOOM) 个 raster 单位的倒数
988
+ var k = Math.pow(2, z - ZOOM); // z 像素 / raster 单位
989
+ var tileR = 256 / k; // 一块瓦片占多少 raster 单位
990
+ var pct = function (v) { return (v / RASTER * 100) + '%'; };
991
+
992
  var tx = Math.floor(x / 256), ty = Math.floor(y / 256);
993
+ // 场景中心位于 raster 的正中;瓦片 (tx,ty) 左上角相对中心的 z 像素偏移
994
+ var span = Math.ceil(RASTER / 2 / tileR) + 1;
995
+ for (var dx = -span; dx <= span; dx++) {
996
+ for (var dy = -span; dy <= span; dy++) {
997
+ var gx = tx + dx, gy = ty + dy;
998
+ if (gy < 0 || gy >= Math.pow(2, z)) continue;
999
  var img = document.createElement('img');
1000
+ img.src = 'https://tile.openstreetmap.org/' + z + '/' + gx + '/' + gy + '.png';
1001
  img.loading = 'lazy';
1002
+ img.alt = '';
1003
+ img.style.left = pct((gx * 256 - x) / k + RASTER / 2);
1004
+ img.style.top = pct((gy * 256 - y) / k + RASTER / 2);
1005
+ img.style.width = pct(tileR);
1006
+ img.style.height = pct(tileR);
1007
  layer.appendChild(img);
1008
  }
1009
  }
 
1047
  'fOrphan', 'fHighCost', 'fFragmented',
1048
  'tierFilter', 'kindFilter', 'signalFilter', 'showReviewedSel'].forEach(function (id) {
1049
  var el = $(id);
1050
+ if (el) el.onchange = function () { S.offset = 0; syncFilterChrome(); loadList(); };
1051
  });
1052
+ $('filterToggle').onclick = function () { toggleDrawer(); };
1053
+ $('filterReset').onclick = function () {
1054
+ FILTERS.forEach(clearFilter);
1055
+ S.offset = 0; syncFilterChrome(); loadList();
1056
+ };
1057
  $('modeScenes').onclick = function () { setMode('scenes'); };
1058
  $('modeQueue').onclick = function () { setMode('queue'); };
1059
  $('pagePrev').onclick = function () { page(-1); };
web/index.html CHANGED
@@ -12,37 +12,30 @@
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">
@@ -55,73 +48,105 @@
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>
 
12
  <header class="topbar">
13
  <div class="brand">
14
  <div class="brand-mark" aria-hidden="true">SG</div>
15
+ <h1>Node Graph Viewer<em>SetVL · 只读</em></h1>
 
 
 
 
 
 
 
 
16
  </div>
17
+
18
+ <!-- 全库读数:一行内用竖线断开,不再单独占一条 progress-strip -->
19
+ <div class="readout" role="group" aria-label="全库统计">
20
+ <b id="statScenes">—</b><span>场景</span>
21
+ <i aria-hidden="true"></i>
22
+ <b id="statItems">—</b><span>节点</span>
23
+ <i aria-hidden="true"></i>
24
+ <b id="statQueue">—</b><span>边</span>
25
+ <i aria-hidden="true"></i>
26
+ <span class="readout-origins" id="originBreakdown" title="边按来源构成">
27
+ <em class="o-chain"><b id="countChain">—</b>走廊链</em>
28
+ <em class="o-inter"><b id="countInter">—</b>路口</em>
29
+ <em class="o-bridge"><b id="countBridge">—</b>桥接</em>
30
+ <em class="o-cost"><b id="countCost">—</b>高花费</em>
31
+ </span>
32
  </div>
33
+
34
+ <div class="origin-meter" id="originMeter" aria-hidden="true"><span id="progressBar"></span></div>
35
+ <span class="readout-label" id="progressLabel">—</span>
36
  <button class="icon-button" id="helpButton" title="帮助与快捷键" aria-label="帮助与快捷键">?</button>
37
  </header>
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  <main class="app-shell">
40
  <!-- 01 队列 --------------------------------------------------------->
41
  <aside class="queue-panel">
 
48
  <button type="button" id="modeQueue">带信号的</button>
49
  </div>
50
  <div class="filter-stack">
51
+ <!-- 一级:搜索。常驻,最宽,视觉重量最大。 -->
52
  <div class="search-wrap">
53
+ <svg class="search-icon" viewBox="0 0 16 16" aria-hidden="true">
54
+ <circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" stroke-width="1.4"/>
55
+ <path d="M10.4 10.4 L14 14" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
56
+ </svg>
57
  <input id="searchInput" type="search" placeholder="scene_id 片段、城市…">
58
  </div>
59
+
60
+ <!-- 二级:最常用的两个,压成一行紧凑控件 -->
61
+ <div class="filter-row">
62
+ <label class="ctl"><span>City</span>
63
+ <select id="cityFilter"><option value="">全部</option></select>
64
  </label>
65
+ <label class="ctl"><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
+ <button type="button" class="filter-toggle" id="filterToggle"
73
+ aria-expanded="false" aria-controls="filterDrawer">
74
+ 筛选<b class="filter-count" id="filterCount" hidden>0</b>
75
+ <svg viewBox="0 0 10 6" aria-hidden="true"><path d="M1 1l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
76
+ </button>
 
 
 
 
 
 
 
 
 
 
77
  </div>
78
+
79
+ <!-- 已激活的筛选以 token 回显:抽屉收起时状态依然可见、可单独撤销 -->
80
+ <div class="token-row" id="activeTokens" hidden></div>
81
+
82
+ <!-- 三级:抽屉。默认收起,把纵向空间让给列表 -->
83
+ <div class="filter-drawer" id="filterDrawer" hidden>
84
+ <div class="drawer-group" id="sceneFilters">
85
+ <p class="drawer-title">场景</p>
86
+ <div class="filter-row">
87
+ <label class="ctl"><span>Min nodes</span>
88
+ <input id="minNodes" type="number" min="1" max="38" placeholder="—">
89
+ </label>
90
+ <label class="ctl"><span>状态</span>
91
+ <select id="editedFilter">
92
+ <option value="">全部</option>
93
+ <option value="1">已人工改过</option>
94
+ <option value="0">未改动</option>
95
+ </select>
96
+ </label>
97
+ </div>
98
+ <div class="chip-row" id="sceneChips">
99
+ <label class="chip"><input id="fOrphan" type="checkbox"><span>含孤儿</span></label>
100
+ <label class="chip"><input id="fHighCost" type="checkbox"><span>含高花费边</span></label>
101
+ <label class="chip"><input id="fFragmented" type="checkbox"><span>路网碎片化</span></label>
102
+ </div>
103
+ </div>
104
+
105
+ <div class="drawer-group" id="queueFilters" hidden>
106
+ <p class="drawer-title">信号队列</p>
107
+ <div class="filter-row">
108
+ <label class="ctl"><span>Kind</span>
109
+ <select id="kindFilter">
110
+ <option value="">节点 + 边</option>
111
+ <option value="node">只看节点</option>
112
+ <option value="edge">只看边</option>
113
+ </select>
114
+ </label>
115
+ <label class="ctl"><span>Tier</span>
116
+ <select id="tierFilter">
117
+ <option value="">全部</option>
118
+ <option value="1">Tier 1 必须</option>
119
+ <option value="2">Tier 2 优先</option>
120
+ <option value="3">Tier 3 抽查</option>
121
+ </select>
122
+ </label>
123
+ </div>
124
+ <div class="filter-row">
125
+ <label class="ctl"><span>Signal</span>
126
+ <select id="signalFilter">
127
+ <option value="">全部信号</option>
128
+ <option value="A1">A1 孤儿</option>
129
+ <option value="A2">A2 吸附偏远</option>
130
+ <option value="A3">A3 无路名</option>
131
+ <option value="A4">A4 归属歧义</option>
132
+ <option value="E1">E1 无道路通路</option>
133
+ <option value="E2">E2 绕路超限</option>
134
+ <option value="E3">E3 绕路偏大</option>
135
+ <option value="E4">E4 选边并列</option>
136
+ </select>
137
+ </label>
138
+ <label class="ctl"><span>已复核</span>
139
+ <select id="showReviewedSel">
140
+ <option value="">不含</option>
141
+ <option value="1">含已复核</option>
142
+ </select>
143
+ </label>
144
+ </div>
145
+ </div>
146
+ <button type="button" class="drawer-reset" id="filterReset">清空全部筛选</button>
147
  </div>
148
  </div>
149
+ <div class="list-meter"><span id="listCount">—</span></div>
150
  <div class="queue-list" id="queueList" role="listbox" aria-label="Review queue"></div>
151
  <div class="queue-footer">
152
  <button class="quiet-button" id="pagePrev">←</button>
web/styles.css CHANGED
@@ -7,6 +7,9 @@
7
  :root {
8
  --ink: #0d1211;
9
  --asphalt: #141a18;
 
 
 
10
  --panel: #1a211f;
11
  --panel-raised: #202926;
12
  --line: #34403c;
@@ -37,11 +40,21 @@
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 {
@@ -54,57 +67,67 @@ button { color: inherit; }
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; }
@@ -125,16 +148,6 @@ button { color: inherit; }
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); }
@@ -142,26 +155,100 @@ button { color: inherit; }
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); }
@@ -175,6 +262,12 @@ button { color: inherit; }
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);
@@ -223,11 +316,22 @@ kbd {
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); }
 
7
  :root {
8
  --ink: #0d1211;
9
  --asphalt: #141a18;
10
+ --rail-w: 320px; /* 左栏:比原来的 294px 宽,场景 id 少截断 */
11
+ --inspect-w: 340px;
12
+ --topbar-h: 46px; /* 原来 70px 顶栏 + 41px 进度条 = 111px,省出 65px */
13
  --panel: #1a211f;
14
  --panel-raised: #202926;
15
  --line: #34403c;
 
40
  * { box-sizing: border-box; }
41
  html, body { height: 100%; }
42
  body {
43
+ /* 之前 .app-shell 用 calc(100vh - 70px - 41px) 写死两条头部的高度。
44
+ 窗口一窄,顶栏换行变高,app-shell 就被挤出视口下沿,而 body 是
45
+ overflow:hidden——底部那排 Prev/Next 直接被裁掉。改成 flex 列,
46
+ 让 app-shell 吃掉剩余高度,头部多高都不会溢出。 */
47
+ display: flex; flex-direction: column; height: 100vh; max-height: 100vh;
48
  margin: 0; overflow: hidden; color: var(--paper);
49
  background: radial-gradient(circle at 74% 8%, rgba(87,126,111,.15), transparent 28rem), var(--ink);
50
  font-family: var(--body); font-size: 14px;
51
  }
52
  button, input, select, textarea { font: inherit; }
53
+ /* 任何带 display 的类选择器都会压过浏览器对 [hidden] 的 UA 样式
54
+ (display:none 来自 UA 样式表,特异度最低)。这里一次性钉死,
55
+ 否则 .drawer-group / .token-row 这类 display:flex 的元素
56
+ 设了 hidden 也照样显示。 */
57
+ [hidden] { display: none !important; }
58
  button { color: inherit; }
59
 
60
  .topographic-grid {
 
67
  }
68
 
69
  /* ---------------------------------------------------------------- 顶栏 */
70
+ /* 一行搞定:品牌 / 读数 / 构成条 / 帮助。旧版拆成 topbar + progress-strip
71
+ 两行共 111px,装饰占掉的纵向空间比左侧列表还多。 */
72
  .topbar {
73
+ flex: 0 0 auto; height: var(--topbar-h);
74
+ display: flex; align-items: center; gap: 15px; padding: 0 15px;
 
75
  border-bottom: 1px solid var(--line); background: rgba(20,26,24,.96);
76
+ position: relative; z-index: 5; white-space: nowrap;
77
  }
78
+ .brand { display: flex; align-items: center; gap: 9px; flex: 0 0 auto; }
79
  .brand-mark {
80
+ width: 24px; height: 24px; display: grid; place-items: center;
81
+ background: var(--signal); color: var(--ink); font: 800 10px var(--mono); letter-spacing: .04em;
82
+ }
83
+ .brand h1 {
84
+ margin: 0; font: 600 13px var(--display); letter-spacing: .09em; text-transform: uppercase;
85
+ display: flex; align-items: baseline; gap: 8px;
86
+ }
87
+ .brand h1 em {
88
+ font: 600 8px var(--mono); letter-spacing: .14em; color: var(--faint);
89
+ text-transform: uppercase; font-style: normal;
90
+ }
91
+
92
+ /* 读数:数字实、单位虚,细竖线断开,不用边框盒子 */
93
+ .readout { display: flex; align-items: baseline; gap: 6px; font: 600 10px var(--mono); min-width: 0; overflow: hidden; }
94
+ .readout > b { color: var(--paper); font: 650 13px var(--display); letter-spacing: .03em; }
95
+ .readout > span { color: var(--faint); letter-spacing: .1em; text-transform: uppercase; }
96
+ .readout > i { width: 1px; height: 13px; background: var(--line); margin: 0 4px; align-self: center; flex: 0 0 auto; }
97
+ .readout-origins { display: flex; gap: 10px; min-width: 0; overflow: hidden; }
98
+ .readout-origins em { font-style: normal; display: flex; align-items: baseline; gap: 3px; color: var(--faint); letter-spacing: .06em; }
99
+ .readout-origins b { font: 650 11px var(--mono); }
100
+ .o-chain b { color: var(--chain); } .o-inter b { color: var(--intersection); }
101
+ .o-bridge b { color: var(--bridge); } .o-cost b { color: var(--paper); }
102
+
103
+ .origin-meter { flex: 1 1 auto; min-width: 50px; height: 2px; background: var(--line-soft); }
104
+ .origin-meter span { display: block; height: 100%; width: 0; background: linear-gradient(90deg, var(--signal), #ffd38b); transition: width .45s ease; }
105
+ .readout-label { flex: 0 0 auto; color: var(--faint); font: 600 9px var(--mono); letter-spacing: .1em; text-transform: uppercase; }
106
+
107
+ /* 窄窗口下按重要性依次藏:构成明细 -> 构成条与标签 */
108
+ @media (max-width: 1400px) { .readout-origins { display: none; } }
109
+ @media (max-width: 1150px) { .origin-meter, .readout-label { display: none; } }
110
+
111
  .icon-button {
112
+ flex: 0 0 auto; margin-left: auto;
113
+ width: 26px; height: 26px; border: 1px solid var(--line); background: transparent;
114
+ color: var(--muted); cursor: pointer; border-radius: var(--radius); font: 700 13px var(--mono);
115
  }
116
  .icon-button:hover { border-color: var(--signal); color: var(--signal); }
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  /* ---------------------------------------------------------------- 三栏 */
119
  .app-shell {
120
+ flex: 1 1 auto; min-height: 0; overflow: hidden;
121
+ display: grid; grid-template-columns: var(--rail-w) minmax(420px, 1fr) var(--inspect-w);
122
  position: relative; z-index: 2;
123
  }
124
+ /* 窄窗口下先收右栏,再收左栏;三栏的最小可用宽度守住,避免内容被压烂 */
125
+ @media (max-width: 1500px) { :root { --rail-w: 300px; --inspect-w: 310px; } }
126
+ @media (max-width: 1280px) { :root { --rail-w: 286px; --inspect-w: 280px; } }
127
+ @media (max-width: 1100px) {
128
+ .app-shell { grid-template-columns: var(--rail-w) minmax(360px, 1fr); }
129
+ .inspector-panel { display: none; }
130
+ }
131
  .queue-panel, .inspector-panel, .evidence-panel { min-height: 0; }
132
  .queue-panel, .inspector-panel { background: rgba(20,26,24,.96); }
133
  .queue-panel { border-right: 1px solid var(--line); display: flex; flex-direction: column; }
 
148
  border-bottom: 2px solid transparent;
149
  }
150
  .mode-switch button.active { color: var(--signal); border-bottom-color: var(--signal); }
 
 
 
 
 
 
 
 
 
 
151
  .scene-item-meta { display: flex; gap: 8px; flex-wrap: wrap; }
152
  .badge { padding: 0 4px; font: 700 9px var(--mono); border: 1px solid; }
153
  .badge.edited { color: var(--yes); border-color: var(--yes); }
 
155
  .badge.cost { color: var(--uncertain); border-color: var(--uncertain); }
156
  .badge.frag { color: var(--no); border-color: var(--no); }
157
 
158
+ /* --- 筛选:三级信息层级 ------------------------------------------------
159
+ 旧版把搜索框、4 个下拉、3 个勾选框平铺成一堵墙,全部常驻、权重相同,
160
+ 既看不出主次,又吃掉了列表的纵向空间。现在:
161
+ 一级 搜索 —— 常驻,最宽
162
+ 二级 City/Sort —— 压成一行紧凑控件(实测这两个用得最多)
163
+ 三级 其余 —— 收进抽屉,已激活项用 token 回显,收起也看得见
164
+ */
165
+ .filter-stack { padding: 9px 12px 8px; border-bottom: 1px solid var(--line); display: flex; flex-direction: column; gap: 7px; flex: 0 0 auto; }
166
+
167
+ .search-wrap { position: relative; display: flex; align-items: center; }
168
+ .search-icon { position: absolute; left: 8px; width: 13px; height: 13px; color: var(--faint); pointer-events: none; }
169
  .search-wrap input {
170
  width: 100%; background: var(--ink); border: 1px solid var(--line); color: var(--paper);
171
+ padding: 7px 9px 7px 27px; border-radius: var(--radius); font: 500 12px var(--body);
172
  }
173
+ .search-wrap input::-webkit-search-cancel-button { filter: invert(.6); }
174
  .search-wrap input:focus { outline: none; border-color: var(--signal); }
175
+ .search-wrap:focus-within .search-icon { color: var(--signal); }
176
+
177
+ .filter-row { display: flex; gap: 6px; align-items: flex-end; }
178
+ .ctl { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
179
+ .ctl > span { color: var(--faint); font: 600 8px var(--mono); letter-spacing: .1em; text-transform: uppercase; }
180
+ .ctl select, .ctl input {
181
+ width: 100%; background: var(--ink); border: 1px solid var(--line); color: var(--paper);
182
+ padding: 5px 6px; border-radius: var(--radius); font: 500 11px var(--body); min-width: 0;
183
+ }
184
+ .ctl select:focus, .ctl input:focus { outline: none; border-color: var(--signal); }
185
+
186
+ .filter-toggle {
187
+ flex: 0 0 auto; display: flex; align-items: center; gap: 5px; cursor: pointer;
188
+ background: transparent; border: 1px solid var(--line); color: var(--muted);
189
+ padding: 5px 8px; border-radius: var(--radius); font: 600 10px var(--mono);
190
+ letter-spacing: .06em;
191
+ }
192
+ .filter-toggle:hover { border-color: var(--signal); color: var(--signal); }
193
+ .filter-toggle svg { width: 9px; height: 6px; transition: transform .18s ease; }
194
+ .filter-toggle[aria-expanded="true"] { border-color: var(--signal); color: var(--signal); }
195
+ .filter-toggle[aria-expanded="true"] svg { transform: rotate(180deg); }
196
+ .filter-count {
197
+ min-width: 14px; height: 14px; padding: 0 3px; display: grid; place-items: center;
198
+ background: var(--signal); color: var(--ink); font: 800 9px var(--mono); border-radius: 7px;
199
+ }
200
+
201
+ /* 已激活筛选的 token:抽屉收起时状态依然可见,点 × 单独撤销 */
202
+ .token-row { display: flex; flex-wrap: wrap; gap: 4px; }
203
+ .token {
204
+ display: inline-flex; align-items: center; gap: 4px; cursor: pointer;
205
+ background: rgba(241,164,60,.1); border: 1px solid rgba(241,164,60,.4);
206
+ color: var(--signal); padding: 2px 4px 2px 6px; border-radius: var(--radius);
207
+ font: 600 9px var(--mono); letter-spacing: .04em;
208
+ }
209
+ .token:hover { background: rgba(241,164,60,.2); }
210
+ .token b { color: var(--paper); font-weight: 700; }
211
+ .token i { font-style: normal; opacity: .65; font-size: 11px; line-height: 1; }
212
+
213
+ .filter-drawer {
214
+ display: flex; flex-direction: column; gap: 8px;
215
+ padding: 9px 10px; border: 1px solid var(--line-soft); border-radius: var(--radius);
216
+ background: rgba(10,14,13,.45);
217
+ animation: drawerIn .16s ease;
218
+ }
219
+ @keyframes drawerIn { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } }
220
+ .drawer-group { display: flex; flex-direction: column; gap: 6px; }
221
+ .drawer-title { margin: 0; color: var(--faint); font: 700 8px var(--mono); letter-spacing: .16em; text-transform: uppercase; }
222
+ .drawer-reset {
223
+ align-self: flex-start; background: transparent; border: 0; cursor: pointer;
224
+ color: var(--faint); font: 600 9px var(--mono); letter-spacing: .08em;
225
+ text-decoration: underline; text-underline-offset: 2px; padding: 0;
226
+ }
227
+ .drawer-reset:hover { color: var(--no); }
228
+
229
+ .chip-row { display: flex; flex-wrap: wrap; gap: 4px; }
230
+ .chip {
231
+ display: inline-flex; align-items: center; gap: 4px; cursor: pointer;
232
+ border: 1px solid var(--line); border-radius: var(--radius); padding: 3px 7px;
233
+ color: var(--muted); font: 600 9px var(--mono); letter-spacing: .04em;
234
+ transition: border-color .14s ease, color .14s ease;
235
+ }
236
+ .chip:hover { border-color: var(--muted); color: var(--paper); }
237
+ .chip input { appearance: none; width: 7px; height: 7px; margin: 0; border: 1px solid var(--muted); border-radius: 50%; }
238
+ .chip:has(input:checked) { border-color: var(--signal); color: var(--signal); background: rgba(241,164,60,.08); }
239
+ .chip input:checked { background: var(--signal); border-color: var(--signal); }
240
+
241
+ /* 列表计数条:把「当前看到多少条」从底部挪上来,底部只留导航 */
242
+ .list-meter {
243
+ flex: 0 0 auto; padding: 4px 13px; border-bottom: 1px solid var(--line-soft);
244
+ color: var(--faint); font: 600 9px var(--mono); letter-spacing: .1em; text-transform: uppercase;
245
+ }
246
+
247
  .queue-list { flex: 1; overflow-y: auto; }
248
  .queue-item {
249
  width: 100%; text-align: left; border: 0; border-left: 2px solid transparent;
250
  border-bottom: 1px solid var(--line-soft); background: transparent;
251
+ padding: 7px 12px; cursor: pointer; position: relative; display: block;
252
  }
253
  .queue-item:hover { background: rgba(255,255,255,.025); }
254
  .queue-item.active { background: rgba(241,164,60,.075); border-left-color: var(--signal); }
 
262
  .tier-1 { background: var(--no); } .tier-2 { background: var(--signal); } .tier-3 { background: var(--uncertain); }
263
  .sig-code { color: var(--signal); font-weight: 800; }
264
  .queue-scene { margin-top: 4px; color: #56635c; font: 500 9px var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
265
+ /* 场景行的第二行:城市 + 徽章同排,没徽章就只有城市,不留占位 */
266
+ .queue-sub { margin-top: 3px; display: flex; align-items: center; gap: 6px; min-width: 0; }
267
+ .queue-sub em { font-style: normal; color: #56635c; font: 600 9px var(--mono); letter-spacing: .08em; flex: 0 0 auto; }
268
+ .queue-badges { display: flex; gap: 4px; flex-wrap: nowrap; overflow: hidden; }
269
+ .queue-top strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
270
+ .queue-top span { flex: 0 0 auto; }
271
  .queue-footer { padding: 9px 13px; border-top: 1px solid var(--line); display: flex; gap: 7px; }
272
  .quiet-button {
273
  flex: 1; background: transparent; border: 1px solid var(--line); color: var(--muted);
 
316
  .satellite-stage { aspect-ratio: 1/1; cursor: crosshair; }
317
  .ground-stage { aspect-ratio: 16/8.3; cursor: zoom-in; }
318
  .ground-stage:hover > img { filter: brightness(1.06); }
319
+ /* .street-map 有 z-index:1 而 .graph-svg 没有,于是电子地图模式下
320
+ 瓦片整个盖在图上——节点和边全看不见。给 SVG 更高的层级。 */
321
+ .graph-svg { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 2; }
322
  .street-map { display: none; position: absolute; inset: 0; z-index: 1; background: #d7d5cb; }
323
  .satellite-stage.map-mode .street-map { display: block; }
324
  .map-tile-layer { position: absolute; inset: 0; }
325
+ /* 瓦片按 stage 的百分比定位,与 SVG 的 400x400 viewBox 共用同一套坐标,
326
+ 缩放窗口或换 zoom 都不会和图层错位(旧版用固定 256px,必然错位)。 */
327
+ .map-tile-layer img { position: absolute; }
328
+ /* 电子地图是浅色底(OSM 标准样式接近白),走廊配色是为深色卫星图挑的
329
+ 高亮色,直接画上去会糊。整层加一圈深色投影把图元从底图上"抬"出来。 */
330
+ .satellite-stage.map-mode .graph-svg {
331
+ filter: drop-shadow(0 0 1px rgba(8,12,11,.9)) drop-shadow(0 0 2.5px rgba(8,12,11,.55));
332
+ }
333
+ /* 电子地图底图压暗一点,让图元成为视觉主角 */
334
+ .satellite-stage.map-mode .map-tile-layer { filter: brightness(.94) saturate(.85); }
335
  .map-controls { display: flex; border: 1px solid var(--line); }
336
  .map-controls button { background: transparent; border: 0; width: 22px; cursor: pointer; color: var(--muted); font: 700 12px var(--mono); }
337
  .map-controls button:hover { color: var(--signal); }