/* 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 动态时序 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 = { "新增": "新", "增强": "↑", "减弱": "↓", "退出": "退" }; /* 近一年入库的新岗位(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_BANDS = [ { name: "初级 L1 · ≤2 年", y: -420 }, { name: "中级 L2 · 3-5 年", y: 0 }, { name: "高级 L3 · >5 年", y: 420 }, ]; /* 技术栈视图:类别锚点网格分布,岗位聚在锚点旁,技能/场景/行业靠向所属岗位质心 */ function applyTechLayout() { const jobs = G.nodes.filter((n) => n.type === "Job"); const cats = [...new Set(jobs.map((n) => n.category || "未分类"))].sort((a, b) => a.localeCompare(b, "zh")); const cols = Math.max(1, Math.ceil(Math.sqrt(cats.length))); G.catAnchors = cats.map((cat, i) => ({ cat, x: (i % cols) * 980 - ((cols - 1) * 980) / 2, y: Math.floor(i / cols) * 780 - 360, })); const anchorOf = (c) => G.catAnchors.find((a) => a.cat === c) || { x: 0, y: 0 }; const byCat = {}; jobs.forEach((n) => { (byCat[n.category || "未分类"] = byCat[n.category || "未分类"] || []).push(n); }); Object.entries(byCat).forEach(([c, list]) => { const a = anchorOf(c); list.sort((x, y) => String(x.title || x.id).localeCompare(String(y.title || y.id), "zh")); const perRow = Math.max(1, Math.ceil(Math.sqrt(list.length))); list.forEach((n, i) => { n.x = a.x + ((i % perRow) - (perRow - 1) / 2) * 180; n.y = a.y + Math.floor(i / perRow) * 150 + 40; n.vx = 0; n.vy = 0; }); }); scatterAroundJobs(G.nodes.filter((n) => n.type !== "Job")); } /* 非岗位节点:靠向相连岗位的质心,按 id 哈希确定性散布 */ function scatterAroundJobs(nodes) { nodes.forEach((n) => { const js = []; G.edges.forEach((e) => { if (e.source === n.id) { const t = G.nodes[G.idx[e.target]]; if (t && t.type === "Job") js.push(t); } if (e.target === n.id) { const s = G.nodes[G.idx[e.source]]; if (s && s.type === "Job") js.push(s); } }); const cx = js.length ? js.reduce((s, j) => s + j.x, 0) / js.length : 0; const cy = js.length ? js.reduce((s, j) => s + j.y, 0) / js.length : 0; const h = hashStr(n.id); const ang = ((h % 360) * Math.PI) / 180; const R = 95 + (h % 5) * 36; n.x = cx + Math.cos(ang) * R; n.y = cy + Math.sin(ang) * R; n.vx = 0; n.vy = 0; }); } /* 职级演进视图:横向三条级别带(初级→中级→高级 自下而上的成长阶梯),技能落带内、岗位摆带底 */ function applyLevelLayout() { [0, 1, 2].forEach((b) => { const yBand = LEVEL_BANDS[b].y; const jl = G.nodes.filter((n) => n.type === "Job" && levelBandOf(n) === b) .sort((x, y) => String(x.title || x.id).localeCompare(String(y.title || y.id), "zh")); // 岗位在带线下方(最多 2 行),技能在带线上方(最多 3 行),两者不重叠 const jPerRow = Math.max(3, Math.ceil(jl.length / 2)); jl.forEach((n, i) => { n.x = ((i % jPerRow) - (jPerRow - 1) / 2) * 190; n.y = yBand + 90 + Math.floor(i / jPerRow) * 85; n.vx = 0; n.vy = 0; }); const sl = G.nodes.filter((n) => n.type !== "Job" && levelBandOf(n) === b) .sort((x, y) => String(x.title || x.id).localeCompare(String(y.title || y.id), "zh")); const perRow = Math.max(4, Math.ceil(sl.length / 4)); sl.forEach((n, i) => { n.x = ((i % perRow) - (perRow - 1) / 2) * 100; n.y = yBand - 170 + Math.floor(i / perRow) * 62; n.vx = 0; n.vy = 0; }); }); } /* 动态时序视图:窗口序列(事件窗口 ∪ 边区间起点),拖动滑块只显示该季度已存在节点与生效边 */ function timeWindows() { const s = new Set(G.events.map((e) => e.window)); G.edges.forEach((e) => (e.intervals || []).forEach(([f]) => s.add(f))); return [...s].filter(Boolean).sort(); } function setupTimeline() { G.timeWins = timeWindows(); const sl = $("#gt-slider"); sl.max = Math.max(0, G.timeWins.length - 1); sl.disabled = !G.timeWins.length; if (G.timeIdx == null || G.timeIdx > G.timeWins.length - 1) G.timeIdx = G.timeWins.length - 1; sl.value = G.timeIdx; applyTimeWindow(); } function applyTimeWindow() { const w = G.timeWins[G.timeIdx]; G.activeEdges = new Set(G.edges.filter((e) => edgeActiveAt(e, w))); G.activeNodes = new Set(); G.activeEdges.forEach((e) => { G.activeNodes.add(e.source); G.activeNodes.add(e.target); }); const hasEdge = new Set(); G.edges.forEach((e) => { hasEdge.add(e.source); hasEdge.add(e.target); }); G.nodes.forEach((n) => { if (n.pending || !hasEdge.has(n.id)) G.activeNodes.add(n.id); }); $("#gt-label").textContent = w ? `当前窗口:${w}` : "暂无时序数据"; requestDraw(); drawMinimap(); } function stopPlay() { if (G.playTimer) { clearInterval(G.playTimer); G.playTimer = null; } const b = $("#gt-play"); if (b) b.textContent = "播放"; } /* 视图切换入口:保留筛选/子图状态,仅重排位置(全景来回切换时恢复原力导向位置) */ function setViewMode(mode) { if (!VIEW_NAMES[mode] || !G.nodes.length) return; if (mode === G.viewMode && mode === "overview") return; if (G.viewMode === "overview" && mode !== "overview") { G._overviewPos = G.nodes.map((n) => ({ x: n.x, y: n.y })); } G.viewMode = mode; $$("#graph-view-tabs button").forEach((b) => b.classList.toggle("active", b.dataset.gview === mode)); $("#graph-timeline").classList.toggle("hidden", mode !== "timeline"); stopPlay(); if (mode === "overview") { if (G._overviewPos) G._overviewPos.forEach((p, i) => { G.nodes[i].x = p.x; G.nodes[i].y = p.y; G.nodes[i].vx = 0; G.nodes[i].vy = 0; }); else layout(); G.activeNodes = null; G.activeEdges = null; } else if (mode === "tech") applyTechLayout(); else if (mode === "level") applyLevelLayout(); else if (mode === "timeline") { // 时序视图沿用全景力导向布局(否则继承上一个视图的特化布局会被压扁) 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(); } if (G.soloJob) { applySoloLayout(); fitSoloView(); } else fitView(); requestDraw(); drawMinimap(); if (mode !== "overview") toast(`已切换为${VIEW_NAMES[mode]}`, "info"); } /* 按当前视图重排(数据重载 / 一键整理布局后调用) */ function relayoutByView() { if (G.viewMode === "tech") applyTechLayout(); else if (G.viewMode === "level") applyLevelLayout(); else if (G.viewMode === "timeline") { 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 = `` + cats.map((c) => ``).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.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(); LEVEL_BANDS.forEach((b, i) => { const sy = b.y * G.cam.k + G.cam.y; ctx.fillStyle = i % 2 ? "rgba(24,144,255,0.025)" : "rgba(19,194,194,0.03)"; ctx.fillRect(0, sy - 175 * G.cam.k, c.clientWidth, 350 * G.cam.k); ctx.fillStyle = "rgba(78,89,105,0.85)"; ctx.font = "bold 13px sans-serif"; ctx.textAlign = "left"; ctx.fillText(b.name, 14, sy - 150 * G.cam.k); if (i > 0) { // 带间分隔虚线 ctx.strokeStyle = "rgba(78,89,105,0.25)"; ctx.setLineDash([6, 5]); ctx.beginPath(); ctx.moveTo(0, sy - 170 * G.cam.k); ctx.lineTo(c.clientWidth, sy - 170 * G.cam.k); ctx.stroke(); ctx.setLineDash([]); } }); /* 成长阶梯方向提示(文字,非图标) */ ctx.fillStyle = "rgba(78,89,105,0.6)"; ctx.font = "11px sans-serif"; ctx.textAlign = "right"; ctx.fillText("成长阶梯:初级 → 中级 → 高级", c.clientWidth - 14, 20); ctx.restore(); } else if (G.viewMode === "tech" && G.catAnchors) { ctx.save(); G.catAnchors.forEach((a) => { const p = { x: a.x * G.cam.k + G.cam.x, y: a.y * G.cam.k + G.cam.y }; const n = G.nodes.filter((x) => x.type === "Job" && (x.category || "未分类") === a.cat).length; const label = `${a.cat}(${n})`; ctx.font = "bold 13px sans-serif"; const tw = ctx.measureText(label).width; ctx.fillStyle = "rgba(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; 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); const dim = sel && !isSel && !isHop; const meta = TYPE_META[n.type] || { color: "#86909c" }; ctx.save(); ctx.globalAlpha = dim ? 0.2 : 1; ctx.fillStyle = 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(); } /* 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" || 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: "match", label: "开始人岗诊断", disabled: !isJob }, { key: "solo", label: G.soloJob === n.id ? "退出岗位子图" : "岗位子图开关", disabled: !isJob }, ]; menu.innerHTML = `
${esc(n.title || n.id)}
` + items.map((it) => ``).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 === "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(); } } /* ================= 工具条 / 筛选 ================= */ 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); }); /* 动态时序视图:滑块拖动 + 自动播放 */ $("#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) => `
${it.text}
${it.sub}
`).join("") : `
暂无系统发现
`; $$("#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 = `—`; 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) => `${esc(s.skill)}${s.exit ? " ✕" : ""}` ).join(`→`); /* 旁列待审核关系 */ const pendRels = G.edges.filter((e) => e.pending && e.source === jobId); if (pendRels.length) { html += `|待审核:` + pendRels.map((e) => `待审 · ${esc(e.target)}`).join(""); } chips.innerHTML = html || `该岗位暂无演化记录`; 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.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: '', Skill: '', Industry: '', Scenario: '', }; 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]) => `
关联${TYPE_META[t].name}
${l.slice(0, 12).map((r) => `
${TYPE_META[t].name} ${esc(r.title)} ${r.imp ? `${r.imp === "required" ? "必备" : "加分"}${r.pending ? "·待审核" : ""}` : ""}
`).join("")} ${l.length > 12 ? `
…共 ${l.length} 项
` : ""}
`).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) => `
证据 JD · 第 ${i + 1} 条(来源信息加载中…)
`).join("") + (srcList.length > 10 ? `
…共 ${srcList.length} 条
` : "") : `
暂无来源记录
`; /* 岗位节点四区块:结论 / 能力清单 / 近期变化 / 可信证据与操作 */ 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 EV_TAG = { "新增": "tag-cyan", "增强": "tag-purple", "减弱": "tag-orange", "退出": "tag-red" }; const skillChip = (r, cls) => `${esc(r.title)}`; const jobBlocks = isJob ? `
结论
${jobDef && (jobDef.responsibilities || [])[0] ? `
${esc(jobDef.responsibilities[0])}
` : ""}
级别${esc(n.level || jobLevel(n) || "—")}
类别${esc(n.category || "—")}
支撑 ${n.source_count ?? (jobDef || {}).source_count ?? "—"} 条 JD · ${n.company_count ?? (jobDef || {}).company_count ?? "—"} 家企业 · 数据截止 ${esc(dataCutoff)}
能力清单
必备技能(${reqSkills.length})
${reqSkills.map((r) => skillChip(r, "tag-green")).join("") || '无'}
加分技能(${prefSkills.length})
${prefSkills.map((r) => skillChip(r, "tag-cyan")).join("") || '无'}
近期变化
${jobEvents.length ? jobEvents.map((e, i) => `
${esc(e.change_type)} ${esc(e.skill)} ${esc(e.window)} · ${(e.source_ids || []).length} 条 JD 支撑
`).join("") : `
近期无变化记录
`}
` : ""; $("#detail-empty").classList.add("hidden"); const body = $("#detail-body"); body.classList.remove("hidden"); body.innerHTML = `
${NODE_ICONS[n.type] || NODE_ICONS.Job}
${esc(n.title || n.id)}
${meta.name} ${isPending ? `待审核` : `正式入库`}
${G.navStack.length ? `` : ""} ${jobBlocks} ${!isJob ? `
基础信息
${kv.map(([k, v]) => `
${k}${esc(v)}
`).join("") || `
暂无属性
`}
` : ""} ${!isJob && relHtml ? `
关联信息(点击跳转)
${relHtml}
` : ""} ${n.type === "Skill" ? `
演化信息
近 12 个季度热度(活跃岗位关系数)
${skillEvents.map((e) => `
${e.change_type} ${esc(e.job_id.replace(/^job_/, ""))}${esc(e.window)}
`).join("")}
` : ""}
可信证据(支撑 JD,点击查看原文)
${srcRowsHtml}
操作
${isPending ? ` ` : ` `} ${isJob ? ` ` : ""}
备注(本地保存)
${notes.map((t, i) => `
${esc(t.time)}
${esc(t.text)}
`).join("") || `
暂无备注
`}
审核留痕
${audit.length ? audit.map((r) => `
${esc(r.operator || "—")}${esc(r.time || "")}
${auditDiff(r)}${r.reason ? `
原因:${esc(r.reason)}` : ""}
`).join("") : `
暂无留痕记录
`}
`; /* 绑定 */ $("#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("
") || "—"; } 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)}`, `
`, ` `); $("#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(); 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.selected && !(G.selected in G.idx)) selectNode(null, false); else if (G.selected) renderDetail(G.selected); }, }; })();