图谱四视图切换(全景/技术栈聚类/职级演进/动态时序)+ 标签按需显示 + 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
+80 -24
View File
@@ -178,7 +178,8 @@ CN.jdMetaLine = async function (el, sid) {
/* JD 证据弹窗(演化事件 / 图谱来源共用)
* sids:单条或数组(数组时支持上一条/下一条翻页)
* ctx:{ skill, jobTitle, changeType, window } 用于生成可读结论与命中依据 */
* ctx:{ skill, jobTitle, changeType, window, keyword } 用于生成可读结论与命中依据;
* keyword 命中词在 JD 原文中以 <mark> 高亮 */
async function showJdEvidence(sids, ctx = {}) {
const list = (Array.isArray(sids) ? sids : [sids]).filter(Boolean);
if (!list.length) return toast("该条目暂无关联的公开 JD 证据", "info");
@@ -202,6 +203,13 @@ async function showJdEvidence(sids, ctx = {}) {
}
try {
const rec = await fetchJd(sid);
/* 关键词高亮:转义后的原文上做单次正则替换,避免嵌套/注入 */
let jdHtml = esc(rec.text);
const kw = (ctx.keyword || "").trim();
if (kw) {
const re = new RegExp(kw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
jdHtml = jdHtml.replace(re, (hit) => `<mark>${hit}</mark>`);
}
$("#modal-body").innerHTML = `
${conclusion ? `<div class="ev-conclusion">${esc(conclusion)}${ctx.window ? `<span class="text-weak">(${esc(ctx.window)})</span>` : ""}</div>` : ""}
<div class="jd-meta">
@@ -209,7 +217,7 @@ async function showJdEvidence(sids, ctx = {}) {
<span>${esc(rec.city || "—")}</span><span>${esc(rec.salary || "—")}</span>
</div>
<div class="ev-source">来源:${esc(rec.source || "公开招聘平台")} · ${esc(rec.company || "企业未披露")} · 发布时间:${esc(rec.publish_date || "未知")}</div>
<div class="jd-evidence">${esc(rec.text)}</div>
<div class="jd-evidence">${jdHtml}</div>
${ctx.skill ? `<div class="ev-basis">命中依据:该 JD 对「${esc(ctx.skill)}」提出明确能力要求${ctx.changeType ? `(${esc(ctx.changeType)})` : ""}</div>` : ""}
<div class="ev-sid">${esc(sid)}</div>`;
} catch (e) {
@@ -871,6 +879,18 @@ function renderEvoStats(jobId) {
`<div class="evo-stat"><span>${k}</span><b>${v}</b></div>`).join("");
}
/* 时点画像差集(Git 风格):返回 新增/退出/保留 三组行数据(纯逻辑,node 可测) */
function diffSkillRows(skillsA, skillsB) {
const setA = new Map(skillsA.map((s) => [s.skill, s.importance]));
const setB = new Map(skillsB.map((s) => [s.skill, s.importance]));
return {
add: [...setB.keys()].filter((s) => !setA.has(s)).map((s) => ({ skill: s, imp: setB.get(s) })),
del: [...setA.keys()].filter((s) => !setB.has(s)).map((s) => ({ skill: s, imp: setA.get(s) })),
keep: [...setA.keys()].filter((s) => setB.has(s))
.map((s) => ({ skill: s, imp: setB.get(s), up: setA.get(s) !== setB.get(s) })),
};
}
async function runEvoCompare() {
const id = $("#evo-job-select").value;
if (!id) return;
@@ -890,29 +910,39 @@ async function runEvoCompare() {
api(`/api/jobs/${encodeURIComponent(id)}/snapshot?at=${atA}`),
api(`/api/jobs/${encodeURIComponent(id)}/snapshot?at=${atB}`),
]);
const setA = new Map(sa.skills.map((s) => [s.skill, s.importance]));
const setB = new Map(sb.skills.map((s) => [s.skill, s.importance]));
const onlyA = [...setA.keys()].filter((s) => !setB.has(s));
const both = [...setA.keys()].filter((s) => setB.has(s));
const onlyB = [...setB.keys()].filter((s) => !setA.has(s));
const chip = (s, cls, extra) =>
`<span class="tag ${cls} diff-chip" data-skill="${esc(s)}">${esc(s)}${extra ? `<i>${extra}</i>` : ""}</span>`;
const col = (title, list, cls, note) => `
<div class="diff-col">
<div class="diff-title">${title}(${list.length})</div>
<div class="diff-chips">${list.map((s) => chip(s, cls,
note === "升" && setA.get(s) !== setB.get(s) ? "↑" : "")).join("") ||
'<span class="text-weak text-aux">无</span>'}</div>
const diff = diffSkillRows(sa.skills, sb.skills);
/* Git 风格行式 diff:+=新增(绿) -=退出(红+删除线) 无前缀=保留(灰),点击行查看该技能证据链 */
const row = (r, kind, sign) => `
<div class="diff-row dr-${kind}" data-skill="${esc(r.skill)}" title="点击查看该技能的证据 JD 原文">
<span class="dr-sign">${sign}</span>
<span class="dr-name">${esc(r.skill)}</span>
<span class="dr-imp">${r.imp === "required" ? "必备" : "加分"}${r.up ? `<i class="dr-up">↑</i>` : ""}</span>
</div>`;
const group = (title, list, kind, sign) => `
<div class="diff-group dr-head-${kind}">${title}(${list.length})</div>
${list.map((r) => row(r, kind, sign)).join("") || `<div class="diff-row dr-none">无</div>`}`;
box.innerHTML = `
<div class="diff-meta">${esc(atA)}(${esc(sa.window)}):${setA.size} 项技能 → ${esc(atB)}(${esc(sb.window)}):${setB.size} 项技能</div>
<div class="diff-grid">
${col(`仅 ${esc(atA)} 有(已退出)`, onlyA, "tag-red")}
${col("两个时点共有(保留)", both, "tag-gray", "升")}
${col(`仅 ${esc(atB)} 有(新增)`, onlyB, "tag-green")}
<div class="diff-meta">${esc(atA)}(${esc(sa.window)}):${sa.skills.length} 项技能 → ${esc(atB)}(${esc(sb.window)}):${sb.skills.length} 项技能</div>
<div class="diff-rows">
${group(`仅 ${esc(atB)} 有 · 新增`, diff.add, "add", "+")}
${group(`仅 ${esc(atA)} 有 · 已退出`, diff.del, "del", "-")}
${group("两个时点共有 · 保留", diff.keep, "keep", "")}
</div>`;
box.querySelectorAll(".diff-chip").forEach((el) => {
el.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(el.dataset.skill); };
const jobTitle = (CN.jobs.find((j) => j.job_id === id) || {}).title || id;
box.querySelectorAll(".diff-row[data-skill]").forEach((el) => {
el.onclick = () => {
const skill = el.dataset.skill;
/* 证据链:取该技能相关演化事件的 source_ids(最新事件优先) */
const evs = evoEvents.filter((e) => e.skill === skill)
.sort((a, b) => (a.window < b.window ? 1 : -1));
const sids = [...new Set(evs.flatMap((e) => e.source_ids || []))];
if (!sids.length) return toast("该技能暂无关联的公开 JD 证据", "info");
const latest = evs[0] || {};
showJdEvidence(sids, {
skill, jobTitle, keyword: skill,
changeType: latest.change_type, window: latest.window,
});
};
});
} catch (e) {
box.innerHTML = `<div class="empty small">
@@ -1309,7 +1339,7 @@ function renderMatchResult(m) {
}
function skillClsMap(m) {
// 技能 → 高亮类别:命中必备=绿,命中加分=蓝,其他已识别=灰
// 技能 → 高亮类别:命中必备=绿,命中加分=琥珀黄,其他已识别=灰
const clsOf = new Map();
(m.matched_required || []).forEach((s) => clsOf.set(s.toLowerCase(), "hl-req"));
(m.matched_preferred || []).forEach((s) => clsOf.set(s.toLowerCase(), "hl-pref"));
@@ -1696,6 +1726,17 @@ function stageWeeks(n) {
return Array.from({ length: n }, () => base);
}
/* 路线甘特图:各阶段横条的起始/宽度百分比(纯逻辑,node 可测) */
function ganttBars(weeks) {
const total = weeks.reduce((a, b) => a + b, 0) || 1;
let acc = 0;
return weeks.map((w) => {
const left = (acc / total) * 100;
acc += w;
return { left, width: (w / total) * 100 };
});
}
/* 阶段预计投入文案:按每周约 8 小时有效学习估算 */
function stageEffortText(w) {
return `${w} 周 / 约 ${w * 8} 小时`;
@@ -1745,11 +1786,26 @@ function renderPath(data) {
/* 阶段语义化命名:基础补齐 → 岗位核心(可多个)→ 项目验证 */
const stageName = (i, n) =>
i === 0 ? "基础补齐" : i === n - 1 ? "项目验证" : "岗位核心";
/* 路线甘特图:横轴周数,每阶段一根横条(已完成绿色填充 / 未完成主色描边),纯 CSS 实现 */
const totalW = weeks.reduce((a, b) => a + b, 0);
const bars = ganttBars(weeks);
const ganttHtml = `
<div class="gantt-wrap">
<div class="gantt-title">路线甘特图(共 ${totalW} 周 · 绿色=已完成阶段)</div>
<div class="gantt-track">${data.learning_path.map((st, i) => `
<div class="gantt-bar${pathState.done.has(st.stage) ? " done" : ""}"
style="left:${bars[i].left}%;width:${bars[i].width}%"
title="阶段 ${st.stage} · ${stageName(i, data.learning_path.length)} · ${weeks[i]} 周 · ${st.skills.length} 项技能">
<span class="gb-label">阶段${st.stage} ${stageName(i, data.learning_path.length)} · ${st.skills.length} 技能</span>
</div>`).join("")}</div>
<div class="gantt-axis">${Array.from({ length: totalW + 1 }, (_, i) =>
`<span class="gantt-tick" style="left:${(i / totalW) * 100}%">${i}</span>`).join("")}</div>
</div>`;
box.innerHTML = (data._demo ? `
<div class="path-demo-banner">
<b>示范路线</b>(以零已掌握技能生成):完成一次人岗诊断后,此处将自动替换为你的个人成长路线图
<button class="btn btn-secondary btn-sm" id="path-goto-match-2">去完成诊断</button>
</div>` : "") + data.learning_path.map((st, i) => {
</div>` : "") + ganttHtml + data.learning_path.map((st, i) => {
const done = pathState.done.has(st.stage);
return `
<div class="stage-card${done ? " done" : ""}" data-stage="${st.stage}">
+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) => {
+14 -1
View File
@@ -153,6 +153,12 @@
<span class="tag tag-cyan">技能点级</span>
<span class="tag tag-green">审核已开启</span>
</div>
<div class="seg graph-view-tabs" id="graph-view-tabs">
<button data-gview="overview" class="active">全景总览</button>
<button data-gview="tech">技术栈</button>
<button data-gview="level">职级演进</button>
<button data-gview="timeline">动态时序</button>
</div>
<div class="graph-actions">
<button class="btn btn-secondary" id="btn-goto-evo">
<svg viewBox="0 0 24 24"><path d="M3 17l5.5-5.5 3.5 3.5L20 7"/><path d="M15 7h5v5"/></svg>
@@ -189,6 +195,13 @@
<button id="solo-exit" title="退出岗位子图">×</button>
</div>
<!-- 动态时序视图:季度时间轴(仅该视图显示) -->
<div class="graph-timeline hidden" id="graph-timeline">
<button class="btn btn-secondary btn-sm" id="gt-play">播放</button>
<input type="range" id="gt-slider" min="0" max="0" step="1" value="0">
<span class="gt-window" id="gt-label">—</span>
</div>
<!-- 左上浮动工具条 -->
<div class="graph-toolbar">
<button class="tool-btn" id="tool-zoom-in" title="放大"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.35-4.35M11 8v6M8 11h6"/></svg></button>
@@ -329,7 +342,7 @@
<button class="btn btn-secondary btn-sm" id="evo-compare-btn">对比</button>
</div>
</div>
<div class="evo-chart-desc">怎么用:选择两个日期,系统回放该岗位在两个时点的完整能力画像并求差集——红色=左边时点有、后来退出;绿色=右边时点新增;灰色=一直保留(带↑表示从加分升级为必备)。</div>
<div class="evo-chart-desc">怎么用:选择两个日期,系统回放该岗位在两个时点的完整能力画像并按行求差(Git 风格)——绿色 + 行=右边时点新增;红色 - 行(删除线)=左边时点有、后来退出;灰色行=一直保留(带↑表示从加分升级为必备)。点击任意行查看该技能的证据 JD 原文(技能名在原文中高亮)。</div>
<div class="evo-diff" id="evo-diff"></div>
</div>
+56 -9
View File
@@ -303,6 +303,17 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
.graph-title { display: flex; align-items: center; gap: 10px; }
.graph-title h2 { font-size: 18px; font-weight: 600; }
.graph-actions { display: flex; gap: 8px; }
.graph-view-tabs { margin: 0 auto; }
/* 动态时序视图:画布顶部居中的季度时间轴 */
.graph-timeline {
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);
}
.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-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; }
@@ -558,7 +569,7 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
}
.hl { border-radius: 3px; padding: 1px 4px; font-weight: 500; white-space: nowrap; }
.hl-req { background: var(--success-light); color: var(--success); box-shadow: inset 0 0 0 1px #b7eb8f; }
.hl-pref { background: var(--primary-light); color: var(--primary); box-shadow: inset 0 0 0 1px #d6e4ff; }
.hl-pref { background: #fdf0da; color: #ad6a10; box-shadow: inset 0 0 0 1px #e8c07d; }
.hl-other { background: var(--bg-page); color: var(--text-2); box-shadow: inset 0 0 0 1px var(--border); }
/* 结构化简历模板(docx 表格风) */
.rd-table {
@@ -673,6 +684,28 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
.stage-check { font-size: 12px; }
.path-done { text-align: center; padding: 40px; color: var(--success); font-size: 15px; }
/* 路线甘特图:横轴周数,每阶段一根横条(已完成绿填充 / 未完成主色描边),纯 CSS */
.gantt-wrap { margin-bottom: 18px; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px 14px 16px; background: var(--bg-page); }
.gantt-title { font-size: 12px; font-weight: 600; color: var(--text-2); margin-bottom: 8px; }
.gantt-track { position: relative; height: 30px; }
.gantt-bar {
position: absolute; top: 0; height: 100%; box-sizing: border-box;
border: 1.5px solid var(--primary); border-radius: 4px; background: var(--primary-light);
display: flex; align-items: center; overflow: hidden;
}
.gantt-bar.done { background: var(--success); border-color: var(--success); }
.gantt-bar .gb-label {
font-size: 11px; color: var(--primary); font-weight: 600;
padding: 0 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.gantt-bar.done .gb-label { color: #fff; }
.gantt-axis { position: relative; height: 14px; margin-top: 4px; }
.gantt-tick {
position: absolute; transform: translateX(-50%);
font-size: 10px; color: var(--text-3); font-variant-numeric: tabular-nums;
}
.gantt-tick::before { content: ""; position: absolute; top: -4px; left: 50%; width: 1px; height: 3px; background: var(--border-dark); }
/* ============ 弹窗 / Toast ============ */
.modal-mask {
position: fixed; inset: 0; background: rgba(29, 33, 41, 0.45);
@@ -767,14 +800,28 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
.evo-dates { display: flex; align-items: center; gap: 8px; }
.evo-dates .input { width: 150px; }
.diff-meta { font-size: 12px; color: var(--text-3); margin-bottom: 10px; }
.diff-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
@media (max-width: 1100px) { .diff-grid { grid-template-columns: 1fr; } }
.diff-col { background: var(--bg-hover); border-radius: var(--radius-sm); padding: 10px 12px; }
.diff-title { font-size: 12px; font-weight: 600; color: var(--text-1); margin-bottom: 8px; }
.diff-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.diff-chip { cursor: pointer; }
.diff-chip:hover { filter: brightness(0.92); }
.diff-chip i { font-style: normal; margin-left: 3px; color: var(--primary); font-weight: 700; }
/* Git 风格行式 diff:+新增(绿) / -退出(红+删除线) / 保留(灰) */
.diff-rows { border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
.diff-group { font-size: 12px; font-weight: 600; color: var(--text-2); padding: 6px 12px; background: var(--bg-hover); border-top: 1px solid var(--border); }
.diff-group:first-child { border-top: none; }
.dr-head-add { color: var(--success); }
.dr-head-del { color: #cf1322; }
.diff-row {
display: flex; align-items: center; gap: 10px; padding: 5px 12px;
font-size: 13px; cursor: pointer; border-top: 1px solid var(--border);
}
.diff-row:hover { filter: brightness(0.97); }
.diff-row .dr-sign { width: 14px; text-align: center; font-family: Consolas, monospace; font-weight: 700; flex: none; }
.diff-row .dr-imp { margin-left: auto; font-size: 11px; color: var(--text-3); flex: none; }
.diff-row .dr-up { font-style: normal; color: var(--primary); font-weight: 700; margin-left: 3px; }
.diff-row.dr-add { background: var(--success-light); }
.diff-row.dr-add .dr-sign, .diff-row.dr-add .dr-name { color: var(--success); }
.diff-row.dr-del { background: #fff1f0; }
.diff-row.dr-del .dr-sign, .diff-row.dr-del .dr-name { color: #cf1322; }
.diff-row.dr-del .dr-name { text-decoration: line-through; }
.diff-row.dr-keep { background: var(--bg-page); color: var(--text-2); }
.diff-row.dr-none { color: var(--text-3); cursor: default; background: #fff; font-size: 12px; }
.jd-evidence mark { background: #ffe58f; color: inherit; border-radius: 2px; padding: 0 1px; }
.evo-heat-wrap { max-height: 260px; max-width: 920px; margin: 0 auto 16px; }
.evo-heat-wrap canvas { max-height: 260px; }
.timeline { max-height: 420px; overflow-y: auto; }