MarcoXL's picture
Claude Opus 5.5
viewer: 中英双语界面(顶栏右侧 中文/EN 切换)
0c571a4
Raw History Blame Contribute Delete
66.9 kB
/* SetVL node graph 浏览器 —— 零构建步骤的原生 JS。
*
* 界面与操作逻辑沿用标注台:三栏布局、全键盘流、底图切换、图片放大、
* 列表 J/K 导航、邻接矩阵、走廊着色。区别是本工具**只读**——展示自动化
* 流水线产出的最终图,不写回任何判定。
*
* 快捷键:
* J/K 列表导航 [ ] 翻页 Q 切换全部场景/带信号的
* B 切底图 E 切边的画法(沿路走线 / 直线) ? 帮助
* 顶栏最右的「中文 / EN」切换界面语言,见下文「中英双语」一节。
*/
'use strict';
/* 写入层的挂载点。
*
* 只读浏览器里恒为 null —— 这一份 app.js 同时服务于 HF 上的只读 viewer 和
* 本地的复核台,两边共用同一套渲染、投影、重叠边分离、朝向箭头。差异只有
* 「能不能改」,由复核台额外加载的 edit.js 把这个对象填上:
* EDIT.panel(row, isNode) -> 判定面板的 HTML
* EDIT.bind(row, isNode) -> 给面板里的控件接线
* EDIT.keys(k, ev) -> 判定用的快捷键,返回 true 表示已消费
* EDIT.boot() -> 往顶栏塞复核人输入框等只有写模式才有的 DOM
* 只读 viewer 不发 edit.js,所以写入代码根本不会到达浏览器。
*/
var EDIT = null;
/* merged_policy_0915 §1-§4 的四类边。策略正文用蓝/绿/橙/红称呼它们,
这里的键就是 origin_auto 的取值。 */
var EDGE_COLOR = {
corridor: 'var(--e-corridor)', // 蓝 §1 同走廊相邻
road_route: 'var(--e-road)', // 绿 §2 路网可达
short_link: 'var(--e-short)', // 橙 §3 短直连
component_merge: 'var(--e-merge)', // 红 §4 分量合并
};
/* 显示名在 I18N 的 class.* 里,两种语言各一份;不认识的取值原样显示。 */
function edgeLabel(origin) {
return I18N.zh['class.' + origin] != null ? tr('class.' + origin) : origin;
}
var S = {
mode: 'scenes', // 'scenes' 浏览全库 | 'queue' 复核队列
queue: [], filtered: [], selected: null, // {kind, scene_id, key:[...]}
scenes: [], sceneSel: null,
scene: null, offset: 0, limit: 100,
// mapZoom 跟 ZOOM 走:raster 单位 = 1 个 z18 像素,mapZoom 18 的瓦片才是
// 原生分辨率(不足则被放大糊掉)。
basemap: 'satellite', mapZoom: 18, edgeMode: 'straight', // 直线更醒目,默认走直线
boot: null,
};
function $(id) { return document.getElementById(id); }
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c];
});
}
function api(path, opts) {
return fetch(path, opts).then(function (r) {
return r.json().catch(function () { return { error: 'HTTP ' + r.status }; })
.then(function (b) {
if (!r.ok) throw new Error(b.error || ('HTTP ' + r.status));
return b;
});
});
}
function toast(msg, isErr) {
var t = $('toast');
t.textContent = msg;
t.className = 'toast show' + (isErr ? ' err' : '');
clearTimeout(toast._t);
toast._t = setTimeout(function () { t.className = 'toast'; }, 2600);
}
/* ---------- 中英双语 ----------------------------------------------------
* 界面文字全部收在 I18N 里,zh / en 两份的键一一对应。
* - index.html 的静态文字用 data-i18n 标出,applyLang() 统一替换;夹着
* <kbd> 的整段用 data-i18n-html,属性用 data-i18n-title / -placeholder /
* -aria-label。
* - JS 拼出来的文字一律走 tr(),切换语言时 setLang() 按当前状态重画一遍。
* 不叫 t():本文件好几处局部变量就叫 t,会把它遮住。
* 数据本身(路名、城市、scene_id、标记码)不翻译;N/NE、SAT/MAP、Nodes 这类
* 本来就是英文的标签两种语言下保持原样。
* 默认跟随浏览器语言,手动切过就记在 localStorage 里。
* tests/test_04_frontend.py 会查:两份键一致、用到的键都在表里、表外没有中文字符串。
*/
var I18N = {
zh: {
// 顶栏
'brand.readonly': 'SetVL · 只读',
'stats.aria': '全库统计',
'stats.scenes': '场景',
'stats.nodes': '节点',
'stats.edges': '边',
'stats.byOrigin': '边按 §1-§4 分类',
'stats.roadShare': '沿路 {p}%',
'origin.corridor': '同走廊',
'origin.road_route': '路网可达',
'origin.short_link': '短直连',
'origin.component_merge': '分量合并',
'help.title': '帮助与快捷键',
// 左栏
'panel.scenes': '场景浏览',
'panel.queue': '复核队列',
'mode.aria': '列表模式',
'mode.scenes': '全部场景',
'mode.queue': '带信号的',
'search.scenes': 'scene_id 片段、城市…',
'search.queue': '场景 ID、城市或标签…',
'opt.all': '全部',
'order.nodesDesc': '节点多→少',
'order.nodesAsc': '节点少→多',
'filter.toggle': '筛选',
'filter.reset': '清空全部筛选',
'token.clear': '点击取消该筛选',
'drawer.scenes': '场景',
'drawer.queue': '信号队列',
'f.minNodes': '最少节点',
'f.edited': '状态',
'f.orphan': '含孤儿',
'f.origin': '含某类边',
'f.kind': 'Kind',
'f.signal': '标记',
'f.reviewed': '已复核',
'edited.yes': '已人工改过',
'edited.no': '未改动',
'chip.orphan': '含孤儿节点',
'chip.noted': '含带标记的边',
'kind.all': '节点 + 边',
'kind.node': '只看节点',
'kind.edge': '只看边',
'sig.all': '全部标记',
'sig.orphan': '§6 孤儿节点',
'sig.water': '§0 跨水',
'sig.building': '§0 穿建筑',
'sig.noRoad': '无道路通路',
'sig.detour': '§4 红边绕远',
'rev.exclude': '不含',
'rev.include': '含已复核',
'list.noScenes': '没有符合条件的场景。',
'list.queueEmpty': '队列为空(可能已全部复核,或筛选过窄)。',
'list.count': '{n} 条 · 第 {page} 页 · 序号 {from}–{to}',
'list.none': '无结果',
'list.onPage': ' · 第 {page} 页',
'badge.edited': '改过 {n}',
'badge.orphan': '孤儿 {n}',
'badge.noted': '标记 {n}',
'item.node': '节点',
'item.edge': '边',
'toast.firstPage': '已是第一页',
'toast.lastPage': '已是最后一页',
'toast.page': '第 {page} 页',
'toast.firstItem': '已是第一条',
'toast.lastItem': '已是最后一条',
'toast.firstScene': '已是本页第一个',
'toast.lastScene': '已是本页最后一个,按 ] 翻页',
'toast.readonlyAdd': '只读浏览,不能新增边',
// 中栏
'scene.none': '未选择',
'edgeStyle.aria': '边的画法',
'edgeStyle.straight': '直线连接(默认,更醒目)',
'edgeStyle.road': '沿道路走线',
'basemap.aria': '底图切换',
'mapZoom.aria': '电子地图缩放',
'zoom.in': '放大',
'zoom.out': '缩小',
'sec.corridor': '§1 同走廊',
'sec.road_route': '§2 路网可达',
'sec.short_link': '§3 短直连',
'sec.component_merge': '§4 分量合并',
'legend.orphan': '孤儿',
'class.corridor': '§1 同走廊相邻',
'class.road_route': '§2 路网可达',
'class.short_link': '§3 短直连',
'class.component_merge': '§4 分量合并',
'class.manual': '人工新增',
'graph.overlap': '(与其它边重合,已错开显示)',
'arrow.title': '{h} 街景(点击放大)',
'ground.aria': '四个朝向的街景',
'ground.empty': '该节点没有街景',
'ground.headings': ' · {n} 个朝向',
'ground.zoom': '放大 {h} 方向的街景',
'ground.alt': '{h} 街景',
'corridor.none': '无走廊',
'corridor.noneParen': '(无走廊)',
'corridor.overridden': ' · 人工覆盖',
'rail.hint': '点击可切换到该节点',
'adj.title': '节点邻接矩阵',
'adj.corridor': '同走廊',
'adj.road': '路网',
'adj.short': '直连',
'adj.merge': '合并',
'adj.rule': '每一格是一条无向边,颜色对应它的来源。点击格子或右侧列表可直接检查该条边。',
'adj.rejected': '已拒',
// 右栏
'insp.title': '属性',
'insp.pick': '从左侧队列选一条,或在图上点节点/边。',
'insp.empty': '在图上点一个节点或一条边查看属性。',
'insp.node': '节点属性',
'insp.edge': '边属性',
'marks.node': '节点标记',
'marks.edge': '边的标记(非阻断)',
'marks.none': '无标记',
'kv.corridor': '走廊(最近)',
'kv.corridorCount': '所属走廊数',
'kv.corridorOffset': '到走廊垂距 m',
'kv.snap': '吸附距离 m',
'kv.orphan': '孤儿 §6',
'kv.override': '人工覆盖',
'kv.corridorChanged': '走廊已改',
'kv.class': '类别',
'kv.euclid': '直线距离 m',
'kv.road': '沿路距离 m',
'kv.pathLength': '走线长度 m',
'kv.detour': '绕路比',
'kv.roadConnected': '有道路可通',
'kv.pathPoints': '走线点数',
'kv.evidence': '证据',
'kv.verdict': '人工裁定',
'yes': '是',
'no': '否',
'readonly.title': '只读浏览',
'readonly.body': '这是结果浏览器,不提供修改。标注与复核在本地的 tool/annotation 里做。',
// 帮助
'help.heading': '快捷键',
'help.close': '关闭',
'help.j': '队列下一条',
'help.k': '队列上一条',
'help.b': '切换卫星图 / 电子地图',
'help.e': '切换边的画法:沿路走线 / 直线',
'help.h': '把当前节点的四个朝向依次放大看(<kbd>Shift</kbd>+<kbd>H</kbd> 反向)',
'help.page': '列表上一页 / 下一页',
'help.q': '切换「全部场景」/「带信号的」',
'help.help': '打开本帮助',
},
en: {
// top bar
'brand.readonly': 'SetVL · read-only',
'stats.aria': 'Dataset totals',
'stats.scenes': 'scenes',
'stats.nodes': 'nodes',
'stats.edges': 'edges',
'stats.byOrigin': 'Edges by class (§1–§4)',
'stats.roadShare': 'On roads {p}%',
'origin.corridor': 'corridor',
'origin.road_route': 'road route',
'origin.short_link': 'short link',
'origin.component_merge': 'merge',
'help.title': 'Help and shortcuts',
// left panel
'panel.scenes': 'Scene browser',
'panel.queue': 'Review queue',
'mode.aria': 'List mode',
'mode.scenes': 'All scenes',
'mode.queue': 'Flagged',
'search.scenes': 'Part of a scene_id, or a city…',
'search.queue': 'Scene ID, city or label…',
'opt.all': 'All',
'order.nodesDesc': 'Most nodes',
'order.nodesAsc': 'Fewest nodes',
'filter.toggle': 'Filters',
'filter.reset': 'Clear all filters',
'token.clear': 'Click to remove this filter',
'drawer.scenes': 'Scenes',
'drawer.queue': 'Signal queue',
'f.minNodes': 'Min nodes',
'f.edited': 'Status',
'f.orphan': 'Has orphans',
'f.origin': 'Has edge class',
'f.kind': 'Kind',
'f.signal': 'Flag',
'f.reviewed': 'Reviewed',
'edited.yes': 'Edited by hand',
'edited.no': 'Untouched',
'chip.orphan': 'Has orphan nodes',
'chip.noted': 'Has flagged edges',
'kind.all': 'Nodes + edges',
'kind.node': 'Nodes only',
'kind.edge': 'Edges only',
'sig.all': 'All flags',
'sig.orphan': '§6 Orphan node',
'sig.water': '§0 Crosses water',
'sig.building': '§0 Crosses a building',
'sig.noRoad': 'No road path',
'sig.detour': '§4 Red edge, long detour',
'rev.exclude': 'Exclude',
'rev.include': 'Include reviewed',
'list.noScenes': 'No scenes match these filters.',
'list.queueEmpty': 'The queue is empty (everything may be reviewed, or the filters are too narrow).',
'list.count': '{n} items · page {page} · #{from}–{to}',
'list.none': 'No results',
'list.onPage': ' · page {page}',
'badge.edited': 'edited {n}',
'badge.orphan': 'orphans {n}',
'badge.noted': 'flags {n}',
'item.node': 'node',
'item.edge': 'edge',
'toast.firstPage': 'Already on the first page',
'toast.lastPage': 'Already on the last page',
'toast.page': 'Page {page}',
'toast.firstItem': 'Already at the first item',
'toast.lastItem': 'Already at the last item',
'toast.firstScene': 'First scene on this page',
'toast.lastScene': 'Last scene on this page. Press ] for the next page',
'toast.readonlyAdd': 'Read-only: edges cannot be added',
// centre
'scene.none': 'Nothing selected',
'edgeStyle.aria': 'Edge style',
'edgeStyle.straight': 'Straight lines (default, easier to see)',
'edgeStyle.road': 'Follow the roads',
'basemap.aria': 'Basemap',
'mapZoom.aria': 'Street map zoom',
'zoom.in': 'Zoom in',
'zoom.out': 'Zoom out',
'sec.corridor': '§1 Same corridor',
'sec.road_route': '§2 Road route',
'sec.short_link': '§3 Short link',
'sec.component_merge': '§4 Component merge',
'legend.orphan': 'Orphan',
'class.corridor': '§1 Corridor neighbours',
'class.road_route': '§2 Road route',
'class.short_link': '§3 Short link',
'class.component_merge': '§4 Component merge',
'class.manual': 'Added by hand',
'graph.overlap': ' (overlaps another edge, drawn offset)',
'arrow.title': '{h} street view (click to enlarge)',
'ground.aria': 'Street views in four headings',
'ground.empty': 'No street view for this node',
'ground.headings': ' · {n} headings',
'ground.zoom': 'Enlarge the {h} street view',
'ground.alt': '{h} street view',
'corridor.none': 'no corridor',
'corridor.noneParen': '(no corridor)',
'corridor.overridden': ' · manual override',
'rail.hint': 'Click to switch to that node',
'adj.title': 'Node adjacency matrix',
'adj.corridor': 'corridor',
'adj.road': 'road',
'adj.short': 'short',
'adj.merge': 'merge',
'adj.rule': 'Each cell is one undirected edge, coloured by its origin. Click a cell or a row in the list on the right to inspect that edge.',
'adj.rejected': 'rejected',
// right panel
'insp.title': 'Properties',
'insp.pick': 'Pick an item from the list on the left, or click a node or edge on the map.',
'insp.empty': 'Click a node or an edge on the map to see its properties.',
'insp.node': 'Node properties',
'insp.edge': 'Edge properties',
'marks.node': 'Node flags',
'marks.edge': 'Edge flags (non-blocking)',
'marks.none': 'No flags',
'kv.corridor': 'Corridor (nearest)',
'kv.corridorCount': 'Corridor count',
'kv.corridorOffset': 'Corridor offset (m)',
'kv.snap': 'Snap distance (m)',
'kv.orphan': 'Orphan §6',
'kv.override': 'Manual override',
'kv.corridorChanged': 'corridor changed',
'kv.class': 'Class',
'kv.euclid': 'Straight line (m)',
'kv.road': 'Road distance (m)',
'kv.pathLength': 'Path length (m)',
'kv.detour': 'Detour ratio',
'kv.roadConnected': 'Road-connected',
'kv.pathPoints': 'Path points',
'kv.evidence': 'Evidence',
'kv.verdict': 'Manual verdict',
'yes': 'yes',
'no': 'no',
'readonly.title': 'Read-only',
'readonly.body': 'This is a results browser; nothing can be changed here. Labelling and review happen locally in tool/annotation.',
// help
'help.heading': 'Keyboard shortcuts',
'help.close': 'Close',
'help.j': 'Next item in the list',
'help.k': 'Previous item in the list',
'help.b': 'Toggle satellite / street map',
'help.e': 'Toggle edge style: along roads / straight',
'help.h': 'Enlarge the four headings of the current node in turn (<kbd>Shift</kbd>+<kbd>H</kbd> goes backwards)',
'help.page': 'Previous / next page',
'help.q': 'Switch between "All scenes" and "Flagged"',
'help.help': 'Open this help',
},
};
var LANG = (function () {
var saved = null;
try { saved = localStorage.getItem('ui_lang'); } catch (e) {} // iframe 里存储可能被禁
if (saved === 'zh' || saved === 'en') return saved;
var pref = (navigator.languages && navigator.languages[0]) || navigator.language || '';
return /^zh\b/i.test(pref) ? 'zh' : 'en';
})();
/* 取当前语言的文字。{name} 占位符由 vars 填;漏了的键直接显示键名,一眼能看到。 */
function tr(key, vars) {
var s = I18N[LANG][key];
if (s == null) s = I18N.zh[key];
if (s == null) return key;
return vars ? s.replace(/\{(\w+)\}/g, function (m, k) { return k in vars ? vars[k] : m; }) : s;
}
/* 只换元素自己的那段文字,子元素原样留着——「筛选」按钮里还嵌着计数徽标和
箭头图标,直接赋 textContent 会把它们一起抹掉。前后空白也保留,
「<b>3</b> 同走廊」里数字和词之间的空格就靠它。 */
function setOwnText(el, s) {
for (var n = el.firstChild; n; n = n.nextSibling) {
if (n.nodeType === 3 && n.nodeValue.trim()) {
var m = /^(\s*)[\s\S]*?(\s*)$/.exec(n.nodeValue);
n.nodeValue = m[1] + s + m[2];
return;
}
}
el.appendChild(document.createTextNode(s));
}
function applyLang() {
function each(sel, fn) { Array.prototype.forEach.call(document.querySelectorAll(sel), fn); }
document.documentElement.lang = LANG === 'zh' ? 'zh-CN' : 'en';
each('[data-i18n]', function (el) { setOwnText(el, tr(el.getAttribute('data-i18n'))); });
each('[data-i18n-html]', function (el) { el.innerHTML = tr(el.getAttribute('data-i18n-html')); });
['title', 'placeholder', 'aria-label'].forEach(function (attr) {
each('[data-i18n-' + attr + ']', function (el) {
el.setAttribute(attr, tr(el.getAttribute('data-i18n-' + attr)));
});
});
$('langZh').classList.toggle('active', LANG === 'zh');
$('langEn').classList.toggle('active', LANG === 'en');
$('langZh').setAttribute('aria-pressed', String(LANG === 'zh'));
$('langEn').setAttribute('aria-pressed', String(LANG === 'en'));
}
function setLang(lang) {
if (lang === LANG || !I18N[lang]) return;
LANG = lang;
try { localStorage.setItem('ui_lang', lang); } catch (e) {}
applyLang();
// 下面这些是 JS 拼出来的,applyLang 够不着,按当前状态重画
syncModeText();
syncFilterChrome();
renderList();
if (S.boot) renderStats(S.boot);
if (S.scene) { drawScene(); renderInspector(); }
}
/* ---------- Web Mercator:lat/lon -> 400x400 瓦片像素 -------------------
* 瓦片名 City_year_<lon>_<lat>.png,north-up,zoom 18,400x400。
* 与 benchmark 的 satellite_registration 一致。
*
* ZOOM 曾经写成 17,于是 1 个 SVG 单位被当成 1 个 z17 像素,而实际影像
* 1 像素 = 1 个 z18 像素。结果每个节点都画在它到瓦片中心真实距离的一半处:
* 形状不变、整体塌向中心,边缘节点最多偏 123 px(地面 45~68 m)。
* zoom 18 是量出来的,不是推的:相邻两张卫星图(经度差 0.0018775 度
* = 175 个 z17 像素)做归一化互相关,峰值稳定落在 350 像素,六城两轴一致,
* 即 1 z17 像素 = 2 影像像素。见 tests/test_06_projection_ground_truth.py。
* 同一结论在 research/SetVL-viewers/build.py:49-54 早已验证过(那里写的是
* 「grid steps 350 px -- 7/8 of a tile -- neighbours overlap by exactly 50 px」),
* 本工具是从一个已知正确的值回退成 17 的。 */
var ZOOM = 18, RASTER = 400;
function mercator(lat, lon) {
var s = 256 * Math.pow(2, ZOOM);
var x = (lon + 180) / 360 * s;
var sy = Math.sin(lat * Math.PI / 180);
var y = (0.5 - Math.log((1 + sy) / (1 - sy)) / (4 * Math.PI)) * s;
return [x, y];
}
function tileCenter(satPath) {
var stem = String(satPath || '').split('/').pop().replace(/\.png$/, '');
var p = stem.split('_');
var lat = parseFloat(p[p.length - 1]), lon = parseFloat(p[p.length - 2]);
return (isFinite(lat) && isFinite(lon)) ? [lat, lon] : null;
}
function makeProjector(satPath) {
var c = tileCenter(satPath);
if (!c) return null;
var m = mercator(c[0], c[1]);
return function (lat, lon) {
var q = mercator(lat, lon);
return [q[0] - m[0] + RASTER / 2, q[1] - m[1] + RASTER / 2];
};
}
function corridorColor(cid) {
if (!cid) return '#66736d';
var h = 0;
for (var i = 0; i < cid.length; i++) h = (h * 31 + cid.charCodeAt(i)) >>> 0;
return 'hsl(' + (h % 360) + ' 58% 60%)';
}
/* ---------- 队列 ------------------------------------------------------ */
function queueQuery() {
var q = new URLSearchParams();
if ($('cityFilter').value) q.set('city', $('cityFilter').value);
if ($('kindFilter').value) q.set('kind', $('kindFilter').value);
if ($('signalFilter').value) q.set('note', $('signalFilter').value);
if ($('showReviewedSel').value) q.set('include_reviewed', '1');
q.set('limit', String(S.limit));
q.set('offset', String(S.offset));
return q.toString();
}
function sceneQuery() {
var q = new URLSearchParams();
var v;
if ((v = $('cityFilter').value)) q.set('city', v);
if ((v = $('orderFilter').value)) q.set('order', v);
if ((v = $('minNodes').value)) q.set('min_nodes', v);
if ((v = $('editedFilter').value)) q.set('edited', v);
if ($('fOrphan').checked) q.set('orphan', '1');
if ($('fNoted').checked) q.set('noted', '1');
if ($('originFilter').value) q.set('origin', $('originFilter').value);
if ((v = $('searchInput').value.trim())) q.set('q', v);
q.set('limit', String(S.limit));
q.set('offset', String(S.offset));
return q.toString();
}
function loadScenes(keepSelection) {
return api('/api/scenes?' + sceneQuery()).then(function (d) {
S.scenes = d.items;
renderScenes();
if (!keepSelection && S.scenes.length && !S.scene) openScene(S.scenes[0].scene_id);
}).catch(function (e) { toast(e.message, true); });
}
function renderScenes() {
updateListMeter();
var list = $('queueList');
list.innerHTML = '';
if (!S.scenes.length) {
list.innerHTML = '<p class="empty-note">' + tr('list.noScenes') + '</p>';
return;
}
S.scenes.forEach(function (sc) {
var b = document.createElement('button');
b.className = 'queue-item' + (S.scene && S.scene.scene.scene_id === sc.scene_id ? ' active' : '')
+ (sc.revision_count ? ' reviewed' : '');
var badges = '';
if (sc.revision_count) badges += '<span class="badge edited">' + tr('badge.edited', { n: sc.revision_count }) + '</span>';
if (sc.orphan_count) badges += '<span class="badge orphan">' + tr('badge.orphan', { n: sc.orphan_count }) + '</span>';
if (sc.noted_count) badges += '<span class="badge cost">' + tr('badge.noted', { n: sc.noted_count }) + '</span>';
// 三行压成两行:城市并进第一行(原来单独占一行只为显示一个词),
// 没有徽章时整条 meta 行不渲染,而不是留一个占位的破折号。
var name = sc.scene_id.split(':')[1] || sc.scene_id;
b.title = sc.scene_id;
b.innerHTML =
'<div class="queue-top"><strong>' + esc(name) + '</strong>' +
'<span>' + sc.node_count + 'n/' + sc.edge_count + 'e</span></div>' +
'<div class="queue-sub"><em>' + esc(sc.city) + '</em>' +
(badges ? '<span class="queue-badges">' + badges + '</span>' : '') + '</div>';
b.onclick = function () { openScene(sc.scene_id); };
list.appendChild(b);
});
}
function openScene(sceneId) {
S.selected = null;
S.scene = null;
loadScene(sceneId).then(function () {
renderScenes();
// 打开场景时默认选中第一个节点,右栏才有东西可改
if (S.scene && S.scene.nodes.length) pickNode(S.scene.nodes[0]);
else renderInspector();
});
}
function loadList(keepSelection) {
return S.mode === 'queue' ? loadQueue(keepSelection) : loadScenes(keepSelection);
}
function setMode(mode) {
S.mode = mode;
S.offset = 0;
$('modeScenes').classList.toggle('active', mode === 'scenes');
$('modeQueue').classList.toggle('active', mode === 'queue');
$('sceneFilters').hidden = mode !== 'scenes';
$('queueFilters').hidden = mode !== 'queue';
syncModeText();
syncFilterChrome();
loadList();
}
/* 随模式变的两处文字。切换语言时也要单独重设,所以不内联在 setMode 里。 */
function syncModeText() {
var q = S.mode === 'queue';
$('panelTitle').textContent = tr(q ? 'panel.queue' : 'panel.scenes');
$('searchInput').placeholder = tr(q ? 'search.queue' : 'search.scenes');
}
/* ---------- 筛选抽屉与 token ------------------------------------------
* 抽屉里的控件收起后就看不见了,所以已激活的项必须在外面留痕:
* 一个计数徽标 + 一排可点掉的 token。否则「筛了但忘了筛过」是必然的。
*/
var FILTERS = [ // label 是 I18N 的键
{ id: 'minNodes', label: 'f.minNodes', mode: 'scenes', type: 'value' },
{ id: 'editedFilter', label: 'f.edited', mode: 'scenes', type: 'select' },
{ id: 'fOrphan', label: 'f.orphan', mode: 'scenes', type: 'check' },
{ id: 'fNoted', label: 'chip.noted', mode: 'scenes', type: 'check' },
{ id: 'originFilter', label: 'f.origin', mode: 'scenes', type: 'select' },
{ id: 'kindFilter', label: 'f.kind', mode: 'queue', type: 'select' },
{ id: 'signalFilter', label: 'f.signal', mode: 'queue', type: 'select' },
{ id: 'showReviewedSel', label: 'f.reviewed', mode: 'queue', type: 'select' },
];
function activeFilters() {
return FILTERS.filter(function (f) {
if (f.mode !== S.mode) return false;
var el = $(f.id);
if (!el) return false;
return f.type === 'check' ? el.checked : !!String(el.value || '').trim();
});
}
function clearFilter(f) {
var el = $(f.id);
if (!el) return;
if (f.type === 'check') el.checked = false; else el.value = '';
}
function syncFilterChrome() {
var act = activeFilters();
var badge = $('filterCount');
badge.textContent = act.length;
badge.hidden = act.length === 0;
var row = $('activeTokens');
row.innerHTML = '';
row.hidden = act.length === 0;
act.forEach(function (f) {
var el = $(f.id);
var shown = f.type === 'check' ? ''
: (el.tagName === 'SELECT' ? el.options[el.selectedIndex].textContent : el.value);
var b = document.createElement('button');
b.type = 'button';
b.className = 'token';
b.title = tr('token.clear');
b.innerHTML = esc(tr(f.label)) + (shown ? ' <b>' + esc(shown) + '</b>' : '') + ' <i>×</i>';
b.onclick = function () { clearFilter(f); S.offset = 0; syncFilterChrome(); loadList(); };
row.appendChild(b);
});
}
function toggleDrawer(force) {
var btn = $('filterToggle'), box = $('filterDrawer');
var open = force != null ? force : box.hidden;
box.hidden = !open;
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
}
function loadQueue(keepSelection) {
return api('/api/queue?' + queueQuery()).then(function (d) {
S.queue = d.items;
applySearch();
if (!keepSelection && S.filtered.length) selectItem(S.filtered[0]);
else renderList();
}).catch(function (e) { toast(e.message, true); });
}
function applySearch() {
var term = $('searchInput').value.trim().toLowerCase();
S.filtered = !term ? S.queue : S.queue.filter(function (i) {
return (i.scene_id + ' ' + i.city + ' ' + i.item_key).toLowerCase().indexOf(term) >= 0
|| i.signals.some(function (s) { return s.code.toLowerCase() === term; });
});
renderList();
}
function noteHtml(notes) {
/* 队列项必须自带「为什么被列出来」。策略里没有分数,所以直接列标记码。 */
return (notes || []).map(function (n) {
var extra = Object.keys(n).filter(function (k) { return k !== 'code'; })
.map(function (k) {
var v = n[k];
return typeof v === 'number' ? (+v).toFixed(1) : String(v);
}).join(' ');
return '<span class="sig-code">' + esc(n.code) + '</span>' +
(extra ? '<span>' + esc(extra) + '</span>' : '');
}).join('') || '<span>—</span>';
}
function itemId(i) { return i.kind + '|' + i.scene_id + '|' + i.item_key; }
/* 左栏渲染的统一入口。
*
* 必须按模式分流:pickNode/pickEdge 里如果直接调 renderQueue(),
* 浏览模式下会把刚渲染好的场景列表覆盖成「队列为空」——点开一个场景后
* 左栏就空了,其他场景全看不见。
*/
function renderList() {
if (S.mode === 'queue') renderQueue();
else renderScenes();
}
/* 列表上方的计数条。
*
* 必须由 renderScenes / renderQueue 自己调,不能只挂在 renderList 上:
* loadScenes 和 openScene 都是直接调 renderScenes() 的,走 renderList
* 的只是一部分路径——翻页时计数不动就是这么来的。 */
function updateListMeter() {
var n = (S.mode === 'queue' ? S.filtered : S.scenes).length;
var pg = Math.floor(S.offset / S.limit) + 1;
$('listCount').textContent = n
? tr('list.count', { n: n, page: pg, from: S.offset + 1, to: S.offset + n })
: (tr('list.none') + (S.offset ? tr('list.onPage', { page: pg }) : ''));
}
function renderQueue() {
updateListMeter();
var list = $('queueList');
list.innerHTML = '';
if (!S.filtered.length) {
list.innerHTML = '<p class="empty-note">' + tr('list.queueEmpty') + '</p>';
return;
}
var selId = S.selected ? itemId(S.selected) : null;
S.filtered.forEach(function (i) {
var b = document.createElement('button');
b.className = 'queue-item'
+ (itemId(i) === selId ? ' active' : '')
+ (i.review_status === 'reviewed' ? ' reviewed' : '')
+ (i.review_status === 'needs_adjudication' ? ' adjudication' : '');
b.innerHTML =
'<div class="queue-top"><strong>' + (i.kind === 'node' ? '●' : '─') + ' ' +
esc(i.item_key) + '</strong><span>' + tr(i.kind === 'node' ? 'item.node' : 'item.edge') +
'</span></div>' +
'<div class="queue-meta">' + noteHtml(i.notes) + '</div>' +
'<div class="queue-scene">' + esc(i.scene_id) + '</div>';
b.onclick = function () { selectItem(i); };
list.appendChild(b);
});
}
function page(delta) {
var next = S.offset + delta * S.limit;
if (next < 0) { toast(tr('toast.firstPage')); return; }
S.offset = next;
loadList(true).then(function () {
var n = S.mode === 'queue' ? S.filtered.length : S.scenes.length;
if (!n && delta > 0) { S.offset -= S.limit; loadList(true); toast(tr('toast.lastPage')); }
else toast(tr('toast.page', { page: Math.floor(S.offset / S.limit) + 1 }));
});
}
function navigate(delta) {
if (S.mode === 'scenes') { navigateScenes(delta); return; }
if (!S.filtered.length) return;
var idx = S.selected ? S.filtered.findIndex(function (i) {
return itemId(i) === itemId(S.selected);
}) : -1;
var next = idx + delta;
if (next < 0 || next >= S.filtered.length) {
toast(tr(next < 0 ? 'toast.firstItem' : 'toast.lastItem'));
return;
}
selectItem(S.filtered[next]);
var el = $('queueList').children[next];
if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
}
function navigateScenes(delta) {
if (!S.scenes.length) return;
var cur = S.scene ? S.scene.scene.scene_id : null;
var idx = S.scenes.findIndex(function (x) { return x.scene_id === cur; });
var next = idx + delta;
if (next < 0 || next >= S.scenes.length) {
toast(tr(next < 0 ? 'toast.firstScene' : 'toast.lastScene'));
return;
}
openScene(S.scenes[next].scene_id);
var el = $('queueList').children[next];
if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
}
function selectItem(item) {
var key = item.kind === 'node'
? [item.scene_id, item.item_key]
: [item.scene_id].concat(item.item_key.split('-'));
S.selected = { kind: item.kind, scene_id: item.scene_id, key: key, queue: item };
renderList();
loadScene(item.scene_id).then(renderInspector);
}
/* ---------- 场景 ------------------------------------------------------ */
function loadScene(sceneId) {
if (S.scene && S.scene.scene.scene_id === sceneId) {
drawScene();
return Promise.resolve();
}
return api('/api/scene/' + encodeURIComponent(sceneId)).then(function (d) {
S.scene = d;
drawScene();
}).catch(function (e) { toast(e.message, true); });
}
function sceneCorridors() {
var seen = {}, out = [];
(S.scene ? S.scene.nodes : []).forEach(function (n) {
var cid = n.corridor_id;
if (cid && !seen[cid]) {
seen[cid] = 1;
out.push({ id: cid, name: n.corridor_name || cid, color: corridorColor(cid) });
}
});
out.sort(function (a, b) { return a.name < b.name ? -1 : 1; });
return out;
}
/* 给某个节点列出可选走廊。
*
* 光靠「本场景其他节点已归属的走廊」是不够的:歧义节点纠结的那条对手路
* 往往一个节点都没有,于是正确答案根本不在选项里,这条复核就做不了。
* 所以先放该节点自己的邻近候选(带距离,按远近排),再补上场景里的其它走廊。
*/
function corridorChoices(row) {
var out = [], seen = {};
(row && row.corridor_candidates ? row.corridor_candidates : []).forEach(function (c) {
if (seen[c.corridor_id]) return;
seen[c.corridor_id] = 1;
out.push({ id: c.corridor_id, name: c.name || c.corridor_id,
color: corridorColor(c.corridor_id), distance: c.distance_m });
});
sceneCorridors().forEach(function (c) {
if (seen[c.id]) return;
seen[c.id] = 1;
out.push({ id: c.id, name: c.name, color: c.color, distance: null });
});
return out;
}
function drawScene() {
if (!S.scene) return;
var sc = S.scene.scene;
$('sceneTitle').textContent = sc.scene_id.split(':')[1] || sc.scene_id;
$('sceneCity').textContent = sc.city;
$('sceneId').textContent = sc.scene_id;
$('mNodes').textContent = S.scene.nodes.length;
$('mEdges').textContent = S.scene.edges.length;
var cors = sceneCorridors();
$('mCorridors').textContent = cors.length;
$('mOrphans').textContent = S.scene.nodes.filter(function (n) { return n.is_orphan_auto; }).length;
$('satelliteImage').src = (S.scene.asset && S.scene.asset.satellite)
? '/asset?path=' + encodeURIComponent(S.scene.asset.satellite) : '';
drawGraph();
drawNodeChips(cors);
drawAdjacency();
drawGroundImages();
if (S.basemap === 'map') renderMapTiles();
}
/* ---------- 重合边的分离 ----------------------------------------------
* 直线更醒目,所以默认就画直线(`E` 可切到沿路走线)。
*
* 直线模式下**真正重合的很少**:实测 464 条边里只有 6 对、涉及 8 条边
* (1.7%)。因为直线只在「共线且投影重叠」时才压在一起,而策略的 §2.5 /
* §3.3 本来就禁止「第三个节点落在路径/直线上」,大部分共线情形已被排除。
* 剩下的是两条边恰好首尾错开地共线。
*
* 所以分离用**整条边平移**,不是逐点侧移:
* - 直线平移之后还是直线,不会被弯成弧
* - 端点跟着挪,但挪动量被压在节点圆点半径(5)以内,看上去仍然连着节点
*
* 逐点侧移那一版是为沿路走线写的(那种模式下大量边共用同一条道路中心线,
* 遮挡 17%),它会把直线弯掉,与「直线更醒目」正好相反。
*/
var SEP = 4.2; // 同组相邻两条边的间距(raster 单位,画布 400)
var MAX_OFF = 6.0; // 单条边的最大平移量。
// 节点圆点半径 5 且画在边之上,所以平移 5 以内完全被盖住;
// 取 6 是给大组留余地(5 条边一组时挤到 2.25 反而低于容差),
// 超出的那 1 单位比边宽(2)还窄,看上去仍然连着节点。
var NEAR_TOL = 2.5; // 多近算"压在一起"
var OVERLAP_FRAC = 0.6; // 采样点落在容差内的比例门槛
var NSAMP = 9; // 每条边取几个采样点做重合判定
function distToSegment(p, a, b) {
var dx = b[0] - a[0], dy = b[1] - a[1];
var L2 = dx * dx + dy * dy;
var t = L2 ? ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / L2 : 0;
t = t < 0 ? 0 : (t > 1 ? 1 : t);
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
}
function distToPolyline(p, pts) {
var best = Infinity;
for (var i = 0; i + 1 < pts.length; i++) {
var d = distToSegment(p, pts[i], pts[i + 1]);
if (d < best) best = d;
}
return best;
}
function polyLength(pts) {
var L = 0;
for (var i = 0; i + 1 < pts.length; i++)
L += Math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]);
return L;
}
/* 沿折线等距取 n 个点(含两端)。 */
function sampleAlong(pts, n) {
var total = polyLength(pts);
if (!total) return [pts[0]];
var segs = [];
for (var i = 0; i + 1 < pts.length; i++)
segs.push(Math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]));
var out = [];
for (var k = 0; k < n; k++) {
var want = total * k / (n - 1), acc = 0, j = 0;
while (j < segs.length && acc + segs[j] < want) { acc += segs[j]; j++; }
if (j >= segs.length) j = segs.length - 1;
var t = segs[j] ? (want - acc) / segs[j] : 0;
out.push([pts[j][0] + (pts[j + 1][0] - pts[j][0]) * t,
pts[j][1] + (pts[j + 1][1] - pts[j][1]) * t]);
}
return out;
}
/* 两条边压在一起的程度。取两个方向的较大者——一条短边整个趴在长边上时,
短→长的比例高而长→短的低,只看一个方向会漏掉。 */
function overlapFraction(a, b) {
var hit = 0;
var pts = sampleAlong(a, NSAMP);
pts.forEach(function (p) { if (distToPolyline(p, b) <= NEAR_TOL) hit++; });
return hit / pts.length;
}
/* 返回每条边的平移量(沿自身法线,单位 raster)。不重合的为 0。 */
function planOffsets(polys) {
var n = polys.length;
var parent = [];
for (var i = 0; i < n; i++) parent.push(i);
function find(x) { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; }
function join(x, y) { var a = find(x), b = find(y); if (a !== b) parent[Math.max(a, b)] = Math.min(a, b); }
for (var i = 0; i < n; i++) {
if (!polys[i] || polys[i].length < 2) continue;
for (var j = i + 1; j < n; j++) {
if (!polys[j] || polys[j].length < 2) continue;
var f = Math.max(overlapFraction(polys[i], polys[j]),
overlapFraction(polys[j], polys[i]));
if (f >= OVERLAP_FRAC) join(i, j);
}
}
var groups = {};
for (var k = 0; k < n; k++) {
if (!polys[k] || polys[k].length < 2) continue;
var r = find(k);
(groups[r] || (groups[r] = [])).push(k);
}
var off = {};
Object.keys(groups).forEach(function (g) {
var list = groups[g];
if (list.length < 2) return;
list.sort(function (a, b) { return a - b; }); // 确定性:按边的绘制序
// 组大时收窄间距,保证最外侧那条仍在节点圆点内(否则边看着从节点脱开)
var sep = Math.min(SEP, 2 * MAX_OFF / (list.length - 1));
list.forEach(function (ei, idx) {
off[ei] = (idx - (list.length - 1) / 2) * sep;
});
});
return off;
}
/* 整条折线沿法线平移 dist。直线平移后仍是直线。
拐角处取相邻两段法线的平均,折线不会在拐点裂开。 */
function offsetPolyline(pts, dist) {
if (!dist || pts.length < 2) return pts;
// 法线方向必须与「边的朝向」无关。边的两端按 source<target 的字母序存,
// 与几何走向无关——同一条线上首尾相反的两条边,各按自己的方向算法线会
// 指向相反侧,于是 +d 和 -d 把它们推到了**同一边**,等于没分开。
// 把整体走向归一到 [0,180),方向落在下半圆时翻转平移量。
var deg = (Math.atan2(pts[pts.length - 1][1] - pts[0][1],
pts[pts.length - 1][0] - pts[0][0]) * 180 / Math.PI + 360) % 360;
if (deg >= 180) dist = -dist;
var norms = [];
for (var i = 0; i + 1 < pts.length; i++) {
var dx = pts[i + 1][0] - pts[i][0], dy = pts[i + 1][1] - pts[i][1];
var L = Math.hypot(dx, dy) || 1;
norms.push([-dy / L, dx / L]);
}
return pts.map(function (p, i) {
var a = norms[Math.max(0, i - 1)], b = norms[Math.min(norms.length - 1, i)];
var nx = (a[0] + b[0]) / 2, ny = (a[1] + b[1]) / 2;
var L = Math.hypot(nx, ny) || 1;
return [p[0] + nx / L * dist, p[1] + ny / L * dist];
});
}
function drawGraph() {
var svg = $('graphSvg');
svg.innerHTML = '';
var proj = makeProjector(S.scene.scene.satellite_path);
if (!proj) return;
var pos = {};
S.scene.nodes.forEach(function (n) { pos[n.label] = proj(n.lat, n.lon); });
var NS = 'http://www.w3.org/2000/svg';
function mk(tag, attrs) {
var el = document.createElementNS(NS, tag);
for (var k in attrs) el.setAttribute(k, attrs[k]);
return el;
}
var sel = S.selected;
// 先把每条边的几何算出来,再统一规划重叠偏移,最后才画。
var geo = S.scene.edges.map(function (e) {
var a = pos[e.source_label], b = pos[e.target_label];
if (!a || !b) return null;
// §5:橙边(短直连)和红边(分量合并)按定义就是直线,没有 road_path。
var costly = e.origin === 'component_merge';
if (S.edgeMode === 'road' && e.road_path && e.road_path.length > 1) {
// 折线两端落在吸附点上,不在节点上——补两段短接头,否则边看着是断的
return { e: e, costly: costly, road: true,
pts: [a].concat(e.road_path.map(function (q) { return proj(q[0], q[1]); })).concat([b]) };
}
return { e: e, costly: costly, road: false, pts: [a, b] };
});
// 整条边平移:直线平移后仍是直线
var off = planOffsets(geo.map(function (g) { return g ? g.pts : []; }));
geo.forEach(function (g, ei) {
if (!g) return;
var e = g.e;
var colour = EDGE_COLOR[e.origin] || 'var(--muted)';
var isSel = sel && sel.kind === 'edge'
&& sel.key[1] === e.source_label && sel.key[2] === e.target_label;
var dead = !e.in_graph;
var shift = off[ei] || 0;
var pts = offsetPolyline(g.pts, shift);
var el;
if (pts.length === 2) {
el = mk('line', {
x1: pts[0][0].toFixed(2), y1: pts[0][1].toFixed(2),
x2: pts[1][0].toFixed(2), y2: pts[1][1].toFixed(2),
stroke: isSel ? '#fff' : colour,
'stroke-width': isSel ? 3 : 2.0,
opacity: dead ? .22 : 1,
'stroke-linecap': 'round',
style: 'cursor:pointer',
});
} else {
el = mk('polyline', {
points: pts.map(function (q) { return q[0].toFixed(2) + ',' + q[1].toFixed(2); }).join(' '),
fill: 'none', stroke: isSel ? '#fff' : colour,
'stroke-width': isSel ? 3 : 1.8,
opacity: dead ? .22 : 1,
'stroke-linejoin': 'round', 'stroke-linecap': 'round',
style: 'cursor:pointer',
});
}
el.onclick = function () { pickEdge(e); };
var title = mk('title', {});
title.textContent = e.source_label + '–' + e.target_label + ' · ' +
(edgeLabel(e.origin) || '') +
(shift ? tr('graph.overlap') : '');
el.appendChild(title);
svg.appendChild(el);
});
S.scene.nodes.forEach(function (n) {
var p = pos[n.label];
if (!p) return;
var isSel = sel && sel.kind === 'node' && sel.key[1] === n.label;
var g = mk('g', { style: 'cursor:pointer' });
g.appendChild(mk('circle', {
cx: p[0], cy: p[1], r: isSel ? 6.5 : 5,
fill: corridorColor(n.corridor_id),
stroke: isSel ? '#fff' : (n.is_orphan_auto ? 'var(--anchor)' : '#0b0e0d'),
'stroke-width': isSel ? 2.4 : (n.is_orphan_auto ? 2.2 : 1.2),
}));
var t = mk('text', {
x: p[0], y: p[1], fill: '#0b0e0d',
style: 'font:800 6px var(--mono);text-anchor:middle;dominant-baseline:central;pointer-events:none',
});
t.textContent = n.label;
g.appendChild(t);
g.onclick = function () { pickNode(n); };
svg.appendChild(g);
});
drawHeadingArrows(svg, mk, pos);
}
/* ---------- 朝向箭头 ----------------------------------------------------
* 街景有 4 个朝向,但光看图片说不出它朝哪边。给当前选中的节点(选中一条边
* 时则是两端)在图上画出这 4 个方向,正在看的那个用信号色加粗——这样
* 「图片里的这条路」和「图上的这条边」能直接对上。
* heading 以正北为 0、顺时针增大;SVG 的 y 轴向下,所以
* dx = sin(θ), dy = -cos(θ)。
*/
function drawHeadingArrows(svg, mk, pos) {
var sel = S.selected;
if (!sel) return;
var roles = sel.kind === 'node'
? [['anchor', sel.key[1]]]
: [['anchor', sel.key[1]], ['target', sel.key[2]]];
roles.forEach(function (pair) {
var n = S.scene.nodes.find(function (x) { return x.label === pair[1]; });
if (!n || !n.ground_headings || !n.ground_headings.length) return;
var p = pos[n.label];
if (!p) return;
// 四个朝向的图片现在同时显示,所以箭头地位平等——不再有"当前朝向"。
// 只有鼠标悬在某张图片上时,对应的那支才放大加亮。
var lit = LIT[pair[0]];
n.ground_headings.forEach(function (h, k) {
if (h == null) return;
var th = (Number(h) % 360 + 360) % 360 * Math.PI / 180;
var dx = Math.sin(th), dy = -Math.cos(th);
var on = k === lit;
var r0 = 7.5, r1 = on ? 22 : 14; // 起点让开节点圆点
var x0 = p[0] + dx * r0, y0 = p[1] + dy * r0;
var x1 = p[0] + dx * r1, y1 = p[1] + dy * r1;
// 底图可能是深色卫星图,也可能是浅色电子地图。所以每个箭头都先铺
// 一层深色描边再画本体,两种底图上都读得出来——只靠半透明白色,
// 在 OSM 的浅底上基本看不见。
var col = on ? 'var(--signal)' : '#f2f5ef';
var halo = 'rgba(8,12,11,.85)';
var lw = on ? 2 : 1.3;
var gg = mk('g', { style: 'cursor:pointer' });
gg.appendChild(mk('line', {
x1: x0, y1: y0, x2: x1, y2: y1, stroke: halo,
'stroke-width': lw + 1.6, 'stroke-linecap': 'round',
}));
gg.appendChild(mk('line', {
x1: x0, y1: y0, x2: x1, y2: y1, stroke: col,
'stroke-width': lw, 'stroke-linecap': 'round',
}));
// 箭头:沿朝向的等腰三角形,深色描边 + 本体填充
var hw = on ? 3.4 : 2.4, hl = on ? 6 : 4.2;
var px = -dy, py = dx; // 法线
gg.appendChild(mk('polygon', {
points: [
(x1 + dx * hl) + ',' + (y1 + dy * hl),
(x1 + px * hw) + ',' + (y1 + py * hw),
(x1 - px * hw) + ',' + (y1 - py * hw),
].join(' '),
fill: col, stroke: halo, 'stroke-width': 1.1,
'stroke-linejoin': 'round', 'paint-order': 'stroke',
}));
var tip = mk('title', {});
tip.textContent = tr('arrow.title', { h: headingLabel(h) });
gg.appendChild(tip);
// 点箭头 = 放大那个方向的街景;悬停则反向点亮对应的缩略图
gg.onclick = function (ev) {
ev.stopPropagation();
var tile = $(pair[0] + 'Grid').children[k];
var img = tile && tile.querySelector && tile.querySelector('img');
if (img) { $('lightboxImage').src = img.src; $('lightbox').classList.add('open'); }
};
gg.onmouseenter = function () { setLit(pair[0], k); };
gg.onmouseleave = function () { setLit(pair[0], -1); };
svg.appendChild(gg);
});
});
}
function drawNodeChips(cors) {
var wrap = $('nodeThumbnails');
wrap.innerHTML = '';
S.scene.nodes.forEach(function (n) {
var b = document.createElement('button');
var isSel = S.selected && S.selected.kind === 'node' && S.selected.key[1] === n.label;
b.className = 'node-chip' + (isSel ? ' active' : '') + (n.is_orphan_auto ? ' orphan' : '');
b.textContent = n.label;
b.title = (n.corridor_name_auto || tr('corridor.noneParen')) +
(n.corridor_overridden ? tr('corridor.overridden') : '');
b.onclick = function () { pickNode(n); };
wrap.appendChild(b);
});
var leg = $('corridorLegend');
leg.innerHTML = cors.slice(0, 6).map(function (c) {
return '<div><i style="background:' + c.color + '"></i>' + esc(c.name) + '</div>';
}).join('') || '<div>' + tr('corridor.noneParen') + '</div>';
}
function drawAdjacency() {
var labels = S.scene.nodes.map(function (n) { return n.label; });
var idx = {};
labels.forEach(function (l, i) { idx[l] = i; });
var cell = {};
var counts = { corridor: 0, road_route: 0, short_link: 0, component_merge: 0 };
S.scene.edges.forEach(function (e) {
cell[e.source_label + '|' + e.target_label] = e;
cell[e.target_label + '|' + e.source_label] = e;
if (counts[e.origin_auto] != null) counts[e.origin_auto]++;
});
$('gCorridor').textContent = counts.corridor;
$('gRoad').textContent = counts.road_route;
$('gShort').textContent = counts.short_link;
$('gMerge').textContent = counts.component_merge;
var sel = S.selected;
var html = '<tr><th></th>' + labels.map(function (l) { return '<th>' + l + '</th>'; }).join('') + '</tr>';
labels.forEach(function (r) {
html += '<tr><th>' + r + '</th>';
labels.forEach(function (c) {
if (r === c) { html += '<td class="diag"></td>'; return; }
var e = cell[r + '|' + c];
if (!e) {
// 空格子也可点——那是「自动化没给出这条边,但人认为该有」的入口
html += '<td></td>';
return;
}
var isSel = sel && sel.kind === 'edge'
&& sel.key[1] === e.source_label && sel.key[2] === e.target_label;
var cls = 'edge e-' + (e.origin || 'manual') + (isSel ? ' sel' : '') +
(e.in_graph ? '' : ' rejected');
html += '<td class="' + cls + '" data-e="' + e.source_label + '-' +
e.target_label + '">' + (e.in_graph ? '·' : '×') + '</td>';
});
html += '</tr>';
});
var tbl = $('adjacencyTable');
tbl.innerHTML = html;
Array.prototype.forEach.call(tbl.querySelectorAll('td.addable'), function (td) {
td.onclick = function () {
toast(tr('toast.readonlyAdd'));
};
});
Array.prototype.forEach.call(tbl.querySelectorAll('td.edge'), function (td) {
td.onclick = function () {
var k = td.getAttribute('data-e').split('-');
var e = S.scene.edges.find(function (x) {
return x.source_label === k[0] && x.target_label === k[1];
});
if (e) pickEdge(e);
};
});
var listEl = $('edgeList');
listEl.innerHTML = '';
S.scene.edges.forEach(function (e) {
var d = document.createElement('div');
var isSel = sel && sel.kind === 'edge'
&& sel.key[1] === e.source_label && sel.key[2] === e.target_label;
d.className = 'edge-row' + (isSel ? ' sel' : '');
var colour = EDGE_COLOR[e.origin] || 'var(--muted)';
if (!e.in_graph) d.classList.add('rejected');
d.innerHTML = '<i style="background:' + colour + '"></i>' +
esc(e.source_label) + '–' + esc(e.target_label) +
(e.is_manual ? ' <span style="color:var(--cyan)">M</span>' : '') +
(e.in_graph ? '' : ' <span style="color:var(--no)">' + tr('adj.rejected') + '</span>') +
((e.notes && e.notes.length) ? ' <span class="cost-tag" title="' +
esc(e.notes.map(function (n) { return n.code; }).join(', ')) + '">!</span>' : '') +
'<span class="len">' + (e.euclid_distance_m == null ? '—' : (+e.euclid_distance_m).toFixed(0) + ' m') + '</span>';
d.onclick = function () { pickEdge(e); };
listEl.appendChild(d);
});
}
function headingLabel(h) {
if (h == null) return '—';
var d = ((+h % 360) + 360) % 360; // 文件名里有负角,先归一到 [0,360)
var names = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
return names[Math.round(d / 45) % 8] + ' ' + Math.round(d) + '°';
}
/* 四个朝向同时铺开。
*
* 旧版一次只显示一张,下面一排按钮来回切——要看全一个路口得点三次,
* 而且切走之后就记不住上一张长什么样。现在四张并排、各自标方位,
* 不用做任何选择就能一眼看完。
*
* 悬停某一张会点亮地图上对应的箭头(见 drawHeadingArrows),
* 「图片里的这条路」和「图上的这个方向」就对上了。
*/
var LIT = { anchor: -1, target: -1 }; // 当前悬停的朝向下标,-1 = 无
/* 双向联动的唯一入口:改 LIT 就同时更新缩略图的边框和地图上的箭头。
两边各写一次会漏——比如从箭头移开时忘了取消图片的高亮。 */
function setLit(role, k) {
if (LIT[role] === k) return;
LIT[role] = k;
var grid = $(role + 'Grid');
if (grid) {
Array.prototype.forEach.call(grid.children, function (el, i) {
if (el.classList) el.classList.toggle('lit', i === k);
});
}
drawGraph();
}
function drawGroundImages() {
var sel = S.selected;
if (!sel || !S.scene) return;
function fill(role, label) {
var n = S.scene.nodes.find(function (x) { return x.label === label; });
$(role + 'Badge').textContent = label || '—';
var assets = (n && n.ground_assets) || [];
var headings = (n && n.ground_headings) || [];
$(role + 'Caption').textContent = n
? (n.lat.toFixed(6) + ', ' + n.lon.toFixed(6) + ' · ' +
(n.corridor_name_auto || tr('corridor.none')) +
(assets.length ? tr('ground.headings', { n: assets.length }) : ''))
: '—';
var grid = $(role + 'Grid');
grid.innerHTML = '';
grid.className = 'ground-grid' + (assets.length === 1 ? ' single' : '');
if (!assets.length) {
grid.innerHTML = '<div class="ground-empty">' + tr('ground.empty') + '</div>';
return;
}
assets.forEach(function (rel, k) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'ground-tile';
b.setAttribute('aria-label', tr('ground.zoom', { h: headingLabel(headings[k]) }));
var img = document.createElement('img');
img.src = '/asset?path=' + encodeURIComponent(rel);
img.alt = tr('ground.alt', { h: headingLabel(headings[k]) });
img.loading = 'lazy';
var tag = document.createElement('b');
tag.textContent = headingLabel(headings[k]);
b.appendChild(img);
b.appendChild(tag);
b.onclick = function () {
$('lightboxImage').src = img.src;
$('lightbox').classList.add('open');
};
// 悬停联动:图片 <-> 地图上的箭头
b.onmouseenter = function () { setLit(role, k); };
b.onmouseleave = function () { setLit(role, -1); };
grid.appendChild(b);
});
}
if (sel.kind === 'node') {
$('anchorKind').textContent = 'Node';
$('targetCard').style.display = 'none';
LIT.target = -1;
fill('anchor', sel.key[1]);
} else {
$('anchorKind').textContent = 'Source';
$('targetCard').style.display = '';
fill('anchor', sel.key[1]);
fill('target', sel.key[2]);
}
}
/* H 依次放大看四个朝向 —— 四张都在屏幕上了,但缩略图偏小,
想逐张看大图时不必用鼠标去点。 */
function cycleGround(step) {
if (!S.selected || !S.scene) return;
var role = 'anchor', label = S.selected.key[1];
var n = S.scene.nodes.find(function (x) { return x.label === label; });
var assets = (n && n.ground_assets) || [];
if (!assets.length) return;
var next = (LIT[role] + step) % assets.length;
if (next < 0) next += assets.length;
LIT[role] = next;
var tile = $(role + 'Grid').children[next];
if (tile && tile.querySelector) {
var img = tile.querySelector('img');
if (img) { $('lightboxImage').src = img.src; $('lightbox').classList.add('open'); }
}
drawGraph();
}
function pickNode(n) {
S.selected = { kind: 'node', scene_id: S.scene.scene.scene_id,
key: [S.scene.scene.scene_id, n.label] };
drawScene();
renderInspector();
renderList();
}
function pickEdge(e) {
S.selected = { kind: 'edge', scene_id: S.scene.scene.scene_id,
key: [S.scene.scene.scene_id, e.source_label, e.target_label] };
drawScene();
renderInspector();
renderList();
}
/* ---------- 判定面板 -------------------------------------------------- */
function currentRow() {
if (!S.selected || !S.scene) return null;
if (S.selected.kind === 'node') {
return S.scene.nodes.find(function (n) { return n.label === S.selected.key[1]; });
}
return S.scene.edges.find(function (e) {
return e.source_label === S.selected.key[1] && e.target_label === S.selected.key[2];
});
}
function kv(pairs) {
return '<dl class="kv">' + pairs.map(function (p) {
return '<dt>' + esc(p[0]) + '</dt><dd>' + esc(p[1] == null ? 'null' : p[1]) + '</dd>';
}).join('') + '</dl>';
}
function renderInspector() {
var body = $('inspectorBody');
var row = currentRow();
if (!row) {
body.innerHTML = '<p class="empty-note">' + tr('insp.empty') + '</p>';
$('itemId').textContent = '—';
return;
}
var isNode = S.selected.kind === 'node';
$('decisionTitle').textContent = tr(isNode ? 'insp.node' : 'insp.edge');
$('itemId').textContent = isNode ? row.label : (row.source_label + '→' + row.target_label);
var html = '';
// merged_policy_0915 没有置信度分数,也没有分诊分层。
// 这里展示的是策略真正产出的东西:非阻断标记。
html += '<section class="signal-block"><div class="block-title">' +
'<span>' + tr(isNode ? 'marks.node' : 'marks.edge') + '</span></div>';
var marks = isNode
? (row.is_orphan ? [{ code: 'orphan', snap_m: row.snap_distance_m_auto }] : [])
: (row.notes || []);
html += marks.length ? marks.map(function (sg) {
var extra = Object.keys(sg).filter(function (k) { return k !== 'code'; })
.map(function (k) {
var v = sg[k];
return k + '=' + (typeof v === 'number' ? (+v).toFixed(3) : JSON.stringify(v));
}).join(' ');
return '<div class="signal-row"><b>' + esc(sg.code) + '</b><em>' + esc(extra) + '</em></div>';
}).join('') : '<div class="signal-row"><em>' + tr('marks.none') + '</em></div>';
html += '</section>';
if (isNode) {
html += kv([
['panoid', row.panoid],
['lat / lon', row.lat.toFixed(6) + ', ' + row.lon.toFixed(6)],
[tr('kv.corridor'), row.corridor_name || '—'],
[tr('kv.corridorCount'), (row.corridor_ids || []).length],
[tr('kv.corridorOffset'), row.corridor_offset_m_auto],
[tr('kv.snap'), row.snap_distance_m_auto],
[tr('kv.orphan'), tr(row.is_orphan ? 'yes' : 'no')],
[tr('kv.override'), row.corridor_overridden ? tr('kv.corridorChanged') : '—'],
]).replace('<dl', '<dl style="padding:11px 14px"');
} else {
html += kv([
[tr('kv.class'), edgeLabel(row.origin)],
[tr('kv.euclid'), row.euclid_distance_m_auto],
[tr('kv.road'), row.road_route_m_auto],
[tr('kv.pathLength'), row.path_length_m_auto],
[tr('kv.detour'), row.detour_ratio_auto],
[tr('kv.roadConnected'), tr(row.road_connected ? 'yes' : 'no')],
[tr('kv.pathPoints'), row.road_path ? row.road_path.length : '—'],
[tr('kv.evidence'), row.evidence ? JSON.stringify(row.evidence) : '—'],
[tr('kv.verdict'), row.verdict || '—'],
]).replace('<dl', '<dl style="padding:11px 14px"');
}
html += EDIT ? EDIT.panel(row, isNode)
: ('<section class="field-block"><div class="block-title">' +
'<span>' + tr('readonly.title') + '</span></div><p style="margin:0;color:var(--faint);font-size:11px">' +
tr('readonly.body') + '</p></section>');
body.innerHTML = html;
if (EDIT) EDIT.bind(row, isNode);
drawGroundImages();
}
function setEdgeMode(mode) {
S.edgeMode = mode;
$('edgeRoad').classList.toggle('active', mode === 'road');
$('edgeStraight').classList.toggle('active', mode === 'straight');
if (S.scene) drawGraph();
}
/* ---------- 底图:OSM 电子地图瓦片 ------------------------------------ */
function setBasemap(mode) {
S.basemap = mode;
$('satelliteStage').classList.toggle('map-mode', mode === 'map');
$('baseSat').classList.toggle('active', mode === 'satellite');
$('baseMap').classList.toggle('active', mode === 'map');
if (mode === 'map') renderMapTiles();
}
/* 瓦片按 raster 百分比定位,和 SVG 的 400x400 viewBox 共用同一套坐标。
*
* 旧版把瓦片按固定 256 CSS px 摆在 stage 里,而 SVG 把 400 个 raster 单位
* 拉伸到 stage 的实际宽度(通常 500+ px)。两套尺度不一致,图和底图对不上;
* 窗口一改大小错得更多。现在换算成百分比,缩放与 zoom 都自动跟随。
*/
function renderMapTiles() {
if (!S.scene) return;
var c = tileCenter(S.scene.scene.satellite_path);
if (!c) return;
var layer = $('mapTileLayer');
layer.innerHTML = '';
var z = S.mapZoom;
var s = 256 * Math.pow(2, z);
var x = (c[1] + 180) / 360 * s;
var sy = Math.sin(c[0] * Math.PI / 180);
var y = (0.5 - Math.log((1 + sy) / (1 - sy)) / (4 * Math.PI)) * s;
// z 级的 1 像素 = 2^(z-ZOOM) 个 raster 单位的倒数
var k = Math.pow(2, z - ZOOM); // z 像素 / raster 单位
var tileR = 256 / k; // 一块瓦片占多少 raster 单位
var pct = function (v) { return (v / RASTER * 100) + '%'; };
var tx = Math.floor(x / 256), ty = Math.floor(y / 256);
// 场景中心位于 raster 的正中;瓦片 (tx,ty) 左上角相对中心的 z 像素偏移
var span = Math.ceil(RASTER / 2 / tileR) + 1;
for (var dx = -span; dx <= span; dx++) {
for (var dy = -span; dy <= span; dy++) {
var gx = tx + dx, gy = ty + dy;
if (gy < 0 || gy >= Math.pow(2, z)) continue;
var img = document.createElement('img');
img.src = 'https://tile.openstreetmap.org/' + z + '/' + gx + '/' + gy + '.png';
img.loading = 'lazy';
img.alt = '';
img.style.left = pct((gx * 256 - x) / k + RASTER / 2);
img.style.top = pct((gy * 256 - y) / k + RASTER / 2);
img.style.width = pct(tileR);
img.style.height = pct(tileR);
layer.appendChild(img);
}
}
}
/* ---------- 统计与进度 ------------------------------------------------ */
function refreshStats() {
return api('/api/stats').then(function (st) {
S.boot = st;
renderStats(st);
}).catch(function () {});
}
/* 单位由 HTML 里紧跟的 <span> 给出,数字这里不再带单位——
之前两处都写了,顶栏显示成「119,996 节点 节点」。 */
function renderStats(st) {
$('statScenes').textContent = st.scenes.toLocaleString();
$('statItems').textContent = st.nodes.toLocaleString();
$('statQueue').textContent = st.edges.toLocaleString();
var o = st.origins || {};
var cor = o.corridor || 0, road = o.road_route || 0;
var short_ = o.short_link || 0, merge = o.component_merge || 0;
var tot = cor + road + short_ + merge || 1;
$('countCorridor').textContent = cor.toLocaleString();
$('countRoad').textContent = road.toLocaleString();
$('countShort').textContent = short_.toLocaleString();
$('countMerge').textContent = merge.toLocaleString();
// 进度条表示「边有多少来自道路本身」(§1 蓝 + §2 绿),
// 剩下的是路网表达不了、靠 §3/§4 补上的。
var fromRoad = (cor + road) / tot * 100;
$('progressLabel').textContent = tr('stats.roadShare', { p: Math.round(fromRoad) });
$('progressBar').style.width = fromRoad + '%';
}
/* ---------- 启动与键盘 ------------------------------------------------ */
function boot() {
api('/api/bootstrap').then(function (b) {
var sel = $('cityFilter');
b.cities.forEach(function (c) {
var o = document.createElement('option');
o.value = o.textContent = c;
sel.appendChild(o);
});
return refreshStats();
}).then(function () { setMode('scenes'); });
['cityFilter', 'orderFilter', 'minNodes', 'editedFilter',
'fOrphan', 'fNoted', 'originFilter',
'kindFilter', 'signalFilter', 'showReviewedSel'].forEach(function (id) {
var el = $(id);
if (el) el.onchange = function () { S.offset = 0; syncFilterChrome(); loadList(); };
});
$('filterToggle').onclick = function () { toggleDrawer(); };
$('filterReset').onclick = function () {
FILTERS.forEach(clearFilter);
S.offset = 0; syncFilterChrome(); loadList();
};
$('modeScenes').onclick = function () { setMode('scenes'); };
$('modeQueue').onclick = function () { setMode('queue'); };
$('pagePrev').onclick = function () { page(-1); };
$('pageNext').onclick = function () { page(1); };
$('searchInput').oninput = function () {
clearTimeout(boot._t);
boot._t = setTimeout(function () {
if (S.mode === 'queue') applySearch();
else { S.offset = 0; loadScenes(true); }
}, 250);
};
$('previousButton').onclick = function () { navigate(-1); };
$('nextButton').onclick = function () { navigate(1); };
$('edgeRoad').onclick = function () { setEdgeMode('road'); };
$('edgeStraight').onclick = function () { setEdgeMode('straight'); };
$('baseSat').onclick = function () { setBasemap('satellite'); };
$('baseMap').onclick = function () { setBasemap('map'); };
$('mapZoomIn').onclick = function () { S.mapZoom = Math.min(19, S.mapZoom + 1); renderMapTiles(); };
$('mapZoomOut').onclick = function () { S.mapZoom = Math.max(13, S.mapZoom - 1); renderMapTiles(); };
$('langZh').onclick = function () { setLang('zh'); };
$('langEn').onclick = function () { setLang('en'); };
$('helpButton').onclick = function () { $('helpDialog').showModal(); };
$('helpClose').onclick = function () { $('helpDialog').close(); };
$('lightbox').onclick = function () { this.classList.remove('open'); };
document.addEventListener('keydown', function (ev) {
var tag = (ev.target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
if (ev.metaKey || ev.ctrlKey || ev.altKey) return;
var k = ev.key.toLowerCase();
if (k === '?') { $('helpDialog').showModal(); ev.preventDefault(); return; }
if (EDIT && EDIT.keys(k, ev)) { ev.preventDefault(); return; }
if (k === 'j') { navigate(1); ev.preventDefault(); }
else if (k === 'k') { navigate(-1); ev.preventDefault(); }
else if (k === 'b') { setBasemap(S.basemap === 'map' ? 'satellite' : 'map'); ev.preventDefault(); }
else if (k === '[') { page(-1); ev.preventDefault(); }
else if (k === ']') { page(1); ev.preventDefault(); }
else if (k === 'q') { setMode(S.mode === 'queue' ? 'scenes' : 'queue'); ev.preventDefault(); }
else if (k === 'e') { setEdgeMode(S.edgeMode === 'road' ? 'straight' : 'road'); ev.preventDefault(); }
else if (k === 'h') { cycleGround(ev.shiftKey ? -1 : 1); ev.preventDefault(); }
});
}
document.addEventListener('DOMContentLoaded', function () {
applyLang(); // 最先换语言,英文用户不会先看到一闪中文
if (EDIT) EDIT.boot(); // 写模式的 DOM 要在 boot() 取 $() 之前就位
boot();
});