mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
perf(webui): 星图力导向布局改网格近邻 + Web Worker,消除主线程 10.9s 阻塞
生产库 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
This commit is contained in:
@ -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 帧错位(看着像标签漂移)。
|
||||
|
||||
@ -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 取一个顶层函数的源码体(到下一个行首 `}` 为止)。
|
||||
|
||||
310
internal/plugins/webui/static/starmap-layout-worker.js
Normal file
310
internal/plugins/webui/static/starmap-layout-worker.js
Normal file
@ -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<j 全对全求距,但斥力**只在 d<25 时才施加**
|
||||
* (原代码那段 `if (d < 25)`)。既然只关心近邻,就没必要算 3.52 亿次:
|
||||
* 把空间切成边长 == 斥力半径的格子,每点只与「自身格 + 相邻 26 格」
|
||||
* (3×3×3)内的点比较即可。
|
||||
*
|
||||
* 为何格边长必须 == 斥力半径:这样「距离 < 25 的点」必然落在相邻
|
||||
* 26 格内,绝不遗漏 —— 这是敢跳过远处配对的**唯一依据**。
|
||||
* 格边长一旦小于斥力半径,就会漏掉真实邻居,图会塌成一团。
|
||||
*
|
||||
* 复杂度:均匀分布下每格约 1 点 ⇒ 每轮约 27n 次比较。
|
||||
* n=3754、15 轮时约 152 万次,而不是 3.52 亿次。
|
||||
*/
|
||||
var CUTOFF = 25;
|
||||
var grid = new Map();
|
||||
|
||||
function rebuildGrid() {
|
||||
grid.clear();
|
||||
for (var i = 0; i < n; i++) {
|
||||
var key =
|
||||
Math.floor(pos[i * 3] / CUTOFF) +
|
||||
"|" +
|
||||
Math.floor(pos[i * 3 + 1] / CUTOFF) +
|
||||
"|" +
|
||||
Math.floor(pos[i * 3 + 2] / CUTOFF);
|
||||
var bucket = grid.get(key);
|
||||
if (!bucket) grid.set(key, (bucket = []));
|
||||
bucket.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
var iters = iterationsFor(n);
|
||||
var th = maxMoveThreshold(n, maxR);
|
||||
var converged = false;
|
||||
var used = 0;
|
||||
|
||||
// 早停用影子副本:与本轮开始时的位置比,看整体是否已经稳定。
|
||||
var shadow = new Float64Array(pos.length);
|
||||
shadow.set(pos);
|
||||
|
||||
for (var it = 0; it < iters; it++) {
|
||||
rebuildGrid();
|
||||
|
||||
// —— 斥力(只算 3×3×3 邻格内的近邻)——
|
||||
for (var i1 = 0; i1 < n; i1++) {
|
||||
var cxi = Math.floor(pos[i1 * 3] / CUTOFF);
|
||||
var cyi = Math.floor(pos[i1 * 3 + 1] / CUTOFF);
|
||||
var czi = Math.floor(pos[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];
|
||||
// 每对只处理一次:只做 j > 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),
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user