From 6d91df1a91a26d876a45afdbc38a2e275ae0eed6 Mon Sep 17 00:00:00 2001 From: FinStab Date: Fri, 28 Aug 2026 11:44:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BE=E8=B0=B1=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=89=A9=E5=85=85=EF=BC=9A=E8=81=8C=E7=BA=A7?= =?UTF-8?q?=E8=A7=86=E5=9B=BE=E6=94=B9=E4=B8=BA=E5=B2=97=E4=BD=8D=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E6=A0=91=EF=BC=88L1=E2=86=92L2=E2=86=92L3=E6=88=90?= =?UTF-8?q?=E9=95=BF=E9=98=B6=E6=A2=AF=EF=BC=89=E3=80=81=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=9B=BE=E8=B0=B1=E6=90=9C=E7=B4=A2=E5=AE=9A=E4=BD=8D=E3=80=81?= =?UTF-8?q?=E5=B2=97=E4=BD=8D=E5=AF=B9=E6=AF=94=E6=A8=A1=E5=BC=8F=EF=BC=88?= =?UTF-8?q?=E4=B8=89=E8=89=B2=E4=BA=A4=E9=9B=86/=E5=B7=AE=E9=9B=86+?= =?UTF-8?q?=E8=BD=AC=E5=B2=97=E5=B0=B1=E7=BB=AA=E5=BA=A6=EF=BC=89=E3=80=81?= =?UTF-8?q?=E6=97=B6=E5=BA=8F=E8=A7=86=E5=9B=BE=E5=AD=A3=E5=BA=A6=E8=A7=A3?= =?UTF-8?q?=E8=AF=B4=E9=9D=A2=E6=9D=BF=EF=BC=8C=E7=BA=AF=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E5=8D=95=E6=B5=8B83=E4=BE=8B=E5=85=A8=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_frontend_logic.js | 52 ++++ webui/graph.js | 473 +++++++++++++++++++++++++++++++---- webui/index.html | 36 ++- webui/style.css | 83 ++++++ 4 files changed, 597 insertions(+), 47 deletions(-) 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 `
+ ${meta.name} + ${esc(n.title || n.id)} + ${n.pending ? `待审核` : ""} +
`; + }).join("") : `
没有匹配的节点
`; + dd.classList.remove("hidden"); + dd.querySelectorAll(".gsd-item").forEach((el) => { + el.onmousedown = (e) => { e.preventDefault(); go(+el.dataset.i); }; + }); + }; + input.addEventListener("input", render); + input.addEventListener("focus", render); + input.addEventListener("blur", () => setTimeout(close, 120)); + input.addEventListener("keydown", (e) => { + if (e.key === "Escape") { input.blur(); close(); } + else if (e.key === "Enter") { e.preventDefault(); go(active >= 0 ? active : 0); } + else if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + if (!items.length) return; + active = (active + (e.key === "ArrowDown" ? 1 : -1) + items.length) % items.length; + dd.querySelectorAll(".gsd-item").forEach((el, i) => el.classList.toggle("active", i === active)); + } + }); + } + + /* ================= 岗位对比模式 ================= */ + /* 岗位对比:技能交集/差集 + 转岗缺口与就绪度(纯函数,单测可提取) */ + function compareJobs(edges, a, b) { + const skillsOf = (j) => { + const m = new Map(); + edges.forEach((e) => { + if (e.source === j && e.rel === "REQUIRES" && !e.pending) { + m.set(e.target, e.importance === "required" ? "required" : "preferred"); + } + }); + return m; + }; + const A = skillsOf(a), B = skillsOf(b); + const shared = [], onlyA = [], onlyB = []; + A.forEach((imp, s) => { + if (B.has(s)) shared.push({ skill: s, impA: imp, impB: B.get(s) }); + else onlyA.push({ skill: s, imp }); + }); + B.forEach((imp, s) => { + if (!A.has(s)) onlyB.push({ skill: s, imp }); + }); + const gaps = onlyB.filter((x) => x.imp === "required").map((x) => x.skill); + const bReq = [...B.values()].filter((v) => v === "required").length; + const covered = shared.filter((x) => x.impB === "required").length; + return { shared, onlyA, onlyB, gaps, bReq, covered, ready: bReq ? covered / bReq : 0 }; + } + + function jobOptionsHtml(selectedId) { + return G.nodes.filter((n) => n.type === "Job" && !n.pending) + .sort((a, b) => String(a.title || a.id).localeCompare(String(b.title || b.id), "zh")) + .map((n) => ``).join(""); + } + + /* 进入对比:强制全景视图(其他视图有可见性过滤,会藏掉对比节点) */ + function enterCompare(aId, bId) { + const jobs = G.nodes.filter((n) => n.type === "Job" && !n.pending); + if (jobs.length < 2) return toast("岗位数量不足,无法对比", "info"); + if (G.viewMode !== "overview") setViewMode("overview"); + if (!aId || !(aId in G.idx)) aId = (DEFAULT_SOLO_JOB in G.idx) ? DEFAULT_SOLO_JOB : jobs[0].id; + G.compare = { a: aId, b: bId || null }; + G.compareSets = null; + $("#graph-compare-bar").classList.remove("hidden"); + $("#gc-a").innerHTML = jobOptionsHtml(G.compare.a); + $("#gc-b").innerHTML = `` + jobOptionsHtml(G.compare.b); + if (G.compare.b) updateCompare(); + else { renderComparePanel(); requestDraw(); } + } + + function exitCompare(quiet) { + if (!G.compare) return; + G.compare = null; G.compareSets = null; + $("#graph-compare-bar").classList.add("hidden"); + selectNode(null, false); + requestDraw(); drawMinimap(); + if (!quiet) toast("已退出岗位对比", "info"); + } + + function updateCompare() { + const cp = G.compare; + if (!cp || !cp.a || !cp.b) return; + if (cp.a === cp.b) { G.compareSets = null; renderComparePanel(); requestDraw(); return; } + const r = compareJobs(G.edges, cp.a, cp.b); + G.compareSets = { + a: cp.a, b: cp.b, + shared: new Set(r.shared.map((x) => x.skill)), + onlyA: new Set(r.onlyA.map((x) => x.skill)), + onlyB: new Set(r.onlyB.map((x) => x.skill)), + result: r, + }; + const na = G.nodes[G.idx[cp.a]], nb = G.nodes[G.idx[cp.b]]; + $("#gc-name-a").textContent = `${(na && na.title) || cp.a} 独有`; + $("#gc-name-b").textContent = `${(nb && nb.title) || cp.b} 独有`; + $("#gc-dot-a").style.background = COMPARE_COLORS.a; + $("#gc-dot-b").style.background = COMPARE_COLORS.b; + renderComparePanel(); + requestDraw(); drawMinimap(); + } + + /* 对比结果渲染到右侧详情面板:概览三数 + 转岗就绪度 + 分组技能清单 */ + function renderComparePanel() { + $("#detail-empty").classList.add("hidden"); + const body = $("#detail-body"); + body.classList.remove("hidden"); + const cp = G.compare || {}; + const na = cp.a ? G.nodes[G.idx[cp.a]] : null; + const chip = (s, cls) => `${esc(s)}`; + const head = ` +
+
+
岗位对比
+
技能交集 / 差集分析
+
+ +
`; + if (!G.compareSets) { + body.innerHTML = head + ` +
等待选择
+
+ 基准岗位:「${esc((na && na.title) || "—")}」
+ 请在顶部对比条选择另一个岗位,画布将以三色高亮:青绿=共有技能、蓝紫=A 独有、琥珀=B 独有,其余节点自动淡出。 +
`; + $("#dtc-close").onclick = () => exitCompare(); + return; + } + const cs = G.compareSets; + const nb = G.nodes[G.idx[cs.b]]; + const r = cs.result; + const pct = Math.round(r.ready * 100); + const prefB = r.onlyB.filter((x) => x.imp !== "required"); + body.innerHTML = head + ` +
+
${esc((na && na.title) || cs.a)}基准岗位
+ VS +
${esc((nb && nb.title) || cs.b)}目标岗位
+
+
+
${r.shared.length}共有技能
+
${r.onlyA.length}A 独有
+
${r.onlyB.length}B 独有
+
+
转岗就绪度(${esc((na && na.title) || cs.a)} → ${esc((nb && nb.title) || cs.b)})
+
+ 目标必备技能已覆盖 ${r.covered}/${r.bReq}(${pct}%) +
+
+
需补齐必备(${r.gaps.length})
+
${r.gaps.map((s) => chip(s, "tag-orange")).join("") || '无缺口,能力结构已达标'}
+
可拓展加分(${prefB.length})
+
${prefB.map((x) => chip(x.skill, "tag-cyan")).join("") || '—'}
+
+
共有技能(${r.shared.length})
+
${r.shared.map((x) => chip(x.skill, "tag-green")).join("") || '无共有技能'}
+
+
${esc((na && na.title) || cs.a)} 独有(${r.onlyA.length})
+
${r.onlyA.map((x) => chip(x.skill, "tag-purple")).join("") || '—'}
+
`; + $("#dtc-close").onclick = () => exitCompare(); + body.querySelectorAll(".dt-skill").forEach((el) => { el.onclick = () => locateNode(el.dataset.id); }); + } + + /* ================= 动态时序:当季解说面板 ================= */ + /* 时序季度摘要:当季新晋岗位 / 新增关系 / 能力事件分组(纯函数,单测可提取) */ + function windowDigest(edges, events, nodes, w) { + const newEdges = edges.filter((e) => !e.pending && (e.intervals || []).some(([f]) => f === w)); + const evs = events.filter((e) => e.window === w); + const byType = (t) => evs.filter((e) => e.change_type === t); + const firstSeen = {}; + edges.forEach((e) => { + (e.intervals || []).forEach(([f]) => { + if (f && (!(e.source in firstSeen) || f < firstSeen[e.source])) firstSeen[e.source] = f; + }); + }); + const newJobs = nodes.filter((n) => n.type === "Job" && firstSeen[n.id] === w).map((n) => n.title || n.id); + return { newEdges, newJobs, added: byType("新增"), boosted: byType("增强"), weakened: byType("减弱"), exited: byType("退出") }; + } + + /* 当季解说面板:拖到哪个季度,就讲哪个季度的图谱故事 */ + function renderTimePanel() { + const panel = $("#gt-panel"); + if (!panel) return; + if (G.viewMode !== "timeline" || !G.activeNodes) { panel.classList.add("hidden"); return; } + const w = G.timeWins[G.timeIdx]; + if (!w) { panel.classList.add("hidden"); return; } + const d = windowDigest(G.edges, G.events, G.nodes, w); + const typeOf = (id) => (G.nodes[G.idx[id]] || {}).type; + const jobCount = [...G.activeNodes].filter((id) => typeOf(id) === "Job").length; + const skillCount = [...G.activeNodes].filter((id) => typeOf(id) === "Skill").length; + const sec = (title, html) => (html ? `
${title}
${html}
` : ""); + const evItem = (e) => `
+ ${esc(e.change_type)}${esc(e.job_id.replace(/^job_/, ""))} · ${esc(e.skill)}
`; + const relItems = d.newEdges.slice(0, 6).map((e) => { + const s = G.nodes[G.idx[e.source]], t = G.nodes[G.idx[e.target]]; + return `
${esc((s && s.title) || e.source)} → ${esc((t && t.title) || e.target)}
`; + }).join(""); + const evAll = [...d.added, ...d.boosted, ...d.weakened, ...d.exited]; + const quiet = !d.newJobs.length && !d.newEdges.length && !evAll.length; + panel.innerHTML = ` +
${esc(w)} 季度解读
+
+
${jobCount}在图岗位
+
${skillCount}在图技能
+
${G.activeEdges.size}生效关系
+
+ ${sec("本季新晋岗位", d.newJobs.map((j) => `
${esc(j)}
`).join(""))} + ${sec(`本季新增关系(${d.newEdges.length})`, relItems + (d.newEdges.length > 6 ? `
…共 ${d.newEdges.length} 条
` : ""))} + ${sec(`能力事件(新增 ${d.added.length} · 增强 ${d.boosted.length} · 减弱 ${d.weakened.length} · 退出 ${d.exited.length})`, + evAll.slice(0, 8).map(evItem).join("") + (evAll.length > 8 ? `
…共 ${evAll.length} 条
` : ""))} + ${quiet ? `
本季度图谱结构保持稳定,无新增变化
` : `
点击条目可定位对应节点
`}`; + panel.classList.remove("hidden"); + panel.querySelectorAll(".gtp-item[data-id]").forEach((el) => { el.onclick = () => locateNode(el.dataset.id); }); + } + /* ================= 工具条 / 筛选 ================= */ function bindToolbar() { document.addEventListener("click", (e) => { if (!e.target.closest("#ctx-menu")) hideCtxMenu(); }); @@ -830,6 +1173,33 @@ }; /* 视图切换分段控件(全景 / 技术栈 / 职级演进 / 动态时序) */ $$("#graph-view-tabs button").forEach((b) => { b.onclick = () => setViewMode(b.dataset.gview); }); + /* 职级演进视图:切换能力树岗位 */ + $("#gl-job-select").onchange = (e) => { + G.levelJob = e.target.value; + applyLevelLayout(); + fitLevelView(); + selectNode(G.levelJob, false); + }; + /* 岗位对比模式:头部按钮 + 对比条 */ + $("#btn-compare").onclick = () => { + if (G.compare) return exitCompare(); + const n = G.selected != null ? G.nodes[G.idx[G.selected]] : null; + enterCompare((n && n.type === "Job" && !n.pending) ? n.id : null, null); + }; + $("#gc-a").onchange = (e) => { + if (!G.compare) return; + G.compare.a = e.target.value; + if (G.compare.b === G.compare.a) { G.compare.b = null; $("#gc-b").value = ""; } + G.compareSets = null; + if (G.compare.b) updateCompare(); else { renderComparePanel(); requestDraw(); drawMinimap(); } + }; + $("#gc-b").onchange = (e) => { + if (!G.compare) return; + G.compare.b = e.target.value || null; + if (G.compare.b) updateCompare(); + else { G.compareSets = null; renderComparePanel(); requestDraw(); drawMinimap(); } + }; + $("#gc-exit").onclick = () => exitCompare(); /* 动态时序视图:滑块拖动 + 自动播放 */ $("#gt-slider").addEventListener("input", (e) => { G.timeIdx = +e.target.value; @@ -1084,6 +1454,18 @@ const n = G.nodes[G.idx[id]]; if (!n) { G.pendingLocate = id; return; } // 数据未就绪:加载完成后补定位 G.pendingLocate = null; + /* 职级能力树视图下定位:岗位→切换能力树对象;树外技能→退回全景再定位 */ + if (G.viewMode === "level" && G.levelJob) { + if (n.type === "Job") { + G.levelJob = id; + const lvSel = $("#gl-job-select"); + if (lvSel) lvSel.value = id; + applyLevelLayout(); + fitLevelView(); + } else if (!neighborsOf(G.levelJob).has(id)) { + setViewMode("overview"); + } + } /* 目标不在当前岗位子图内:自动退出子图(即全量「岗位生态总览」),避免"定位到不可见节点" */ if (G.soloJob && id !== G.soloJob && !neighborsOf(G.soloJob).has(id)) { G.soloJob = null; @@ -1208,7 +1590,6 @@ ? G.events.filter((e) => e.job_id === id).sort((a, b) => (a.window < b.window ? 1 : -1)).slice(0, 3) : []; const dataCutoff = ((G.events.at(-1) || {}).window) || latestWin || "2026Q3"; - const EV_TAG = { "新增": "tag-cyan", "增强": "tag-purple", "减弱": "tag-orange", "退出": "tag-red" }; const skillChip = (r, cls) => `${esc(r.title)}`; const jobBlocks = isJob ? `
结论
@@ -1471,6 +1852,7 @@ this.inited = true; bindCanvas(); bindToolbar(); + bindSearch(); this._p = load(); await this._p; }, @@ -1492,6 +1874,7 @@ async reload() { await load(); if (G.soloJob && G.idx[G.soloJob]) { applySoloLayout(); fitSoloView(); } // 子图模式下重排同心分层 + if (G.compare && G.compare.b) updateCompare(); // 对比模式随数据重算 if (G.selected && !(G.selected in G.idx)) selectNode(null, false); else if (G.selected) renderDetail(G.selected); }, diff --git a/webui/index.html b/webui/index.html index 38b3773..e7f8698 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4,7 +4,7 @@ CareerNexus 职联 · 岗位与能力图谱平台 - + @@ -169,6 +169,12 @@ 技能点级 审核已开启
+ +
@@ -176,6 +182,9 @@
+ @@ -218,6 +227,29 @@ —
+ + + + + + + + +
@@ -665,6 +697,6 @@
- + diff --git a/webui/style.css b/webui/style.css index 1301dfa..e3c750d 100644 --- a/webui/style.css +++ b/webui/style.css @@ -321,6 +321,89 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: .graph-timeline input[type="range"] { width: 240px; accent-color: var(--primary); } .gt-window { font-size: 12px; color: var(--text-1); font-weight: 600; white-space: nowrap; } +/* 图谱搜索定位(头部) */ +.graph-search { position: relative; display: flex; align-items: center; } +.graph-search svg { + position: absolute; left: 9px; width: 14px; height: 14px; + stroke: var(--text-3); fill: none; stroke-width: 2; pointer-events: none; +} +.graph-search input { + width: 210px; padding: 6px 10px 6px 28px; font-size: 13px; + border: 1px solid var(--border-dark); border-radius: var(--radius-sm); + outline: none; transition: border-color .15s, box-shadow .15s; +} +.graph-search input:focus { border-color: var(--primary); box-shadow: 0 0 0 2px rgba(24,144,255,.12); } +.gs-dropdown { + position: absolute; top: calc(100% + 6px); left: 0; width: 280px; max-height: 320px; overflow: auto; + background: #fff; border: 1px solid var(--border); border-radius: var(--radius); + box-shadow: var(--shadow-pop); z-index: 60; padding: 4px; +} +.gsd-item { + display: flex; align-items: center; gap: 8px; padding: 7px 8px; + border-radius: var(--radius-sm); cursor: pointer; font-size: 13px; +} +.gsd-item:hover, .gsd-item.active { background: #e8f3ff; } +.gsd-item .gsd-name { flex: 1; color: var(--text-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.gsd-empty { padding: 10px; font-size: 12px; color: var(--text-3); text-align: center; } + +/* 职级演进视图:画布顶部岗位选择条(与时间轴同款胶囊) */ +.graph-level-bar { + position: absolute; top: 12px; left: 50%; transform: translateX(-50%); + display: flex; align-items: center; gap: 10px; z-index: 6; + background: rgba(255,255,255,0.94); border: 1px solid var(--border); border-radius: 999px; + padding: 6px 14px; box-shadow: var(--shadow-pop); +} +.gl-label { font-size: 12px; font-weight: 600; color: var(--text-1); white-space: nowrap; } +.gl-hint { font-size: 11px; white-space: nowrap; } +.select-sm { padding: 4px 8px; font-size: 12px; max-width: 220px; } + +/* 岗位对比模式:画布顶部对比条 */ +.graph-compare-bar { + position: absolute; top: 12px; left: 50%; transform: translateX(-50%); + display: flex; align-items: center; gap: 8px; z-index: 6; + background: rgba(255,255,255,0.96); border: 1px solid var(--border); border-radius: 999px; + padding: 6px 14px; box-shadow: var(--shadow-pop); +} +.gc-vs { font-size: 12px; color: var(--text-3); font-weight: 600; } +.gc-legend { display: flex; align-items: center; gap: 10px; margin-left: 6px; font-size: 11px; color: var(--text-2); } +.gc-legend span { display: inline-flex; align-items: center; gap: 4px; } +.gc-legend em { font-style: normal; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.gc-dot { width: 9px; height: 9px; border-radius: 3px; display: inline-block; } + +/* 动态时序视图:当季解说面板(画布左上,工具条之下) */ +.gt-panel { + position: absolute; top: 64px; left: 60px; width: 272px; max-height: calc(100% - 190px); overflow: auto; + background: rgba(255,255,255,0.96); border: 1px solid var(--border); border-radius: var(--radius); + box-shadow: var(--shadow-pop); z-index: 5; padding: 12px 14px; +} +.gtp-title { font-size: 13px; font-weight: 600; color: var(--text-1); margin-bottom: 6px; } +.gtp-stats { display: flex; gap: 12px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px dashed var(--border); } +.gtp-stat b { display: block; font-size: 16px; color: var(--primary); font-weight: 600; } +.gtp-stat span { font-size: 11px; color: var(--text-3); } +.gtp-sec { margin-bottom: 8px; } +.gtp-sec-title { font-size: 12px; font-weight: 600; color: var(--text-2); margin-bottom: 4px; } +.gtp-item { + font-size: 12px; color: var(--text-2); padding: 2px 0; cursor: pointer; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.gtp-item:hover { color: var(--primary); } +.gtp-item .tag { margin-right: 4px; } +.gtp-more { font-size: 11px; color: var(--text-3); padding: 2px 0; } + +/* 岗位对比:右侧详情面板 */ +.dtc-hero { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 4px; } +.dtc-job { flex: 1; min-width: 0; text-align: center; padding: 8px 6px; border-radius: var(--radius-sm); font-size: 13px; font-weight: 600; } +.dtc-job small { display: block; font-weight: 400; font-size: 11px; color: var(--text-3); margin-top: 2px; } +.dtc-vs { font-size: 12px; color: var(--text-3); font-weight: 600; } +.dtc-stats { display: flex; gap: 8px; margin: 8px 0; } +.dtc-stat { flex: 1; text-align: center; background: var(--bg-hover, #f7f8fa); border-radius: var(--radius-sm); padding: 8px 4px; } +.dtc-stat b { display: block; font-size: 18px; font-weight: 600; } +.dtc-stat span { font-size: 11px; color: var(--text-3); } +.dtc-ready { margin: 6px 0 2px; } +.dtc-ready-bar { height: 8px; border-radius: 4px; background: var(--border); overflow: hidden; margin-top: 4px; } +.dtc-ready-bar i { display: block; height: 100%; border-radius: 4px; background: var(--primary); } + + .graph-body { display: flex; gap: 12px; height: calc(100vh - var(--topbar-h) - 130px); min-height: 520px; } .graph-canvas-wrap { flex: 1; position: relative; overflow: hidden; min-width: 0; } #graph-canvas { width: 100%; height: 100%; display: block; cursor: grab; }