1883 lines
92 KiB
JavaScript
1883 lines
92 KiB
JavaScript
/* CareerNexus 职联 · 全景图谱工作台(graph.js)
|
||
* canvas 力导向 + 缩放平移 + 小地图 + 详情面板 + 节点编辑/备注/审核。
|
||
* 依赖 app.js 暴露的 CN 命名空间。 */
|
||
"use strict";
|
||
|
||
(function () {
|
||
const { $, $$, api, post, esc, toast, openModal, closeModal, TYPE_META } = CN;
|
||
|
||
/* ================= 状态 ================= */
|
||
const G = {
|
||
nodes: [], edges: [], events: [], idx: {},
|
||
cam: { x: 0, y: 0, k: 1 }, // 相机:世界 -> 屏幕 screen = (world - cam.pan) * k ... 使用 cam.x/y 为平移(屏幕像素)
|
||
showLabels: false, // 默认关闭:悬停/选中/缩放时才显示
|
||
filters: { types: { Job: true, Skill: true, Industry: false, Scenario: false }, category: "", level: "",
|
||
edges: { required: true, preferred: true, applies: true, belongs: true } },
|
||
selected: null, // 选中节点 id
|
||
soloJob: null, // 岗位子图模式:只看该岗位及其一跳邻居
|
||
viewMode: "overview", // 视图:overview 全景 / tech 技术栈 / level 职级演进 / timeline 动态时序
|
||
levelJob: null, // 职级演进视图:当前展示能力树的岗位
|
||
compare: null, compareSets: null, // 岗位对比模式:{a,b} 与集合缓存
|
||
timeWins: [], timeIdx: null, // 时序视图:窗口序列与当前下标
|
||
activeNodes: null, activeEdges: null,// 时序视图:当前窗口生效的节点/边集合
|
||
playTimer: null, // 时序视图:播放定时器
|
||
catAnchors: null, // 技术栈视图:类别锚点 [{cat,x,y}]
|
||
hover: null,
|
||
navStack: [], // 详情面板导航栈
|
||
drag: null, // {mode:'pan'|'node', ...}
|
||
pending: [], // 待审核候选
|
||
raf: null,
|
||
};
|
||
|
||
const canvas = () => $("#graph-canvas");
|
||
const NOTES_KEY = "cn_notes";
|
||
const VIEW_KEY = "cn_graph_view";
|
||
const DEFAULT_SOLO_JOB = "job_Java开发工程师"; // 未选中岗位时的演示/跳转默认岗位
|
||
/* 变化类型颜色(与 app.js CHANGE_META 一致):新增=青绿 / 增强=蓝紫 / 减弱=琥珀 / 退出=砖红 */
|
||
const CHANGE_COLORS = { "新增": "#7bc8a4", "增强": "#8f9ff0", "减弱": "#e8c07d", "退出": "#d99a91" };
|
||
const CHANGE_MARK = { "新增": "新", "增强": "↑", "减弱": "↓", "退出": "退" };
|
||
/* 变化类型对应的标签样式(详情面板 / 时序解说面板共用) */
|
||
const EV_TAG = { "新增": "tag-cyan", "增强": "tag-purple", "减弱": "tag-orange", "退出": "tag-red" };
|
||
/* 岗位对比模式配色(低饱和):共有=青绿 / A 独有=蓝紫 / B 独有=琥珀 */
|
||
const COMPARE_COLORS = { shared: "#7bc8a4", a: "#8f9ff0", b: "#e8c07d" };
|
||
/* 近一年入库的新岗位(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() {
|
||
const wins = [...new Set(G.events.map((e) => e.window))].sort().slice(-2);
|
||
const m = new Map();
|
||
G.events.forEach((e) => {
|
||
if (!wins.includes(e.window)) return;
|
||
if (G.soloJob && e.job_id !== G.soloJob) return;
|
||
m.set(e.skill, e); // 事件按窗口升序,后者覆盖前者 = 取最近事件
|
||
});
|
||
G.recentChange = m;
|
||
}
|
||
|
||
/* 同心分层布局(岗位子图):中心岗位 → 第一圈必备技能 → 第二圈加分技能 → 外圈场景/行业 */
|
||
function applySoloLayout() {
|
||
const job = G.nodes[G.idx[G.soloJob]];
|
||
if (!job) return;
|
||
const rings = [[], [], []];
|
||
G.edges.forEach((e) => {
|
||
if (e.source !== G.soloJob) return;
|
||
const t = G.nodes[G.idx[e.target]];
|
||
if (!t) return;
|
||
if (e.rel === "REQUIRES") rings[e.importance === "required" ? 0 : 1].push(t);
|
||
else rings[2].push(t); // APPLIES_TO / BELONGS_TO → 外圈
|
||
});
|
||
const base = [150, 260, 370];
|
||
let prevR = 0;
|
||
rings.forEach((nodes, ri) => {
|
||
nodes.sort((a, b) => String(a.title || a.id).localeCompare(String(b.title || b.id), "zh"));
|
||
const R = Math.max(base[ri], prevR + 90, (nodes.length * 34) / (2 * Math.PI));
|
||
prevR = R;
|
||
nodes.forEach((n, i) => {
|
||
const a = (i / Math.max(1, nodes.length)) * Math.PI * 2 - Math.PI / 2;
|
||
n.x = job.x + Math.cos(a) * R;
|
||
n.y = job.y + Math.sin(a) * R;
|
||
n.vx = 0; n.vy = 0;
|
||
});
|
||
});
|
||
}
|
||
|
||
/* 视野适配子图(只按子图节点的包围盒,而非全图) */
|
||
function fitSoloView() {
|
||
const c = canvas();
|
||
const ids = new Set([G.soloJob, ...neighborsOf(G.soloJob)]);
|
||
let minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9;
|
||
G.nodes.forEach((n) => {
|
||
if (!ids.has(n.id) || !visible(n)) return;
|
||
minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x);
|
||
minY = Math.min(minY, n.y); maxY = Math.max(maxY, n.y);
|
||
});
|
||
if (minX > maxX) 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 + 160), H / (h + 160), 1.6));
|
||
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();
|
||
}
|
||
|
||
/* ================= 视图切换:全景 / 技术栈 / 职级演进 / 动态时序 ================= */
|
||
const VIEW_NAMES = { overview: "全景总览", tech: "技术栈视图", level: "职级演进视图", timeline: "动态时序视图" };
|
||
/* 职级演进视图:成长阶梯三列(岗位能力树,左岗位 → 右三列技能带) */
|
||
const LEVEL_COLS = [
|
||
{ name: "初级 L1 · ≤2 年", x: -300 },
|
||
{ name: "中级 L2 · 3-5 年", x: 0 },
|
||
{ name: "高级 L3 · >5 年", x: 300 },
|
||
];
|
||
const LEVEL_JOB_X = -560; // 目标岗位列的 x 坐标
|
||
|
||
/* 技术栈视图:类别锚点网格分布,岗位聚在锚点旁,技能/场景/行业靠向所属岗位质心 */
|
||
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;
|
||
});
|
||
}
|
||
|
||
/* 职级演进视图:岗位能力树布局。左侧目标岗位,右侧 L1/L2/L3 三列技能带(列内必备在前、加分在后),
|
||
* 场景/行业节点纵排在岗位下方;只显示该岗位及其一跳邻居,彻底解决旧版全员三带压扁问题 */
|
||
function applyLevelLayout() {
|
||
const job = G.nodes[G.idx[G.levelJob]];
|
||
if (!job) return;
|
||
job.x = LEVEL_JOB_X; job.y = 0; job.vx = 0; job.vy = 0;
|
||
const buckets = [[], [], []];
|
||
const outer = []; // 场景 / 行业
|
||
G.edges.forEach((e) => {
|
||
if (e.source !== G.levelJob) return;
|
||
const t = G.nodes[G.idx[e.target]];
|
||
if (!t) return;
|
||
if (t.type === "Skill") buckets[levelBandOf(t)].push({ n: t, imp: e.importance });
|
||
else outer.push(t);
|
||
});
|
||
buckets.forEach((list, b) => {
|
||
list.sort((x, y) => {
|
||
const rx = (x.imp === "required" ? 0 : 1) - (y.imp === "required" ? 0 : 1);
|
||
return rx || String(x.n.title || x.n.id).localeCompare(String(y.n.title || y.n.id), "zh");
|
||
});
|
||
const cx = LEVEL_COLS[b].x;
|
||
const cols = list.length > 9 ? 2 : 1; // 技能多时分双列,避免纵向过长
|
||
const perCol = Math.ceil(list.length / cols) || 1;
|
||
list.forEach((it, i) => {
|
||
const c = Math.floor(i / perCol);
|
||
const r = i % perCol;
|
||
it.n.x = cx + (c - (cols - 1) / 2) * 132;
|
||
it.n.y = (r - (perCol - 1) / 2) * 56;
|
||
it.n.vx = 0; it.n.vy = 0;
|
||
});
|
||
});
|
||
outer.sort((a, b) => String(a.title || a.id).localeCompare(String(b.title || b.id), "zh"));
|
||
outer.forEach((n, i) => {
|
||
n.x = LEVEL_JOB_X; n.y = 96 + i * 52; n.vx = 0; n.vy = 0;
|
||
});
|
||
}
|
||
|
||
/* 职级演进视图初始化:填充岗位选择条(含待审核候选),默认沿用当前选中岗位 */
|
||
function setupLevelView() {
|
||
const sel = $("#gl-job-select");
|
||
const jobs = G.nodes.filter((n) => n.type === "Job")
|
||
.sort((a, b) => String(a.title || a.id).localeCompare(String(b.title || b.id), "zh"));
|
||
sel.innerHTML = jobs.map((n) =>
|
||
`<option value="${esc(n.id)}">${esc(n.title || n.id)}${n.pending ? "(待审核)" : ""}</option>`).join("");
|
||
const cur = G.selected != null ? G.nodes[G.idx[G.selected]] : null;
|
||
G.levelJob = (cur && cur.type === "Job") ? cur.id
|
||
: (G.levelJob && G.levelJob in G.idx) ? G.levelJob
|
||
: (DEFAULT_SOLO_JOB in G.idx) ? DEFAULT_SOLO_JOB
|
||
: (jobs[0] && jobs[0].id) || null;
|
||
sel.value = G.levelJob || "";
|
||
applyLevelLayout();
|
||
if (G.levelJob) selectNode(G.levelJob, false); // 右侧面板同步展示岗位定义,信息闭环
|
||
}
|
||
|
||
/* 视野适配职级能力树(只按可见节点的包围盒) */
|
||
function fitLevelView() {
|
||
const c = canvas();
|
||
let minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9, cnt = 0;
|
||
G.nodes.forEach((n) => {
|
||
if (!visible(n)) return;
|
||
cnt++;
|
||
minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x);
|
||
minY = Math.min(minY, n.y); maxY = Math.max(maxY, n.y);
|
||
});
|
||
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.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 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}` : "暂无时序数据";
|
||
renderTimePanel();
|
||
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 }));
|
||
}
|
||
if (mode !== "overview" && G.compare) exitCompare(true); // 对比模式只在全景视图下生效
|
||
G.viewMode = mode;
|
||
$$("#graph-view-tabs button").forEach((b) => b.classList.toggle("active", b.dataset.gview === mode));
|
||
$("#graph-timeline").classList.toggle("hidden", mode !== "timeline");
|
||
$("#graph-level-bar").classList.toggle("hidden", mode !== "level");
|
||
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") setupLevelView();
|
||
else if (mode === "timeline") {
|
||
// 时序视图沿用全景力导向布局(否则继承上一个视图的特化布局会被压扁)
|
||
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();
|
||
setupTimeline();
|
||
}
|
||
renderTimePanel();
|
||
if (G.soloJob) { applySoloLayout(); fitSoloView(); }
|
||
else if (mode === "level") fitLevelView();
|
||
else fitView();
|
||
requestDraw(); drawMinimap();
|
||
if (mode !== "overview") toast(`已切换为${VIEW_NAMES[mode]}`, "info");
|
||
}
|
||
|
||
/* 按当前视图重排(数据重载 / 一键整理布局后调用) */
|
||
function relayoutByView() {
|
||
if (G.viewMode === "tech") applyTechLayout();
|
||
else if (G.viewMode === "level") setupLevelView();
|
||
else if (G.viewMode === "timeline") {
|
||
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();
|
||
setupTimeline();
|
||
}
|
||
}
|
||
|
||
/* ================= 数据装载 ================= */
|
||
async function load() {
|
||
$("#graph-loading").classList.remove("hidden");
|
||
const [data, pending] = await Promise.all([
|
||
api("/api/graph"),
|
||
api("/api/review/pending").catch(() => []),
|
||
]);
|
||
CN.graphData = data;
|
||
G.events = data.events || [];
|
||
G.pending = pending;
|
||
G.nodes = data.nodes.map((n) => ({ ...n, x: 0, y: 0, vx: 0, vy: 0 }));
|
||
G.idx = Object.fromEntries(G.nodes.map((n, i) => [n.id, i]));
|
||
G.edges = data.edges.filter((e) => e.source in G.idx && e.target in G.idx);
|
||
|
||
/* 合并待审核候选为虚线节点 */
|
||
pending.forEach((c) => {
|
||
if (!(c.job_id in G.idx)) {
|
||
G.idx[c.job_id] = G.nodes.push({
|
||
id: c.job_id, type: "Job", title: c.title, category: c.category,
|
||
status: "pending", pending: true, expected_years: c.expected_years,
|
||
source_count: c.source_count, company_count: c.company_count,
|
||
x: 0, y: 0, vx: 0, vy: 0,
|
||
}) - 1;
|
||
}
|
||
[...c.required_skills.map((s) => [s, "required"]), ...c.preferred_skills.map((s) => [s, "preferred"])].forEach(([s, imp]) => {
|
||
if (!(s in G.idx)) {
|
||
G.idx[s] = G.nodes.push({ id: s, type: "Skill", category: "", x: 0, y: 0, vx: 0, vy: 0 }) - 1;
|
||
}
|
||
G.edges.push({ source: c.job_id, target: s, rel: "REQUIRES", importance: imp, weight: 1, intervals: [], sources: [], pending: true });
|
||
});
|
||
});
|
||
|
||
buildFilterOptions();
|
||
layout();
|
||
relayoutByView(); // 保持在非全景视图时重载后仍为该视图布局
|
||
restoreView() || fitView();
|
||
/* 默认展示全景图谱(全量总览);岗位子图通过详情面板「岗位子图」按钮或演示导览进入 */
|
||
updateRecentChange();
|
||
renderSuggest();
|
||
renderChain(G.soloJob || null);
|
||
$("#graph-loading").classList.add("hidden");
|
||
requestDraw();
|
||
drawMinimap();
|
||
if (G.pendingLocate) { const t = G.pendingLocate; G.pendingLocate = null; locateNode(t); }
|
||
}
|
||
|
||
function buildFilterOptions() {
|
||
const cats = [...new Set(G.nodes.filter((n) => n.type === "Job" && n.category).map((n) => n.category))].sort();
|
||
$("#fp-category").innerHTML = `<option value="">全部类别</option>` +
|
||
cats.map((c) => `<option value="${esc(c)}">${esc(c)}</option>`).join("");
|
||
}
|
||
|
||
/* ================= 力导向布局 ================= */
|
||
function layout() {
|
||
const N = G.nodes.length;
|
||
G.nodes.forEach((n, i) => {
|
||
const a = (i / N) * Math.PI * 2;
|
||
n.x = Math.cos(a) * 400 + (Math.random() - 0.5) * 60;
|
||
n.y = Math.sin(a) * 400 + (Math.random() - 0.5) * 60;
|
||
n.vx = 0; n.vy = 0;
|
||
});
|
||
for (let iter = 0; iter < 320; iter++) {
|
||
for (let i = 0; i < N; i++) {
|
||
const a = G.nodes[i];
|
||
for (let j = i + 1; j < N; j++) {
|
||
const b = G.nodes[j];
|
||
let dx = a.x - b.x, dy = a.y - b.y;
|
||
let d2 = dx * dx + dy * dy || 1;
|
||
if (d2 > 640000) continue;
|
||
const d = Math.sqrt(d2);
|
||
const f = 2600 / d2;
|
||
dx = (dx / d) * f; dy = (dy / d) * f;
|
||
a.vx += dx; a.vy += dy; b.vx -= dx; b.vy -= dy;
|
||
}
|
||
}
|
||
G.edges.forEach((e) => {
|
||
const a = G.nodes[G.idx[e.source]], b = G.nodes[G.idx[e.target]];
|
||
let dx = b.x - a.x, dy = b.y - a.y;
|
||
const d = Math.sqrt(dx * dx + dy * dy) || 1;
|
||
const rest = (a.type === "Job" && b.type === "Job") ? 260 : 130;
|
||
const f = (d - rest) * 0.015;
|
||
dx = (dx / d) * f; dy = (dy / d) * f;
|
||
a.vx += dx; a.vy += dy; b.vx -= dx; b.vy -= dy;
|
||
});
|
||
G.nodes.forEach((n) => {
|
||
n.vx *= 0.55; n.vy *= 0.55;
|
||
n.x += Math.max(-24, Math.min(24, n.vx));
|
||
n.y += Math.max(-24, Math.min(24, n.vy));
|
||
});
|
||
}
|
||
}
|
||
|
||
function worldBounds() {
|
||
let minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9;
|
||
G.nodes.forEach((n) => {
|
||
minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x);
|
||
minY = Math.min(minY, n.y); maxY = Math.max(maxY, n.y);
|
||
});
|
||
return { minX, minY, maxX, maxY, w: maxX - minX || 1, h: maxY - minY || 1 };
|
||
}
|
||
|
||
/* ================= 相机 ================= */
|
||
function fitView() {
|
||
const c = canvas(), b = worldBounds();
|
||
const W = c.clientWidth, H = c.clientHeight;
|
||
G.cam.k = Math.min(W / (b.w + 120), H / (b.h + 120), 1.6);
|
||
G.cam.x = (W - b.w * G.cam.k) / 2 - b.minX * G.cam.k;
|
||
G.cam.y = (H - b.h * G.cam.k) / 2 - b.minY * G.cam.k;
|
||
requestDraw(); drawMinimap();
|
||
}
|
||
const toScreen = (n) => ({ x: n.x * G.cam.k + G.cam.x, y: n.y * G.cam.k + G.cam.y });
|
||
const toWorld = (sx, sy) => ({ x: (sx - G.cam.x) / G.cam.k, y: (sy - G.cam.y) / G.cam.k });
|
||
|
||
function zoomAt(factor, cx, cy) {
|
||
const c = canvas();
|
||
cx = cx ?? c.clientWidth / 2; cy = cy ?? c.clientHeight / 2;
|
||
const w = toWorld(cx, cy);
|
||
G.cam.k = Math.max(0.15, Math.min(4, G.cam.k * factor));
|
||
G.cam.x = cx - w.x * G.cam.k;
|
||
G.cam.y = cy - w.y * G.cam.k;
|
||
requestDraw(); drawMinimap();
|
||
}
|
||
|
||
function centerOn(node, k) {
|
||
const c = canvas();
|
||
if (k) G.cam.k = k;
|
||
G.cam.x = c.clientWidth / 2 - node.x * G.cam.k;
|
||
G.cam.y = c.clientHeight / 2 - node.y * G.cam.k;
|
||
requestDraw(); drawMinimap();
|
||
}
|
||
|
||
/* ================= 可见性(筛选) ================= */
|
||
function jobLevel(n) {
|
||
const y = n.expected_years;
|
||
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.viewMode === "level" && G.levelJob) { // 职级能力树:只保留当前岗位及其一跳邻居
|
||
if (n.id !== G.levelJob && !neighborsOf(G.levelJob).has(n.id)) return false;
|
||
}
|
||
if (G.soloJob) { // 岗位子图:只保留该岗位与一跳邻居
|
||
if (n.id !== G.soloJob && !neighborsOf(G.soloJob).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;
|
||
}
|
||
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;
|
||
if (e.rel === "BELONGS_TO") return ef.belongs !== false;
|
||
return true;
|
||
}
|
||
function neighborsOf(id) {
|
||
const set = new Set();
|
||
G.edges.forEach((e) => {
|
||
if (!edgeVisible(e)) return;
|
||
if (e.source === id) set.add(e.target);
|
||
if (e.target === id) set.add(e.source);
|
||
});
|
||
return set;
|
||
}
|
||
|
||
/* ================= 渲染 ================= */
|
||
function requestDraw() {
|
||
if (G.raf) return;
|
||
G.raf = requestAnimationFrame(() => { G.raf = null; draw(); });
|
||
}
|
||
|
||
function nodeRadius(n) {
|
||
return n.type === "Job" ? 13 : n.type === "Scenario" ? 8 : n.type === "Industry" ? 10 : 6.5;
|
||
}
|
||
|
||
function draw() {
|
||
const c = canvas();
|
||
if (!c.clientWidth) return;
|
||
const dpr = window.devicePixelRatio || 1;
|
||
c.width = c.clientWidth * dpr; c.height = c.clientHeight * dpr;
|
||
const ctx = c.getContext("2d");
|
||
ctx.scale(dpr, dpr);
|
||
ctx.clearRect(0, 0, c.clientWidth, c.clientHeight);
|
||
|
||
const sel = G.selected;
|
||
const hop = sel ? neighborsOf(sel) : null;
|
||
G._newPulse = false;
|
||
|
||
/* ---- 视图区域底纹与标签(职级能力树三列 / 技术栈类别锚点) ---- */
|
||
if (G.viewMode === "level") {
|
||
ctx.save();
|
||
/* 目标岗位列底纹 */
|
||
const jx = LEVEL_JOB_X * G.cam.k + G.cam.x;
|
||
ctx.fillStyle = "rgba(24,144,255,0.05)";
|
||
ctx.fillRect(jx - 120 * G.cam.k, 0, 240 * G.cam.k, c.clientHeight);
|
||
ctx.fillStyle = "rgba(29,33,41,0.8)"; ctx.font = "bold 13px sans-serif"; ctx.textAlign = "center";
|
||
ctx.fillText("目标岗位", jx, 76);
|
||
/* L1/L2/L3 三列技能带(列头避开顶部岗位选择条) */
|
||
LEVEL_COLS.forEach((col, i) => {
|
||
const sx = col.x * G.cam.k + G.cam.x;
|
||
ctx.fillStyle = i % 2 ? "rgba(24,144,255,0.03)" : "rgba(19,194,194,0.035)";
|
||
ctx.fillRect(sx - 128 * G.cam.k, 0, 256 * G.cam.k, c.clientHeight);
|
||
ctx.fillStyle = "rgba(29,33,41,0.8)"; ctx.font = "bold 13px sans-serif"; ctx.textAlign = "center";
|
||
ctx.fillText(col.name, sx, 76);
|
||
/* 列间成长箭头(跟随平移) */
|
||
const nx = (i < LEVEL_COLS.length - 1 ? LEVEL_COLS[i + 1].x : col.x) * G.cam.k + G.cam.x;
|
||
if (i < LEVEL_COLS.length - 1) {
|
||
const ay = 71, ax0 = sx + 96 * G.cam.k, ax1 = nx - 96 * G.cam.k;
|
||
if (ax1 > ax0) {
|
||
ctx.strokeStyle = "rgba(24,144,255,0.45)"; ctx.lineWidth = 1.6;
|
||
ctx.beginPath(); ctx.moveTo(ax0, ay); ctx.lineTo(ax1, ay); ctx.stroke();
|
||
ctx.fillStyle = "rgba(24,144,255,0.7)";
|
||
ctx.beginPath(); ctx.moveTo(ax1, ay); ctx.lineTo(ax1 - 7, ay - 4); ctx.lineTo(ax1 - 7, ay + 4); ctx.closePath(); ctx.fill();
|
||
}
|
||
}
|
||
});
|
||
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(24,144,255,0.08)";
|
||
ctx.strokeStyle = "rgba(24,144,255,0.35)";
|
||
ctx.beginPath(); ctx.roundRect(p.x - tw / 2 - 10, p.y - 14, tw + 20, 24, 12); ctx.fill(); ctx.stroke();
|
||
ctx.fillStyle = "#1890ff"; ctx.textAlign = "center";
|
||
ctx.fillText(label, p.x, p.y + 3);
|
||
});
|
||
ctx.restore();
|
||
}
|
||
|
||
/* ---- 边 ---- */
|
||
G.edges.forEach((e) => {
|
||
const a = G.nodes[G.idx[e.source]], b = G.nodes[G.idx[e.target]];
|
||
if (!visible(a) || !visible(b)) return;
|
||
if (!edgeVisible(e)) return;
|
||
const pa = toScreen(a), pb = toScreen(b);
|
||
const isHot = sel && (e.source === sel || e.target === sel);
|
||
const dim = (sel && !isHot) || (G.compareSets && e.source !== G.compareSets.a && e.source !== G.compareSets.b);
|
||
const isReq = e.importance === "required";
|
||
ctx.save();
|
||
if (e.rel === "APPLIES_TO") {
|
||
ctx.strokeStyle = dim ? "rgba(114,46,209,0.05)" : isHot ? "rgba(114,46,209,0.7)" : "rgba(114,46,209,0.22)";
|
||
ctx.setLineDash([]);
|
||
ctx.lineWidth = isHot ? 1.4 : 0.8;
|
||
} else if (isReq) {
|
||
ctx.strokeStyle = dim ? "rgba(78,89,105,0.05)" : isHot ? "rgba(24,144,255,0.75)" : "rgba(78,89,105,0.28)";
|
||
ctx.setLineDash([]);
|
||
ctx.lineWidth = isHot ? 1.8 : 1;
|
||
} else {
|
||
ctx.strokeStyle = dim ? "rgba(19,194,194,0.05)" : isHot ? "rgba(19,194,194,0.8)" : "rgba(19,194,194,0.4)";
|
||
ctx.setLineDash([5, 4]);
|
||
ctx.lineWidth = isHot ? 1.6 : 1;
|
||
}
|
||
if (e.pending) ctx.setLineDash([3, 4]);
|
||
ctx.beginPath(); ctx.moveTo(pa.x, pa.y); ctx.lineTo(pb.x, pb.y); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
|
||
/* 箭头(指向 target 节点边缘) */
|
||
const dx = pb.x - pa.x, dy = pb.y - pa.y;
|
||
const d = Math.sqrt(dx * dx + dy * dy) || 1;
|
||
const rB = nodeRadius(b) * G.cam.k + 2;
|
||
const ax = pb.x - (dx / d) * rB, ay = pb.y - (dy / d) * rB;
|
||
const ang = Math.atan2(dy, dx);
|
||
const ah = isHot ? 7 : 5.5;
|
||
ctx.fillStyle = ctx.strokeStyle;
|
||
ctx.beginPath();
|
||
ctx.moveTo(ax, ay);
|
||
ctx.lineTo(ax - ah * Math.cos(ang - 0.45), ay - ah * Math.sin(ang - 0.45));
|
||
ctx.lineTo(ax - ah * Math.cos(ang + 0.45), ay - ah * Math.sin(ang + 0.45));
|
||
ctx.closePath(); ctx.fill();
|
||
|
||
/* 关系标签(颜色 + 文字双重编码) */
|
||
if (G.showLabels && e.rel === "REQUIRES" && G.cam.k > 0.55 && !dim) {
|
||
ctx.font = "10px sans-serif"; ctx.textAlign = "center";
|
||
ctx.fillStyle = isReq ? "rgba(24,144,255,0.85)" : "rgba(9,151,156,0.9)";
|
||
ctx.fillText(isReq ? "必备" : "加分", (pa.x + pb.x) / 2, (pa.y + pb.y) / 2 - 3);
|
||
}
|
||
ctx.restore();
|
||
});
|
||
|
||
/* ---- 节点 ---- */
|
||
G.nodes.forEach((n) => {
|
||
if (!visible(n)) return;
|
||
const p = toScreen(n);
|
||
const r = Math.max(2.5, nodeRadius(n) * Math.sqrt(G.cam.k));
|
||
const isSel = n.id === sel;
|
||
const isHop = hop && hop.has(n.id);
|
||
/* 岗位对比模式:共有/A 独有/B 独有 三色编码,非对比集合节点淡出 */
|
||
const cmp = G.compareSets;
|
||
const cmpRole = cmp
|
||
? (n.id === cmp.a ? "A" : n.id === cmp.b ? "B"
|
||
: cmp.shared.has(n.id) ? "shared" : cmp.onlyA.has(n.id) ? "onlyA" : cmp.onlyB.has(n.id) ? "onlyB" : null)
|
||
: null;
|
||
const dim = (sel && !isSel && !isHop) || (cmp && !cmpRole);
|
||
const meta = TYPE_META[n.type] || { color: "#86909c" };
|
||
ctx.save();
|
||
ctx.globalAlpha = dim ? 0.2 : 1;
|
||
ctx.fillStyle = cmpRole === "shared" ? COMPARE_COLORS.shared
|
||
: cmpRole === "onlyA" ? COMPARE_COLORS.a
|
||
: cmpRole === "onlyB" ? COMPARE_COLORS.b : meta.color;
|
||
if (n.type === "Skill") {
|
||
const s = r * 1.7;
|
||
ctx.beginPath(); ctx.roundRect(p.x - s / 2, p.y - s / 2, s, s, 2.5); ctx.fill();
|
||
} else {
|
||
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, 7); ctx.fill();
|
||
}
|
||
if (n.pending || n.status === "pending") {
|
||
ctx.setLineDash([3, 3]);
|
||
ctx.strokeStyle = "#ff7d00"; ctx.lineWidth = 1.6;
|
||
ctx.beginPath(); ctx.arc(p.x, p.y, r + 3.5, 0, 7); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
}
|
||
if (isSel) {
|
||
ctx.strokeStyle = "#1d2129"; ctx.lineWidth = 2;
|
||
ctx.beginPath(); ctx.arc(p.x, p.y, r + 4, 0, 7); ctx.stroke();
|
||
} else if (isHop) {
|
||
ctx.strokeStyle = "rgba(24,144,255,0.55)"; ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.arc(p.x, p.y, r + 2.5, 0, 7); ctx.stroke();
|
||
}
|
||
/* 对比模式:基准岗位(蓝环)/ 目标岗位(琥珀环)外圈标记 */
|
||
if (cmpRole === "A" || cmpRole === "B") {
|
||
ctx.strokeStyle = cmpRole === "A" ? "#1890ff" : "#e8c07d";
|
||
ctx.lineWidth = 2.4;
|
||
ctx.beginPath(); ctx.arc(p.x, p.y, r + 5, 0, 7); ctx.stroke();
|
||
}
|
||
/* 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.2 : 1;
|
||
G._newPulse = true; // 有 NEW 节点在画面上:持续请求重绘形成呼吸
|
||
}
|
||
/* 标签按需显示(文字重叠治理):岗位常显;职级能力树视图节点少全显;
|
||
* 技能/场景/行业仅在选中/悬停/选中节点一跳邻居时显示;
|
||
* 关系标签开关(showLabels)打开且放大到一定程度时恢复全量标签 */
|
||
if (n.type === "Job" || G.viewMode === "level" || 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)";
|
||
const label = n.title || n.id;
|
||
const tw = ctx.measureText(label).width;
|
||
ctx.fillRect(p.x - tw / 2 - 3, p.y - r - 17, tw + 6, 13);
|
||
ctx.fillStyle = n.type === "Job" ? "#1d2129" : "#4e5969";
|
||
ctx.fillText(label, p.x, p.y - r - 7);
|
||
}
|
||
/* 变化角标:最近 2 季度有事件的技能节点右上角显示 新/↑/↓/退 */
|
||
const ch = n.type === "Skill" && G.recentChange ? G.recentChange.get(n.id) : null;
|
||
if (ch && !dim) {
|
||
const mark = CHANGE_MARK[ch.change_type] || "";
|
||
const bc = CHANGE_COLORS[ch.change_type] || "#86909c";
|
||
const bx = p.x + r * 0.95, by = p.y - r * 0.95;
|
||
ctx.fillStyle = bc;
|
||
ctx.beginPath(); ctx.arc(bx, by, 7, 0, 7); ctx.fill();
|
||
ctx.strokeStyle = "#fff"; ctx.lineWidth = 1.2;
|
||
ctx.beginPath(); ctx.arc(bx, by, 7, 0, 7); ctx.stroke();
|
||
ctx.fillStyle = "#fff"; ctx.font = "bold 8px sans-serif";
|
||
ctx.textAlign = "center"; ctx.textBaseline = "middle";
|
||
ctx.fillText(mark, bx, by + 0.5);
|
||
ctx.textBaseline = "alphabetic";
|
||
}
|
||
ctx.restore();
|
||
});
|
||
|
||
/* 悬停提示:有近期变化的技能显示变化时间与证据量 */
|
||
if (G.hover && G.recentChange && G.recentChange.has(G.hover) && G.idx[G.hover] != null) {
|
||
const n = G.nodes[G.idx[G.hover]];
|
||
const e = G.recentChange.get(G.hover);
|
||
const p = toScreen(n);
|
||
const r = Math.max(2.5, nodeRadius(n) * Math.sqrt(G.cam.k));
|
||
const txt = `${e.window} ${e.change_type} · 支撑 ${(e.source_ids || []).length} 条 JD`;
|
||
ctx.font = "11px sans-serif";
|
||
const tw = ctx.measureText(txt).width;
|
||
const bx = Math.max(4, Math.min(c.clientWidth - tw - 20, p.x - tw / 2 - 8));
|
||
ctx.fillStyle = "rgba(29,33,41,0.92)";
|
||
ctx.beginPath(); ctx.roundRect(bx, p.y + r + 10, tw + 16, 20, 4); ctx.fill();
|
||
ctx.fillStyle = "#fff"; ctx.textAlign = "center";
|
||
ctx.fillText(txt, bx + tw / 2 + 8, p.y + r + 24);
|
||
}
|
||
|
||
/* NEW 标记呼吸:本帧画到 NEW 节点则继续排队下一帧(复用 requestDraw,不另开定时器) */
|
||
if (G._newPulse) requestDraw();
|
||
}
|
||
|
||
/* ================= 小地图 ================= */
|
||
function drawMinimap() {
|
||
const mm = $("#minimap-canvas");
|
||
const ctx = mm.getContext("2d");
|
||
const W = mm.width, H = mm.height;
|
||
ctx.clearRect(0, 0, W, H);
|
||
if (!G.nodes.length) return;
|
||
const b = worldBounds();
|
||
const k = Math.min((W - 10) / b.w, (H - 10) / b.h);
|
||
const ox = (W - b.w * k) / 2 - b.minX * k;
|
||
const oy = (H - b.h * k) / 2 - b.minY * k;
|
||
G.edges.forEach((e) => {
|
||
const a = G.nodes[G.idx[e.source]], bb = G.nodes[G.idx[e.target]];
|
||
if (!visible(a) || !visible(bb)) return;
|
||
ctx.strokeStyle = "rgba(120,130,150,0.25)";
|
||
ctx.beginPath();
|
||
ctx.moveTo(a.x * k + ox, a.y * k + oy);
|
||
ctx.lineTo(bb.x * k + ox, bb.y * k + oy);
|
||
ctx.stroke();
|
||
});
|
||
G.nodes.forEach((n) => {
|
||
if (!visible(n)) return;
|
||
const meta = TYPE_META[n.type] || { color: "#86909c" };
|
||
ctx.fillStyle = n.pending ? "#ff7d00" : meta.color;
|
||
ctx.beginPath();
|
||
ctx.arc(n.x * k + ox, n.y * k + oy, n.type === "Job" ? 2.6 : 1.6, 0, 7);
|
||
ctx.fill();
|
||
});
|
||
/* 视口框 */
|
||
const c = canvas();
|
||
const tl = toWorld(0, 0), br = toWorld(c.clientWidth, c.clientHeight);
|
||
ctx.strokeStyle = "#1890ff"; ctx.lineWidth = 1.4;
|
||
ctx.strokeRect(tl.x * k + ox, tl.y * k + oy, (br.x - tl.x) * k, (br.y - tl.y) * k);
|
||
mm._proj = { k, ox, oy };
|
||
}
|
||
|
||
$("#minimap-canvas").addEventListener("click", (e) => {
|
||
const mm = e.currentTarget, p = mm._proj;
|
||
if (!p) return;
|
||
const rect = mm.getBoundingClientRect();
|
||
const wx = (e.clientX - rect.left - p.ox) / p.k;
|
||
const wy = (e.clientY - rect.top - p.oy) / p.k;
|
||
centerOn({ x: wx, y: wy });
|
||
});
|
||
|
||
/* ================= 交互:平移 / 缩放 / 点选 / 拖拽节点 ================= */
|
||
function pickNode(sx, sy) {
|
||
let best = null, bd = 1e9;
|
||
G.nodes.forEach((n) => {
|
||
if (!visible(n)) return;
|
||
const p = toScreen(n);
|
||
const r = Math.max(8, nodeRadius(n) * Math.sqrt(G.cam.k) + 4);
|
||
const d = (p.x - sx) ** 2 + (p.y - sy) ** 2;
|
||
if (d < r * r && d < bd) { bd = d; best = n; }
|
||
});
|
||
return best;
|
||
}
|
||
|
||
function bindCanvas() {
|
||
const c = canvas();
|
||
c.addEventListener("mousedown", (e) => {
|
||
const rect = c.getBoundingClientRect();
|
||
const sx = e.clientX - rect.left, sy = e.clientY - rect.top;
|
||
const node = pickNode(sx, sy);
|
||
G.drag = node
|
||
? { mode: "node", node, moved: false }
|
||
: { mode: "pan", sx, sy, camX: G.cam.x, camY: G.cam.y, moved: false };
|
||
c.classList.add("dragging");
|
||
});
|
||
window.addEventListener("mousemove", (e) => {
|
||
const rect = c.getBoundingClientRect();
|
||
const sx = e.clientX - rect.left, sy = e.clientY - rect.top;
|
||
if (!G.drag) {
|
||
const h = pickNode(sx, sy);
|
||
const id = h ? h.id : null;
|
||
if (id !== G.hover) { G.hover = id; requestDraw(); }
|
||
c.classList.toggle("hovering", !!h);
|
||
return;
|
||
}
|
||
G.drag.moved = true;
|
||
if (G.drag.mode === "pan") {
|
||
G.cam.x = G.drag.camX + (sx - G.drag.sx);
|
||
G.cam.y = G.drag.camY + (sy - G.drag.sy);
|
||
} else {
|
||
const w = toWorld(sx, sy);
|
||
G.drag.node.x = w.x; G.drag.node.y = w.y;
|
||
}
|
||
requestDraw();
|
||
});
|
||
window.addEventListener("mouseup", () => {
|
||
if (G.drag && G.drag.mode === "node" && G.drag.moved) drawMinimap();
|
||
if (G.drag && G.drag.mode === "pan" && G.drag.moved) drawMinimap();
|
||
G.drag = null;
|
||
c.classList.remove("dragging");
|
||
});
|
||
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);
|
||
});
|
||
/* 节点右键菜单:选中节点 + 弹出自定义菜单(查看证据 / 时点对比 / 人岗诊断 / 子图开关) */
|
||
c.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault();
|
||
const rect = c.getBoundingClientRect();
|
||
const node = pickNode(e.clientX - rect.left, e.clientY - rect.top);
|
||
if (!node) { hideCtxMenu(); return; }
|
||
if (G.selected !== node.id) selectNode(node.id);
|
||
showCtxMenu(node, e.clientX, e.clientY);
|
||
});
|
||
c.addEventListener("wheel", (e) => {
|
||
e.preventDefault();
|
||
const rect = c.getBoundingClientRect();
|
||
zoomAt(e.deltaY < 0 ? 1.12 : 0.89, e.clientX - rect.left, e.clientY - rect.top);
|
||
}, { passive: false });
|
||
window.addEventListener("resize", () => { requestDraw(); drawMinimap(); });
|
||
}
|
||
|
||
/* ================= 节点右键菜单(CSS 定位 DOM,点空白 / Esc 关闭) ================= */
|
||
function hideCtxMenu() {
|
||
const m = $("#ctx-menu");
|
||
if (m) m.classList.add("hidden");
|
||
}
|
||
function showCtxMenu(n, clientX, clientY) {
|
||
const menu = $("#ctx-menu");
|
||
const wrap = $("#graph-canvas-wrap");
|
||
const wr = wrap.getBoundingClientRect();
|
||
const isJob = n.type === "Job" && !(n.pending || n.status === "pending");
|
||
const items = [
|
||
{ key: "evidence", label: "查看证据 JD 原文" },
|
||
{ key: "compare", label: "能力画像时点对比", disabled: !isJob },
|
||
{ key: "cmp", label: "与该岗位对比…", disabled: !isJob },
|
||
{ key: "match", label: "开始人岗诊断", disabled: !isJob },
|
||
{ key: "solo", label: G.soloJob === n.id ? "退出岗位子图" : "岗位子图开关", disabled: !isJob },
|
||
];
|
||
menu.innerHTML = `<div class="ctx-title">${esc(n.title || n.id)}</div>` + items.map((it) =>
|
||
`<button class="ctx-item" data-key="${it.key}"${it.disabled ? " disabled" : ""}>${it.label}</button>`).join("");
|
||
menu.classList.remove("hidden");
|
||
/* 先渲染量尺寸,再定位并防止溢出画布容器 */
|
||
let x = clientX - wr.left, y = clientY - wr.top;
|
||
x = Math.max(4, Math.min(x, wr.width - menu.offsetWidth - 4));
|
||
y = Math.max(4, Math.min(y, wr.height - menu.offsetHeight - 4));
|
||
menu.style.left = `${x}px`;
|
||
menu.style.top = `${y}px`;
|
||
menu.querySelectorAll(".ctx-item:not(:disabled)").forEach((b) => {
|
||
b.onclick = () => { hideCtxMenu(); ctxAction(b.dataset.key, n); };
|
||
});
|
||
}
|
||
function ctxAction(key, n) {
|
||
if (key === "evidence") {
|
||
const sources = new Set();
|
||
G.edges.forEach((e) => {
|
||
if (e.source === n.id || e.target === n.id) (e.sources || []).forEach((s) => sources.add(s));
|
||
});
|
||
const list = [...sources];
|
||
if (!list.length) return toast("该节点暂无来源证据记录", "info");
|
||
CN.showJdEvidence(list, { jobTitle: n.title || n.id });
|
||
} else if (key === "cmp") {
|
||
enterCompare(n.id, G.compare && G.compare.a !== n.id ? G.compare.a : null);
|
||
} else if (key === "compare") {
|
||
CN.switchView("evolution");
|
||
$("#evo-job-select").value = n.id;
|
||
$("#evo-job-select").dispatchEvent(new Event("change"));
|
||
setTimeout(() => { const d = $("#evo-diff"); if (d) d.scrollIntoView({ block: "center" }); }, 450);
|
||
} else if (key === "match") {
|
||
CN.switchView("match");
|
||
$("#match-job-select").value = n.id;
|
||
$("#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();
|
||
}
|
||
}
|
||
|
||
/* ================= 图谱搜索定位 ================= */
|
||
/* 搜索排序:前缀命中优先,其次按 岗位→技能→场景→行业,取前 8 条(纯函数,单测可提取) */
|
||
function searchRank(nodes, q) {
|
||
const s = String(q || "").trim().toLowerCase();
|
||
if (!s) return [];
|
||
const tOrder = { Job: 0, Skill: 1, Scenario: 2, Industry: 3 };
|
||
const hit = nodes.filter((n) => String(n.title || n.id).toLowerCase().includes(s));
|
||
hit.sort((a, b) => {
|
||
const ta = String(a.title || a.id).toLowerCase();
|
||
const tb = String(b.title || b.id).toLowerCase();
|
||
const pa = ta.startsWith(s) ? 0 : 1, pb = tb.startsWith(s) ? 0 : 1;
|
||
return pa - pb || (tOrder[a.type] ?? 9) - (tOrder[b.type] ?? 9) || ta.localeCompare(tb, "zh");
|
||
});
|
||
return hit.slice(0, 8);
|
||
}
|
||
|
||
function bindSearch() {
|
||
const input = $("#gs-input"), dd = $("#gs-dropdown");
|
||
let items = [], active = -1;
|
||
const close = () => { dd.classList.add("hidden"); active = -1; };
|
||
const go = (i) => {
|
||
const n = items[i];
|
||
if (!n) return;
|
||
input.value = "";
|
||
close();
|
||
locateNode(n.id);
|
||
};
|
||
const render = () => {
|
||
if (!input.value.trim()) { close(); return; }
|
||
items = searchRank(G.nodes, input.value);
|
||
active = -1;
|
||
dd.innerHTML = items.length ? items.map((n, i) => {
|
||
const meta = TYPE_META[n.type] || { name: n.type, tag: "tag-gray" };
|
||
return `<div class="gsd-item" data-i="${i}">
|
||
<span class="tag ${meta.tag}">${meta.name}</span>
|
||
<span class="gsd-name">${esc(n.title || n.id)}</span>
|
||
${n.pending ? `<span class="tag tag-orange">待审核</span>` : ""}
|
||
</div>`;
|
||
}).join("") : `<div class="gsd-empty">没有匹配的节点</div>`;
|
||
dd.classList.remove("hidden");
|
||
dd.querySelectorAll(".gsd-item").forEach((el) => {
|
||
el.onmousedown = (e) => { e.preventDefault(); go(+el.dataset.i); };
|
||
});
|
||
};
|
||
input.addEventListener("input", render);
|
||
input.addEventListener("focus", render);
|
||
input.addEventListener("blur", () => setTimeout(close, 120));
|
||
input.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape") { input.blur(); close(); }
|
||
else if (e.key === "Enter") { e.preventDefault(); go(active >= 0 ? active : 0); }
|
||
else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||
e.preventDefault();
|
||
if (!items.length) return;
|
||
active = (active + (e.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
|
||
dd.querySelectorAll(".gsd-item").forEach((el, i) => el.classList.toggle("active", i === active));
|
||
}
|
||
});
|
||
}
|
||
|
||
/* ================= 岗位对比模式 ================= */
|
||
/* 岗位对比:技能交集/差集 + 转岗缺口与就绪度(纯函数,单测可提取) */
|
||
function compareJobs(edges, a, b) {
|
||
const skillsOf = (j) => {
|
||
const m = new Map();
|
||
edges.forEach((e) => {
|
||
if (e.source === j && e.rel === "REQUIRES" && !e.pending) {
|
||
m.set(e.target, e.importance === "required" ? "required" : "preferred");
|
||
}
|
||
});
|
||
return m;
|
||
};
|
||
const A = skillsOf(a), B = skillsOf(b);
|
||
const shared = [], onlyA = [], onlyB = [];
|
||
A.forEach((imp, s) => {
|
||
if (B.has(s)) shared.push({ skill: s, impA: imp, impB: B.get(s) });
|
||
else onlyA.push({ skill: s, imp });
|
||
});
|
||
B.forEach((imp, s) => {
|
||
if (!A.has(s)) onlyB.push({ skill: s, imp });
|
||
});
|
||
const gaps = onlyB.filter((x) => x.imp === "required").map((x) => x.skill);
|
||
const bReq = [...B.values()].filter((v) => v === "required").length;
|
||
const covered = shared.filter((x) => x.impB === "required").length;
|
||
return { shared, onlyA, onlyB, gaps, bReq, covered, ready: bReq ? covered / bReq : 0 };
|
||
}
|
||
|
||
function jobOptionsHtml(selectedId) {
|
||
return G.nodes.filter((n) => n.type === "Job" && !n.pending)
|
||
.sort((a, b) => String(a.title || a.id).localeCompare(String(b.title || b.id), "zh"))
|
||
.map((n) => `<option value="${esc(n.id)}"${n.id === selectedId ? " selected" : ""}>${esc(n.title || n.id)}</option>`).join("");
|
||
}
|
||
|
||
/* 进入对比:强制全景视图(其他视图有可见性过滤,会藏掉对比节点) */
|
||
function enterCompare(aId, bId) {
|
||
const jobs = G.nodes.filter((n) => n.type === "Job" && !n.pending);
|
||
if (jobs.length < 2) return toast("岗位数量不足,无法对比", "info");
|
||
if (G.viewMode !== "overview") setViewMode("overview");
|
||
if (!aId || !(aId in G.idx)) aId = (DEFAULT_SOLO_JOB in G.idx) ? DEFAULT_SOLO_JOB : jobs[0].id;
|
||
G.compare = { a: aId, b: bId || null };
|
||
G.compareSets = null;
|
||
$("#graph-compare-bar").classList.remove("hidden");
|
||
$("#gc-a").innerHTML = jobOptionsHtml(G.compare.a);
|
||
$("#gc-b").innerHTML = `<option value="">选择对比岗位…</option>` + jobOptionsHtml(G.compare.b);
|
||
if (G.compare.b) updateCompare();
|
||
else { renderComparePanel(); requestDraw(); }
|
||
}
|
||
|
||
function exitCompare(quiet) {
|
||
if (!G.compare) return;
|
||
G.compare = null; G.compareSets = null;
|
||
$("#graph-compare-bar").classList.add("hidden");
|
||
selectNode(null, false);
|
||
requestDraw(); drawMinimap();
|
||
if (!quiet) toast("已退出岗位对比", "info");
|
||
}
|
||
|
||
function updateCompare() {
|
||
const cp = G.compare;
|
||
if (!cp || !cp.a || !cp.b) return;
|
||
if (cp.a === cp.b) { G.compareSets = null; renderComparePanel(); requestDraw(); return; }
|
||
const r = compareJobs(G.edges, cp.a, cp.b);
|
||
G.compareSets = {
|
||
a: cp.a, b: cp.b,
|
||
shared: new Set(r.shared.map((x) => x.skill)),
|
||
onlyA: new Set(r.onlyA.map((x) => x.skill)),
|
||
onlyB: new Set(r.onlyB.map((x) => x.skill)),
|
||
result: r,
|
||
};
|
||
const na = G.nodes[G.idx[cp.a]], nb = G.nodes[G.idx[cp.b]];
|
||
$("#gc-name-a").textContent = `${(na && na.title) || cp.a} 独有`;
|
||
$("#gc-name-b").textContent = `${(nb && nb.title) || cp.b} 独有`;
|
||
$("#gc-dot-a").style.background = COMPARE_COLORS.a;
|
||
$("#gc-dot-b").style.background = COMPARE_COLORS.b;
|
||
renderComparePanel();
|
||
requestDraw(); drawMinimap();
|
||
}
|
||
|
||
/* 对比结果渲染到右侧详情面板:概览三数 + 转岗就绪度 + 分组技能清单 */
|
||
function renderComparePanel() {
|
||
$("#detail-empty").classList.add("hidden");
|
||
const body = $("#detail-body");
|
||
body.classList.remove("hidden");
|
||
const cp = G.compare || {};
|
||
const na = cp.a ? G.nodes[G.idx[cp.a]] : null;
|
||
const chip = (s, cls) => `<span class="tag ${cls} dt-skill" data-id="${esc(s)}" title="点击聚焦该技能">${esc(s)}</span>`;
|
||
const head = `
|
||
<div class="dt-head">
|
||
<div class="dt-title-row"><div>
|
||
<div class="dt-name">岗位对比</div>
|
||
<div class="dt-tags"><span class="tag tag-blue">技能交集 / 差集分析</span></div>
|
||
</div></div>
|
||
<button class="icon-btn sm dt-close" id="dtc-close" title="退出对比"><svg viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></svg></button>
|
||
</div>`;
|
||
if (!G.compareSets) {
|
||
body.innerHTML = head + `
|
||
<div class="dt-sec"><div class="dt-sec-title">等待选择</div>
|
||
<div class="text-secondary" style="font-size:13px;line-height:1.8">
|
||
基准岗位:「${esc((na && na.title) || "—")}」<br>
|
||
请在顶部对比条选择另一个岗位,画布将以三色高亮:青绿=共有技能、蓝紫=A 独有、琥珀=B 独有,其余节点自动淡出。
|
||
</div></div>`;
|
||
$("#dtc-close").onclick = () => exitCompare();
|
||
return;
|
||
}
|
||
const cs = G.compareSets;
|
||
const nb = G.nodes[G.idx[cs.b]];
|
||
const r = cs.result;
|
||
const pct = Math.round(r.ready * 100);
|
||
const prefB = r.onlyB.filter((x) => x.imp !== "required");
|
||
body.innerHTML = head + `
|
||
<div class="dtc-hero">
|
||
<div class="dtc-job" style="background:rgba(143,159,240,.14)">${esc((na && na.title) || cs.a)}<small>基准岗位</small></div>
|
||
<span class="dtc-vs">VS</span>
|
||
<div class="dtc-job" style="background:rgba(232,192,125,.16)">${esc((nb && nb.title) || cs.b)}<small>目标岗位</small></div>
|
||
</div>
|
||
<div class="dtc-stats">
|
||
<div class="dtc-stat"><b style="color:${COMPARE_COLORS.shared}">${r.shared.length}</b><span>共有技能</span></div>
|
||
<div class="dtc-stat"><b style="color:${COMPARE_COLORS.a}">${r.onlyA.length}</b><span>A 独有</span></div>
|
||
<div class="dtc-stat"><b style="color:${COMPARE_COLORS.b}">${r.onlyB.length}</b><span>B 独有</span></div>
|
||
</div>
|
||
<div class="dt-sec"><div class="dt-sec-title">转岗就绪度(${esc((na && na.title) || cs.a)} → ${esc((nb && nb.title) || cs.b)})</div>
|
||
<div class="dtc-ready">
|
||
<span class="text-aux text-secondary">目标必备技能已覆盖 ${r.covered}/${r.bReq}(${pct}%)</span>
|
||
<div class="dtc-ready-bar"><i style="width:${pct}%"></i></div>
|
||
</div>
|
||
<div class="kv" style="margin-top:10px"><span class="k">需补齐必备(${r.gaps.length})</span></div>
|
||
<div class="dt-chips">${r.gaps.map((s) => chip(s, "tag-orange")).join("") || '<span class="text-weak text-aux">无缺口,能力结构已达标</span>'}</div>
|
||
<div class="kv" style="margin-top:8px"><span class="k">可拓展加分(${prefB.length})</span></div>
|
||
<div class="dt-chips">${prefB.map((x) => chip(x.skill, "tag-cyan")).join("") || '<span class="text-weak text-aux">—</span>'}</div>
|
||
</div>
|
||
<div class="dt-sec"><div class="dt-sec-title">共有技能(${r.shared.length})</div>
|
||
<div class="dt-chips">${r.shared.map((x) => chip(x.skill, "tag-green")).join("") || '<span class="text-weak text-aux">无共有技能</span>'}</div>
|
||
</div>
|
||
<div class="dt-sec"><div class="dt-sec-title">${esc((na && na.title) || cs.a)} 独有(${r.onlyA.length})</div>
|
||
<div class="dt-chips">${r.onlyA.map((x) => chip(x.skill, "tag-purple")).join("") || '<span class="text-weak text-aux">—</span>'}</div>
|
||
</div>`;
|
||
$("#dtc-close").onclick = () => exitCompare();
|
||
body.querySelectorAll(".dt-skill").forEach((el) => { el.onclick = () => locateNode(el.dataset.id); });
|
||
}
|
||
|
||
/* ================= 动态时序:当季解说面板 ================= */
|
||
/* 时序季度摘要:当季新晋岗位 / 新增关系 / 能力事件分组(纯函数,单测可提取) */
|
||
function windowDigest(edges, events, nodes, w) {
|
||
const newEdges = edges.filter((e) => !e.pending && (e.intervals || []).some(([f]) => f === w));
|
||
const evs = events.filter((e) => e.window === w);
|
||
const byType = (t) => evs.filter((e) => e.change_type === t);
|
||
const firstSeen = {};
|
||
edges.forEach((e) => {
|
||
(e.intervals || []).forEach(([f]) => {
|
||
if (f && (!(e.source in firstSeen) || f < firstSeen[e.source])) firstSeen[e.source] = f;
|
||
});
|
||
});
|
||
const newJobs = nodes.filter((n) => n.type === "Job" && firstSeen[n.id] === w).map((n) => n.title || n.id);
|
||
return { newEdges, newJobs, added: byType("新增"), boosted: byType("增强"), weakened: byType("减弱"), exited: byType("退出") };
|
||
}
|
||
|
||
/* 当季解说面板:拖到哪个季度,就讲哪个季度的图谱故事 */
|
||
function renderTimePanel() {
|
||
const panel = $("#gt-panel");
|
||
if (!panel) return;
|
||
if (G.viewMode !== "timeline" || !G.activeNodes) { panel.classList.add("hidden"); return; }
|
||
const w = G.timeWins[G.timeIdx];
|
||
if (!w) { panel.classList.add("hidden"); return; }
|
||
const d = windowDigest(G.edges, G.events, G.nodes, w);
|
||
const typeOf = (id) => (G.nodes[G.idx[id]] || {}).type;
|
||
const jobCount = [...G.activeNodes].filter((id) => typeOf(id) === "Job").length;
|
||
const skillCount = [...G.activeNodes].filter((id) => typeOf(id) === "Skill").length;
|
||
const sec = (title, html) => (html ? `<div class="gtp-sec"><div class="gtp-sec-title">${title}</div>${html}</div>` : "");
|
||
const evItem = (e) => `<div class="gtp-item" data-id="${esc(e.skill)}" title="点击定位技能节点">
|
||
<span class="tag ${EV_TAG[e.change_type] || "tag-gray"}">${esc(e.change_type)}</span>${esc(e.job_id.replace(/^job_/, ""))} · ${esc(e.skill)}</div>`;
|
||
const relItems = d.newEdges.slice(0, 6).map((e) => {
|
||
const s = G.nodes[G.idx[e.source]], t = G.nodes[G.idx[e.target]];
|
||
return `<div class="gtp-item" data-id="${esc(e.target)}" title="点击定位">${esc((s && s.title) || e.source)} → ${esc((t && t.title) || e.target)}</div>`;
|
||
}).join("");
|
||
const evAll = [...d.added, ...d.boosted, ...d.weakened, ...d.exited];
|
||
const quiet = !d.newJobs.length && !d.newEdges.length && !evAll.length;
|
||
panel.innerHTML = `
|
||
<div class="gtp-title">${esc(w)} 季度解读</div>
|
||
<div class="gtp-stats">
|
||
<div class="gtp-stat"><b>${jobCount}</b><span>在图岗位</span></div>
|
||
<div class="gtp-stat"><b>${skillCount}</b><span>在图技能</span></div>
|
||
<div class="gtp-stat"><b>${G.activeEdges.size}</b><span>生效关系</span></div>
|
||
</div>
|
||
${sec("本季新晋岗位", d.newJobs.map((j) => `<div class="gtp-item">${esc(j)}</div>`).join(""))}
|
||
${sec(`本季新增关系(${d.newEdges.length})`, relItems + (d.newEdges.length > 6 ? `<div class="gtp-more">…共 ${d.newEdges.length} 条</div>` : ""))}
|
||
${sec(`能力事件(新增 ${d.added.length} · 增强 ${d.boosted.length} · 减弱 ${d.weakened.length} · 退出 ${d.exited.length})`,
|
||
evAll.slice(0, 8).map(evItem).join("") + (evAll.length > 8 ? `<div class="gtp-more">…共 ${evAll.length} 条</div>` : ""))}
|
||
${quiet ? `<div class="gtp-more">本季度图谱结构保持稳定,无新增变化</div>` : `<div class="gtp-more">点击条目可定位对应节点</div>`}`;
|
||
panel.classList.remove("hidden");
|
||
panel.querySelectorAll(".gtp-item[data-id]").forEach((el) => { el.onclick = () => locateNode(el.dataset.id); });
|
||
}
|
||
|
||
/* ================= 工具条 / 筛选 ================= */
|
||
function bindToolbar() {
|
||
document.addEventListener("click", (e) => { if (!e.target.closest("#ctx-menu")) hideCtxMenu(); });
|
||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") hideCtxMenu(); });
|
||
$("#tool-zoom-in").onclick = () => zoomAt(1.25);
|
||
$("#tool-zoom-out").onclick = () => zoomAt(0.8);
|
||
$("#tool-fit").onclick = fitView;
|
||
$("#tool-relayout").onclick = () => {
|
||
$("#graph-loading").classList.remove("hidden");
|
||
setTimeout(() => {
|
||
layout(); relayoutByView(); fitView();
|
||
$("#graph-loading").classList.add("hidden"); toast("布局已整理", "success");
|
||
}, 30);
|
||
};
|
||
/* 视图切换分段控件(全景 / 技术栈 / 职级演进 / 动态时序) */
|
||
$$("#graph-view-tabs button").forEach((b) => { b.onclick = () => setViewMode(b.dataset.gview); });
|
||
/* 职级演进视图:切换能力树岗位 */
|
||
$("#gl-job-select").onchange = (e) => {
|
||
G.levelJob = e.target.value;
|
||
applyLevelLayout();
|
||
fitLevelView();
|
||
selectNode(G.levelJob, false);
|
||
};
|
||
/* 岗位对比模式:头部按钮 + 对比条 */
|
||
$("#btn-compare").onclick = () => {
|
||
if (G.compare) return exitCompare();
|
||
const n = G.selected != null ? G.nodes[G.idx[G.selected]] : null;
|
||
enterCompare((n && n.type === "Job" && !n.pending) ? n.id : null, null);
|
||
};
|
||
$("#gc-a").onchange = (e) => {
|
||
if (!G.compare) return;
|
||
G.compare.a = e.target.value;
|
||
if (G.compare.b === G.compare.a) { G.compare.b = null; $("#gc-b").value = ""; }
|
||
G.compareSets = null;
|
||
if (G.compare.b) updateCompare(); else { renderComparePanel(); requestDraw(); drawMinimap(); }
|
||
};
|
||
$("#gc-b").onchange = (e) => {
|
||
if (!G.compare) return;
|
||
G.compare.b = e.target.value || null;
|
||
if (G.compare.b) updateCompare();
|
||
else { G.compareSets = null; renderComparePanel(); requestDraw(); drawMinimap(); }
|
||
};
|
||
$("#gc-exit").onclick = () => exitCompare();
|
||
/* 动态时序视图:滑块拖动 + 自动播放 */
|
||
$("#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;
|
||
e.currentTarget.classList.toggle("active", G.showLabels);
|
||
requestDraw();
|
||
};
|
||
$("#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")) {
|
||
$("#graph-filter-panel").classList.add("hidden");
|
||
}
|
||
});
|
||
$$("#graph-filter-panel input[data-ftype]").forEach((cb) => {
|
||
cb.onchange = () => { G.filters.types[cb.dataset.ftype] = cb.checked; requestDraw(); drawMinimap(); };
|
||
});
|
||
$$("#graph-filter-panel input[data-etype]").forEach((cb) => {
|
||
cb.onchange = () => { G.filters.edges[cb.dataset.etype] = cb.checked; requestDraw(); drawMinimap(); };
|
||
});
|
||
$("#fp-category").onchange = (e) => { G.filters.category = e.target.value; requestDraw(); drawMinimap(); };
|
||
$("#fp-level").onchange = (e) => { G.filters.level = e.target.value; requestDraw(); drawMinimap(); };
|
||
$("#fp-reset").onclick = () => {
|
||
G.filters = { types: { Job: true, Skill: true, Industry: false, Scenario: false }, category: "", level: "",
|
||
edges: { required: true, preferred: true, applies: true, belongs: true } };
|
||
$$("#graph-filter-panel input[data-ftype]").forEach((cb) => { cb.checked = true; });
|
||
$$("#graph-filter-panel input[data-etype]").forEach((cb) => { cb.checked = true; });
|
||
$("#fp-category").value = ""; $("#fp-level").value = "";
|
||
requestDraw(); drawMinimap();
|
||
};
|
||
/* 头部主操作:查看演化 / 查看证据 / 开始匹配;保存/分享/导出收进「更多」 */
|
||
const activeJobId = () => {
|
||
const n = G.selected != null ? G.nodes[G.idx[G.selected]] : null;
|
||
if (n && n.type === "Job" && !n.pending) return G.selected;
|
||
if (G.soloJob) return G.soloJob;
|
||
return DEFAULT_SOLO_JOB;
|
||
};
|
||
$("#btn-goto-evo").onclick = () => {
|
||
CN.switchView("evolution");
|
||
$("#evo-job-select").value = activeJobId();
|
||
$("#evo-job-select").dispatchEvent(new Event("change"));
|
||
};
|
||
$("#btn-goto-match").onclick = () => {
|
||
CN.switchView("match");
|
||
$("#match-job-select").value = activeJobId();
|
||
$("#match-job-select").dispatchEvent(new Event("change"));
|
||
toast("已带入目标岗位,请上传或载入示例简历", "success");
|
||
};
|
||
$("#btn-goto-evidence").onclick = () => {
|
||
const id = G.selected || G.soloJob;
|
||
if (!id) return toast("请先在画布中选择一个节点", "info");
|
||
const sources = new Set();
|
||
G.edges.forEach((e) => {
|
||
if (e.source === id || e.target === id) (e.sources || []).forEach((s) => sources.add(s));
|
||
});
|
||
const list = [...sources];
|
||
if (!list.length) return toast("该节点暂无来源证据记录", "info");
|
||
const n = G.nodes[G.idx[id]];
|
||
CN.showJdEvidence(list, { jobTitle: n.title || id });
|
||
};
|
||
$("#btn-more-menu").onclick = (e) => {
|
||
e.stopPropagation();
|
||
$("#more-menu").classList.toggle("hidden");
|
||
};
|
||
document.addEventListener("click", (e) => {
|
||
if (!e.target.closest(".more-wrap")) $("#more-menu").classList.add("hidden");
|
||
});
|
||
$("#btn-save-view").onclick = () => {
|
||
localStorage.setItem(VIEW_KEY, JSON.stringify({ cam: G.cam, filters: G.filters, labels: G.showLabels }));
|
||
$("#more-menu").classList.add("hidden");
|
||
toast("当前视图(位置/缩放/筛选)已保存", "success");
|
||
};
|
||
$("#btn-share-graph").onclick = async () => {
|
||
const url = `${location.origin}/#view=graph${G.selected ? `&node=${encodeURIComponent(G.selected)}` : ""}`;
|
||
$("#more-menu").classList.add("hidden");
|
||
try { await navigator.clipboard.writeText(url); toast("分享链接已复制到剪贴板", "success"); }
|
||
catch { CN.sysCopy({ title: "分享链接(请复制)", text: url }); }
|
||
};
|
||
$("#btn-export-graph").onclick = () => {
|
||
$("#more-menu").classList.add("hidden");
|
||
const c = canvas();
|
||
const tmp = document.createElement("canvas");
|
||
tmp.width = c.width; tmp.height = c.height;
|
||
const tctx = tmp.getContext("2d");
|
||
tctx.fillStyle = "#ffffff";
|
||
tctx.fillRect(0, 0, tmp.width, tmp.height);
|
||
tctx.drawImage(c, 0, 0);
|
||
tmp.toBlob((blob) => {
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `CareerNexus_岗位能力图谱_${new Date().toISOString().slice(0, 10)}.png`;
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
toast("图谱 PNG 已导出", "success");
|
||
});
|
||
};
|
||
}
|
||
|
||
function restoreView() {
|
||
try {
|
||
const v = JSON.parse(localStorage.getItem(VIEW_KEY) || "null");
|
||
if (!v) return false;
|
||
G.cam = v.cam;
|
||
G.filters = Object.assign(
|
||
{ types: { Job: true, Skill: true, Industry: false, Scenario: false }, category: "", level: "",
|
||
edges: { required: true, preferred: true, applies: true, belongs: true } },
|
||
v.filters);
|
||
G.filters.edges = Object.assign({ required: true, preferred: true, applies: true, belongs: true },
|
||
(v.filters && v.filters.edges) || {});
|
||
G.showLabels = v.labels !== false;
|
||
$("#tool-labels").classList.toggle("active", G.showLabels);
|
||
$$("#graph-filter-panel input[data-ftype]").forEach((cb) => { cb.checked = !!G.filters.types[cb.dataset.ftype]; });
|
||
$$("#graph-filter-panel input[data-etype]").forEach((cb) => { cb.checked = G.filters.edges[cb.dataset.etype] !== false; });
|
||
$("#fp-category").value = G.filters.category || "";
|
||
$("#fp-level").value = G.filters.level || "";
|
||
return true;
|
||
} catch { return false; }
|
||
}
|
||
|
||
/* ================= 系统发现(原画布浮层「智能建议」,移入右侧详情面板底部可折叠区块) ================= */
|
||
const IGNORED_KEY = "cn_ignored_findings";
|
||
const ignoredSet = () => new Set(JSON.parse(localStorage.getItem(IGNORED_KEY) || "[]"));
|
||
function renderSuggest() {
|
||
const ig = ignoredSet();
|
||
const items = [];
|
||
G.pending.forEach((c) => items.push({
|
||
color: "#ff7d00", kind: "pending", id: c.job_id, nodeId: c.job_id,
|
||
text: `新岗位候选「${esc(c.title)}」待审核`,
|
||
sub: `原因:聚类发现 ${c.source_count} 条 JD / ${c.company_count} 家企业指向未收录岗位 · 证据 ${c.source_count} 条`,
|
||
}));
|
||
G.events.slice(-4).reverse().forEach((e) => items.push({
|
||
color: "#1890ff", kind: "event", id: `${e.job_id}|${e.skill}|${e.window}`, nodeId: e.job_id,
|
||
text: `「${esc(e.job_id.replace(/^job_/, ""))}」${esc(e.change_type)}技能 ${esc(e.skill)}`,
|
||
sub: `证据:窗口 ${esc(e.window)} · 需求指数 ${e.prev_weight.toFixed(2)}→${e.curr_weight.toFixed(2)} · ${(e.source_ids || []).length} 条 JD`,
|
||
}));
|
||
const list = items.filter((it) => !ig.has(`${it.kind}|${it.id}`));
|
||
$("#gs-list").innerHTML = list.length ? list.map((it, i) => `
|
||
<div class="gs-item" data-i="${i}">
|
||
<span class="gs-dot" style="background:${it.color}"></span>
|
||
<div class="gs-body">
|
||
<div class="gs-text">${it.text}</div>
|
||
<div class="gs-sub">${it.sub}</div>
|
||
<div class="gs-acts">
|
||
<button class="link-btn" data-accept="${i}">${it.kind === "pending" ? "采纳(去审核台)" : "采纳(去演化页)"}</button>
|
||
<button class="link-btn gs-ignore" data-ignore="${i}">忽略</button>
|
||
</div>
|
||
</div>
|
||
</div>`).join("") : `<div class="empty small">暂无系统发现</div>`;
|
||
$$("#gs-list .gs-item").forEach((el) => {
|
||
el.onclick = (ev) => {
|
||
if (ev.target.dataset.accept !== undefined || ev.target.dataset.ignore !== undefined) return;
|
||
locateNode(list[+el.dataset.i].nodeId);
|
||
};
|
||
});
|
||
$$("#gs-list [data-accept]").forEach((b) => {
|
||
b.onclick = () => {
|
||
const it = list[+b.dataset.accept];
|
||
if (it.kind === "pending") {
|
||
if (CN.openReviewDesk) CN.openReviewDesk(it.id);
|
||
} else {
|
||
CN.switchView("evolution");
|
||
$("#evo-job-select").value = it.nodeId;
|
||
$("#evo-job-select").dispatchEvent(new Event("change"));
|
||
}
|
||
};
|
||
});
|
||
$$("#gs-list [data-ignore]").forEach((b) => {
|
||
b.onclick = () => {
|
||
const it = list[+b.dataset.ignore];
|
||
const s = ignoredSet();
|
||
s.add(`${it.kind}|${it.id}`);
|
||
localStorage.setItem(IGNORED_KEY, JSON.stringify([...s]));
|
||
renderSuggest();
|
||
};
|
||
});
|
||
}
|
||
|
||
/* ================= 演化逻辑链 ================= */
|
||
function renderChain(jobId) {
|
||
const chips = $("#gc-chips");
|
||
if (!jobId) {
|
||
$("#gc-job-name").textContent = "选择岗位节点后展示能力演化路径";
|
||
chips.innerHTML = `<span class="text-weak text-aux">—</span>`;
|
||
return;
|
||
}
|
||
const job = G.nodes[G.idx[jobId]];
|
||
$("#gc-job-name").textContent = job ? `「${job.title || jobId}」能力演化路径` : "";
|
||
const evs = G.events.filter((e) => e.job_id === jobId)
|
||
.sort((a, b) => (a.window < b.window ? -1 : 1));
|
||
const seq = [];
|
||
const seen = new Set();
|
||
evs.forEach((e) => {
|
||
if (seen.has(e.skill)) return;
|
||
seen.add(e.skill);
|
||
if (e.change_type === "新增") seq.push({ skill: e.skill, exit: false, window: e.window });
|
||
if (e.change_type === "退出") {
|
||
const found = seq.find((s) => s.skill === e.skill);
|
||
if (found) found.exit = true;
|
||
}
|
||
});
|
||
let html = seq.map((s) =>
|
||
`<span class="chain-chip${s.exit ? " exit" : ""}" data-skill="${esc(s.skill)}" title="${esc(s.window)}${s.exit ? " · 已退出" : ""}">${esc(s.skill)}${s.exit ? " ✕" : ""}</span>`
|
||
).join(`<span class="chain-arrow">→</span>`);
|
||
/* 旁列待审核关系 */
|
||
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("");
|
||
}
|
||
chips.innerHTML = html || `<span class="text-weak text-aux">该岗位暂无演化记录</span>`;
|
||
chips.querySelectorAll("[data-skill]").forEach((el) => {
|
||
el.onclick = () => locateNode(el.dataset.skill);
|
||
});
|
||
}
|
||
|
||
/* ================= 选中 / 定位 ================= */
|
||
function selectNode(id, pushNav = true) {
|
||
if (pushNav && G.selected && id && id !== G.selected) G.navStack.push(G.selected);
|
||
G.selected = id;
|
||
if (id) {
|
||
renderDetail(id);
|
||
const n = G.nodes[G.idx[id]];
|
||
renderChain(n && n.type === "Job" ? id : null);
|
||
} else {
|
||
$("#detail-body").classList.add("hidden");
|
||
$("#detail-empty").classList.remove("hidden");
|
||
renderChain(null);
|
||
}
|
||
requestDraw();
|
||
}
|
||
|
||
function locateNode(id) {
|
||
/* 定位也记入 URL,刷新/分享后回到同一节点 */
|
||
const hash = new URLSearchParams(location.hash.slice(1));
|
||
hash.set("view", "graph");
|
||
hash.set("node", id);
|
||
history.replaceState(null, "", "#" + hash.toString());
|
||
const n = G.nodes[G.idx[id]];
|
||
if (!n) { G.pendingLocate = id; return; } // 数据未就绪:加载完成后补定位
|
||
G.pendingLocate = null;
|
||
/* 职级能力树视图下定位:岗位→切换能力树对象;树外技能→退回全景再定位 */
|
||
if (G.viewMode === "level" && G.levelJob) {
|
||
if (n.type === "Job") {
|
||
G.levelJob = id;
|
||
const lvSel = $("#gl-job-select");
|
||
if (lvSel) lvSel.value = id;
|
||
applyLevelLayout();
|
||
fitLevelView();
|
||
} else if (!neighborsOf(G.levelJob).has(id)) {
|
||
setViewMode("overview");
|
||
}
|
||
}
|
||
/* 目标不在当前岗位子图内:自动退出子图(即全量「岗位生态总览」),避免"定位到不可见节点" */
|
||
if (G.soloJob && id !== G.soloJob && !neighborsOf(G.soloJob).has(id)) {
|
||
G.soloJob = null;
|
||
updateSoloBadge();
|
||
updateRecentChange();
|
||
fitView();
|
||
}
|
||
selectNode(id);
|
||
centerOn(n, Math.max(G.cam.k, 0.9));
|
||
}
|
||
CN.gotoNode = locateNode;
|
||
|
||
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 soloExit = $("#solo-exit");
|
||
if (soloExit) soloExit.onclick = () => {
|
||
G.soloJob = null;
|
||
updateSoloBadge();
|
||
updateRecentChange();
|
||
fitView();
|
||
toast("已退出子图,当前为岗位生态总览(全量图)", "info");
|
||
};
|
||
|
||
/* ================= 备注(localStorage) ================= */
|
||
const getNotes = () => JSON.parse(localStorage.getItem(NOTES_KEY) || "{}");
|
||
function nodeNotes(id) { return getNotes()[id] || []; }
|
||
function addNote(id, text) {
|
||
const all = getNotes();
|
||
(all[id] = all[id] || []).push({ text, time: new Date().toLocaleString("zh-CN") });
|
||
localStorage.setItem(NOTES_KEY, JSON.stringify(all));
|
||
}
|
||
function delNote(id, i) {
|
||
const all = getNotes();
|
||
(all[id] || []).splice(i, 1);
|
||
localStorage.setItem(NOTES_KEY, JSON.stringify(all));
|
||
}
|
||
|
||
/* ================= 详情面板 ================= */
|
||
const NODE_ICONS = {
|
||
Job: '<svg viewBox="0 0 24 24"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>',
|
||
Skill: '<svg viewBox="0 0 24 24"><path d="M12 2l2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.5-4.8 2.5.9-5.4L4.2 7.7l5.4-.8L12 2z"/></svg>',
|
||
Industry: '<svg viewBox="0 0 24 24"><path d="M3 21h18M5 21V8l6-4 6 4v13M9 12h2M13 12h2M9 16h2M13 16h2"/></svg>',
|
||
Scenario: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.6 3.8 5.7 3.8 9S14.5 18.4 12 21c-2.5-2.6-3.8-5.7-3.8-9S9.5 5.6 12 3z"/></svg>',
|
||
};
|
||
|
||
async function renderDetail(id) {
|
||
const n = G.nodes[G.idx[id]];
|
||
if (!n) return;
|
||
const meta = TYPE_META[n.type] || { name: n.type, tag: "tag-gray", color: "#86909c" };
|
||
const isPending = n.pending || n.status === "pending";
|
||
const isJob = n.type === "Job" && !isPending;
|
||
|
||
/* 关联 */
|
||
const rels = { Job: [], Skill: [], Scenario: [], Industry: [] };
|
||
const sources = new Set();
|
||
G.edges.forEach((e) => {
|
||
let other = null, imp = "";
|
||
if (e.source === id) { other = e.target; imp = e.importance; }
|
||
else if (e.target === id) { other = e.source; imp = e.importance; }
|
||
else return;
|
||
(e.sources || []).forEach((s) => sources.add(s));
|
||
const on = G.nodes[G.idx[other]];
|
||
if (on && rels[on.type]) rels[on.type].push({ id: other, title: on.title || other, imp, pending: e.pending });
|
||
});
|
||
|
||
/* 岗位定义(结论区块的一句话定义来源;失败不阻塞面板) */
|
||
const jobDef = isJob
|
||
? await api(`/api/jobs/${encodeURIComponent(id)}`).then((d) => d.definition || d).catch(() => null)
|
||
: null;
|
||
|
||
/* 基础信息(非岗位节点 / 待审核候选保留原始平铺) */
|
||
const kv = [];
|
||
if (n.type === "Job") {
|
||
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 || "通用技能"]);
|
||
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]);
|
||
const latestWin = G.edges.filter((e) => (e.source === id || e.target === id) && e.intervals?.length)
|
||
.flatMap((e) => e.intervals.map((i) => i[0])).sort().at(-1);
|
||
if (latestWin) kv.push(["更新时间", latestWin]);
|
||
|
||
const relHtml = Object.entries(rels).filter(([, l]) => l.length).map(([t, l]) => `
|
||
<div class="kv"><span class="k">关联${TYPE_META[t].name}</span></div>
|
||
<div class="rel-list">${l.slice(0, 12).map((r) => `
|
||
<div class="rel-item" data-id="${esc(r.id)}">
|
||
<span class="ri-kind tag ${TYPE_META[t].tag}">${TYPE_META[t].name}</span>
|
||
<span class="ri-name">${esc(r.title)}</span>
|
||
${r.imp ? `<span class="ri-extra">${r.imp === "required" ? "必备" : "加分"}${r.pending ? "·待审核" : ""}</span>` : ""}
|
||
</div>`).join("")}
|
||
${l.length > 12 ? `<div class="text-weak text-aux" style="padding:2px 8px">…共 ${l.length} 项</div>` : ""}
|
||
</div>`).join("");
|
||
|
||
const srcList = [...sources];
|
||
const notes = nodeNotes(id);
|
||
const audit = await api(`/api/audit?node_id=${encodeURIComponent(id)}&limit=6`).catch(() => []);
|
||
const skillEvents = n.type === "Skill"
|
||
? G.events.filter((e) => e.skill === id).sort((a, b) => (a.window < b.window ? 1 : -1)).slice(0, 3) : [];
|
||
|
||
/* 证据来源行:可读文案「企业 · 平台 · 日期」异步填充,内部 source_id 不直接展示 */
|
||
const srcRowsHtml = srcList.length
|
||
? srcList.slice(0, 10).map((s, i) => `
|
||
<div class="src-item" data-sid="${esc(s)}">
|
||
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
|
||
<span class="src-meta" data-sid="${esc(s)}">证据 JD · 第 ${i + 1} 条(来源信息加载中…)</span></div>`).join("")
|
||
+ (srcList.length > 10 ? `<div class="text-weak text-aux" style="padding:2px 8px">…共 ${srcList.length} 条</div>` : "")
|
||
: `<div class="text-weak text-aux">暂无来源记录</div>`;
|
||
|
||
/* 岗位节点四区块:结论 / 能力清单 / 近期变化 / 可信证据与操作 */
|
||
const reqSkills = rels.Skill.filter((r) => r.imp === "required");
|
||
const prefSkills = rels.Skill.filter((r) => r.imp !== "required");
|
||
const jobEvents = isJob
|
||
? G.events.filter((e) => e.job_id === id).sort((a, b) => (a.window < b.window ? 1 : -1)).slice(0, 3)
|
||
: [];
|
||
const dataCutoff = ((G.events.at(-1) || {}).window) || latestWin || "2026Q3";
|
||
const skillChip = (r, cls) => `<span class="tag ${cls} dt-skill" data-id="${esc(r.id)}" title="点击聚焦该技能">${esc(r.title)}</span>`;
|
||
const jobBlocks = isJob ? `
|
||
<div class="dt-sec"><div class="dt-sec-title">结论</div>
|
||
${jobDef && (jobDef.responsibilities || [])[0] ? `<div class="dt-def">${esc(jobDef.responsibilities[0])}</div>` : ""}
|
||
<div class="kv"><span class="k">级别</span><span class="v">${esc(n.level || jobLevel(n) || "—")}</span></div>
|
||
<div class="kv"><span class="k">类别</span><span class="v">${esc(n.category || "—")}</span></div>
|
||
<div class="dt-support">支撑 ${n.source_count ?? (jobDef || {}).source_count ?? "—"} 条 JD · ${n.company_count ?? (jobDef || {}).company_count ?? "—"} 家企业 · 数据截止 ${esc(dataCutoff)}</div>
|
||
</div>
|
||
<div class="dt-sec"><div class="dt-sec-title">能力清单</div>
|
||
<div class="kv"><span class="k">必备技能(${reqSkills.length})</span></div>
|
||
<div class="dt-chips">${reqSkills.map((r) => skillChip(r, "tag-green")).join("") || '<span class="text-weak text-aux">无</span>'}</div>
|
||
<div class="kv"><span class="k">加分技能(${prefSkills.length})</span></div>
|
||
<div class="dt-chips">${prefSkills.map((r) => skillChip(r, "tag-cyan")).join("") || '<span class="text-weak text-aux">无</span>'}</div>
|
||
</div>
|
||
<div class="dt-sec"><div class="dt-sec-title">近期变化</div>
|
||
${jobEvents.length ? jobEvents.map((e, i) => `
|
||
<div class="dt-ev" data-evi="${i}" title="点击查看证据 JD">
|
||
<span class="tag ${EV_TAG[e.change_type] || "tag-gray"}">${esc(e.change_type)}</span>
|
||
<span class="dt-ev-skill">${esc(e.skill)}</span>
|
||
<span class="text-weak text-aux">${esc(e.window)} · ${(e.source_ids || []).length} 条 JD 支撑</span>
|
||
</div>`).join("") : `<div class="text-weak text-aux">近期无变化记录</div>`}
|
||
</div>` : "";
|
||
|
||
$("#detail-empty").classList.add("hidden");
|
||
const body = $("#detail-body");
|
||
body.classList.remove("hidden");
|
||
body.innerHTML = `
|
||
<div class="dt-head">
|
||
<div class="dt-title-row">
|
||
<span class="dt-node-icon" style="background:${meta.color}">${NODE_ICONS[n.type] || NODE_ICONS.Job}</span>
|
||
<div>
|
||
<div class="dt-name">${esc(n.title || n.id)}</div>
|
||
<div class="dt-tags">
|
||
<span class="tag ${meta.tag}">${meta.name}</span>
|
||
${isPending ? `<span class="tag tag-orange">待审核</span>` : `<span class="tag tag-green">正式入库</span>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button class="icon-btn sm dt-close" id="dt-close" title="关闭"><svg viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></svg></button>
|
||
</div>
|
||
${G.navStack.length ? `<button class="btn btn-text btn-sm dt-back" id="dt-back">← 返回上一个节点</button>` : ""}
|
||
|
||
${jobBlocks}
|
||
|
||
${!isJob ? `<div class="dt-sec"><div class="dt-sec-title">基础信息</div>
|
||
${kv.map(([k, v]) => `<div class="kv"><span class="k">${k}</span><span class="v">${esc(v)}</span></div>`).join("") || `<div class="text-weak text-aux">暂无属性</div>`}
|
||
</div>` : ""}
|
||
|
||
${!isJob && relHtml ? `<div class="dt-sec"><div class="dt-sec-title">关联信息(点击跳转)</div>${relHtml}</div>` : ""}
|
||
|
||
${n.type === "Skill" ? `
|
||
<div class="dt-sec"><div class="dt-sec-title">演化信息</div>
|
||
<div class="sparkline-box"><canvas id="dt-spark" width="264" height="52"></canvas>
|
||
<div class="text-weak text-aux" style="margin-top:4px">近 12 个季度热度(活跃岗位关系数)</div></div>
|
||
${skillEvents.map((e) => `<div class="audit-item"><div class="au-head">
|
||
<span class="tag ${EV_TAG[e.change_type] || "tag-gray"}">${e.change_type}</span>
|
||
<b>${esc(e.job_id.replace(/^job_/, ""))}</b><span class="text-weak">${esc(e.window)}</span></div></div>`).join("")}
|
||
</div>` : ""}
|
||
|
||
<div class="dt-sec"><div class="dt-sec-title">可信证据(支撑 JD,点击查看原文)</div>
|
||
${srcRowsHtml}
|
||
</div>
|
||
|
||
<div class="dt-sec"><div class="dt-sec-title">操作</div>
|
||
<div class="dt-actions">
|
||
${isPending ? `
|
||
<button class="btn btn-primary btn-sm" id="dt-accept">人工批准入图</button>
|
||
<button class="btn btn-danger btn-sm" id="dt-reject">退回继续观察</button>` : `
|
||
<button class="btn btn-secondary btn-sm" id="dt-edit">编辑节点</button>
|
||
<button class="btn btn-secondary btn-sm" id="dt-sources">查看支撑 JD</button>`}
|
||
<button class="btn btn-secondary btn-sm" id="dt-note">添加备注</button>
|
||
${isJob ? `<button class="btn btn-secondary btn-sm" id="dt-evo">查看演化</button>
|
||
<button class="btn btn-secondary btn-sm" id="dt-solo">岗位子图</button>
|
||
<button class="btn btn-primary btn-sm full" id="dt-match">开始人岗诊断</button>
|
||
<button class="btn btn-secondary btn-sm full" id="dt-learn">生成学习路径</button>` : ""}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="dt-sec"><div class="dt-sec-title">备注(本地保存)</div>
|
||
<div id="dt-notes">${notes.map((t, i) => `
|
||
<div class="note-item"><div class="nt-head"><span>${esc(t.time)}</span>
|
||
<button class="nt-del" data-i="${i}">删除</button></div>${esc(t.text)}</div>`).join("") ||
|
||
`<div class="text-weak text-aux">暂无备注</div>`}</div>
|
||
</div>
|
||
|
||
<div class="dt-sec"><div class="dt-sec-title">审核留痕</div>
|
||
${audit.length ? audit.map((r) => `
|
||
<div class="audit-item">
|
||
<div class="au-head"><b>${esc(r.operator || "—")}</b><span class="text-weak">${esc(r.time || "")}</span></div>
|
||
<div class="au-diff">${auditDiff(r)}${r.reason ? `<br>原因:${esc(r.reason)}` : ""}</div>
|
||
</div>`).join("") : `<div class="text-weak text-aux">暂无留痕记录</div>`}
|
||
</div>`;
|
||
|
||
/* 绑定 */
|
||
$("#dt-close").onclick = () => selectNode(null, false);
|
||
const back = $("#dt-back");
|
||
if (back) back.onclick = () => { const prev = G.navStack.pop(); if (prev) selectNode(prev, false); };
|
||
body.querySelectorAll(".rel-item").forEach((el) => { el.onclick = () => locateNode(el.dataset.id); });
|
||
body.querySelectorAll(".dt-skill").forEach((el) => { el.onclick = () => locateNode(el.dataset.id); });
|
||
body.querySelectorAll(".src-item").forEach((el) => {
|
||
el.onclick = () => CN.showJdEvidence(el.dataset.sid, { jobTitle: n.title || n.id });
|
||
});
|
||
/* 来源行异步填充「企业 · 平台 · 日期」 */
|
||
body.querySelectorAll(".src-meta").forEach((el) => { CN.jdMetaLine(el, el.dataset.sid); });
|
||
body.querySelectorAll(".dt-ev").forEach((el) => {
|
||
el.onclick = () => {
|
||
const e = jobEvents[+el.dataset.evi];
|
||
CN.showJdEvidence(e.source_ids || [], {
|
||
skill: e.skill, jobTitle: n.title || n.id, changeType: e.change_type, window: e.window,
|
||
});
|
||
};
|
||
});
|
||
body.querySelectorAll(".nt-del").forEach((el) => {
|
||
el.onclick = () => { delNote(id, +el.dataset.i); renderDetail(id); };
|
||
});
|
||
$("#dt-note").onclick = async () => {
|
||
const text = await CN.sysPrompt({ title: `为「${n.title || n.id}」添加备注`, label: "备注内容:", placeholder: "如:评审时重点关注的技能" });
|
||
if (text && text.trim()) { addNote(id, text.trim()); renderDetail(id); toast("备注已保存", "success"); }
|
||
};
|
||
const srcBtn = $("#dt-sources");
|
||
if (srcBtn) srcBtn.onclick = () => {
|
||
if (srcList.length) CN.showJdEvidence(srcList, { jobTitle: n.title || n.id });
|
||
else toast("暂无来源记录", "info");
|
||
};
|
||
const editBtn = $("#dt-edit");
|
||
if (editBtn) editBtn.onclick = () => openEditModal(n);
|
||
const evoBtn = $("#dt-evo");
|
||
if (evoBtn) evoBtn.onclick = () => {
|
||
CN.switchView("evolution");
|
||
$("#evo-job-select").value = id;
|
||
$("#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();
|
||
};
|
||
const matchBtn = $("#dt-match");
|
||
if (matchBtn) matchBtn.onclick = () => {
|
||
CN.switchView("match");
|
||
$("#match-job-select").value = id;
|
||
$("#match-job-select").dispatchEvent(new Event("change"));
|
||
toast("已带入目标岗位,请上传或载入示例简历", "success");
|
||
};
|
||
const learnBtn = $("#dt-learn");
|
||
if (learnBtn) learnBtn.onclick = () => {
|
||
CN.switchView("path");
|
||
$("#path-job-select").value = id;
|
||
$("#btn-path").click();
|
||
};
|
||
const accBtn = $("#dt-accept"), rejBtn = $("#dt-reject");
|
||
if (accBtn) accBtn.onclick = () => reviewCandidate(id, "accept");
|
||
if (rejBtn) rejBtn.onclick = () => reviewCandidate(id, "reject");
|
||
|
||
/* 技能 sparkline */
|
||
if (n.type === "Skill") drawSkillSpark(id);
|
||
}
|
||
|
||
function auditDiff(r) {
|
||
const parts = [];
|
||
const before = r.before || {}, after = r.after || {};
|
||
Object.keys(after).forEach((k) => {
|
||
if (["job_id", "responsibilities", "scenarios", "required_skills", "preferred_skills"].includes(k)) return;
|
||
parts.push(`${esc(k)}: ${esc(JSON.stringify(before[k]) ?? "—")} → ${esc(JSON.stringify(after[k]))}`);
|
||
});
|
||
if (!parts.length && r.action) parts.push(`动作:${esc(r.action)}`);
|
||
return parts.join("<br>") || "—";
|
||
}
|
||
|
||
function drawSkillSpark(skillId) {
|
||
const c = $("#dt-spark");
|
||
if (!c) return;
|
||
/* 各季度活跃关系数:intervals 覆盖窗口即活跃 */
|
||
const wins = [...new Set(G.edges.flatMap((e) => (e.intervals || []).map((i) => i[0])))].sort();
|
||
const last12 = wins.slice(-12);
|
||
const values = last12.map((w) => G.edges.filter((e) =>
|
||
(e.source === skillId || e.target === skillId) &&
|
||
(e.intervals || []).some(([f, t]) => f <= w && (t === null || w < t))).length);
|
||
const ctx = c.getContext("2d");
|
||
ctx.clearRect(0, 0, c.width, c.height);
|
||
const max = Math.max(1, ...values);
|
||
values.forEach((v, i) => {
|
||
const bw = c.width / values.length;
|
||
const h = (v / max) * (c.height - 16);
|
||
ctx.fillStyle = "#1890ff";
|
||
ctx.fillRect(i * bw + 2, c.height - h - 12, bw - 4, h);
|
||
if (i % 3 === 0) {
|
||
ctx.fillStyle = "#86909c"; ctx.font = "9px sans-serif"; ctx.textAlign = "center";
|
||
ctx.fillText(last12[i].slice(2), i * bw + bw / 2, c.height - 2);
|
||
}
|
||
});
|
||
}
|
||
|
||
/* ================= 节点编辑弹窗 ================= */
|
||
function openEditModal(n) {
|
||
openModal(`编辑节点 · ${esc(n.title || n.id)}`, `
|
||
<div class="form-row"><label class="form-label">名称</label>
|
||
<input id="ef-title" class="input" style="width:100%" value="${esc(n.title || "")}"></div>
|
||
<div class="form-row"><label class="form-label">级别</label>
|
||
<select id="ef-level" class="select" style="width:100%">
|
||
<option value="">未设置</option>
|
||
${["初级", "中级", "高级"].map((l) => `<option ${n.level === l ? "selected" : ""}>${l}</option>`).join("")}
|
||
</select></div>
|
||
<div class="form-row"><label class="form-label">重要性</label>
|
||
<select id="ef-importance" class="select" style="width:100%">
|
||
<option value="">未设置</option>
|
||
<option value="required" ${n.importance === "required" ? "selected" : ""}>必备</option>
|
||
<option value="preferred" ${n.importance === "preferred" ? "selected" : ""}>加分</option>
|
||
</select></div>
|
||
<div class="form-row"><label class="form-label">需求指数(0-1)</label>
|
||
<input id="ef-weight" type="number" min="0" max="1" step="0.05" class="input" style="width:100%" value="${n.weight ?? ""}"></div>
|
||
<div class="form-row"><label class="form-label">操作人</label>
|
||
<input id="ef-operator" class="input" style="width:100%" value="评审专家"></div>
|
||
<div class="form-row"><label class="form-label">修改原因</label>
|
||
<input id="ef-reason" class="input" style="width:100%" placeholder="选填,将写入审核留痕"></div>`,
|
||
`<button class="btn btn-secondary" id="ef-cancel">取消</button>
|
||
<button class="btn btn-primary" id="ef-submit">提交修改</button>`);
|
||
$("#ef-cancel").onclick = closeModal;
|
||
$("#ef-submit").onclick = async () => {
|
||
const updates = {};
|
||
const title = $("#ef-title").value.trim();
|
||
if (title && title !== (n.title || "")) updates.title = title;
|
||
if ($("#ef-level").value) updates.level = $("#ef-level").value;
|
||
if ($("#ef-importance").value) updates.importance = $("#ef-importance").value;
|
||
if ($("#ef-weight").value !== "") updates.weight = +$("#ef-weight").value;
|
||
if (!Object.keys(updates).length) return toast("没有需要提交的修改", "info");
|
||
try {
|
||
await post("/api/graph/node/update", {
|
||
node_id: n.id, updates,
|
||
operator: $("#ef-operator").value.trim() || "评审专家",
|
||
reason: $("#ef-reason").value.trim(),
|
||
});
|
||
Object.assign(n, updates);
|
||
closeModal();
|
||
toast("节点已更新并留痕", "success");
|
||
renderDetail(n.id);
|
||
requestDraw();
|
||
} catch (e) { toast(e.message, "error"); }
|
||
};
|
||
}
|
||
|
||
/* ================= 候选审核 ================= */
|
||
async function reviewCandidate(id, action) {
|
||
await CN.doReview(id, action);
|
||
}
|
||
|
||
/* ================= 对外接口 ================= */
|
||
CN.graph = {
|
||
inited: false,
|
||
_p: null,
|
||
async init() {
|
||
if (this.inited) return;
|
||
this.inited = true;
|
||
bindCanvas();
|
||
bindToolbar();
|
||
bindSearch();
|
||
this._p = load();
|
||
await this._p;
|
||
},
|
||
/* 数据装载完成 Promise(演示导览等需要等图谱就绪后定位) */
|
||
ready() { return this._p || Promise.resolve(); },
|
||
has(id) { return id in G.idx; },
|
||
/* 进入指定岗位的局部子图(演示导览第 3 步):同心分层布局 */
|
||
solo(id) {
|
||
if (!(id in G.idx)) return;
|
||
G._defaultApplied = true;
|
||
G.soloJob = id;
|
||
updateSoloBadge();
|
||
updateRecentChange();
|
||
applySoloLayout();
|
||
selectNode(id, false);
|
||
fitSoloView();
|
||
},
|
||
onShow() { requestDraw(); drawMinimap(); },
|
||
async reload() {
|
||
await load();
|
||
if (G.soloJob && G.idx[G.soloJob]) { applySoloLayout(); fitSoloView(); } // 子图模式下重排同心分层
|
||
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);
|
||
},
|
||
};
|
||
})();
|