图谱工作台功能扩充:职级视图改为岗位能力树(L1→L2→L3成长阶梯)、新增图谱搜索定位、岗位对比模式(三色交集/差集+转岗就绪度)、时序视图季度解说面板,纯函数单测83例全过

This commit is contained in:
FinStab
2026-08-28 11:44:50 +08:00
parent 3058fc8aca
commit 6d91df1a91
4 changed files with 597 additions and 47 deletions
+428 -45
View File
@@ -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) =>
`<option value="${esc(n.id)}">${esc(n.title || n.id)}${n.pending ? "(待审核)" : ""}</option>`).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 `<div class="gsd-item" data-i="${i}">
<span class="tag ${meta.tag}">${meta.name}</span>
<span class="gsd-name">${esc(n.title || n.id)}</span>
${n.pending ? `<span class="tag tag-orange">待审核</span>` : ""}
</div>`;
}).join("") : `<div class="gsd-empty">没有匹配的节点</div>`;
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) => `<option value="${esc(n.id)}"${n.id === selectedId ? " selected" : ""}>${esc(n.title || n.id)}</option>`).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 = `<option value="">选择对比岗位…</option>` + 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) => `<span class="tag ${cls} dt-skill" data-id="${esc(s)}" title="点击聚焦该技能">${esc(s)}</span>`;
const head = `
<div class="dt-head">
<div class="dt-title-row"><div>
<div class="dt-name">岗位对比</div>
<div class="dt-tags"><span class="tag tag-blue">技能交集 / 差集分析</span></div>
</div></div>
<button class="icon-btn sm dt-close" id="dtc-close" title="退出对比"><svg viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></svg></button>
</div>`;
if (!G.compareSets) {
body.innerHTML = head + `
<div class="dt-sec"><div class="dt-sec-title">等待选择</div>
<div class="text-secondary" style="font-size:13px;line-height:1.8">
基准岗位:「${esc((na && na.title) || "—")}」<br>
请在顶部对比条选择另一个岗位,画布将以三色高亮:青绿=共有技能、蓝紫=A 独有、琥珀=B 独有,其余节点自动淡出。
</div></div>`;
$("#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 + `
<div class="dtc-hero">
<div class="dtc-job" style="background:rgba(143,159,240,.14)">${esc((na && na.title) || cs.a)}<small>基准岗位</small></div>
<span class="dtc-vs">VS</span>
<div class="dtc-job" style="background:rgba(232,192,125,.16)">${esc((nb && nb.title) || cs.b)}<small>目标岗位</small></div>
</div>
<div class="dtc-stats">
<div class="dtc-stat"><b style="color:${COMPARE_COLORS.shared}">${r.shared.length}</b><span>共有技能</span></div>
<div class="dtc-stat"><b style="color:${COMPARE_COLORS.a}">${r.onlyA.length}</b><span>A 独有</span></div>
<div class="dtc-stat"><b style="color:${COMPARE_COLORS.b}">${r.onlyB.length}</b><span>B 独有</span></div>
</div>
<div class="dt-sec"><div class="dt-sec-title">转岗就绪度(${esc((na && na.title) || cs.a)} → ${esc((nb && nb.title) || cs.b)})</div>
<div class="dtc-ready">
<span class="text-aux text-secondary">目标必备技能已覆盖 ${r.covered}/${r.bReq}(${pct}%)</span>
<div class="dtc-ready-bar"><i style="width:${pct}%"></i></div>
</div>
<div class="kv" style="margin-top:10px"><span class="k">需补齐必备(${r.gaps.length})</span></div>
<div class="dt-chips">${r.gaps.map((s) => chip(s, "tag-orange")).join("") || '<span class="text-weak text-aux">无缺口,能力结构已达标</span>'}</div>
<div class="kv" style="margin-top:8px"><span class="k">可拓展加分(${prefB.length})</span></div>
<div class="dt-chips">${prefB.map((x) => chip(x.skill, "tag-cyan")).join("") || '<span class="text-weak text-aux">—</span>'}</div>
</div>
<div class="dt-sec"><div class="dt-sec-title">共有技能(${r.shared.length})</div>
<div class="dt-chips">${r.shared.map((x) => chip(x.skill, "tag-green")).join("") || '<span class="text-weak text-aux">无共有技能</span>'}</div>
</div>
<div class="dt-sec"><div class="dt-sec-title">${esc((na && na.title) || cs.a)} 独有(${r.onlyA.length})</div>
<div class="dt-chips">${r.onlyA.map((x) => chip(x.skill, "tag-purple")).join("") || '<span class="text-weak text-aux">—</span>'}</div>
</div>`;
$("#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 ? `<div class="gtp-sec"><div class="gtp-sec-title">${title}</div>${html}</div>` : "");
const evItem = (e) => `<div class="gtp-item" data-id="${esc(e.skill)}" title="点击定位技能节点">
<span class="tag ${EV_TAG[e.change_type] || "tag-gray"}">${esc(e.change_type)}</span>${esc(e.job_id.replace(/^job_/, ""))} · ${esc(e.skill)}</div>`;
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 `<div class="gtp-item" data-id="${esc(e.target)}" title="点击定位">${esc((s && s.title) || e.source)} → ${esc((t && t.title) || e.target)}</div>`;
}).join("");
const evAll = [...d.added, ...d.boosted, ...d.weakened, ...d.exited];
const quiet = !d.newJobs.length && !d.newEdges.length && !evAll.length;
panel.innerHTML = `
<div class="gtp-title">${esc(w)} 季度解读</div>
<div class="gtp-stats">
<div class="gtp-stat"><b>${jobCount}</b><span>在图岗位</span></div>
<div class="gtp-stat"><b>${skillCount}</b><span>在图技能</span></div>
<div class="gtp-stat"><b>${G.activeEdges.size}</b><span>生效关系</span></div>
</div>
${sec("本季新晋岗位", d.newJobs.map((j) => `<div class="gtp-item">${esc(j)}</div>`).join(""))}
${sec(`本季新增关系(${d.newEdges.length})`, relItems + (d.newEdges.length > 6 ? `<div class="gtp-more">…共 ${d.newEdges.length} 条</div>` : ""))}
${sec(`能力事件(新增 ${d.added.length} · 增强 ${d.boosted.length} · 减弱 ${d.weakened.length} · 退出 ${d.exited.length})`,
evAll.slice(0, 8).map(evItem).join("") + (evAll.length > 8 ? `<div class="gtp-more">…共 ${evAll.length} 条</div>` : ""))}
${quiet ? `<div class="gtp-more">本季度图谱结构保持稳定,无新增变化</div>` : `<div class="gtp-more">点击条目可定位对应节点</div>`}`;
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) => `<span class="tag ${cls} dt-skill" data-id="${esc(r.id)}" title="点击聚焦该技能">${esc(r.title)}</span>`;
const jobBlocks = isJob ? `
<div class="dt-sec"><div class="dt-sec-title">结论</div>
@@ -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);
},