Files
CareerNexus/webui/graph.js
T

1428 lines
68 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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: -340 },
{ name: "中级 L2 · 3-5 年", y: 0 },
{ name: "高级 L3 · >5 年", y: 340 },
];
/* 技术栈视图:类别锚点网格分布,岗位聚在锚点旁,技能/场景/行业靠向所属岗位质心 */
function applyTechLayout() {
const jobs = G.nodes.filter((n) => n.type === "Job");
const cats = [...new Set(jobs.map((n) => n.category || "未分类"))].sort((a, b) => a.localeCompare(b, "zh"));
const cols = Math.max(1, Math.ceil(Math.sqrt(cats.length)));
G.catAnchors = cats.map((cat, i) => ({
cat, x: (i % cols) * 980 - ((cols - 1) * 980) / 2, y: Math.floor(i / cols) * 780 - 360,
}));
const anchorOf = (c) => G.catAnchors.find((a) => a.cat === c) || { x: 0, y: 0 };
const byCat = {};
jobs.forEach((n) => { (byCat[n.category || "未分类"] = byCat[n.category || "未分类"] || []).push(n); });
Object.entries(byCat).forEach(([c, list]) => {
const a = anchorOf(c);
list.sort((x, y) => String(x.title || x.id).localeCompare(String(y.title || y.id), "zh"));
const perRow = Math.max(1, Math.ceil(Math.sqrt(list.length)));
list.forEach((n, i) => {
n.x = a.x + ((i % perRow) - (perRow - 1) / 2) * 180;
n.y = a.y + Math.floor(i / perRow) * 150 + 40;
n.vx = 0; n.vy = 0;
});
});
scatterAroundJobs(G.nodes.filter((n) => n.type !== "Job"));
}
/* 非岗位节点:靠向相连岗位的质心,按 id 哈希确定性散布 */
function scatterAroundJobs(nodes) {
nodes.forEach((n) => {
const js = [];
G.edges.forEach((e) => {
if (e.source === n.id) { const t = G.nodes[G.idx[e.target]]; if (t && t.type === "Job") js.push(t); }
if (e.target === n.id) { const s = G.nodes[G.idx[e.source]]; if (s && s.type === "Job") js.push(s); }
});
const cx = js.length ? js.reduce((s, j) => s + j.x, 0) / js.length : 0;
const cy = js.length ? js.reduce((s, j) => s + j.y, 0) / js.length : 0;
const h = hashStr(n.id);
const ang = ((h % 360) * Math.PI) / 180;
const R = 95 + (h % 5) * 36;
n.x = cx + Math.cos(ang) * R;
n.y = cy + Math.sin(ang) * R;
n.vx = 0; n.vy = 0;
});
}
/* 职级演进视图:横向三条级别带(初级→中级→高级 自下而上的成长阶梯),技能落带内、岗位摆带底 */
function applyLevelLayout() {
[0, 1, 2].forEach((b) => {
const yBand = LEVEL_BANDS[b].y;
const jl = G.nodes.filter((n) => n.type === "Job" && levelBandOf(n) === b)
.sort((x, y) => String(x.title || x.id).localeCompare(String(y.title || y.id), "zh"));
// 岗位带内多行排列(一行排开会横向过长,fitView 按比例缩成横带)
const jPerRow = Math.max(3, Math.ceil(Math.sqrt(jl.length) * 1.8));
jl.forEach((n, i) => {
n.x = ((i % jPerRow) - (jPerRow - 1) / 2) * 190;
n.y = yBand + 150 - 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(Math.sqrt(sl.length) * 1.9));
sl.forEach((n, i) => {
n.x = ((i % perRow) - (perRow - 1) / 2) * 108;
n.y = yBand - 120 + Math.floor(i / perRow) * 72;
n.vx = 0; n.vy = 0;
});
});
}
/* 动态时序视图:窗口序列(事件窗口 ∪ 边区间起点),拖动滑块只显示该季度已存在节点与生效边 */
function timeWindows() {
const s = new Set(G.events.map((e) => e.window));
G.edges.forEach((e) => (e.intervals || []).forEach(([f]) => s.add(f)));
return [...s].filter(Boolean).sort();
}
function setupTimeline() {
G.timeWins = timeWindows();
const sl = $("#gt-slider");
sl.max = Math.max(0, G.timeWins.length - 1);
sl.disabled = !G.timeWins.length;
if (G.timeIdx == null || G.timeIdx > G.timeWins.length - 1) G.timeIdx = G.timeWins.length - 1;
sl.value = G.timeIdx;
applyTimeWindow();
}
function applyTimeWindow() {
const w = G.timeWins[G.timeIdx];
G.activeEdges = new Set(G.edges.filter((e) => edgeActiveAt(e, w)));
G.activeNodes = new Set();
G.activeEdges.forEach((e) => { G.activeNodes.add(e.source); G.activeNodes.add(e.target); });
const hasEdge = new Set();
G.edges.forEach((e) => { hasEdge.add(e.source); hasEdge.add(e.target); });
G.nodes.forEach((n) => { if (n.pending || !hasEdge.has(n.id)) G.activeNodes.add(n.id); });
$("#gt-label").textContent = w ? `当前窗口:${w}` : "暂无时序数据";
requestDraw(); drawMinimap();
}
function stopPlay() {
if (G.playTimer) { clearInterval(G.playTimer); G.playTimer = null; }
const b = $("#gt-play");
if (b) b.textContent = "播放";
}
/* 视图切换入口:保留筛选/子图状态,仅重排位置(全景来回切换时恢复原力导向位置) */
function setViewMode(mode) {
if (!VIEW_NAMES[mode] || !G.nodes.length) return;
if (mode === G.viewMode && mode === "overview") return;
if (G.viewMode === "overview" && mode !== "overview") {
G._overviewPos = G.nodes.map((n) => ({ x: n.x, y: n.y }));
}
G.viewMode = mode;
$$("#graph-view-tabs button").forEach((b) => b.classList.toggle("active", b.dataset.gview === mode));
$("#graph-timeline").classList.toggle("hidden", mode !== "timeline");
stopPlay();
if (mode === "overview") {
if (G._overviewPos) G._overviewPos.forEach((p, i) => { G.nodes[i].x = p.x; G.nodes[i].y = p.y; G.nodes[i].vx = 0; G.nodes[i].vy = 0; });
else layout();
G.activeNodes = null; G.activeEdges = null;
} else if (mode === "tech") applyTechLayout();
else if (mode === "level") applyLevelLayout();
else if (mode === "timeline") {
// 时序视图沿用全景力导向布局(否则继承上一个视图的特化布局会被压扁)
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 = `<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.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(47,84,235,0.025)" : "rgba(19,194,194,0.03)";
ctx.fillRect(0, sy - 175 * G.cam.k, c.clientWidth, 350 * G.cam.k);
ctx.fillStyle = "rgba(78,89,105,0.85)";
ctx.font = "bold 13px sans-serif"; ctx.textAlign = "left";
ctx.fillText(b.name, 14, sy - 150 * G.cam.k);
if (i > 0) { // 带间分隔虚线
ctx.strokeStyle = "rgba(78,89,105,0.25)"; ctx.setLineDash([6, 5]);
ctx.beginPath(); ctx.moveTo(0, sy - 170 * G.cam.k); ctx.lineTo(c.clientWidth, sy - 170 * G.cam.k); ctx.stroke();
ctx.setLineDash([]);
}
});
/* 成长阶梯方向提示(文字,非图标) */
ctx.fillStyle = "rgba(78,89,105,0.6)"; ctx.font = "11px sans-serif"; ctx.textAlign = "right";
ctx.fillText("成长阶梯:初级 → 中级 → 高级", c.clientWidth - 14, 20);
ctx.restore();
} else if (G.viewMode === "tech" && G.catAnchors) {
ctx.save();
G.catAnchors.forEach((a) => {
const p = { x: a.x * G.cam.k + G.cam.x, y: a.y * G.cam.k + G.cam.y };
const n = G.nodes.filter((x) => x.type === "Job" && (x.category || "未分类") === a.cat).length;
const label = `${a.cat}(${n})`;
ctx.font = "bold 13px sans-serif";
const tw = ctx.measureText(label).width;
ctx.fillStyle = "rgba(47,84,235,0.08)";
ctx.strokeStyle = "rgba(47,84,235,0.35)";
ctx.beginPath(); ctx.roundRect(p.x - tw / 2 - 10, p.y - 14, tw + 20, 24, 12); ctx.fill(); ctx.stroke();
ctx.fillStyle = "#2f54eb"; ctx.textAlign = "center";
ctx.fillText(label, p.x, p.y + 3);
});
ctx.restore();
}
/* ---- 边 ---- */
G.edges.forEach((e) => {
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(47,84,235,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(47,84,235,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.15 : 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(47,84,235,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.15 : 1;
G._newPulse = true; // 有 NEW 节点在画面上:持续请求重绘形成呼吸
}
/* 标签按需显示(文字重叠治理):岗位常显;技能/场景/行业仅在选中/悬停/选中节点一跳邻居时显示;
* 关系标签开关(showLabels)打开且放大到一定程度时恢复全量标签 */
if (n.type === "Job" || isSel || isHop || n.id === G.hover || (G.showLabels && G.cam.k > 1.05)) {
ctx.font = `${n.type === "Job" ? 11 : 10}px sans-serif`;
ctx.textAlign = "center";
ctx.fillStyle = "rgba(255,255,255,0.85)";
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 = "#2f54eb"; 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("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(); });
}
/* ================= 工具条 / 筛选 ================= */
function bindToolbar() {
$("#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: "#2f54eb", 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.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 EV_TAG = { "新增": "tag-cyan", "增强": "tag-purple", "减弱": "tag-orange", "退出": "tag-red" };
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 = "#2f54eb";
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();
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);
},
};
})();