图谱可视化对标业内实践:枢纽技能按连接度大小编码、时序Timebar生长曲线柱图(点击跳转)、技术栈类别hull气泡轮廓、画布全屏、边悬停tooltip与点击直达JD证据、双击渐进披露(岗位子图/技能反查聚焦),修复gt-bar类名冲突,前端单测87例全过

This commit is contained in:
FinStab
2026-08-28 12:19:49 +08:00
parent 6d91df1a91
commit 6312dd5f4a
4 changed files with 272 additions and 49 deletions
+224 -39
View File
@@ -15,6 +15,9 @@
edges: { required: true, preferred: true, applies: true, belongs: true } },
selected: null, // 选中节点 id
soloJob: null, // 岗位子图模式:只看该岗位及其一跳邻居
focusSkill: null, // 技能聚焦模式:只看该技能及引用它的岗位(双击技能进入)
hoverEdge: null, // 鼠标悬停的边(tooltip + 高亮)
deg: {}, // 节点连接度缓存(枢纽节点大小编码)
viewMode: "overview", // 视图:overview 全景 / tech 技术栈 / level 职级演进 / timeline 动态时序
levelJob: null, // 职级演进视图:当前展示能力树的岗位
compare: null, compareSets: null, // 岗位对比模式:{a,b} 与集合缓存
@@ -209,8 +212,8 @@
if (G.levelJob) selectNode(G.levelJob, false); // 右侧面板同步展示岗位定义,信息闭环
}
/* 视野适配职级能力树(只按可见节点的包围盒) */
function fitLevelView() {
/* 视野适配当前可见节点(职级能力树 / 技能聚焦等局部视图通用) */
function fitVisibleView(padX = 280, padY = 240, kMax = 1.4) {
const c = canvas();
let minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9, cnt = 0;
G.nodes.forEach((n) => {
@@ -222,11 +225,72 @@
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.k = Math.max(0.2, Math.min(W / (w + padX), H / (h + padY), kMax));
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();
}
function fitLevelView() { fitVisibleView(280, 240, 1.4); }
/* ================= 双击聚焦(渐进披露):岗位子图 / 技能反查 ================= */
function toggleSolo(n) {
G.soloJob = G.soloJob === n.id ? null : n.id;
G.focusSkill = null;
G._defaultApplied = true;
updateSoloBadge();
updateRecentChange();
if (G.soloJob) {
applySoloLayout();
fitSoloView();
toast(`已进入「${n.title || n.id}」岗位子图(同心分层:中心岗位 → 必备 → 加分 → 场景/行业,双击空白退出)`, "success");
} else fitView();
}
/* 技能反查聚焦布局:技能居中,引用它的岗位环绕(toggle 与数据重载共用) */
function layoutSkillFocus() {
const sn = G.nodes[G.idx[G.focusSkill]];
if (!sn) return;
sn.x = 0; sn.y = 0; sn.vx = 0; sn.vy = 0;
const js = [];
G.edges.forEach((e) => {
if (e.target === G.focusSkill && e.rel === "REQUIRES") {
const s = G.nodes[G.idx[e.source]];
if (s) js.push(s);
}
});
js.sort((a, b) => String(a.title || a.id).localeCompare(String(b.title || b.id), "zh"));
const R = Math.max(220, (js.length * 96) / (2 * Math.PI));
js.forEach((j, i) => {
const a = (i / Math.max(1, js.length)) * Math.PI * 2 - Math.PI / 2;
j.x = Math.cos(a) * R; j.y = Math.sin(a) * R; j.vx = 0; j.vy = 0;
});
return js.length;
}
/* 技能反查聚焦:技能居中,引用它的岗位环绕,回答"哪些岗位需要这项技能" */
function toggleSkillFocus(n) {
G.focusSkill = G.focusSkill === n.id ? null : n.id;
G.soloJob = null;
G._defaultApplied = true;
updateSoloBadge();
updateRecentChange();
if (G.focusSkill) {
const cnt = layoutSkillFocus() || 0;
selectNode(n.id, false);
fitVisibleView(260, 240, 1.5);
toast(`已聚焦技能「${n.title || n.id}」:共 ${cnt} 个岗位引用(双击空白退出)`, "success");
} else fitView();
requestDraw(); drawMinimap();
}
/* 退出一切聚焦状态(子图 / 技能聚焦),回到岗位生态总览 */
function exitFocus() {
G.soloJob = null; G.focusSkill = null;
updateSoloBadge();
updateRecentChange();
fitView();
toast("已退出聚焦,当前为岗位生态总览(全量图)", "info");
}
/* 动态时序视图:窗口序列(事件窗口 ∪ 边区间起点),拖动滑块只显示该季度已存在节点与生效边 */
function timeWindows() {
@@ -241,6 +305,14 @@
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;
/* Timebar 趋势柱图:截至各季度的累计生效关系数(图谱生长曲线),与滑块下标一一对应,点击跳转 */
const counts = G.timeWins.map((w) => G.edges.filter((e) => edgeActiveAt(e, w)).length);
const cMax = Math.max(1, ...counts);
$("#gt-bars").innerHTML = counts.map((v, i) =>
`<i class="gtb-bar" data-i="${i}" style="height:${Math.max(10, (v / cMax) * 100)}%" title="${G.timeWins[i]} · 生效关系 ${v} 条"></i>`).join("");
$$("#gt-bars .gtb-bar").forEach((el) => {
el.onclick = () => { G.timeIdx = +el.dataset.i; sl.value = G.timeIdx; applyTimeWindow(); };
});
applyTimeWindow();
}
function applyTimeWindow() {
@@ -252,6 +324,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}` : "暂无时序数据";
$$("#gt-bars .gtb-bar").forEach((el, i) => el.classList.toggle("active", i === G.timeIdx));
renderTimePanel();
requestDraw(); drawMinimap();
}
@@ -269,6 +342,7 @@
G._overviewPos = G.nodes.map((n) => ({ x: n.x, y: n.y }));
}
if (mode !== "overview" && G.compare) exitCompare(true); // 对比模式只在全景视图下生效
if (mode !== "overview" && G.focusSkill) { G.focusSkill = null; updateSoloBadge(); } // 技能聚焦只在全景下生效
G.viewMode = mode;
$$("#graph-view-tabs button").forEach((b) => b.classList.toggle("active", b.dataset.gview === mode));
$("#graph-timeline").classList.toggle("hidden", mode !== "timeline");
@@ -338,6 +412,7 @@
});
buildFilterOptions();
computeDegrees();
layout();
relayoutByView(); // 保持在非全景视图时重载后仍为该视图布局
restoreView() || fitView();
@@ -462,6 +537,25 @@
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return h;
}
/* 凸包(Andrew 单调链):技术栈视图类别气泡轮廓用(纯函数,单测可提取) */
function convexHull(points) {
const pts = [...points].sort((a, b) => a.x - b.x || a.y - b.y);
if (pts.length < 3) return pts;
const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
const lower = [];
for (const p of pts) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
lower.push(p);
}
const upper = [];
for (let i = pts.length - 1; i >= 0; i--) {
const p = pts[i];
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop();
upper.push(p);
}
lower.pop(); upper.pop();
return lower.concat(upper);
}
function visible(n) {
if (!G.filters.types[n.type]) return false;
if (G.viewMode === "timeline" && G.activeNodes && !G.activeNodes.has(n.id)) return false;
@@ -471,6 +565,9 @@
if (G.soloJob) { // 岗位子图:只保留该岗位与一跳邻居
if (n.id !== G.soloJob && !neighborsOf(G.soloJob).has(n.id)) return false;
}
if (G.focusSkill) { // 技能反查聚焦:只保留该技能与引用它的岗位
if (n.id !== G.focusSkill && !neighborsOf(G.focusSkill).has(n.id)) return false;
}
if (n.type === "Job") {
if (G.filters.category && n.category !== G.filters.category) return false;
if (G.filters.level && jobLevel(n) !== G.filters.level) return false;
@@ -501,8 +598,20 @@
G.raf = requestAnimationFrame(() => { G.raf = null; draw(); });
}
/* 节点连接度统计:枢纽技能(被大量岗位引用)在画布上按尺寸可辨 */
function computeDegrees() {
G.deg = {};
G.edges.forEach((e) => {
G.deg[e.source] = (G.deg[e.source] || 0) + 1;
G.deg[e.target] = (G.deg[e.target] || 0) + 1;
});
}
function nodeRadius(n) {
return n.type === "Job" ? 13 : n.type === "Scenario" ? 8 : n.type === "Industry" ? 10 : 6.5;
const base = n.type === "Job" ? 13 : n.type === "Scenario" ? 8 : n.type === "Industry" ? 10 : 6.5;
if (n.type !== "Skill") return base;
const d = G.deg[n.id] || 0;
return base + (d >= 2 ? Math.min(5.5, Math.log2(d) * 1.6) : 0); // 度数 ≥2 起对数放大,封顶接近岗位尺寸
}
function draw() {
@@ -549,6 +658,33 @@
ctx.restore();
} else if (G.viewMode === "tech" && G.catAnchors) {
ctx.save();
/* 类别气泡轮廓(hull):同类岗位凸包 + 粗圆角描边向外扩张,低饱和分色区分技术栈 */
const HULL_FILLS = ["rgba(24,144,255,", "rgba(19,194,194,", "rgba(114,46,209,", "rgba(250,140,22,", "rgba(82,196,26,", "rgba(235,47,150,"];
G.catAnchors.forEach((a, ci) => {
const pts = G.nodes.filter((x) => x.type === "Job" && (x.category || "未分类") === a.cat && visible(x)).map((n) => toScreen(n));
if (!pts.length) return;
const tone = HULL_FILLS[ci % HULL_FILLS.length];
ctx.save();
ctx.fillStyle = tone + "0.06)";
ctx.strokeStyle = tone + "0.06)";
ctx.lineJoin = "round";
const hull = convexHull(pts);
if (hull.length < 3) {
const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length;
const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length;
const rr = Math.max(...pts.map((p) => Math.hypot(p.x - cx, p.y - cy)), 0) + 52;
ctx.beginPath(); ctx.arc(cx, cy, rr, 0, 7); ctx.fill();
} else {
ctx.beginPath();
hull.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
ctx.closePath();
ctx.lineWidth = 92; // 粗描边 + 圆角连接 ≈ 凸包向外圆角扩张 46px
ctx.stroke();
ctx.fill();
}
ctx.restore();
});
/* 类别锚点标签 */
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;
@@ -782,6 +918,42 @@
return best;
}
/* 边拾取:点到线段距离 ≤8px 的最近边(悬停 tooltip / 点击查看证据) */
function pickEdge(sx, sy) {
let best = null, bd = 64;
G.edges.forEach((e) => {
const a = G.nodes[G.idx[e.source]], b = G.nodes[G.idx[e.target]];
if (!a || !b || !visible(a) || !visible(b) || !edgeVisible(e)) return;
const pa = toScreen(a), pb = toScreen(b);
const dx = pb.x - pa.x, dy = pb.y - pa.y;
const len2 = dx * dx + dy * dy || 1;
let t = ((sx - pa.x) * dx + (sy - pa.y) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const d2 = (sx - (pa.x + t * dx)) ** 2 + (sy - (pa.y + t * dy)) ** 2;
if (d2 < bd) { bd = d2; best = e; }
});
return best;
}
/* 边悬停 tooltip:关系类型 / 需求指数 / 证据量 / 起始窗口 */
function updateEdgeTip(e, sx, sy) {
const tip = $("#edge-tip");
const changed = (e || null) !== (G.hoverEdge || null);
G.hoverEdge = e || null;
if (!e) { tip.classList.add("hidden"); if (changed) requestDraw(); return; }
const a = G.nodes[G.idx[e.source]], b = G.nodes[G.idx[e.target]];
const relLabel = e.rel === "REQUIRES" ? (e.importance === "required" ? "必备技能" : "加分技能")
: e.rel === "APPLIES_TO" ? "应用场景" : "所属行业";
const firstWin = (e.intervals || []).map((i) => i[0]).sort()[0];
tip.innerHTML = `<div class="et-title">${esc((a && a.title) || e.source)} → ${esc((b && b.title) || e.target)}</div>
<div class="et-sub">${relLabel}${e.weight != null ? ` · 需求指数 ${Number(e.weight).toFixed(2)}` : ""} · 证据 ${(e.sources || []).length} 条 JD${firstWin ? ` · 自 ${esc(firstWin)}` : ""}</div>`;
const wrap = $("#graph-canvas-wrap").getBoundingClientRect();
tip.style.left = `${Math.max(4, Math.min(sx + 14, wrap.width - 250))}px`;
tip.style.top = `${Math.max(4, Math.min(sy + 16, wrap.height - 52))}px`;
tip.classList.remove("hidden");
if (changed) requestDraw();
}
function bindCanvas() {
const c = canvas();
c.addEventListener("mousedown", (e) => {
@@ -800,9 +972,12 @@
const h = pickNode(sx, sy);
const id = h ? h.id : null;
if (id !== G.hover) { G.hover = id; requestDraw(); }
c.classList.toggle("hovering", !!h);
c.classList.toggle("hovering", !!h || !!pickEdge(sx, sy));
const inCanvas = sx >= 0 && sy >= 0 && sx <= c.clientWidth && sy <= c.clientHeight;
updateEdgeTip(!h && inCanvas ? pickEdge(sx, sy) : null, sx, sy);
return;
}
updateEdgeTip(null);
G.drag.moved = true;
if (G.drag.mode === "pan") {
G.cam.x = G.drag.camX + (sx - G.drag.sx);
@@ -822,9 +997,29 @@
c.addEventListener("click", (e) => {
if (G.drag && G.drag.moved) return;
const rect = c.getBoundingClientRect();
const node = pickNode(e.clientX - rect.left, e.clientY - rect.top);
selectNode(node ? node.id : null);
const sx = e.clientX - rect.left, sy = e.clientY - rect.top;
const node = pickNode(sx, sy);
if (node) return selectNode(node.id);
/* 点空白优先尝试边:命中带证据的边则直接打开 JD 证据链 */
const edge = pickEdge(sx, sy);
if (edge && (edge.sources || []).length) {
const a = G.nodes[G.idx[edge.source]];
return CN.showJdEvidence(edge.sources, { jobTitle: (a && (a.title || a.id)) || "" });
}
selectNode(null);
});
/* 双击渐进披露:岗位=子图开关;技能=反查聚焦(只看引用它的岗位);双击空白退出聚焦 */
c.addEventListener("dblclick", (e) => {
const rect = c.getBoundingClientRect();
const node = pickNode(e.clientX - rect.left, e.clientY - rect.top);
if (!node) {
if (G.soloJob || G.focusSkill) exitFocus();
return;
}
if (node.type === "Job" && !(node.pending || node.status === "pending")) toggleSolo(node);
else if (node.type === "Skill") toggleSkillFocus(node);
});
c.addEventListener("mouseleave", () => updateEdgeTip(null));
/* 节点右键菜单:选中节点 + 弹出自定义菜单(查看证据 / 时点对比 / 人岗诊断 / 子图开关) */
c.addEventListener("contextmenu", (e) => {
e.preventDefault();
@@ -894,15 +1089,7 @@
$("#match-job-select").dispatchEvent(new Event("change"));
toast("已带入目标岗位,请上传或载入示例简历", "success");
} else if (key === "solo") {
G.soloJob = G.soloJob === n.id ? null : n.id;
G._defaultApplied = true;
updateSoloBadge();
updateRecentChange();
if (G.soloJob) {
applySoloLayout();
fitSoloView();
toast(`已进入「${n.title || n.id}」岗位子图(同心分层:中心岗位 → 必备 → 加分 → 场景/行业)`, "success");
} else fitView();
toggleSolo(n);
}
}
@@ -1222,6 +1409,13 @@
e.currentTarget.classList.toggle("active", G.showLabels);
requestDraw();
};
/* 全屏演示:画布容器进/出全屏,尺寸变化后重绘 */
$("#tool-fullscreen").onclick = () => {
const wrap = $("#graph-canvas-wrap");
if (document.fullscreenElement) document.exitFullscreen();
else if (wrap.requestFullscreen) wrap.requestFullscreen();
};
document.addEventListener("fullscreenchange", () => { requestDraw(); drawMinimap(); });
$("#tool-filter").onclick = () => $("#graph-filter-panel").classList.toggle("hidden");
document.addEventListener("click", (e) => {
if (!e.target.closest("#graph-filter-panel") && !e.target.closest("#tool-filter")) {
@@ -1481,20 +1675,16 @@
function updateSoloBadge() {
const badge = $("#solo-badge");
if (!badge) return;
badge.classList.toggle("hidden", !G.soloJob);
if (G.soloJob) {
const n = G.nodes[G.idx[G.soloJob]];
$("#solo-badge-text").textContent = `岗位子图:${(n && (n.title || n.id)) || G.soloJob}`;
const fid = G.soloJob || G.focusSkill;
badge.classList.toggle("hidden", !fid);
if (fid) {
const n = G.nodes[G.idx[fid]];
const kind = G.soloJob ? "岗位子图" : "技能聚焦";
$("#solo-badge-text").textContent = `${kind}:${(n && (n.title || n.id)) || fid}`;
}
}
const soloExit = $("#solo-exit");
if (soloExit) soloExit.onclick = () => {
G.soloJob = null;
updateSoloBadge();
updateRecentChange();
fitView();
toast("已退出子图,当前为岗位生态总览(全量图)", "info");
};
if (soloExit) soloExit.onclick = () => exitFocus();
/* ================= 备注(localStorage) ================= */
const getNotes = () => JSON.parse(localStorage.getItem(NOTES_KEY) || "{}");
@@ -1549,7 +1739,11 @@
kv.push(["类别", n.category || "—"], ["期望年限", n.expected_years != null ? `${n.expected_years} 年` : "—"]);
if (n.source_count != null) kv.push(["支撑 JD", `${n.source_count} 条 / ${n.company_count} 家企业`]);
}
if (n.type === "Skill") kv.push(["类别", n.category || "通用技能"]);
if (n.type === "Skill") {
kv.push(["类别", n.category || "通用技能"]);
const refs = G.edges.filter((e) => e.target === id && e.rel === "REQUIRES").length;
if (refs) kv.push(["枢纽度", `被 ${refs} 个岗位引用`]);
}
kv.push(["级别", n.level || (n.type === "Job" ? jobLevel(n) || "—" : "—")]);
if (n.weight != null) kv.push(["需求指数", Number(n.weight).toFixed(2)]);
if (n.importance) kv.push(["重要性", n.importance === "required" ? "必备" : n.importance]);
@@ -1723,17 +1917,7 @@
$("#evo-job-select").dispatchEvent(new Event("change"));
};
const soloBtn = $("#dt-solo");
if (soloBtn) soloBtn.onclick = () => {
G.soloJob = G.soloJob === id ? null : id;
G._defaultApplied = true;
updateSoloBadge();
updateRecentChange();
if (G.soloJob) {
applySoloLayout();
fitSoloView();
toast(`已进入「${n.title || id}」岗位子图(同心分层:中心岗位 → 必备 → 加分 → 场景/行业)`, "success");
} else fitView();
};
if (soloBtn) soloBtn.onclick = () => toggleSolo(n);
const matchBtn = $("#dt-match");
if (matchBtn) matchBtn.onclick = () => {
CN.switchView("match");
@@ -1874,6 +2058,7 @@
async reload() {
await load();
if (G.soloJob && G.idx[G.soloJob]) { applySoloLayout(); fitSoloView(); } // 子图模式下重排同心分层
if (G.focusSkill && G.idx[G.focusSkill]) { layoutSkillFocus(); fitVisibleView(260, 240, 1.5); } // 技能聚焦重排
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);