Spaces:
Sleeping
Sleeping
Download web/app.js from MarcoXL/node-graph-viewer: direct link, hf CLI and curl.
- Browser
- Download file 66.9 kB
-
https://huggingface.co/spaces/MarcoXL/node-graph-viewer/resolve/main/web/app.js
- Command line
-
hf download hf://spaces/MarcoXL/node-graph-viewer/web/app.js
-
curl -L -o app.js https://huggingface.co/spaces/MarcoXL/node-graph-viewer/resolve/main/web/app.js
66.9 kB
| /* SetVL node graph 浏览器 —— 零构建步骤的原生 JS。 | |
| * | |
| * 界面与操作逻辑沿用标注台:三栏布局、全键盘流、底图切换、图片放大、 | |
| * 列表 J/K 导航、邻接矩阵、走廊着色。区别是本工具**只读**——展示自动化 | |
| * 流水线产出的最终图,不写回任何判定。 | |
| * | |
| * 快捷键: | |
| * J/K 列表导航 [ ] 翻页 Q 切换全部场景/带信号的 | |
| * B 切底图 E 切边的画法(沿路走线 / 直线) ? 帮助 | |
| * 顶栏最右的「中文 / EN」切换界面语言,见下文「中英双语」一节。 | |
| */ | |
| ; | |
| /* 写入层的挂载点。 | |
| * | |
| * 只读浏览器里恒为 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() 统一替换;夹着 | |
| * <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(); | |
| }); | |