Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- web/app.js +325 -30
- web/index.html +106 -81
- 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(
|
| 132 |
-
'<
|
| 133 |
-
'<div class="queue-
|
| 134 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
var el;
|
| 376 |
-
|
| 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 (
|
| 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:
|
| 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 |
-
|
| 708 |
-
var
|
| 709 |
-
var
|
| 710 |
-
|
| 711 |
-
|
|
|
|
| 712 |
var img = document.createElement('img');
|
| 713 |
-
img.src = 'https://tile.openstreetmap.org/' + z + '/' +
|
| 714 |
img.loading = 'lazy';
|
| 715 |
-
img.
|
| 716 |
-
img.style.
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 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 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
| 64 |
</label>
|
| 65 |
-
<label><span>Sort</span>
|
| 66 |
<select id="orderFilter">
|
| 67 |
-
<option value="scene_id">
|
| 68 |
<option value="nodes_desc">节点多→少</option>
|
| 69 |
<option value="nodes_asc">节点少→多</option>
|
| 70 |
</select>
|
| 71 |
</label>
|
| 72 |
-
<
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 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 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
<
|
| 100 |
-
<
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
<
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
<
|
| 110 |
-
<
|
| 111 |
-
<
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 59 |
-
|
| 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:
|
| 65 |
.brand-mark {
|
| 66 |
-
width:
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
.
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
}
|
| 82 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
.icon-button {
|
| 84 |
-
|
| 85 |
-
|
|
|
|
| 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 |
-
|
| 105 |
-
display: grid; grid-template-columns:
|
| 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 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
.
|
| 153 |
-
|
| 154 |
-
.filter-
|
| 155 |
-
.
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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 |
-
.
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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); }
|