图谱工作台功能扩充:职级视图改为岗位能力树(L1→L2→L3成长阶梯)、新增图谱搜索定位、岗位对比模式(三色交集/差集+转岗就绪度)、时序视图季度解说面板,纯函数单测83例全过
This commit is contained in:
+428
-45
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user