Files
CareerNexus/webui/graph.js
T

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