演化页去可视化炫技:删除能力生命线泳道图,全岗位扫描热力图改为横条排行(负责人反馈:不直观)

This commit is contained in:
RoyirinRosmontis
2026-08-19 15:48:11 +08:00
parent d2be567668
commit ef9fe9faa3
3 changed files with 2281 additions and 2345 deletions
+28 -99
View File
@@ -749,56 +749,42 @@ const STEADY_COLOR = "#86909c"; // 持续:中性蓝灰
let evoEvents = []; let evoEvents = [];
/* 全部岗位 × 季度 演化热力图 */ /* 全部岗位 × 季度 演化热力图 */
function renderEvoHeatmap() { function renderEvoScan() {
const box = $("#evo-heatmap"); const box = $("#evo-scan");
const events = (CN.graphData && CN.graphData.events) || []; const events = (CN.graphData && CN.graphData.events) || [];
const jobs = CN.jobs || []; const jobs = CN.jobs || [];
if (!events.length || !jobs.length) { if (!events.length || !jobs.length) {
box.innerHTML = `<div class="empty small">暂无演化事件数据</div>`; box.innerHTML = `<div class="empty small">暂无演化事件数据</div>`;
return; return;
} }
const windows = [...new Set(events.map((e) => e.window))].sort().slice(-12); // 全岗位扫描仅近 12 季度 const winSet = new Set([...new Set(events.map((e) => e.window))].sort().slice(-12));
// 统计每个岗位每季度事件数与类型分布 const per = {};
const cell = {};
events.forEach((e) => { events.forEach((e) => {
const k = `${e.job_id}|${e.window}`; if (!winSet.has(e.window)) return;
cell[k] = cell[k] || { n: 0, types: {} }; const p = per[e.job_id] = per[e.job_id] || { add: 0, out: 0, other: 0 };
cell[k].n += 1; if (e.change_type === "新增") p.add++;
cell[k].types[e.change_type] = (cell[k].types[e.change_type] || 0) + 1; else if (e.change_type === "退出") p.out++;
else p.other++;
}); });
// 按总事件数降序排列岗位 const rows = jobs.map((j) => ({ j, ...(per[j.job_id] || { add: 0, out: 0, other: 0 }) }))
const totals = {}; .map((r) => ({ ...r, total: r.add + r.out + r.other }))
events.forEach((e) => { totals[e.job_id] = (totals[e.job_id] || 0) + 1; }); .filter((r) => r.total > 0)
const rows = [...jobs].sort((a, b) => (totals[b.job_id] || 0) - (totals[a.job_id] || 0)); .sort((a, b) => b.total - a.total);
const labelEvery = Math.ceil(windows.length / 10); const max = Math.max(1, ...rows.map((r) => r.total));
// 色阶:开方缩放 + 12 封顶,避免个别高峰季度(如 57 件)把其余格子压到看不清 box.innerHTML = rows.map((r) => `
const alphaOf = (n) => 0.18 + 0.82 * Math.sqrt(Math.min(n, 12) / 12); <div class="scan-row" data-job="${esc(r.j.job_id)}"
box.innerHTML = ` title="${esc(r.j.title)}:新增 ${r.add} · 退出 ${r.out} · 增强/减弱 ${r.other}(点击进岗位报告)">
<div class="hm-grid" style="grid-template-columns: 132px repeat(${windows.length}, 1fr)"> <span class="scan-label">${esc(r.j.title)}</span>
<div class="hm-corner"></div> <span class="scan-track"><i class="scan-bar" style="width:${(r.add / max) * 100}%;background:var(--success)"></i><i class="scan-bar" style="width:${(r.out / max) * 100}%;background:var(--danger)"></i><i class="scan-bar" style="width:${(r.other / max) * 100}%;background:var(--primary)"></i></span>
${windows.map((w, i) => `<div class="hm-col-label">${i % labelEvery === 0 ? esc(w) : ""}</div>`).join("")} <span class="scan-num">${r.total}</span>
${rows.map((j) => ` </div>`).join("") + `
<div class="hm-row-label ${j.job_id === $("#evo-job-select").value ? "active" : ""}" <div class="hm-legend"><span>横条构成:</span><i class="hm-l" style="background:var(--success)"></i>新增技能
data-job="${esc(j.job_id)}" title="点击查看「${esc(j.title)}」演化详情">${esc(j.title)}</div> <i class="hm-l" style="background:var(--danger)"></i>退出技能
${windows.map((w) => { <i class="hm-l" style="background:var(--primary)"></i>增强/减弱
const c = cell[`${j.job_id}|${w}`]; <span class="text-weak">(近 12 季度,点击行进入该岗位报告)</span></div>`;
const tip = c box.querySelectorAll(".scan-row").forEach((el) => {
? `${j.title} · ${w}:${c.n} 个事件(${Object.entries(c.types).map(([t, n]) => `${t} ${n}`).join(",")})`
: `${j.title} · ${w}:无事件`;
return `<div class="hm-cell" data-n="${c ? c.n : 0}" title="${esc(tip)}"
style="background:${c ? `rgba(47,84,235,${alphaOf(c.n).toFixed(3)})` : "var(--bg-page)"}"></div>`;
}).join("")}`).join("")}
</div>
<div class="hm-legend">
<span>事件数:</span><i class="hm-l" style="background:rgba(47,84,235,0.12)"></i>少
<i class="hm-l" style="background:rgba(47,84,235,0.5)"></i>
<i class="hm-l" style="background:rgba(47,84,235,1)"></i>多
<span class="text-weak">(悬停查看类型分布,点击行标签切换岗位)</span>
</div>`;
box.querySelectorAll(".hm-row-label").forEach((el) => {
el.onclick = () => { el.onclick = () => {
$("#evo-job-select").value = el.dataset.job; $("#evo-job-select").value = el.dataset.job;
/* 点击行 → 转入该岗位报告(默认 Tab) */
$$("#evo-tabs .match-tab").forEach((x) => x.classList.toggle("active", x.dataset.evotab === "report")); $$("#evo-tabs .match-tab").forEach((x) => x.classList.toggle("active", x.dataset.evotab === "report"));
$("#evo-tab-report").classList.remove("hidden"); $("#evo-tab-report").classList.remove("hidden");
$("#evo-tab-scan").classList.add("hidden"); $("#evo-tab-scan").classList.add("hidden");
@@ -814,7 +800,7 @@ $$("#evo-tabs .match-tab").forEach((b) => {
const tab = b.dataset.evotab; const tab = b.dataset.evotab;
$("#evo-tab-report").classList.toggle("hidden", tab !== "report"); $("#evo-tab-report").classList.toggle("hidden", tab !== "report");
$("#evo-tab-scan").classList.toggle("hidden", tab !== "scan"); $("#evo-tab-scan").classList.toggle("hidden", tab !== "scan");
if (tab === "scan") renderEvoHeatmap(); if (tab === "scan") renderEvoScan();
}; };
}); });
@@ -964,7 +950,6 @@ function drawHeat() {
function renderEvolution() { function renderEvolution() {
const gran = $$("#evo-gran button").find((b) => b.classList.contains("active"))?.dataset.gran || "quarter"; const gran = $$("#evo-gran button").find((b) => b.classList.contains("active"))?.dataset.gran || "quarter";
drawStackBars($("#evo-stack-canvas"), evoEvents); drawStackBars($("#evo-stack-canvas"), evoEvents);
renderEvoGantt($("#evo-job-select").value);
renderEvoReplace($("#evo-job-select").value); renderEvoReplace($("#evo-job-select").value);
/* 热度走势:每季度实际生效的技能关系数(按 REQUIRES 边的生效区间统计) */ /* 热度走势:每季度实际生效的技能关系数(按 REQUIRES 边的生效区间统计) */
const jobEdges = ((CN.graphData || {}).edges || []) const jobEdges = ((CN.graphData || {}).edges || [])
@@ -1078,62 +1063,6 @@ function drawStackBars(canvas, events) {
/* 能力生命线(Swimlane):每个核心技能一行(近 8 季度),线段=生效区间,圆点=变更事件。 /* 能力生命线(Swimlane):每个核心技能一行(近 8 季度),线段=生效区间,圆点=变更事件。
* 行状态取该技能最近一次事件:新增=青绿 / 增强=蓝紫 / 减弱=琥珀 / 退出=砖红 / 持续=蓝灰 */ * 行状态取该技能最近一次事件:新增=青绿 / 增强=蓝紫 / 减弱=琥珀 / 退出=砖红 / 持续=蓝灰 */
function renderEvoGantt(jobId) {
const box = $("#evo-gantt");
const g = CN.graphData || {};
const allEvents = g.events || [];
const windows = [...new Set(allEvents.map((e) => e.window))].sort().slice(-8);
const edges = (g.edges || []).filter((e) => e.source === jobId && e.rel === "REQUIRES" && (e.intervals || []).length);
if (!windows.length || !edges.length) {
box.innerHTML = `<div class="empty small">暂无该岗位的能力生命线数据</div>`;
return;
}
/* Top 12:当前仍生效优先,按当前需求指数降序 */
const alive = (e) => e.intervals.some(([s, en]) => en === null);
const rows = edges.slice().sort((a, b) =>
((alive(b) ? 1 : 0) - (alive(a) ? 1 : 0)) || ((b.weight || 0) - (a.weight || 0))).slice(0, 12);
const dots = {};
evoEvents.forEach((e) => { dots[`${e.skill}|${e.window}`] = e.change_type; });
const lastWin = windows[windows.length - 1];
const recentWins = windows.slice(-4);
const rowMeta = (edge) => {
const activeNow = edge.intervals.some(([s, en]) => s <= lastWin && (en === null || lastWin < en));
if (!activeNow) return { label: "退出", color: CHANGE_META["退出"].color };
const lastEv = evoEvents.filter((e) => e.skill === edge.target && recentWins.includes(e.window)).at(-1);
if (lastEv) return { label: lastEv.change_type, color: (CHANGE_META[lastEv.change_type] || {}).color || STEADY_COLOR };
return { label: edge.importance === "required" ? "核心" : "加分", color: STEADY_COLOR };
};
const cellHtml = (edge, w, color) => {
const active = edge.intervals.some(([s, en]) => s <= w && (en === null || w < en));
const dotType = dots[`${edge.target}|${w}`];
const dot = dotType
? `<i class="sl-dot" style="background:${(CHANGE_META[dotType] || {}).color || "#999"}" title="${w} ${dotType}"></i>` : "";
return `<div class="sl-cell" title="${esc(edge.target)} · ${esc(w)}${active ? "(生效中)" : ""}">
${active ? `<div class="sl-seg" style="background:${color}"></div>` : ""}${dot}</div>`;
};
box.innerHTML = `
<div class="sl-grid" style="grid-template-columns: 128px repeat(${windows.length}, 1fr) 56px">
<div class="hm-corner"></div>
${windows.map((w) => `<div class="hm-col-label">${esc(w.slice(2))}</div>`).join("")}
<div class="hm-col-label">状态</div>
${rows.map((e) => {
const m = rowMeta(e);
return `<div class="gt-label" data-skill="${esc(e.target)}" title="点击跳转图谱节点">${esc(e.target)}</div>
${windows.map((w) => cellHtml(e, w, m.color)).join("")}
<div class="sl-status" style="color:${m.color}">${m.label}</div>`;
}).join("")}
</div>
<div class="hm-legend">
<span>行状态:</span>
${Object.entries(CHANGE_META).map(([t, m]) => `<i class="hm-l" style="background:${m.color}"></i>${t}`).join("")}
<i class="hm-l" style="background:${STEADY_COLOR}"></i>持续
<span class="text-weak">(圆点 = 该季度变更事件,点击技能名跳转图谱)</span>
</div>`;
box.querySelectorAll(".gt-label").forEach((el) => {
el.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(el.dataset.skill); };
});
}
/* 能力更替流:旧核心(已退出必备) → 持续能力 → 新核心(近 4 季度新进入) */ /* 能力更替流:旧核心(已退出必备) → 持续能力 → 新核心(近 4 季度新进入) */
function renderEvoReplace(jobId) { function renderEvoReplace(jobId) {
const box = $("#evo-replace"); const box = $("#evo-replace");
@@ -2321,7 +2250,7 @@ window.addEventListener("resize", () => {
if (pathGoto) pathGoto.onclick = () => switchView("match"); if (pathGoto) pathGoto.onclick = () => switchView("match");
await loadDashboard(); // 先加载图谱缓存(演化页甘特图依赖) await loadDashboard(); // 先加载图谱缓存(演化页甘特图依赖)
await loadEvolution(); await loadEvolution();
renderEvoHeatmap(); renderEvoScan();
/* 分享链接:#view=graph&node=xxx */ /* 分享链接:#view=graph&node=xxx */
const hash = new URLSearchParams(location.hash.slice(1)); const hash = new URLSearchParams(location.hash.slice(1));
if (hash.get("view")) switchView(hash.get("view")); if (hash.get("view")) switchView(hash.get("view"));
+4 -9
View File
@@ -298,10 +298,10 @@
</div> </div>
<div id="evo-tab-scan" class="hidden"> <div id="evo-tab-scan" class="hidden">
<!-- 全部岗位演化热力图:仅近 12 季度,无事件格子置为极浅色 --> <!-- 全岗位扫描:近 12 季度各岗位变化量横条排行(比热力图直观) -->
<div class="evo-chart-title text-aux text-secondary">全部岗位演化热力图 · 近 12 季度(每格 = 该季度演化事件数,点击行切换岗位并回到岗位报告)</div> <div class="evo-chart-title text-aux text-secondary">全岗位扫描 · 近 12 季度变化量排行(点击行进入该岗位报告)</div>
<div class="evo-chart-desc">怎么看:每行一个岗位、每列一个季度,颜色越深说明该岗位当季能力变化越多。用于快速发现"哪些岗位正在剧烈变化";点击行标签进入该岗位的详细报告。</div> <div class="evo-chart-desc">怎么看:每行一个岗位,横条长度=近 12 个季度的能力变化总量;绿色段=新增的技能,红色段=退出的技能。条越长说明这个岗位的能力要求变化越剧烈。</div>
<div class="evo-heatmap" id="evo-heatmap"><div class="empty small">加载中…</div></div> <div class="evo-scan" id="evo-scan"><div class="empty small">加载中…</div></div>
</div> </div>
<div id="evo-tab-report"> <div id="evo-tab-report">
@@ -313,11 +313,6 @@
<div class="evo-chart-desc">是什么:系统根据近两个季度的招聘 JD 自动归纳出的能力变化结论。新增=以前不要求、现在要求;增强=要求变重;减弱=要求变少;退出=不再要求。每条结论都可追溯到原始 JD 证据。</div> <div class="evo-chart-desc">是什么:系统根据近两个季度的招聘 JD 自动归纳出的能力变化结论。新增=以前不要求、现在要求;增强=要求变重;减弱=要求变少;退出=不再要求。每条结论都可追溯到原始 JD 证据。</div>
<div class="evo-summary" id="evo-summary"><div class="empty small">加载中…</div></div> <div class="evo-summary" id="evo-summary"><div class="empty small">加载中…</div></div>
<!-- 能力生命线(Swimlane):每个核心技能一行,线段=生效区间,圆点=变更事件 -->
<div class="evo-chart-title text-aux text-secondary">能力生命线(近 8 季度 · 按当前需求指数取 Top 12;线段 = 生效区间,圆点 = 变更事件)</div>
<div class="evo-chart-desc">怎么看:每行是一项核心技能的"在岗生命周期"——横线表示这段时间里该技能属于岗位要求,彩色圆点标记发生变化的时间点(颜色含义见图例)。一眼看出技能何时进入、何时增强、何时退出。</div>
<div class="evo-gantt" id="evo-gantt"></div>
<!-- 能力更替流:旧核心 → 持续能力 → 新核心 --> <!-- 能力更替流:旧核心 → 持续能力 → 新核心 -->
<div class="evo-chart-title text-aux text-secondary">能力更替流(旧核心 → 持续能力 → 新核心)</div> <div class="evo-chart-title text-aux text-secondary">能力更替流(旧核心 → 持续能力 → 新核心)</div>
<div class="evo-chart-desc">是什么:把岗位技能按演化结果分三列——左边是已退出的旧要求,中间是一直有效的持续能力,右边是新进入的要求。用于回答"这个岗位正在从什么技术栈转向什么技术栈"。</div> <div class="evo-chart-desc">是什么:把岗位技能按演化结果分三列——左边是已退出的旧要求,中间是一直有效的持续能力,右边是新进入的要求。用于回答"这个岗位正在从什么技术栈转向什么技术栈"。</div>
+12
View File
@@ -1168,3 +1168,15 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
/* 演化页图表说明文字 */ /* 演化页图表说明文字 */
.evo-chart-desc { font-size: 12px; color: var(--text-3); line-height: 1.7; margin: -2px 0 8px; } .evo-chart-desc { font-size: 12px; color: var(--text-3); line-height: 1.7; margin: -2px 0 8px; }
/* 全岗位扫描:横条排行 */
.evo-scan { margin-bottom: 16px; }
.scan-row {
display: flex; align-items: center; gap: 10px; padding: 5px 4px;
cursor: pointer; border-radius: var(--radius-sm);
}
.scan-row:hover { background: var(--bg-hover); }
.scan-label { flex: none; width: 132px; font-size: 13px; color: var(--text-1); text-align: right; }
.scan-track { flex: 1; display: flex; height: 14px; background: var(--bg-page); border-radius: 3px; overflow: hidden; }
.scan-bar { display: block; height: 100%; }
.scan-num { flex: none; width: 34px; font-size: 12px; color: var(--text-2); font-variant-numeric: tabular-nums; }