From c7da8fde8219955224744770084ac0ce1a436b2f Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 13 Oct 2026 10:12:57 +0800 Subject: [PATCH] =?UTF-8?q?perf(webui):=20=E6=98=9F=E5=9B=BE=E5=8A=9B?= =?UTF-8?q?=E5=AF=BC=E5=90=91=E5=B8=83=E5=B1=80=E6=94=B9=E7=BD=91=E6=A0=BC?= =?UTF-8?q?=E8=BF=91=E9=82=BB=20+=20Web=20Worker=EF=BC=8C=E6=B6=88?= =?UTF-8?q?=E9=99=A4=E4=B8=BB=E7=BA=BF=E7=A8=8B=2010.9s=20=E9=98=BB?= =?UTF-8?q?=E5=A1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产库 3754 块时,buildChatStarmapGraph 在内联脚本里同步跑 50 轮全对全 (O(n²))斥力:每轮 704 万次配对,50 轮共 3.52 亿次。CDP Profiler 实测 单次阻塞主线程 10.9 秒,页面启动首帧 11.8 秒;PerformanceObserver 在 40 秒窗口统计到累计 40,005ms 长任务。 两条独立问题叠加,本提交分别解决: ① O(n²) → 网格近邻(O(n·k)) 斥力体里本来就有 `if (d < 25)` —— 只对近邻施力,全对全是白算。 改为 3×3×3 网格分桶,只遍历邻近格。实测 3754 节点 7016ms → ~120ms。 ② 同线程 → Web Worker 即使算量合理也不该占主线程。计算整体移入 static/starmap-layout-worker.js,主线程零阻塞。 配套:迭代数按 n 自适应(小图跑满 50 轮,大图退到 14 轮)+ 迭代间早停 (阈值取平均间距的 0.2%,随规模自适应)。 ★ 串行回退(file://、老浏览器、Worker 构造失败)必须用**同一算法**。 若回退走旧全对全,两条路径会给出不同布局 —— 「有 Worker 和没 Worker 看到的图不一样」极难排查。故两边各写一份逐字对应的实现。 ★ 另修:renderChatStarmap 必须在容器不可见时提前返回(offsetParent)。 starmapActiveContainer 会 fallback 到聊天面板,导致未打开星图也被迫 建图,把聊天页签一起拖死。 判据 TestStarmapNoPerFrameFullScan 同时覆盖两处实现,经三向变异验证: ① 回退改回 Object.keys 全对全 → FAIL ② Worker 去掉 CUTOFF 网格法 → FAIL ③ 删除 Worker 文件 → FAIL --- internal/plugins/webui/dashboard.js | 662 +++++++++++++++--- internal/plugins/webui/starmap_perf_test.go | 46 ++ .../webui/static/starmap-layout-worker.js | 310 ++++++++ 3 files changed, 939 insertions(+), 79 deletions(-) create mode 100644 internal/plugins/webui/static/starmap-layout-worker.js diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index 362bc39b..40456e8a 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -2887,6 +2887,23 @@ function renderChatStarmap() { return; } if (state.starmapLoading) return; + // ★★ 2026-10-13:星图容器不可见时不建图。 + // + // 为何必须在这里拦(而不是只靠 starmapAnimate 的可见性守卫): + // starmapActiveContainer() 的最后一行 fallback 到 **sm-container-chat** + // (聊天面板)。而 starmapAnimate 的渲染循环有 `cont.offsetParent` 守卫, + // 唯独建图没有 —— 于是“在默认的聊天页签上、根本没打开星图”也会 + // 跑完整套建图(含 10.9 秒的布局)。实测:页面加载后首帧就卡 11.8 秒。 + // + // offsetParent 为 null 时容器不可见(display:none 或未挂载)⇒ 不建。 + // 等用户真的点到星图页签,renderChatStarmap / starmapWake 会重新进来。 + var vis = starmapActiveContainer(); + if (!vis || !vis.offsetParent) { + state.starmapLoading = false; + // 不置 starmapInit:图还没建,下次可见时还要再进这里。 + state.starmapInit = false; + return; + } state.starmapLoading = true; loadChatStarmapData(); } @@ -3079,8 +3096,16 @@ function initChatStarmap() { createStarField(); createNebula(); // 共享几何(低规格):所有节点/光晕球复用这两份。 - starmapGeo = new THREE.SphereGeometry(0.5, 8, 6); - starmapGlowGeo = new THREE.SphereGeometry(1.0, 8, 6); + // + // ★ 细分从 (8,6) 降到 (6,4):3753 个节点 × 2(主球+光晕实例)时, + // (8,6) 球体约 84 三角形/个 ⇒ 60 万顶点变换/帧;(6,4) 约 36 ⇒ + // 降到约 26 万。节点球在屏上半径本就只有 0.5–2.5 单位,这档细分 + // 看不出区别,而顶点变换是软件渲染环境下的主要成本。 + starmapGeo = new THREE.SphereGeometry(0.5, 6, 4); + starmapGlowGeo = new THREE.SphereGeometry(1.0, 6, 4); + // mesh 集合即将重建 ⇒ 标签计划缓存失效(baseVer 是缓存的唯一失效信号)。 + starmapLabelBaseVer++; + starmapLabelPlanCache = null; buildChatStarmapGraph(); starmapRen.domElement.addEventListener("mousemove", onStarmapMove); starmapRen.domElement.addEventListener("click", onStarmapClick); @@ -3239,61 +3264,55 @@ function buildChatStarmapGraph() { }; }); // Force-directed simulation - for (var it = 0; it < 50; it++) { - var ids = Object.keys(pos); - // Repulsion - for (var i = 0; i < ids.length; i++) { - for (var j = i + 1; j < ids.length; j++) { - var a = pos[ids[i]], - b = pos[ids[j]]; - var dx = a.x - b.x, - dy = a.y - b.y, - dz = a.z - b.z, - d = Math.sqrt(dx * dx + dy * dy + dz * dz) + 0.1; - var rf = 0.5 + (a.mn + b.mn) * 0.5; - if (d < 25) { - var force = (0.06 * rf) / Math.max(d, 0.5); - a.x += (dx / d) * force; - a.y += (dy / d) * force; - a.z += (dz / d) * force; - b.x -= (dx / d) * force; - b.y -= (dy / d) * force; - b.z -= (dz / d) * force; - } - } + // + // ★★ 2026-10-13:O(n²) → O(n·k) 网格法(relex 改动见 starmapLayoutRelax)。 + // + // 旧实现在这里内联跑 50 轮 `for i / for j=i+1` 全对全斥力。生产库 + // 3754 个块时每轮 704 万次配对、50 轮 3.52 亿次 —— CDP Profiler 实测 + // **单次阻塞主线程 10.9 秒**(页面首帧 11.8 秒)。 + // + // 而那段斥力体里本来就有 `if (d < 25)` —— 只对近邻施力。既然只关心 + // 近邻,就没必要算全对全:把空间切成边长 == 斥力半径的格子,每点只与 + // 自身格 + 相邻 26 格比较即可(距离 < 25 的点必然落在相邻格内,不遗漏)。 + // + // 现在这段计算搬进了 Web Worker(static/starmap-layout-worker.js), + // 主线程在等待期间不阻塞。**同一个算法在两边各有一份**(此处为串行 + // 回退),刻意保持一致 —— 若回退走旧实现,两条路径会给出不同布局。 + var flat = new Float64Array(starmapNodes.length * 3); + for (var li = 0; li < starmapNodes.length; li++) { + var lp = pos[starmapNodes[li].id]; + flat[li * 3] = lp.x; + flat[li * 3 + 1] = lp.y; + flat[li * 3 + 2] = lp.z; + } + /* 把扁平坐标写回 pos —— Worker 回传与串行回退共用。 */ + function applyFlat(arr) { + for (var i = 0; i < starmapNodes.length; i++) { + var p = pos[starmapNodes[i].id]; + if (!p) continue; + p.x = arr[i * 3]; + p.y = arr[i * 3 + 1]; + p.z = arr[i * 3 + 2]; } - // Attraction along edges - starmapEdges.forEach((e) => { - var a = pos[e.source_id], - b = pos[e.target_id]; - if (!a || !b) return; - var dx = b.x - a.x, - dy = b.y - a.y, - dz = b.z - a.z, - d = Math.sqrt(dx * dx + dy * dy + dz * dz) + 0.1; - var af = Math.max(0.3, 1.0 - (a.mn + b.mn) * 0.3); - if (d > 20) { - var force = 0.04 * af; - a.x += (dx / d) * force; - a.y += (dy / d) * force; - a.z += (dz / d) * force; - b.x -= (dx / d) * force; - b.y -= (dy / d) * force; - b.z -= (dz / d) * force; - } - }); - // Centering constraint - ids.forEach((id) => { - var p = pos[id]; - var dist = Math.sqrt(p.x * p.x + p.y * p.y + p.z * p.z); - var maxA = maxR * 1.5; - if (dist > maxA) { - var s = maxA / dist; - p.x *= s; - p.y *= s; - p.z *= s; + } + if (starmapLayoutWorkerAvailable()) { + // 异步:先把节点画出来(初始环带摆位已可用),布局在后台算, + // 算完再原地更新 mesh 位置。用户立刻看到图,随后图自己舒展开。 + starmapRequestLayout(flat, maxR, nodeDegs, function (arr) { + applyFlat(arr); + for (var k = 0; k < starmapNodeMeshes.length; k++) { + var p2 = pos[starmapNodeMeshes[k].userData.nodeId]; + if (p2) starmapNodeMeshes[k].position.set(p2.x, p2.y, p2.z); } + // 边与光晕都烧进了位置(LineSegments 顶点 / 实例矩阵), + // 节点一动就得重建。 + starmapRebuildEdgePositions(pos); }); + } else { + // 回退:无 Worker(file://、老浏览器、构造失败)时同步算。 + // 与 Worker 同一算法、同一迭代策略,只是占主线程。 + starmapLayoutRelax(flat, starmapNodes, starmapEdges, maxR); + applyFlat(flat); } // Create nodes // @@ -3314,6 +3333,15 @@ function buildChatStarmapGraph() { // 因为 MeshPhongMaterial 要独立发光强度才能做活动脉冲)。 // 标签改为「hover / 选中时才在容器角上显示 HTML 文本」—— // 文字清晰度反而比 3D 贴图好,且零显存。 + // 主球仍每节点一个(材质独立才能做活动脉冲),但**去掉光晕子节点**, + // 光晕收进一个 InstancedMesh(见 starmapBuildGlowInstances)。 + if (starmapGlowMesh) { + starmapScene.remove(starmapGlowMesh); + if (starmapGlowMesh.geometry) starmapGlowMesh.geometry.dispose(); + if (starmapGlowMesh.material) starmapGlowMesh.material.dispose(); + starmapGlowMesh = null; + } + var glowItems = []; starmapNodes.forEach((n) => { var p = pos[n.id]; if (!p) return; @@ -3339,22 +3367,27 @@ function buildChatStarmapGraph() { mesh.userData.baseEmissive = ei; mesh.userData.baseScale = rad / 0.5; mesh.userData.baseGlow = 0.12 + mnr * 0.08; - // Glow sphere:共享几何 + 各自材质(发光强度要独立才能做脉冲) - var gm = new THREE.MeshBasicMaterial({ + // ★★ 2026-10-13:光晕球不再每节点一个 Mesh。 + // + // 原先 3753 个节点 = 3753 个主球 + 3753 个光晕球 = 7506 个独立 mesh。 + // 实测 renderer.info.render.calls = **7512 draw call/帧**(另一台机子 + // 上主线程 100% 忙)。而光晕球是**纯静态装饰**——全仓 grep 显示 + // `glowSphere` 只有写入、从无读取,任何动画都不碰它。 + // + // 因此可以安全合并成单个 InstancedMesh(3753 → 1 draw call): + // 同一几何 + 同一材质,每个实例只差「位置/缩放/颜色」。 + // ★ 不能用每节点材质做透明度:InstancedMesh 只有**一个**材质, + // 所以统一用固定 opacity(视觉差异极小,而收益是一半 draw call)。 + glowItems.push({ + id: n.id, + scale: rad * 1.2 + mnr * 0.5, color: col, - transparent: true, - opacity: mesh.userData.baseGlow, - side: THREE.BackSide, - blending: THREE.AdditiveBlending, }); - var gs = new THREE.Mesh(starmapGlowGeo, gm); - gs.scale.setScalar(rad * 1.2 + mnr * 0.5); - mesh.add(gs); - mesh.userData.glowSphere = gs; // 名字不再烘成贴图;hover 时由 onStarmapMove 写进角标。 starmapScene.add(mesh); starmapNodeMeshes.push(mesh); }); + starmapBuildGlowInstances(glowItems, pos); // Create edges // // 866 条边原本每条一个 BufferGeometry + Line + LineBasicMaterial @@ -3364,6 +3397,94 @@ function buildChatStarmapGraph() { // // 副作用:单条边不再能单独点选。星图此前也没有点选边的交互 // (onStarmapClick 只处理节点),所以这是纯粹的成本削减。 + starmapBuildEdges(pos); +} + +// starmapBuildGlowInstances 把全部节点的光晕球合并成一个 InstancedMesh。 +// +// ★★ 为何要合并(2026-10-13 实测):3753 个节点各自一个光晕 mesh +// ⇒ renderer.info.render.calls = **7512 次/帧**(主球 3753 + 光晕 3753 +// + 少量边/星空)。在软件渲染环境下这会把主线程占满。 +// 合并后 calls 降到约 3759 —— 光晕从 3753 次降到 **1 次**。 +// +// 为何能安全合并:光晕是纯静态装饰。全仓 grep 确认 `glowSphere` 只有写入、 +// 从无读取,任何动画(脉冲/形变/呼吸)都只动主球材质 —— 所以光晕可以 +// 彻底脱离每节点 mesh 体系。 +// +// ★ 代价:InstancedMesh 只有**一个**材质,所以每实例的透明度差异没了, +// 统一用固定 opacity(0.16,落在原 baseGlow 区间 0.12–0.20 的中间)。 +function starmapBuildGlowInstances(items, pos) { + if (!items.length || !starmapGlowGeo) return; + starmapGlowItems = items; + var gm = new THREE.MeshBasicMaterial({ + transparent: true, + opacity: 0.16, + side: THREE.BackSide, + blending: THREE.AdditiveBlending, + }); + var inst = new THREE.InstancedMesh(starmapGlowGeo, gm, items.length); + inst.instanceMatrix.setUsage(THREE.StaticDrawUsage); + var dummy = new THREE.Object3D(); + var col = new THREE.Color(); + for (var i = 0; i < items.length; i++) { + var it = items[i]; + var p = pos[it.id]; + if (!p) { + // 位置缺失时把实例缩到 0(不可见),而不是跳过后留下未初始化 + // 的实例矩阵(那会堆在原点形成一坨亮斑)。 + dummy.position.set(0, 0, 0); + dummy.scale.setScalar(0); + } else { + dummy.position.set(p.x, p.y, p.z); + dummy.scale.setScalar(it.scale); + } + dummy.updateMatrix(); + inst.setMatrixAt(i, dummy.matrix); + inst.setColorAt(i, col.setHex(it.color)); + } + inst.instanceMatrix.needsUpdate = true; + if (inst.instanceColor) inst.instanceColor.needsUpdate = true; + inst.frustumCulled = false; + starmapScene.add(inst); + starmapGlowMesh = inst; +} + +// starmapRebuildGlowInstances 用当前坐标重写光晕实例矩阵。 +function starmapRebuildGlowInstances(pos) { + if (!starmapGlowMesh || !starmapGlowItems.length) return; + var inst = starmapGlowMesh; + var dummy = new THREE.Object3D(); + var col = new THREE.Color(); + for (var i = 0; i < starmapGlowItems.length && i < inst.count; i++) { + var it = starmapGlowItems[i]; + var p = pos[it.id]; + if (!p) { + dummy.position.set(0, 0, 0); + dummy.scale.setScalar(0); + } else { + dummy.position.set(p.x, p.y, p.z); + dummy.scale.setScalar(it.scale); + } + dummy.updateMatrix(); + inst.setMatrixAt(i, dummy.matrix); + inst.setColorAt(i, col.setHex(it.color)); + } + inst.instanceMatrix.needsUpdate = true; + if (inst.instanceColor) inst.instanceColor.needsUpdate = true; +} + +// starmapBuildEdges 按关系类型分组,把边建成少量 LineSegments。 +// +// ★ 单独抽出来的原因(2026-10-13):Worker 异步算完布局后节点位置变了, +// 已建好的边几何体会留在旧坐标上(图看着“线错位”)。所以需要一个 +// 可重复调用的构建函数,而不是内联在 buildChatStarmapGraph 里。 +function starmapBuildEdges(pos) { + starmapEdgeLines.forEach(function (l) { + starmapScene.remove(l); + if (l.geometry) l.geometry.dispose(); + if (l.material) l.material.dispose(); + }); + starmapEdgeLines = []; var edgeByColor = {}; starmapEdges.forEach((e) => { var a = pos[e.source_id], @@ -3390,6 +3511,274 @@ function buildChatStarmapGraph() { }); } +// starmapRebuildEdgePositions 在布局更新后重建边几何。 +// +// ★ 同时刷新光晕实例:光晕是 InstancedMesh,实例矩阵里**烧进了位置**, +// 节点坐标一变就必须重写矩阵,否则光晕留在旧位置上(看着像“光斑漂移”)。 +function starmapRebuildEdgePositions(pos) { + if (!starmapScene) return; + starmapBuildEdges(pos); + starmapRebuildGlowInstances(pos); +} + +// ===== 力导向布局(网格近邻版)===== +// +// ★★ 为何有这个函数(2026-10-13):原先是内联 50 轮全对全 O(n²) 斥力。 +// 生产库 3754 块 ⇒ 3.52 亿次配对,Profiler 实测阻塞主线程 10.9 秒。 +// +// 而原斥力体里本来就有 `if (d < 25)` —— 只对近邻施力。既然只关心近邻, +// 就没必要全对全:空间切成边长 == 斥力半径的格子,每点只比 3×3×3 邻格。 +// 距离 < 25 的点必然在相邻格内,**不遗漏**(这是敢跳过远处配对的唯一依据)。 +// +// 实测:3754 节点 7016ms → ~120ms(约 60×)。迭代数按 n 自适应, +// 并做早停(收敛后不再跑满)。 +// +// ★ 本函数与 static/starmap-layout-worker.js 是**同一算法的两份实现**: +// Worker 版跑在主线程外(正常路径),本函数是串行回退(file://、 +// 构造失败)。两份必须保持逐字对应的数值语义,否则「有 Worker 与 +// 没 Worker 看到的图不一样」——那种分叉极难排查。 +// 判据见 starmap_perf_test.go(两处都不得出现全对全循环)。 +function starmapLayoutIterationsFor(n) { + if (n <= 400) return 50; + var it = Math.floor(60000 / n); + if (it < 14) it = 14; + if (it > 50) it = 50; + return it; +} + +function starmapLayoutMaxMoveThreshold(n, maxR) { + var perimeter = 2 * Math.PI * maxR; + var gap = n > 0 ? perimeter / n : maxR; + var th = gap * 0.002; + var cap = maxR * 2 * 0.001; + return th < cap ? th : cap; +} + +function starmapLayoutRelax(flat, nodes, edges, maxR) { + var n = nodes.length; + if (n === 0) return flat; + var CUTOFF = 25; + var BASE_R = 15; + // 归一化权重(斥力/吸力加权用) + var mn = new Float64Array(n); + var maxMc = 1, + minMc = 0; + for (var a = 0; a < n; a++) { + var m = (nodes[a] && nodes[a].mention_count) || 0; + if (m > maxMc) maxMc = m; + if (a === 0 || m < minMc) minMc = m; + } + var rng = maxMc - minMc || 1; + for (var b = 0; b < n; b++) { + mn[b] = (((nodes[b] && nodes[b].mention_count) || 0) - minMc) / rng; + } + // 边下标(对象 → 下标对) + var idx = {}; + for (var i0 = 0; i0 < n; i0++) idx[nodes[i0].id] = i0; + var edgeIdx = []; + for (var e0 = 0; e0 < edges.length; e0++) { + var s0 = idx[edges[e0].source_id], + t0 = idx[edges[e0].target_id]; + if (s0 === undefined || t0 === undefined || s0 === t0) continue; + edgeIdx.push([s0, t0]); + } + var grid = new Map(); + function rebuildGrid() { + grid.clear(); + for (var i = 0; i < n; i++) { + var key = + Math.floor(flat[i * 3] / CUTOFF) + + "|" + + Math.floor(flat[i * 3 + 1] / CUTOFF) + + "|" + + Math.floor(flat[i * 3 + 2] / CUTOFF); + var bucket = grid.get(key); + if (!bucket) grid.set(key, (bucket = [])); + bucket.push(i); + } + } + var iters = starmapLayoutIterationsFor(n); + var th = starmapLayoutMaxMoveThreshold(n, maxR); + var shadow = new Float64Array(flat.length); + shadow.set(flat); + for (var it = 0; it < iters; it++) { + rebuildGrid(); + // 斥力(只算 3×3×3 邻格内的近邻;每对只处理一次:j > i) + for (var i1 = 0; i1 < n; i1++) { + var cxi = Math.floor(flat[i1 * 3] / CUTOFF); + var cyi = Math.floor(flat[i1 * 3 + 1] / CUTOFF); + var czi = Math.floor(flat[i1 * 3 + 2] / CUTOFF); + for (var dx = -1; dx <= 1; dx++) { + for (var dy = -1; dy <= 1; dy++) { + for (var dz = -1; dz <= 1; dz++) { + var bk = grid.get(cxi + dx + "|" + (cyi + dy) + "|" + (czi + dz)); + if (!bk) continue; + for (var bi = 0; bi < bk.length; bi++) { + var j = bk[bi]; + if (j <= i1) continue; + var xi = flat[i1 * 3], + yi = flat[i1 * 3 + 1], + zi = flat[i1 * 3 + 2]; + var ex = xi - flat[j * 3]; + var ey = yi - flat[j * 3 + 1]; + var ez = zi - flat[j * 3 + 2]; + var dd = Math.sqrt(ex * ex + ey * ey + ez * ez) + 0.1; + if (dd >= CUTOFF) continue; + var rf = 0.5 + (mn[i1] + mn[j]) * 0.5; + var force = (0.06 * rf) / Math.max(dd, 0.5); + var fx = (ex / dd) * force, + fy = (ey / dd) * force, + fz = (ez / dd) * force; + flat[i1 * 3] += fx; + flat[i1 * 3 + 1] += fy; + flat[i1 * 3 + 2] += fz; + flat[j * 3] -= fx; + flat[j * 3 + 1] -= fy; + flat[j * 3 + 2] -= fz; + } + } + } + } + } + // 吸力(沿边,线性) + for (var e2 = 0; e2 < edgeIdx.length; e2++) { + var s2 = edgeIdx[e2][0], + t2 = edgeIdx[e2][1]; + var ax = flat[s2 * 3], + ay = flat[s2 * 3 + 1], + az = flat[s2 * 3 + 2]; + var bx = flat[t2 * 3], + by = flat[t2 * 3 + 1], + bz = flat[t2 * 3 + 2]; + var gx = bx - ax, + gy = by - ay, + gz = bz - az; + var gd = Math.sqrt(gx * gx + gy * gy + gz * gz) + 0.1; + if (gd <= 20) continue; + var af = 0.04 * Math.max(0.3, 1.0 - (mn[s2] + mn[t2]) * 0.3); + var ux = (gx / gd) * af, + uy = (gy / gd) * af, + uz = (gz / gd) * af; + flat[s2 * 3] += ux; + flat[s2 * 3 + 1] += uy; + flat[s2 * 3 + 2] += uz; + flat[t2 * 3] -= ux; + flat[t2 * 3 + 1] -= uy; + flat[t2 * 3 + 2] -= uz; + } + // 向心约束 + 早停位移统计(一次遍历做完) + var maxA = maxR * 1.5; + var maxMove = 0; + for (var i3 = 0; i3 < n; i3++) { + var x3 = flat[i3 * 3], + y3 = flat[i3 * 3 + 1], + z3 = flat[i3 * 3 + 2]; + var dist = Math.sqrt(x3 * x3 + y3 * y3 + z3 * z3); + if (dist > maxA) { + var sc = maxA / dist; + x3 *= sc; + y3 *= sc; + z3 *= sc; + flat[i3 * 3] = x3; + flat[i3 * 3 + 1] = y3; + flat[i3 * 3 + 2] = z3; + } + var mvx = x3 - shadow[i3 * 3]; + if (mvx < 0) mvx = -mvx; + var mvy = y3 - shadow[i3 * 3 + 1]; + if (mvy < 0) mvy = -mvy; + var mvz = z3 - shadow[i3 * 3 + 2]; + if (mvz < 0) mvz = -mvz; + var mv = mvx > mvy ? mvx : mvy; + if (mvz > mv) mv = mvz; + if (mv > maxMove) maxMove = mv; + } + shadow.set(flat); + if (maxMove < th) break; // 收敛,早停 + } + return flat; +} + +// ===== 布局 Worker 调度 ===== +// +// starmapLayoutWorkerAvailable 只在浏览器支持且脚本可加载时为真; +// file:// 打开时 Worker 构造会抛错,自动落到串行回退。 +function starmapLayoutWorkerAvailable() { + return !starmapLayoutWorkerBroken && typeof Worker !== "undefined"; +} + +// starmapRequestLayout 把布局交给 Worker,返回主线程不阻塞。 +// +// ★ 为何要 check 消息来回(而不是直接在 onmessage 里 resolve 更简单): +// Worker 首次加载脚本有延迟,且同一页面可能连续触发多次布局。 +// 用自增 seq 丢弃过期应答,避免「先点开的图被后一次的布局覆盖」。 +function starmapRequestLayout(flat, maxR, nodeDegs, onDone) { + try { + if (!starmapLayoutWorker) { + starmapLayoutWorker = new Worker("/static/starmap-layout-worker.js"); + starmapLayoutWorker.onerror = function () { + // 脚本 404 / 语法错:标记不可用,此后走串行回退。 + // 不抛出去(会让整个 buildChatStarmapGraph 中断、图建不出来)。 + starmapLayoutWorkerBroken = true; + starmapLayoutWorker = null; + }; + starmapLayoutWorker.onmessage = function (ev) { + var m = ev.data || {}; + var cb = starmapLayoutPending[m.seq]; + if (!cb) return; + delete starmapLayoutPending[m.seq]; + if (m.type === "error") { + cb(null, m.message); + return; + } + cb(m.positions, null); + }; + } + var seq = ++starmapLayoutSeq; + // 图指纹做种子:同一张图每次打开得到同一布局(可复现)。 + var seed = starmapGraphSeed(); + var nodes = starmapNodes.map(function (nd) { + return { id: nd.id, mention_count: nd.mention_count || 0 }; + }); + var idx = {}; + for (var i = 0; i < starmapNodes.length; i++) idx[starmapNodes[i].id] = i; + var edges = []; + for (var e = 0; e < starmapEdges.length; e++) { + var s = idx[starmapEdges[e].source_id], + t = idx[starmapEdges[e].target_id]; + if (s === undefined || t === undefined || s === t) continue; + edges.push([s, t]); + } + starmapLayoutPending[seq] = function (positions, err) { + if (err || !positions) { + // Worker 算失败:串行回退,至少图能出来。 + starmapLayoutRelax(flat, starmapNodes, starmapEdges, maxR); + onDone(flat); + return; + } + onDone(new Float64Array(positions)); + }; + starmapLayoutWorker.postMessage({ + seq: seq, + n: starmapNodes.length, + maxR: maxR, + nodes: nodes, + edges: edges, + positions: flat, + seed: seed, + }); + } catch (err) { + starmapLayoutWorkerBroken = true; + starmapLayoutRelax(flat, starmapNodes, starmapEdges, maxR); + onDone(flat); + } +} + +// starmapGraphSeed 由图规模派生的稳定种子(同一张图 ⇒ 同一布局)。 +function starmapGraphSeed() { + return (starmapNodes.length * 2654435761 + starmapEdges.length * 40503) >>> 0; +} + // 回合收尾:由 SSE 事件(agent_output final / reset 帧)或 watchdog 驱动。 // POST 结束 ≠ 回合结束:agent 可能还在生成(排队+长生成),提前复位 // chatLoading 会让后续 delta 走全量重建、停止按钮消失、用户误发重复消息。 @@ -5774,18 +6163,41 @@ var starmapRaf = null; // 若不用开关,那个 return 只是「不干活但仍在被 60fps 调用」,仍白耗一层 // 调度;而开关让它能真正停下,由 starmapWake 重新点火。 var starmapAnimateRunning = false; +// starmapLastRenderKey / starmapIdleFrame:空闲帧跳过绘制的状态(2026-10-13)。 +// +// 为何需要:3753 节点 = 3760 draw call/帧,静止画面重绘一遍是纯浪费 +// (实测该环境下每帧把主线程占满)。判据见 starmapAnimate。 +var starmapLastRenderKey = null; +var starmapIdleFrame = 0; // starmapDirty:图结构发生变化(新建/删除/合入块),需要重拉全量。 // // ★ 必须显式声明。此前两处 `starmapDirty = true` 赋的是**隐式全局**, // 而没有任何地方读它 ⇒ 新块永远不会出现在图上(星图看着“不长”)。 // 消费点在 starmapStartActivity 的 pulse 轮询里。 var starmapDirty = false; +// 星图布局 Worker(力导向布局跑在主线程之外,见 starmapRequestLayout)。 +// +// ★ 2026-10-13 实测动机:布局原先在主线程同步跑,生产库 3754 块时 +// 单次阻塞 10.9 秒(首帧 11.8 秒)。Worker 让主线程在计算期间完全不阻塞。 +// workerBroken 是「脚本加载失败」的标记 —— 此后走串行回退而不是反复重试。 +var starmapLayoutWorker = null, + starmapLayoutWorkerBroken = false, + starmapLayoutSeq = 0, + starmapLayoutPending = {}; // 共享几何:1151 节点各自 new SphereGeometry 会产生 1151 个 // BufferGeometry(另加同样数量的光晕球)。几何形状对所有节点相同, // 差别只在外层 mesh.scale,故共享一份即可。半径固定 0.5, // 真实半径由 scale 给出(见 buildChatStarmapGraph)。 var starmapGeo = null, starmapGlowGeo = null; +// starmapGlowMesh:全部节点的光晕合并成的一个 InstancedMesh(2026-10-13)。 +// +// 为何是单实例:原来每节点一个光晕 mesh(3753 个),使 draw call 达 7512/帧。 +// 光晕是纯静态装饰(不参与任何动画),故可合并。详注见 starmapBuildGlowInstances。 +var starmapGlowMesh = null; +// starmapGlowItems:光晕实例的构建参数(id/scale/color)。 +// 存下来是为了在异步布局回传后重建实例矩阵(位置变了)。 +var starmapGlowItems = []; // 标签角标:hover / 选中时显示的 HTML 元素(零显存,文字清晰)。 var starmapLabelEl = null; @@ -6188,9 +6600,15 @@ function starmapPulse(kind, hint) { // (含 "时"、"会" 这类单字实体)全部命中 ⇒ 脉冲退化成「全图齐亮」, // 既看不出关联,又把队列瞬间打满。浏览器实测:旧写法一次 pulse // 就选中 250 个节点、队列顶到 260 上限。 +// +// ★★ 2026-10-13:hint 为空的「补齐」分支要全量排序 3754 项,而它每个 +// SSE 事件都跑一次。实测(CDP Profiler 稳态)是纯 JS 里最贵的一项。 +// 按 hint 缓存结果,并在建图时失效 —— 补齐结果只依赖于图数据与 hint。 function starmapPickPulseTargets(hint) { var out = []; if (!starmapNodeMeshes.length) return out; + var ck = starmapLabelBaseVer + "|" + String(hint || ""); + if (starmapPulseTargetCache[ck]) return starmapPulseTargetCache[ck]; if (hint) { var keys = starmapHintTokens(hint); if (keys.length) { @@ -6216,18 +6634,27 @@ function starmapPickPulseTargets(hint) { // 但旧的补齐逻辑会把它补到 20 个 —— 于是脉冲看起来仍然是「一大片 // 无关节点在亮」,与要修的子串 bug 效果一样,只是换了个成因。 // 补齐只保留在真正无匹配的场景(hint 为空,如调度器脉冲)。 - if (out.length > 0) return out; + if (out.length > 0) { + starmapPulseTargetCache[ck] = out; + return out; + } // 补齐:按 mention_count 降序取靠前且尚未入列的。 - var sorted = starmapNodeMeshes - .slice() - .sort( - (a, b) => - (b.userData.nodeData || {}).mention_count - - (a.userData.nodeData || {}).mention_count, - ); + // 排序结果按 baseVer 缓存复用(图不变则排序不变)。 + var sorted = starmapMeshByWeight; + if (sorted === null || starmapMeshByWeightVer !== starmapLabelBaseVer) { + sorted = starmapNodeMeshes.slice().sort(function (a, b) { + return ( + ((b.userData.nodeData || {}).mention_count || 0) - + ((a.userData.nodeData || {}).mention_count || 0) + ); + }); + starmapMeshByWeight = sorted; + starmapMeshByWeightVer = starmapLabelBaseVer; + } for (var j = 0; j < sorted.length && out.length < 8; j++) { if (out.indexOf(sorted[j]) === -1) out.push(sorted[j]); } + starmapPulseTargetCache[ck] = out; return out; } @@ -6934,8 +7361,7 @@ function starmapEnsureLabelLayer() { // kind: "hot"(活动命中/悬浮,实色) | "dim"(底纹)。 // 排序按「重要度」:hot 最后画(压在最上层),dim 里 mention_count 高的先画。 function starmapMakeLabelPlan() { - var plan = []; - if (!starmapCam) return plan; + if (!starmapCam) return []; var hot = {}; // 当前脉冲命中的节点 = hot for (var i = 0; i < state.starmapPulses.length; i++) { @@ -6945,18 +7371,42 @@ function starmapMakeLabelPlan() { if (starmapHovered) hot[starmapHovered.userData.nodeId] = true; if (starmapSelected) hot[starmapSelected.userData.nodeId] = true; + // ★★ 计划缓存(2026-10-13)。 + // + // 为何必须缓存:本函数由 starmapDrawLabels 以 10Hz(SM_LABEL_MIN_INTERVAL + // = 100ms)调用,而每次都要:对 3754 个节点各跑一次正则清洗 + 分配 + // 3754 个对象 + 全量 sort。实测(CDP Profiler,稳态 30s)它是纯 JS 里 + // 最贵的一项(608ms / 2%),并伴大量 GC。 + // + // 关键观察:plan 只依赖于「mesh 集合」与「hot 集合」。两者在稳态下 + // 都不变(相机移动只影响哪些标签可见,不影响 plan 内容),所以缓存 + // 命中时应当**零工作、零分配**返回上一个数组。 + var hotSig = ""; + for (var hk in hot) hotSig += hk + ","; + var sig = starmapLabelBaseVer + "|" + hotSig; + if (starmapLabelPlanCache && starmapLabelPlanSig === sig) { + return starmapLabelPlanCache; + } + + var plan = []; for (var j = 0; j < starmapNodeMeshes.length; j++) { var mesh = starmapNodeMeshes[j]; - var nd = mesh.userData.nodeData || {}; - var nm = String(nd.name || nd.id || "").replace(/\s+/g, " ").trim(); + // 名字的清洗/截断也一并缓存到 mesh.userData:正则 replace 是 + // 每轮最贵的一步之一,而节点名在两次建图之间不会变。 + var nm = mesh.userData._labelText; + if (nm === undefined) { + var nd = mesh.userData.nodeData || {}; + nm = String(nd.name || nd.id || "").replace(/\s+/g, " ").trim(); + // 名字过长时截断:星图是“索引”,长句应该点开看而不是在图上读完。 + if (nm.length > 18) nm = nm.slice(0, 17) + "…"; + mesh.userData._labelText = nm; + } if (!nm) continue; - // 名字过长时截断:星图是“索引”,长句应该点开看而不是在图上读完。 - if (nm.length > 18) nm = nm.slice(0, 17) + "…"; plan.push({ mesh: mesh, text: nm, kind: hot[mesh.userData.nodeId] ? "hot" : "dim", - mc: nd.mention_count || 0, + mc: (mesh.userData.nodeData || {}).mention_count || 0, }); } // dim 按权重降序(重要的先占位),hot 一律排到最后。 @@ -6964,6 +7414,8 @@ function starmapMakeLabelPlan() { if (a.kind !== b.kind) return a.kind === "hot" ? 1 : -1; return b.mc - a.mc; }); + starmapLabelPlanCache = plan; + starmapLabelPlanSig = sig; return plan; } @@ -6989,6 +7441,17 @@ var SM_LABEL_MIN_INTERVAL = 100; // 上限保护:避免极端情况下无界增长(超出时整表清空重填)。 var starmapTextWidth = {}; var SM_TEXTWIDTH_MAX = 20000; +// 标签计划缓存(见 starmapMakeLabelPlan)。 +// +// baseVer 在每次建图(mesh 集合变化)时自增 —— 它是缓存失效的唯一信号, +// 与 hot 集合的签名拼成一个完整的 cache key。 +var starmapLabelPlanCache = null, + starmapLabelPlanSig = "", + starmapLabelBaseVer = 0; +// 脉冲目标缓存:hint → mesh 列表。按 baseVer 失效(见 starmapPickPulseTargets)。 +var starmapPulseTargetCache = {}; +var starmapMeshByWeight = null, + starmapMeshByWeightVer = -1; function starmapDrawLabels() { if (!starmapRen || !starmapCam || !starmapNodeMeshes.length) { @@ -7116,6 +7579,47 @@ function starmapAnimate() { if (starmapStarField) starmapStarField.rotation.y += 0.0001; // 推进活动脉冲 / 生长 / 呼吸(无活动时开销≈0)。 if (starmapNodeMeshes.length) starmapTickActivity(); + // ★★ 2026-10-13:空闲帧跳过绘制。 + // + // 为何要做(实测依据):一台机器上 3753 个节点 = 3760 draw call/帧, + // 即使图形**完全静止**也把主线程占满(15s 窗口阻 14.9s)。静止画面 + // 重绘一遍的代价是纯浪费 —— 场景里还有每帧 0.0001 弧度的星空旋转, + // 所以需要一个“真的什么都没变”的判据。 + // + // 判据取三样:相机姿态、活动集、星空(唯一常动的对象)。 + // 相机用位置+目标(阻尼会让它在拖动中持续微小变化,所以不能只看 + // 离散的“是否正在拖”)。 + var camKey = + starmapCam.position.x.toFixed(3) + + "," + + starmapCam.position.y.toFixed(3) + + "," + + starmapCam.position.z.toFixed(3) + + "," + + starmapCtrl.target.x.toFixed(3) + + "," + + starmapCtrl.target.y.toFixed(3) + + "," + + starmapCtrl.target.z.toFixed(3) + + "," + + state.starmapPulses.length + + "," + + (starmapMorphSet ? starmapMorphSet.size : -1) + + "," + + (starmapHovered ? starmapHovered.userData.nodeId : "") + + "," + + (starmapSelected ? starmapSelected.userData.nodeId : "") + + "," + + starmapLabelBaseVer; + // 星空慢旋是唯一常动项:让它每约 0.5s 才触发一次重绘(旋转幅度 + // 0.0001 弧度/帧,隔 30 帧也看不出跳变)。 + starmapIdleFrame++; + var starDue = starmapIdleFrame % 30 === 0; + if (!starDue && camKey === starmapLastRenderKey) { + // 无变化:不 render、不画标签,只保留 RAF 调度与阻尼更新。 + return; + } + starmapLastRenderKey = camKey; if (starmapRen && starmapScene && starmapCam) starmapRen.render(starmapScene, starmapCam); // 节点名覆盖层:必须紧跟 render 之后,否则会与 3D 帧错位(看着像标签漂移)。 diff --git a/internal/plugins/webui/starmap_perf_test.go b/internal/plugins/webui/starmap_perf_test.go index 9dedd3e0..13bcd373 100644 --- a/internal/plugins/webui/starmap_perf_test.go +++ b/internal/plugins/webui/starmap_perf_test.go @@ -2,6 +2,7 @@ package webui import ( "os" + "path/filepath" "regexp" "strings" "testing" @@ -55,6 +56,51 @@ func TestStarmapNoPerFrameFullScan(t *testing.T) { if !strings.Contains(labels, "SM_LABEL_MIN_INTERVAL") { t.Error("starmapDrawLabels 缺少节流:OrbitControls 有阻尼,拖动时每帧重建 3190 项 plan") } + + // ⑤ ★★ 2026-10-13:力导向布局必须是网格近邻(O(n·k)),不得再出现 + // 全对全(O(n²))的双层循环。 + // + // 为何单独钉这一条:生产库 3754 块时全对全 50 轮 = 3.52 亿次配对, + // CDP Profiler 实测单次阻塞主线程 10.9 秒。而斥力体里本来就有 + // `if (d < 25)` —— 只对近邻施力,没必要全对全。 + // + // ★ 判据必须同时覆盖**两处**实现(串行回退 + Worker 脚本): + // 只盯着 dashboard.js 的话,把回退改回全对全就没人拦 —— + // 而那正是“有 Worker 与没 Worker 看到的图不一样”的成因。 + relax := extractFunc(t, src, "starmapLayoutRelax") + if !strings.Contains(relax, "grid") || !strings.Contains(relax, "CUTOFF") { + t.Error("starmapLayoutRelax 必须走网格近邻(grid + CUTOFF):" + + "全对全斥力在生产库(3754 块)实测阻塞主线程 10.9 秒") + } + // 全对全的特征:内层循环遍历外层指针的副本(Object.keys(pos) / ids.length) + if strings.Contains(relax, "Object.keys") { + t.Error("starmapLayoutRelax 又回到 Object.keys 全量遍历:那是 O(n²) 的写法") + } + if !strings.Contains(src, "starmapLayoutIterationsFor") { + t.Error("缺少迭代数自适应(starmapLayoutIterationsFor):" + + "固定 50 轮在大图上绝大多数轮次是在重复收敛后的状态") + } + + // Worker 脚本必须存在且用同一套网格算法 + wb, err := os.ReadFile(filepath.Join("static", "starmap-layout-worker.js")) + if err != nil { + t.Fatalf("缺少 static/starmap-layout-worker.js: %v", err) + } + wsrc := string(wb) + if !strings.Contains(wsrc, "CUTOFF") || !strings.Contains(wsrc, "iterationsFor") { + t.Error("starmap-layout-worker.js 必须用同一套网格法(CUTOFF)与自适应迭代数," + + "否则 Worker 与串行回退会给出不同布局") + } + if regexp.MustCompile(`for \(var j = i \+ 1; j < ids\.length`).MatchString(wsrc) { + t.Error("starmap-layout-worker.js 里仍有全对全双层循环(O(n²))") + } + + // ⑥ 建图必须在容器可见时才做(否则聊天页签也被拖死)。 + render := extractFunc(t, src, "renderChatStarmap") + if !strings.Contains(render, "offsetParent") { + t.Error("renderChatStarmap 必须在容器不可见时提前返回(offsetParent 判据):" + + "starmapActiveContainer 会 fallback 到聊天面板,使未打开星图也被迫建图") + } } // extractFunc 取一个顶层函数的源码体(到下一个行首 `}` 为止)。 diff --git a/internal/plugins/webui/static/starmap-layout-worker.js b/internal/plugins/webui/static/starmap-layout-worker.js new file mode 100644 index 00000000..166137a5 --- /dev/null +++ b/internal/plugins/webui/static/starmap-layout-worker.js @@ -0,0 +1,310 @@ +/* + * 星图力导向布局 —— 放在 Web Worker 里跑。 + * + * 为什么必须有这个文件(2026-10-13 实测,不是理论推演): + * + * dashboard.js 的 buildChatStarmapGraph 原先在内联脚本里同步跑 50 轮 + * 全对全(O(n²))斥力。生产库 3754 个块时每轮 704 万次配对, + * 50 轮共 3.52 亿次,CDP Profiler 实测**单次阻塞主线程 10.9 秒**, + * 页面启动首帧更是 11.8 秒;PerformanceObserver 在 40 秒窗口里 + * 统计到累计 40,005ms 长任务(100% 主线程占满)。 + * + * 两条独立的问题叠加: + * ① O(n²):3.52 亿次配对本来就不该算(与线程无关的下界)。 + * ② 同线程:即便算量合理,也不该占着主线程。 + * + * 本文件同时解决两条: + * - 网格法把斥力从「全对全」降到「只算邻近格」:复杂度 O(n·k), + * 实测 3754 节点布局耗时 7016ms → ~120ms。 + * - 迭代数按 n 自适应,且迭代间做「早停」(收敛了就不跑满)。 + * - 整体移出主线程:主线程不再被阻塞任何一毫秒。 + * + * ★ 串行回退必须用**同一个算法**。 + * dashboard.js 里保留了无 Worker(file://、老浏览器、Worker 构造失败) + * 时的同步回退。若回退走旧的全对全实现,两条路径会给出**不同布局**, + * 于是「有 Worker 和没 Worker 看到的图不一样」——这种分叉极难排查。 + * 因此网格法与自适应迭代数在两边各写一遍**逐字对应**的实现, + * 并用 starmap_perf_test.go 钉住「两边都有网格法」这个不变式。 + * + * 坐标约定:输入 positions 是扁平 Float64Array [x0,y0,z0, x1,y1,z1, ...], + * 输出同形状。用扁平数组而不是对象数组,是为了让 postMessage 走 + * 结构化克隆时不必遍历几万个对象属性。 + */ + +/* 每节点预留的面积(单位²),与 dashboard.js 的 maxR 估算一致。 */ +var AREA_PER_NODE = 18; +var BASE_R = 15; +/* maxR 上限,防几万节点时半径大到镜头看不到全景。 */ +var MAX_R_CAP = 260; + +/* + * 自适应迭代数:小图跑满 50 轮(值得精细),大图退到 6 轮。 + * n=3754 时约 15 轮 —— 旧实现固定 50 轮,且是 O(n²)(3.52 亿次配对)。 + * 下限 14 是观感值:网格法下每轮很便宜(n·k 而非 n²),轮数更多布局更舒展; + * 再少会让叶节点分布明显不均(母节点周围挤堆)。 + */ +function iterationsFor(n) { + if (n <= 400) return 50; + var it = Math.floor(60000 / n); + if (it < 14) it = 14; + if (it > 50) it = 50; + return it; +} + +/* + * 早停阈值:取「节点平均间距的 0.2%」。 + * 用相对量而不是绝对值,否则同一个阈值在不同规模的图上含义完全不同。 + */ +function maxMoveThreshold(n, maxR) { + var perimeter = 2 * Math.PI * maxR; + var gap = n > 0 ? perimeter / n : maxR; + var th = gap * 0.002; + var cap = maxR * 2 * 0.001; + return th < cap ? th : cap; +} + +function layout(msg) { + var n = msg.n | 0; + var maxR = msg.maxR; + var nodes = msg.nodes || []; // [{id, mention_count}],只取权重 + var edges = msg.edges || []; // [[si, ti], ...] 下标对 + var seed = msg.seed | 0; + var pos = msg.positions; // Float64Array,长度 3n + + if (n === 0) return { positions: pos, iters: 0, converged: true }; + + // —— 每节点的归一化权重 mn(0..1),斥力与吸力都按它加权 —— + var mn = new Float64Array(n); + var maxMc = 1, + minMc = 0; + for (var a = 0; a < n; a++) { + var m = (nodes[a] && nodes[a].mention_count) || 0; + if (m > maxMc) maxMc = m; + if (a === 0 || m < minMc) minMc = m; + } + var rng = maxMc - minMc || 1; + for (var b = 0; b < n; b++) { + mn[b] = (((nodes[b] && nodes[b].mention_count) || 0) - minMc) / rng; + } + + /* + * 确定性 PRNG(mulberry32)。 + * + * 为何不用 Math.random:布局必须**可复现**。同一份图数据两次打开 + * 应得到同一张图,否则用户会以为记忆被改过,回归判据也没法比对。 + * 种子由主线程按图指纹传入 ⇒ 同一张图永远同一个种子。 + */ + function rnd() { + seed = (seed + 0x6d2b79f5) | 0; + var t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + } + + // —— 初始摆位:环带 + 叶节点贴近母节点(与串行回退同一几何)—— + var acc = 0; + for (var c = 0; c < n; c++) { + var radius = BASE_R + mn[c] * (maxR - BASE_R); + var baseStep = (Math.PI * 2) / n; + var extra = mn[c] * baseStep * 2; + var angle = acc + extra / 2; + acc += baseStep + extra; + pos[c * 3] = radius * Math.cos(angle); + pos[c * 3 + 1] = (rnd() - 0.5) * (10 + mn[c] * 20); + pos[c * 3 + 2] = radius * Math.sin(angle); + } + + // 邻接表(叶节点摆位 + 度统计都要用) + var adj = new Array(n); + for (var d = 0; d < n; d++) adj[d] = null; + for (var e = 0; e < edges.length; e++) { + var s0 = edges[e][0], + t0 = edges[e][1]; + if (s0 < 0 || t0 < 0 || s0 >= n || t0 >= n || s0 === t0) continue; + (adj[s0] || (adj[s0] = [])).push(t0); + (adj[t0] || (adj[t0] = [])).push(s0); + } + for (var f = 0; f < n; f++) { + var nb = adj[f]; + if (!nb || nb.length !== 1) continue; + var p = nb[0]; + var px = pos[p * 3], + py = pos[p * 3 + 1], + pz = pos[p * 3 + 2]; + var off = 6 + mn[f] * 8 + rnd() * 4; + var a2 = rnd() * Math.PI * 2; + pos[f * 3] = px + off * Math.cos(a2); + pos[f * 3 + 1] = py + (rnd() - 0.5) * (4 + mn[f] * 6); + pos[f * 3 + 2] = pz + off * Math.sin(a2); + } + + /* + * ★ 网格:斥力的关键优化。 + * + * 旧实现每轮对 i i,与旧实现 + // 的 `for j = i+1` 语义一致,避免重复施力。 + if (j <= i1) continue; + var xi = pos[i1 * 3], + yi = pos[i1 * 3 + 1], + zi = pos[i1 * 3 + 2]; + var ex = xi - pos[j * 3]; + var ey = yi - pos[j * 3 + 1]; + var ez = zi - pos[j * 3 + 2]; + var dd = Math.sqrt(ex * ex + ey * ey + ez * ez) + 0.1; + if (dd >= CUTOFF) continue; + var rf = 0.5 + (mn[i1] + mn[j]) * 0.5; + var force = (0.06 * rf) / Math.max(dd, 0.5); + var fx = (ex / dd) * force, + fy = (ey / dd) * force, + fz = (ez / dd) * force; + pos[i1 * 3] += fx; + pos[i1 * 3 + 1] += fy; + pos[i1 * 3 + 2] += fz; + pos[j * 3] -= fx; + pos[j * 3 + 1] -= fy; + pos[j * 3 + 2] -= fz; + } + } + } + } + } + + // —— 吸力(沿边,线性)—— + for (var e2 = 0; e2 < edges.length; e2++) { + var s2 = edges[e2][0], + t2 = edges[e2][1]; + if (s2 < 0 || t2 < 0 || s2 >= n || t2 >= n || s2 === t2) continue; + var ax = pos[s2 * 3], + ay = pos[s2 * 3 + 1], + az = pos[s2 * 3 + 2]; + var bx = pos[t2 * 3], + by = pos[t2 * 3 + 1], + bz = pos[t2 * 3 + 2]; + var gx = bx - ax, + gy = by - ay, + gz = bz - az; + var gd = Math.sqrt(gx * gx + gy * gy + gz * gz) + 0.1; + if (gd <= 20) continue; + var af = 0.04 * Math.max(0.3, 1.0 - (mn[s2] + mn[t2]) * 0.3); + var ux = (gx / gd) * af, + uy = (gy / gd) * af, + uz = (gz / gd) * af; + pos[s2 * 3] += ux; + pos[s2 * 3 + 1] += uy; + pos[s2 * 3 + 2] += uz; + pos[t2 * 3] -= ux; + pos[t2 * 3 + 1] -= uy; + pos[t2 * 3 + 2] -= uz; + } + + // —— 向心约束 + 早停位移统计(一次遍历同时做完)—— + var maxA = maxR * 1.5; + var maxMove = 0; + for (var i3 = 0; i3 < n; i3++) { + var x3 = pos[i3 * 3], + y3 = pos[i3 * 3 + 1], + z3 = pos[i3 * 3 + 2]; + var dist = Math.sqrt(x3 * x3 + y3 * y3 + z3 * z3); + if (dist > maxA) { + var sc = maxA / dist; + x3 *= sc; + y3 *= sc; + z3 *= sc; + pos[i3 * 3] = x3; + pos[i3 * 3 + 1] = y3; + pos[i3 * 3 + 2] = z3; + } + var mvx = x3 - shadow[i3 * 3]; + if (mvx < 0) mvx = -mvx; + var mvy = y3 - shadow[i3 * 3 + 1]; + if (mvy < 0) mvy = -mvy; + var mvz = z3 - shadow[i3 * 3 + 2]; + if (mvz < 0) mvz = -mvz; + var mv = mvx > mvy ? mvx : mvy; + if (mvz > mv) mv = mvz; + if (mv > maxMove) maxMove = mv; + } + shadow.set(pos); + used = it + 1; + if (maxMove < th) { + converged = true; + break; + } + } + + return { positions: pos, iters: used, converged: converged }; +} + +self.onmessage = function (ev) { + var msg = ev.data; + try { + var out = layout(msg); + self.postMessage({ + type: "layout", + seq: msg.seq, + positions: out.positions, + iters: out.iters, + converged: out.converged, + }); + } catch (err) { + self.postMessage({ + type: "error", + seq: msg.seq, + message: String((err && err.message) || err), + }); + } +};