diff --git a/tests/test_frontend_logic.js b/tests/test_frontend_logic.js index cfc1b73..b2e2170 100644 --- a/tests/test_frontend_logic.js +++ b/tests/test_frontend_logic.js @@ -183,5 +183,57 @@ eq(gf.levelBandOf({ type: "Skill", level: "L1" }), 0, "L1技能入带0"); eq(gf.levelBandOf({ type: "Skill", level: "L3" }), 2, "L3技能入带2"); eq(gf.levelBandOf({ type: "Skill" }), 1, "无级别技能默认中级带"); +/* graph.js 纯逻辑:搜索排序 / 岗位对比 / 时序季度摘要 */ +const gf2 = loadGraphFns(["searchRank", "compareJobs", "windowDigest"]); +const SN = [ + { id: "job_Java开发工程师", type: "Job", title: "Java开发工程师" }, + { id: "job_Java架构师", type: "Job", title: "Java架构师" }, + { id: "JavaScript", type: "Skill", title: "JavaScript" }, + { id: "Docker", type: "Skill", title: "Docker" }, +]; +eq(gf2.searchRank(SN, "").length, 0, "搜索:空串无结果"); +eq(gf2.searchRank(SN, "架构").map((n) => n.id).join(","), "job_Java架构师", "搜索:中文子串命中"); +eq(gf2.searchRank(SN, "javascript")[0].id, "JavaScript", "搜索:大小写不敏感"); +eq(gf2.searchRank(SN, "java")[0].type, "Job", "搜索:并列时岗位优先于技能"); + +const CE = [ + { source: "A", target: "Java", rel: "REQUIRES", importance: "required" }, + { source: "A", target: "MySQL", rel: "REQUIRES", importance: "required" }, + { source: "B", target: "Java", rel: "REQUIRES", importance: "required" }, + { source: "B", target: "Go", rel: "REQUIRES", importance: "required" }, + { source: "B", target: "K8s", rel: "REQUIRES", importance: "preferred" }, + { source: "A", target: "X场景", rel: "APPLIES_TO" }, + { source: "B", target: "Y技能", rel: "REQUIRES", importance: "required", pending: true }, +]; +const cmp = gf2.compareJobs(CE, "A", "B"); +eq(cmp.shared.map((x) => x.skill).join(","), "Java", "对比:共有技能(忽略非技能关系与待审核边)"); +eq(cmp.onlyA.map((x) => x.skill).join(","), "MySQL", "对比:A 独有"); +eq(cmp.onlyB.map((x) => x.skill).join(","), "Go,K8s", "对比:B 独有"); +eq(cmp.gaps.join(","), "Go", "对比:转岗必备缺口只算 required"); +eq(cmp.bReq, 2, "对比:目标必备总数"); +eq(cmp.covered, 1, "对比:必备已覆盖数"); +eq(cmp.ready, 0.5, "对比:就绪度=覆盖/必备"); + +const WE = [ + { source: "job_A", target: "Java", intervals: [["2025Q1", null]] }, + { source: "job_A", target: "K8s", intervals: [["2026Q1", null]] }, + { source: "job_B", target: "Go", intervals: [["2026Q1", null]] }, +]; +const WEV = [ + { window: "2026Q1", change_type: "新增", skill: "K8s", job_id: "job_A" }, + { window: "2026Q1", change_type: "退出", skill: "JSP", job_id: "job_A" }, + { window: "2025Q4", change_type: "增强", skill: "Java", job_id: "job_A" }, +]; +const WN = [ + { id: "job_A", type: "Job", title: "A工程师" }, + { id: "job_B", type: "Job", title: "B工程师" }, +]; +const dg = gf2.windowDigest(WE, WEV, WN, "2026Q1"); +eq(dg.newEdges.length, 2, "摘要:当季新增关系数"); +eq(dg.newJobs.join(","), "B工程师", "摘要:当季新晋岗位按首次出现窗口判定"); +eq(dg.added.length, 1, "摘要:当季新增事件"); +eq(dg.exited.length, 1, "摘要:当季退出事件"); +eq(dg.boosted.length, 0, "摘要:非当季事件不计入"); + console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0); diff --git a/webui/graph.js b/webui/graph.js index a38c2a8..541e855 100644 --- a/webui/graph.js +++ b/webui/graph.js @@ -16,6 +16,8 @@ selected: null, // 选中节点 id soloJob: null, // 岗位子图模式:只看该岗位及其一跳邻居 viewMode: "overview", // 视图:overview 全景 / tech 技术栈 / level 职级演进 / timeline 动态时序 + levelJob: null, // 职级演进视图:当前展示能力树的岗位 + compare: null, compareSets: null, // 岗位对比模式:{a,b} 与集合缓存 timeWins: [], timeIdx: null, // 时序视图:窗口序列与当前下标 activeNodes: null, activeEdges: null,// 时序视图:当前窗口生效的节点/边集合 playTimer: null, // 时序视图:播放定时器 @@ -34,6 +36,10 @@ /* 变化类型颜色(与 app.js CHANGE_META 一致):新增=青绿 / 增强=蓝紫 / 减弱=琥珀 / 退出=砖红 */ const CHANGE_COLORS = { "新增": "#7bc8a4", "增强": "#8f9ff0", "减弱": "#e8c07d", "退出": "#d99a91" }; const CHANGE_MARK = { "新增": "新", "增强": "↑", "减弱": "↓", "退出": "退" }; + /* 变化类型对应的标签样式(详情面板 / 时序解说面板共用) */ + const EV_TAG = { "新增": "tag-cyan", "增强": "tag-purple", "减弱": "tag-orange", "退出": "tag-red" }; + /* 岗位对比模式配色(低饱和):共有=青绿 / A 独有=蓝紫 / B 独有=琥珀 */ + const COMPARE_COLORS = { shared: "#7bc8a4", a: "#8f9ff0", b: "#e8c07d" }; /* 近一年入库的新岗位(NEW 标记):常量名单 + 待审核候选(status=pending) */ const NEW_JOB_IDS = new Set(["job_大模型应用工程师", "job_RAG工程师", "job_提示词工程师"]); const isNewJobNode = (n) => n.type === "Job" && (n.pending || n.status === "pending" || NEW_JOB_IDS.has(n.id)); @@ -98,11 +104,13 @@ /* ================= 视图切换:全景 / 技术栈 / 职级演进 / 动态时序 ================= */ const VIEW_NAMES = { overview: "全景总览", tech: "技术栈视图", level: "职级演进视图", timeline: "动态时序视图" }; - const LEVEL_BANDS = [ - { name: "初级 L1 · ≤2 年", y: -420 }, - { name: "中级 L2 · 3-5 年", y: 0 }, - { name: "高级 L3 · >5 年", y: 420 }, + /* 职级演进视图:成长阶梯三列(岗位能力树,左岗位 → 右三列技能带) */ + const LEVEL_COLS = [ + { name: "初级 L1 · ≤2 年", x: -300 }, + { name: "中级 L2 · 3-5 年", x: 0 }, + { name: "高级 L3 · >5 年", x: 300 }, ]; + const LEVEL_JOB_X = -560; // 目标岗位列的 x 坐标 /* 技术栈视图:类别锚点网格分布,岗位聚在锚点旁,技能/场景/行业靠向所属岗位质心 */ function applyTechLayout() { @@ -147,28 +155,77 @@ }); } - /* 职级演进视图:横向三条级别带(初级→中级→高级 自下而上的成长阶梯),技能落带内、岗位摆带底 */ + /* 职级演进视图:岗位能力树布局。左侧目标岗位,右侧 L1/L2/L3 三列技能带(列内必备在前、加分在后), + * 场景/行业节点纵排在岗位下方;只显示该岗位及其一跳邻居,彻底解决旧版全员三带压扁问题 */ function applyLevelLayout() { - [0, 1, 2].forEach((b) => { - const yBand = LEVEL_BANDS[b].y; - const jl = G.nodes.filter((n) => n.type === "Job" && levelBandOf(n) === b) - .sort((x, y) => String(x.title || x.id).localeCompare(String(y.title || y.id), "zh")); - // 岗位在带线下方(最多 2 行),技能在带线上方(最多 3 行),两者不重叠 - const jPerRow = Math.max(3, Math.ceil(jl.length / 2)); - jl.forEach((n, i) => { - n.x = ((i % jPerRow) - (jPerRow - 1) / 2) * 190; - n.y = yBand + 90 + Math.floor(i / jPerRow) * 85; - n.vx = 0; n.vy = 0; + const job = G.nodes[G.idx[G.levelJob]]; + if (!job) return; + job.x = LEVEL_JOB_X; job.y = 0; job.vx = 0; job.vy = 0; + const buckets = [[], [], []]; + const outer = []; // 场景 / 行业 + G.edges.forEach((e) => { + if (e.source !== G.levelJob) return; + const t = G.nodes[G.idx[e.target]]; + if (!t) return; + if (t.type === "Skill") buckets[levelBandOf(t)].push({ n: t, imp: e.importance }); + else outer.push(t); + }); + buckets.forEach((list, b) => { + list.sort((x, y) => { + const rx = (x.imp === "required" ? 0 : 1) - (y.imp === "required" ? 0 : 1); + return rx || String(x.n.title || x.n.id).localeCompare(String(y.n.title || y.n.id), "zh"); }); - const sl = G.nodes.filter((n) => n.type !== "Job" && levelBandOf(n) === b) - .sort((x, y) => String(x.title || x.id).localeCompare(String(y.title || y.id), "zh")); - const perRow = Math.max(4, Math.ceil(sl.length / 4)); - sl.forEach((n, i) => { - n.x = ((i % perRow) - (perRow - 1) / 2) * 100; - n.y = yBand - 170 + Math.floor(i / perRow) * 62; - n.vx = 0; n.vy = 0; + const cx = LEVEL_COLS[b].x; + const cols = list.length > 9 ? 2 : 1; // 技能多时分双列,避免纵向过长 + const perCol = Math.ceil(list.length / cols) || 1; + list.forEach((it, i) => { + const c = Math.floor(i / perCol); + const r = i % perCol; + it.n.x = cx + (c - (cols - 1) / 2) * 132; + it.n.y = (r - (perCol - 1) / 2) * 56; + it.n.vx = 0; it.n.vy = 0; }); }); + outer.sort((a, b) => String(a.title || a.id).localeCompare(String(b.title || b.id), "zh")); + outer.forEach((n, i) => { + n.x = LEVEL_JOB_X; n.y = 96 + i * 52; n.vx = 0; n.vy = 0; + }); + } + + /* 职级演进视图初始化:填充岗位选择条(含待审核候选),默认沿用当前选中岗位 */ + function setupLevelView() { + const sel = $("#gl-job-select"); + const jobs = G.nodes.filter((n) => n.type === "Job") + .sort((a, b) => String(a.title || a.id).localeCompare(String(b.title || b.id), "zh")); + sel.innerHTML = jobs.map((n) => + ``).join(""); + const cur = G.selected != null ? G.nodes[G.idx[G.selected]] : null; + G.levelJob = (cur && cur.type === "Job") ? cur.id + : (G.levelJob && G.levelJob in G.idx) ? G.levelJob + : (DEFAULT_SOLO_JOB in G.idx) ? DEFAULT_SOLO_JOB + : (jobs[0] && jobs[0].id) || null; + sel.value = G.levelJob || ""; + applyLevelLayout(); + if (G.levelJob) selectNode(G.levelJob, false); // 右侧面板同步展示岗位定义,信息闭环 + } + + /* 视野适配职级能力树(只按可见节点的包围盒) */ + function fitLevelView() { + const c = canvas(); + let minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9, cnt = 0; + G.nodes.forEach((n) => { + if (!visible(n)) return; + cnt++; + minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x); + minY = Math.min(minY, n.y); maxY = Math.max(maxY, n.y); + }); + if (!cnt) return fitView(); + const w = maxX - minX || 1, h = maxY - minY || 1; + const W = c.clientWidth, H = c.clientHeight; + G.cam.k = Math.max(0.2, Math.min(W / (w + 280), H / (h + 240), 1.4)); + G.cam.x = (W - w * G.cam.k) / 2 - minX * G.cam.k; + G.cam.y = (H - h * G.cam.k) / 2 - minY * G.cam.k; + requestDraw(); drawMinimap(); } /* 动态时序视图:窗口序列(事件窗口 ∪ 边区间起点),拖动滑块只显示该季度已存在节点与生效边 */ @@ -195,6 +252,7 @@ G.edges.forEach((e) => { hasEdge.add(e.source); hasEdge.add(e.target); }); G.nodes.forEach((n) => { if (n.pending || !hasEdge.has(n.id)) G.activeNodes.add(n.id); }); $("#gt-label").textContent = w ? `当前窗口:${w}` : "暂无时序数据"; + renderTimePanel(); requestDraw(); drawMinimap(); } function stopPlay() { @@ -210,23 +268,27 @@ if (G.viewMode === "overview" && mode !== "overview") { G._overviewPos = G.nodes.map((n) => ({ x: n.x, y: n.y })); } + if (mode !== "overview" && G.compare) exitCompare(true); // 对比模式只在全景视图下生效 G.viewMode = mode; $$("#graph-view-tabs button").forEach((b) => b.classList.toggle("active", b.dataset.gview === mode)); $("#graph-timeline").classList.toggle("hidden", mode !== "timeline"); + $("#graph-level-bar").classList.toggle("hidden", mode !== "level"); stopPlay(); if (mode === "overview") { if (G._overviewPos) G._overviewPos.forEach((p, i) => { G.nodes[i].x = p.x; G.nodes[i].y = p.y; G.nodes[i].vx = 0; G.nodes[i].vy = 0; }); else layout(); G.activeNodes = null; G.activeEdges = null; } else if (mode === "tech") applyTechLayout(); - else if (mode === "level") applyLevelLayout(); + else if (mode === "level") setupLevelView(); else if (mode === "timeline") { // 时序视图沿用全景力导向布局(否则继承上一个视图的特化布局会被压扁) if (G._overviewPos) G._overviewPos.forEach((p, i) => { G.nodes[i].x = p.x; G.nodes[i].y = p.y; G.nodes[i].vx = 0; G.nodes[i].vy = 0; }); else layout(); setupTimeline(); } + renderTimePanel(); if (G.soloJob) { applySoloLayout(); fitSoloView(); } + else if (mode === "level") fitLevelView(); else fitView(); requestDraw(); drawMinimap(); if (mode !== "overview") toast(`已切换为${VIEW_NAMES[mode]}`, "info"); @@ -235,7 +297,7 @@ /* 按当前视图重排(数据重载 / 一键整理布局后调用) */ function relayoutByView() { if (G.viewMode === "tech") applyTechLayout(); - else if (G.viewMode === "level") applyLevelLayout(); + else if (G.viewMode === "level") setupLevelView(); else if (G.viewMode === "timeline") { if (G._overviewPos) G._overviewPos.forEach((p, i) => { G.nodes[i].x = p.x; G.nodes[i].y = p.y; G.nodes[i].vx = 0; G.nodes[i].vy = 0; }); else layout(); @@ -403,6 +465,9 @@ function visible(n) { if (!G.filters.types[n.type]) return false; if (G.viewMode === "timeline" && G.activeNodes && !G.activeNodes.has(n.id)) return false; + if (G.viewMode === "level" && G.levelJob) { // 职级能力树:只保留当前岗位及其一跳邻居 + if (n.id !== G.levelJob && !neighborsOf(G.levelJob).has(n.id)) return false; + } if (G.soloJob) { // 岗位子图:只保留该岗位与一跳邻居 if (n.id !== G.soloJob && !neighborsOf(G.soloJob).has(n.id)) return false; } @@ -453,25 +518,34 @@ const hop = sel ? neighborsOf(sel) : null; G._newPulse = false; - /* ---- 视图区域底纹与标签(技术栈类别锚点 / 职级三条带) ---- */ + /* ---- 视图区域底纹与标签(职级能力树三列 / 技术栈类别锚点) ---- */ if (G.viewMode === "level") { ctx.save(); - LEVEL_BANDS.forEach((b, i) => { - const sy = b.y * G.cam.k + G.cam.y; - ctx.fillStyle = i % 2 ? "rgba(24,144,255,0.025)" : "rgba(19,194,194,0.03)"; - ctx.fillRect(0, sy - 175 * G.cam.k, c.clientWidth, 350 * G.cam.k); - ctx.fillStyle = "rgba(78,89,105,0.85)"; - ctx.font = "bold 13px sans-serif"; ctx.textAlign = "left"; - ctx.fillText(b.name, 14, sy - 150 * G.cam.k); - if (i > 0) { // 带间分隔虚线 - ctx.strokeStyle = "rgba(78,89,105,0.25)"; ctx.setLineDash([6, 5]); - ctx.beginPath(); ctx.moveTo(0, sy - 170 * G.cam.k); ctx.lineTo(c.clientWidth, sy - 170 * G.cam.k); ctx.stroke(); - ctx.setLineDash([]); + /* 目标岗位列底纹 */ + const jx = LEVEL_JOB_X * G.cam.k + G.cam.x; + ctx.fillStyle = "rgba(24,144,255,0.05)"; + ctx.fillRect(jx - 120 * G.cam.k, 0, 240 * G.cam.k, c.clientHeight); + ctx.fillStyle = "rgba(29,33,41,0.8)"; ctx.font = "bold 13px sans-serif"; ctx.textAlign = "center"; + ctx.fillText("目标岗位", jx, 76); + /* L1/L2/L3 三列技能带(列头避开顶部岗位选择条) */ + LEVEL_COLS.forEach((col, i) => { + const sx = col.x * G.cam.k + G.cam.x; + ctx.fillStyle = i % 2 ? "rgba(24,144,255,0.03)" : "rgba(19,194,194,0.035)"; + ctx.fillRect(sx - 128 * G.cam.k, 0, 256 * G.cam.k, c.clientHeight); + ctx.fillStyle = "rgba(29,33,41,0.8)"; ctx.font = "bold 13px sans-serif"; ctx.textAlign = "center"; + ctx.fillText(col.name, sx, 76); + /* 列间成长箭头(跟随平移) */ + const nx = (i < LEVEL_COLS.length - 1 ? LEVEL_COLS[i + 1].x : col.x) * G.cam.k + G.cam.x; + if (i < LEVEL_COLS.length - 1) { + const ay = 71, ax0 = sx + 96 * G.cam.k, ax1 = nx - 96 * G.cam.k; + if (ax1 > ax0) { + ctx.strokeStyle = "rgba(24,144,255,0.45)"; ctx.lineWidth = 1.6; + ctx.beginPath(); ctx.moveTo(ax0, ay); ctx.lineTo(ax1, ay); ctx.stroke(); + ctx.fillStyle = "rgba(24,144,255,0.7)"; + ctx.beginPath(); ctx.moveTo(ax1, ay); ctx.lineTo(ax1 - 7, ay - 4); ctx.lineTo(ax1 - 7, ay + 4); ctx.closePath(); ctx.fill(); + } } }); - /* 成长阶梯方向提示(文字,非图标) */ - ctx.fillStyle = "rgba(78,89,105,0.6)"; ctx.font = "11px sans-serif"; ctx.textAlign = "right"; - ctx.fillText("成长阶梯:初级 → 中级 → 高级", c.clientWidth - 14, 20); ctx.restore(); } else if (G.viewMode === "tech" && G.catAnchors) { ctx.save(); @@ -497,7 +571,7 @@ if (!edgeVisible(e)) return; const pa = toScreen(a), pb = toScreen(b); const isHot = sel && (e.source === sel || e.target === sel); - const dim = sel && !isHot; + const dim = (sel && !isHot) || (G.compareSets && e.source !== G.compareSets.a && e.source !== G.compareSets.b); const isReq = e.importance === "required"; ctx.save(); if (e.rel === "APPLIES_TO") { @@ -547,11 +621,19 @@ const r = Math.max(2.5, nodeRadius(n) * Math.sqrt(G.cam.k)); const isSel = n.id === sel; const isHop = hop && hop.has(n.id); - const dim = sel && !isSel && !isHop; + /* 岗位对比模式:共有/A 独有/B 独有 三色编码,非对比集合节点淡出 */ + const cmp = G.compareSets; + const cmpRole = cmp + ? (n.id === cmp.a ? "A" : n.id === cmp.b ? "B" + : cmp.shared.has(n.id) ? "shared" : cmp.onlyA.has(n.id) ? "onlyA" : cmp.onlyB.has(n.id) ? "onlyB" : null) + : null; + const dim = (sel && !isSel && !isHop) || (cmp && !cmpRole); const meta = TYPE_META[n.type] || { color: "#86909c" }; ctx.save(); ctx.globalAlpha = dim ? 0.2 : 1; - ctx.fillStyle = meta.color; + ctx.fillStyle = cmpRole === "shared" ? COMPARE_COLORS.shared + : cmpRole === "onlyA" ? COMPARE_COLORS.a + : cmpRole === "onlyB" ? COMPARE_COLORS.b : meta.color; if (n.type === "Skill") { const s = r * 1.7; ctx.beginPath(); ctx.roundRect(p.x - s / 2, p.y - s / 2, s, s, 2.5); ctx.fill(); @@ -571,6 +653,12 @@ ctx.strokeStyle = "rgba(24,144,255,0.55)"; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(p.x, p.y, r + 2.5, 0, 7); ctx.stroke(); } + /* 对比模式:基准岗位(蓝环)/ 目标岗位(琥珀环)外圈标记 */ + if (cmpRole === "A" || cmpRole === "B") { + ctx.strokeStyle = cmpRole === "A" ? "#1890ff" : "#e8c07d"; + ctx.lineWidth = 2.4; + ctx.beginPath(); ctx.arc(p.x, p.y, r + 5, 0, 7); ctx.stroke(); + } /* NEW 标记:待审核候选与近一年入库新岗位,节点右上方脉动圆点 + NEW 文字(canvas 绘制,alpha 呼吸复用 requestDraw 循环) */ if (isNewJobNode(n) && !dim) { const pulse = 0.55 + 0.45 * Math.sin(Date.now() / 380); @@ -585,9 +673,10 @@ ctx.globalAlpha = dim ? 0.2 : 1; G._newPulse = true; // 有 NEW 节点在画面上:持续请求重绘形成呼吸 } - /* 标签按需显示(文字重叠治理):岗位常显;技能/场景/行业仅在选中/悬停/选中节点一跳邻居时显示; + /* 标签按需显示(文字重叠治理):岗位常显;职级能力树视图节点少全显; + * 技能/场景/行业仅在选中/悬停/选中节点一跳邻居时显示; * 关系标签开关(showLabels)打开且放大到一定程度时恢复全量标签 */ - if (n.type === "Job" || isSel || isHop || n.id === G.hover || (G.showLabels && G.cam.k > 1.05)) { + if (n.type === "Job" || G.viewMode === "level" || isSel || isHop || n.id === G.hover || (G.showLabels && G.cam.k > 1.05)) { ctx.font = `${n.type === "Job" ? 11 : 10}px sans-serif`; ctx.textAlign = "center"; ctx.fillStyle = "rgba(255,255,255,0.85)"; @@ -766,6 +855,7 @@ const items = [ { key: "evidence", label: "查看证据 JD 原文" }, { key: "compare", label: "能力画像时点对比", disabled: !isJob }, + { key: "cmp", label: "与该岗位对比…", disabled: !isJob }, { key: "match", label: "开始人岗诊断", disabled: !isJob }, { key: "solo", label: G.soloJob === n.id ? "退出岗位子图" : "岗位子图开关", disabled: !isJob }, ]; @@ -791,6 +881,8 @@ const list = [...sources]; if (!list.length) return toast("该节点暂无来源证据记录", "info"); CN.showJdEvidence(list, { jobTitle: n.title || n.id }); + } else if (key === "cmp") { + enterCompare(n.id, G.compare && G.compare.a !== n.id ? G.compare.a : null); } else if (key === "compare") { CN.switchView("evolution"); $("#evo-job-select").value = n.id; @@ -814,6 +906,257 @@ } } + /* ================= 图谱搜索定位 ================= */ + /* 搜索排序:前缀命中优先,其次按 岗位→技能→场景→行业,取前 8 条(纯函数,单测可提取) */ + function searchRank(nodes, q) { + const s = String(q || "").trim().toLowerCase(); + if (!s) return []; + const tOrder = { Job: 0, Skill: 1, Scenario: 2, Industry: 3 }; + const hit = nodes.filter((n) => String(n.title || n.id).toLowerCase().includes(s)); + hit.sort((a, b) => { + const ta = String(a.title || a.id).toLowerCase(); + const tb = String(b.title || b.id).toLowerCase(); + const pa = ta.startsWith(s) ? 0 : 1, pb = tb.startsWith(s) ? 0 : 1; + return pa - pb || (tOrder[a.type] ?? 9) - (tOrder[b.type] ?? 9) || ta.localeCompare(tb, "zh"); + }); + return hit.slice(0, 8); + } + + function bindSearch() { + const input = $("#gs-input"), dd = $("#gs-dropdown"); + let items = [], active = -1; + const close = () => { dd.classList.add("hidden"); active = -1; }; + const go = (i) => { + const n = items[i]; + if (!n) return; + input.value = ""; + close(); + locateNode(n.id); + }; + const render = () => { + if (!input.value.trim()) { close(); return; } + items = searchRank(G.nodes, input.value); + active = -1; + dd.innerHTML = items.length ? items.map((n, i) => { + const meta = TYPE_META[n.type] || { name: n.type, tag: "tag-gray" }; + return `