图谱四视图切换(全景/技术栈聚类/职级演进/动态时序)+ 标签按需显示 + NEW 脉动标记(CSS/Canvas 实现,无 emoji)+ 时点对比 Git Style Diff 与证据关键词高亮 + 简历加分项改琥珀色 + 学习路径甘特图
This commit is contained in:
+249
-4
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user