/* 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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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() 统一替换;夹着 * 的整段用 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': '把当前节点的四个朝向依次放大看(Shift+H 反向)', '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 (Shift+H 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 会把它们一起抹掉。前后空白也保留, 「3 同走廊」里数字和词之间的空格就靠它。 */ 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__.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 = '

' + tr('list.noScenes') + '

'; 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 += '' + tr('badge.edited', { n: sc.revision_count }) + ''; if (sc.orphan_count) badges += '' + tr('badge.orphan', { n: sc.orphan_count }) + ''; if (sc.noted_count) badges += '' + tr('badge.noted', { n: sc.noted_count }) + ''; // 三行压成两行:城市并进第一行(原来单独占一行只为显示一个词), // 没有徽章时整条 meta 行不渲染,而不是留一个占位的破折号。 var name = sc.scene_id.split(':')[1] || sc.scene_id; b.title = sc.scene_id; b.innerHTML = '
' + esc(name) + '' + '' + sc.node_count + 'n/' + sc.edge_count + 'e
' + '
' + esc(sc.city) + '' + (badges ? '' + badges + '' : '') + '
'; 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 ? ' ' + esc(shown) + '' : '') + ' ×'; 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 '' + esc(n.code) + '' + (extra ? '' + esc(extra) + '' : ''); }).join('') || '—'; } 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 = '

' + tr('list.queueEmpty') + '

'; 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 = '
' + (i.kind === 'node' ? '●' : '─') + ' ' + esc(i.item_key) + '' + tr(i.kind === 'node' ? 'item.node' : 'item.edge') + '
' + '
' + noteHtml(i.notes) + '
' + '
' + esc(i.scene_id) + '
'; 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= 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 '
' + esc(c.name) + '
'; }).join('') || '
' + tr('corridor.noneParen') + '
'; } 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 = '' + labels.map(function (l) { return '' + l + ''; }).join('') + ''; labels.forEach(function (r) { html += '' + r + ''; labels.forEach(function (c) { if (r === c) { html += ''; return; } var e = cell[r + '|' + c]; if (!e) { // 空格子也可点——那是「自动化没给出这条边,但人认为该有」的入口 html += ''; 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 += '' + (e.in_graph ? '·' : '×') + ''; }); html += ''; }); 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 = '' + esc(e.source_label) + '–' + esc(e.target_label) + (e.is_manual ? ' M' : '') + (e.in_graph ? '' : ' ' + tr('adj.rejected') + '') + ((e.notes && e.notes.length) ? ' !' : '') + '' + (e.euclid_distance_m == null ? '—' : (+e.euclid_distance_m).toFixed(0) + ' m') + ''; 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 = '
' + tr('ground.empty') + '
'; 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 '
' + pairs.map(function (p) { return '
' + esc(p[0]) + '
' + esc(p[1] == null ? 'null' : p[1]) + '
'; }).join('') + '
'; } function renderInspector() { var body = $('inspectorBody'); var row = currentRow(); if (!row) { body.innerHTML = '

' + tr('insp.empty') + '

'; $('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 += '
' + '' + tr(isNode ? 'marks.node' : 'marks.edge') + '
'; 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 '
' + esc(sg.code) + '' + esc(extra) + '
'; }).join('') : '
' + tr('marks.none') + '
'; html += '
'; 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('
' + '' + tr('readonly.title') + '

' + tr('readonly.body') + '

'); 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 里紧跟的 给出,数字这里不再带单位—— 之前两处都写了,顶栏显示成「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(); });