图谱四视图切换(全景/技术栈聚类/职级演进/动态时序)+ 标签按需显示 + NEW 脉动标记(CSS/Canvas 实现,无 emoji)+ 时点对比 Git Style Diff 与证据关键词高亮 + 简历加分项改琥珀色 + 学习路径甘特图

This commit is contained in:
RoyirinRosmontis
2026-08-20 12:10:35 +08:00
parent b9fcfcb82e
commit c58e2e996f
5 changed files with 442 additions and 38 deletions
+249 -4
View File
@@ -15,6 +15,11 @@
edges: { required: true, preferred: true, applies: true, belongs: true } },
selected: null, // 选中节点 id
soloJob: null, // 岗位子图模式:只看该岗位及其一跳邻居
viewMode: "overview", // 视图:overview 全景 / tech 技术栈 / level 职级演进 / timeline 动态时序
timeWins: [], timeIdx: null, // 时序视图:窗口序列与当前下标
activeNodes: null, activeEdges: null,// 时序视图:当前窗口生效的节点/边集合
playTimer: null, // 时序视图:播放定时器
catAnchors: null, // 技术栈视图:类别锚点 [{cat,x,y}]
hover: null,
navStack: [], // 详情面板导航栈
drag: null, // {mode:'pan'|'node', ...}
@@ -29,6 +34,9 @@
/* 变化类型颜色(与 app.js CHANGE_META 一致):新增=青绿 / 增强=蓝紫 / 减弱=琥珀 / 退出=砖红 */
const CHANGE_COLORS = { "新增": "#7bc8a4", "增强": "#8f9ff0", "减弱": "#e8c07d", "退出": "#d99a91" };
const CHANGE_MARK = { "新增": "新", "增强": "↑", "减弱": "↓", "退出": "退" };
/* 近一年入库的新岗位(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));
/* 变化角标数据:最近 2 季度有事件的技能(子图模式下按当前岗位过滤) */
function updateRecentChange() {
@@ -88,6 +96,142 @@
requestDraw(); drawMinimap();
}
/* ================= 视图切换:全景 / 技术栈 / 职级演进 / 动态时序 ================= */
const VIEW_NAMES = { overview: "全景总览", tech: "技术栈视图", level: "职级演进视图", timeline: "动态时序视图" };
const LEVEL_BANDS = [
{ name: "初级 L1 · ≤2 年", y: -340 },
{ name: "中级 L2 · 3-5 年", y: 0 },
{ name: "高级 L3 · >5 年", y: 340 },
];
/* 技术栈视图:类别锚点网格分布,岗位聚在锚点旁,技能/场景/行业靠向所属岗位质心 */
function applyTechLayout() {
const jobs = G.nodes.filter((n) => n.type === "Job");
const cats = [...new Set(jobs.map((n) => n.category || "未分类"))].sort((a, b) => a.localeCompare(b, "zh"));
const cols = Math.max(1, Math.ceil(Math.sqrt(cats.length)));
G.catAnchors = cats.map((cat, i) => ({
cat, x: (i % cols) * 980 - ((cols - 1) * 980) / 2, y: Math.floor(i / cols) * 780 - 360,
}));
const anchorOf = (c) => G.catAnchors.find((a) => a.cat === c) || { x: 0, y: 0 };
const byCat = {};
jobs.forEach((n) => { (byCat[n.category || "未分类"] = byCat[n.category || "未分类"] || []).push(n); });
Object.entries(byCat).forEach(([c, list]) => {
const a = anchorOf(c);
list.sort((x, y) => String(x.title || x.id).localeCompare(String(y.title || y.id), "zh"));
const perRow = Math.max(1, Math.ceil(Math.sqrt(list.length)));
list.forEach((n, i) => {
n.x = a.x + ((i % perRow) - (perRow - 1) / 2) * 180;
n.y = a.y + Math.floor(i / perRow) * 150 + 40;
n.vx = 0; n.vy = 0;
});
});
scatterAroundJobs(G.nodes.filter((n) => n.type !== "Job"));
}
/* 非岗位节点:靠向相连岗位的质心,按 id 哈希确定性散布 */
function scatterAroundJobs(nodes) {
nodes.forEach((n) => {
const js = [];
G.edges.forEach((e) => {
if (e.source === n.id) { const t = G.nodes[G.idx[e.target]]; if (t && t.type === "Job") js.push(t); }
if (e.target === n.id) { const s = G.nodes[G.idx[e.source]]; if (s && s.type === "Job") js.push(s); }
});
const cx = js.length ? js.reduce((s, j) => s + j.x, 0) / js.length : 0;
const cy = js.length ? js.reduce((s, j) => s + j.y, 0) / js.length : 0;
const h = hashStr(n.id);
const ang = ((h % 360) * Math.PI) / 180;
const R = 95 + (h % 5) * 36;
n.x = cx + Math.cos(ang) * R;
n.y = cy + Math.sin(ang) * R;
n.vx = 0; n.vy = 0;
});
}
/* 职级演进视图:横向三条级别带(初级→中级→高级 自下而上的成长阶梯),技能落带内、岗位摆带底 */
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"));
jl.forEach((n, i) => {
n.x = (i - (jl.length - 1) / 2) * 200;
n.y = yBand + 170;
n.vx = 0; n.vy = 0;
});
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(Math.sqrt(sl.length) * 1.9));
sl.forEach((n, i) => {
n.x = ((i % perRow) - (perRow - 1) / 2) * 108;
n.y = yBand - 120 + Math.floor(i / perRow) * 72;
n.vx = 0; n.vy = 0;
});
});
}
/* 动态时序视图:窗口序列(事件窗口 ∪ 边区间起点),拖动滑块只显示该季度已存在节点与生效边 */
function timeWindows() {
const s = new Set(G.events.map((e) => e.window));
G.edges.forEach((e) => (e.intervals || []).forEach(([f]) => s.add(f)));
return [...s].filter(Boolean).sort();
}
function setupTimeline() {
G.timeWins = timeWindows();
const sl = $("#gt-slider");
sl.max = Math.max(0, G.timeWins.length - 1);
sl.disabled = !G.timeWins.length;
if (G.timeIdx == null || G.timeIdx > G.timeWins.length - 1) G.timeIdx = G.timeWins.length - 1;
sl.value = G.timeIdx;
applyTimeWindow();
}
function applyTimeWindow() {
const w = G.timeWins[G.timeIdx];
G.activeEdges = new Set(G.edges.filter((e) => edgeActiveAt(e, w)));
G.activeNodes = new Set();
G.activeEdges.forEach((e) => { G.activeNodes.add(e.source); G.activeNodes.add(e.target); });
const hasEdge = new Set();
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}` : "暂无时序数据";
requestDraw(); drawMinimap();
}
function stopPlay() {
if (G.playTimer) { clearInterval(G.playTimer); G.playTimer = null; }
const b = $("#gt-play");
if (b) b.textContent = "播放";
}
/* 视图切换入口:保留筛选/子图状态,仅重排位置(全景来回切换时恢复原力导向位置) */
function setViewMode(mode) {
if (!VIEW_NAMES[mode] || !G.nodes.length) return;
if (mode === G.viewMode && mode === "overview") return;
if (G.viewMode === "overview" && mode !== "overview") {
G._overviewPos = G.nodes.map((n) => ({ x: n.x, y: n.y }));
}
G.viewMode = mode;
$$("#graph-view-tabs button").forEach((b) => b.classList.toggle("active", b.dataset.gview === mode));
$("#graph-timeline").classList.toggle("hidden", mode !== "timeline");
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 === "timeline") setupTimeline();
if (G.soloJob) { applySoloLayout(); fitSoloView(); }
else fitView();
requestDraw(); drawMinimap();
if (mode !== "overview") toast(`已切换为${VIEW_NAMES[mode]}`, "info");
}
/* 按当前视图重排(数据重载 / 一键整理布局后调用) */
function relayoutByView() {
if (G.viewMode === "tech") applyTechLayout();
else if (G.viewMode === "level") applyLevelLayout();
else if (G.viewMode === "timeline") setupTimeline();
}
/* ================= 数据装载 ================= */
async function load() {
$("#graph-loading").classList.remove("hidden");
@@ -122,6 +266,7 @@
buildFilterOptions();
layout();
relayoutByView(); // 保持在非全景视图时重载后仍为该视图布局
restoreView() || fitView();
/* 默认展示全景图谱(全量总览);岗位子图通过详情面板「岗位子图」按钮或演示导览进入 */
updateRecentChange();
@@ -224,8 +369,29 @@
if (y == null) return "";
return y <= 2 ? "初级" : y <= 5 ? "中级" : "高级";
}
/* 职级演进视图:节点所属级别带(0=初级 1=中级 2=高级)。技能取词表 level(L1/L2/L3),岗位取 level/expected_years */
function levelBandOf(n) {
if (n.type === "Job") {
const l = n.level || jobLevel(n);
return l === "初级" ? 0 : l === "高级" ? 2 : 1;
}
const lv = { L1: 0, L2: 1, L3: 2 }[n.level];
return lv == null ? 1 : lv;
}
/* 动态时序视图:边在窗口 w 是否生效(intervals: [start, end) 半开区间;无区间信息视为始终生效) */
function edgeActiveAt(e, w) {
if (!w || !e.intervals || !e.intervals.length) return true;
return e.intervals.some(([s, en]) => s <= w && (en === null || w < en));
}
/* 确定性短哈希(技术栈视图技能散布用,避免每次切换位置抖动) */
function hashStr(s) {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return h;
}
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.soloJob) { // 岗位子图:只保留该岗位与一跳邻居
if (n.id !== G.soloJob && !neighborsOf(G.soloJob).has(n.id)) return false;
}
@@ -236,6 +402,7 @@
return true;
}
function edgeVisible(e) {
if (G.viewMode === "timeline" && G.activeEdges && !G.activeEdges.has(e)) return false;
const ef = G.filters.edges || {};
if (e.rel === "REQUIRES") return ef[e.importance === "required" ? "required" : "preferred"] !== false;
if (e.rel === "APPLIES_TO") return ef.applies !== false;
@@ -273,6 +440,44 @@
const sel = G.selected;
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(47,84,235,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([]);
}
});
/* 成长阶梯方向提示(文字,非图标) */
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();
G.catAnchors.forEach((a) => {
const p = { x: a.x * G.cam.k + G.cam.x, y: a.y * G.cam.k + G.cam.y };
const n = G.nodes.filter((x) => x.type === "Job" && (x.category || "未分类") === a.cat).length;
const label = `${a.cat}(${n})`;
ctx.font = "bold 13px sans-serif";
const tw = ctx.measureText(label).width;
ctx.fillStyle = "rgba(47,84,235,0.08)";
ctx.strokeStyle = "rgba(47,84,235,0.35)";
ctx.beginPath(); ctx.roundRect(p.x - tw / 2 - 10, p.y - 14, tw + 20, 24, 12); ctx.fill(); ctx.stroke();
ctx.fillStyle = "#2f54eb"; ctx.textAlign = "center";
ctx.fillText(label, p.x, p.y + 3);
});
ctx.restore();
}
/* ---- 边 ---- */
G.edges.forEach((e) => {
@@ -355,8 +560,23 @@
ctx.strokeStyle = "rgba(47,84,235,0.55)"; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(p.x, p.y, r + 2.5, 0, 7); ctx.stroke();
}
/* 标签:岗位常显,其余放大或选中/悬停时显示 */
if (n.type === "Job" || G.cam.k > 1.05 || isSel || isHop || n.id === G.hover) {
/* NEW 标记:待审核候选与近一年入库新岗位,节点右上方脉动圆点 + NEW 文字(canvas 绘制,alpha 呼吸复用 requestDraw 循环) */
if (isNewJobNode(n) && !dim) {
const pulse = 0.55 + 0.45 * Math.sin(Date.now() / 380);
const bx = p.x + r * 0.95, by = p.y - r * 0.95;
ctx.globalAlpha = pulse;
ctx.fillStyle = "#ff7d00";
ctx.beginPath(); ctx.arc(bx, by, 4, 0, 7); ctx.fill();
ctx.strokeStyle = "#fff"; ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.arc(bx, by, 4, 0, 7); ctx.stroke();
ctx.font = "bold 9px sans-serif"; ctx.textAlign = "left";
ctx.fillText("NEW", bx + 6, by + 3);
ctx.globalAlpha = dim ? 0.15 : 1;
G._newPulse = true; // 有 NEW 节点在画面上:持续请求重绘形成呼吸
}
/* 标签按需显示(文字重叠治理):岗位常显;技能/场景/行业仅在选中/悬停/选中节点一跳邻居时显示;
* 关系标签开关(showLabels)打开且放大到一定程度时恢复全量标签 */
if (n.type === "Job" || 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)";
@@ -399,6 +619,9 @@
ctx.fillStyle = "#fff"; ctx.textAlign = "center";
ctx.fillText(txt, bx + tw / 2 + 8, p.y + r + 24);
}
/* NEW 标记呼吸:本帧画到 NEW 节点则继续排队下一帧(复用 requestDraw,不另开定时器) */
if (G._newPulse) requestDraw();
}
/* ================= 小地图 ================= */
@@ -517,7 +740,29 @@
$("#tool-fit").onclick = fitView;
$("#tool-relayout").onclick = () => {
$("#graph-loading").classList.remove("hidden");
setTimeout(() => { layout(); fitView(); $("#graph-loading").classList.add("hidden"); toast("布局已整理", "success"); }, 30);
setTimeout(() => {
layout(); relayoutByView(); fitView();
$("#graph-loading").classList.add("hidden"); toast("布局已整理", "success");
}, 30);
};
/* 视图切换分段控件(全景 / 技术栈 / 职级演进 / 动态时序) */
$$("#graph-view-tabs button").forEach((b) => { b.onclick = () => setViewMode(b.dataset.gview); });
/* 动态时序视图:滑块拖动 + 自动播放 */
$("#gt-slider").addEventListener("input", (e) => {
G.timeIdx = +e.target.value;
applyTimeWindow();
});
$("#gt-play").onclick = () => {
if (G.playTimer) return stopPlay();
if (!G.timeWins.length) return toast("暂无时序数据可播放", "info");
if (G.timeIdx >= G.timeWins.length - 1) { G.timeIdx = 0; $("#gt-slider").value = 0; applyTimeWindow(); }
$("#gt-play").textContent = "暂停";
G.playTimer = setInterval(() => {
if (G.viewMode !== "timeline" || G.timeIdx >= G.timeWins.length - 1) return stopPlay();
G.timeIdx += 1;
$("#gt-slider").value = G.timeIdx;
applyTimeWindow();
}, 1200);
};
$("#tool-labels").onclick = (e) => {
G.showLabels = !G.showLabels;
@@ -723,7 +968,7 @@
const pendRels = G.edges.filter((e) => e.pending && e.source === jobId);
if (pendRels.length) {
html += `<span class="chain-arrow">|待审核:</span>` + pendRels.map((e) =>
`<span class="chain-chip review" data-skill="${esc(e.target)}">⏳ ${esc(e.target)}</span>`).join("");
`<span class="chain-chip review" data-skill="${esc(e.target)}">待审 · ${esc(e.target)}</span>`).join("");
}
chips.innerHTML = html || `<span class="text-weak text-aux">该岗位暂无演化记录</span>`;
chips.querySelectorAll("[data-skill]").forEach((el) => {