图谱可视化对标业内实践:枢纽技能按连接度大小编码、时序Timebar生长曲线柱图(点击跳转)、技术栈类别hull气泡轮廓、画布全屏、边悬停tooltip与点击直达JD证据、双击渐进披露(岗位子图/技能反查聚焦),修复gt-bar类名冲突,前端单测87例全过
This commit is contained in:
@@ -235,5 +235,14 @@ eq(dg.added.length, 1, "摘要:当季新增事件");
|
|||||||
eq(dg.exited.length, 1, "摘要:当季退出事件");
|
eq(dg.exited.length, 1, "摘要:当季退出事件");
|
||||||
eq(dg.boosted.length, 0, "摘要:非当季事件不计入");
|
eq(dg.boosted.length, 0, "摘要:非当季事件不计入");
|
||||||
|
|
||||||
|
/* graph.js 纯逻辑:凸包(技术栈类别气泡轮廓) */
|
||||||
|
const gf3 = loadGraphFns(["convexHull"]);
|
||||||
|
const sq = [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }, { x: 5, y: 5 }];
|
||||||
|
eq(gf3.convexHull(sq).length, 4, "凸包:内部点被剔除");
|
||||||
|
eq(gf3.convexHull([{ x: 0, y: 0 }, { x: 5, y: 5 }]).length, 2, "凸包:两点原样返回");
|
||||||
|
eq(gf3.convexHull([{ x: 0, y: 0 }, { x: 5, y: 0 }, { x: 10, y: 0 }]).length, 2, "凸包:共线点只留端点");
|
||||||
|
const hullPt = gf3.convexHull(sq);
|
||||||
|
eq(hullPt.some((p) => p.x === 5 && p.y === 5), false, "凸包:中心点不在顶点中");
|
||||||
|
|
||||||
console.log(`\n${pass} passed, ${fail} failed`);
|
console.log(`\n${pass} passed, ${fail} failed`);
|
||||||
process.exit(fail ? 1 : 0);
|
process.exit(fail ? 1 : 0);
|
||||||
|
|||||||
+224
-39
@@ -15,6 +15,9 @@
|
|||||||
edges: { required: true, preferred: true, applies: true, belongs: true } },
|
edges: { required: true, preferred: true, applies: true, belongs: true } },
|
||||||
selected: null, // 选中节点 id
|
selected: null, // 选中节点 id
|
||||||
soloJob: null, // 岗位子图模式:只看该岗位及其一跳邻居
|
soloJob: null, // 岗位子图模式:只看该岗位及其一跳邻居
|
||||||
|
focusSkill: null, // 技能聚焦模式:只看该技能及引用它的岗位(双击技能进入)
|
||||||
|
hoverEdge: null, // 鼠标悬停的边(tooltip + 高亮)
|
||||||
|
deg: {}, // 节点连接度缓存(枢纽节点大小编码)
|
||||||
viewMode: "overview", // 视图:overview 全景 / tech 技术栈 / level 职级演进 / timeline 动态时序
|
viewMode: "overview", // 视图:overview 全景 / tech 技术栈 / level 职级演进 / timeline 动态时序
|
||||||
levelJob: null, // 职级演进视图:当前展示能力树的岗位
|
levelJob: null, // 职级演进视图:当前展示能力树的岗位
|
||||||
compare: null, compareSets: null, // 岗位对比模式:{a,b} 与集合缓存
|
compare: null, compareSets: null, // 岗位对比模式:{a,b} 与集合缓存
|
||||||
@@ -209,8 +212,8 @@
|
|||||||
if (G.levelJob) selectNode(G.levelJob, false); // 右侧面板同步展示岗位定义,信息闭环
|
if (G.levelJob) selectNode(G.levelJob, false); // 右侧面板同步展示岗位定义,信息闭环
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 视野适配职级能力树(只按可见节点的包围盒) */
|
/* 视野适配当前可见节点(职级能力树 / 技能聚焦等局部视图通用) */
|
||||||
function fitLevelView() {
|
function fitVisibleView(padX = 280, padY = 240, kMax = 1.4) {
|
||||||
const c = canvas();
|
const c = canvas();
|
||||||
let minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9, cnt = 0;
|
let minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9, cnt = 0;
|
||||||
G.nodes.forEach((n) => {
|
G.nodes.forEach((n) => {
|
||||||
@@ -222,11 +225,72 @@
|
|||||||
if (!cnt) return fitView();
|
if (!cnt) return fitView();
|
||||||
const w = maxX - minX || 1, h = maxY - minY || 1;
|
const w = maxX - minX || 1, h = maxY - minY || 1;
|
||||||
const W = c.clientWidth, H = c.clientHeight;
|
const W = c.clientWidth, H = c.clientHeight;
|
||||||
G.cam.k = Math.max(0.2, Math.min(W / (w + 280), H / (h + 240), 1.4));
|
G.cam.k = Math.max(0.2, Math.min(W / (w + padX), H / (h + padY), kMax));
|
||||||
G.cam.x = (W - w * G.cam.k) / 2 - minX * G.cam.k;
|
G.cam.x = (W - w * G.cam.k) / 2 - minX * G.cam.k;
|
||||||
G.cam.y = (H - h * G.cam.k) / 2 - minY * G.cam.k;
|
G.cam.y = (H - h * G.cam.k) / 2 - minY * G.cam.k;
|
||||||
requestDraw(); drawMinimap();
|
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() {
|
function timeWindows() {
|
||||||
@@ -241,6 +305,14 @@
|
|||||||
sl.disabled = !G.timeWins.length;
|
sl.disabled = !G.timeWins.length;
|
||||||
if (G.timeIdx == null || G.timeIdx > G.timeWins.length - 1) G.timeIdx = G.timeWins.length - 1;
|
if (G.timeIdx == null || G.timeIdx > G.timeWins.length - 1) G.timeIdx = G.timeWins.length - 1;
|
||||||
sl.value = G.timeIdx;
|
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();
|
applyTimeWindow();
|
||||||
}
|
}
|
||||||
function applyTimeWindow() {
|
function applyTimeWindow() {
|
||||||
@@ -252,6 +324,7 @@
|
|||||||
G.edges.forEach((e) => { hasEdge.add(e.source); hasEdge.add(e.target); });
|
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); });
|
G.nodes.forEach((n) => { if (n.pending || !hasEdge.has(n.id)) G.activeNodes.add(n.id); });
|
||||||
$("#gt-label").textContent = w ? `当前窗口:${w}` : "暂无时序数据";
|
$("#gt-label").textContent = w ? `当前窗口:${w}` : "暂无时序数据";
|
||||||
|
$$("#gt-bars .gtb-bar").forEach((el, i) => el.classList.toggle("active", i === G.timeIdx));
|
||||||
renderTimePanel();
|
renderTimePanel();
|
||||||
requestDraw(); drawMinimap();
|
requestDraw(); drawMinimap();
|
||||||
}
|
}
|
||||||
@@ -269,6 +342,7 @@
|
|||||||
G._overviewPos = G.nodes.map((n) => ({ x: n.x, y: n.y }));
|
G._overviewPos = G.nodes.map((n) => ({ x: n.x, y: n.y }));
|
||||||
}
|
}
|
||||||
if (mode !== "overview" && G.compare) exitCompare(true); // 对比模式只在全景视图下生效
|
if (mode !== "overview" && G.compare) exitCompare(true); // 对比模式只在全景视图下生效
|
||||||
|
if (mode !== "overview" && G.focusSkill) { G.focusSkill = null; updateSoloBadge(); } // 技能聚焦只在全景下生效
|
||||||
G.viewMode = mode;
|
G.viewMode = mode;
|
||||||
$$("#graph-view-tabs button").forEach((b) => b.classList.toggle("active", b.dataset.gview === mode));
|
$$("#graph-view-tabs button").forEach((b) => b.classList.toggle("active", b.dataset.gview === mode));
|
||||||
$("#graph-timeline").classList.toggle("hidden", mode !== "timeline");
|
$("#graph-timeline").classList.toggle("hidden", mode !== "timeline");
|
||||||
@@ -338,6 +412,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
buildFilterOptions();
|
buildFilterOptions();
|
||||||
|
computeDegrees();
|
||||||
layout();
|
layout();
|
||||||
relayoutByView(); // 保持在非全景视图时重载后仍为该视图布局
|
relayoutByView(); // 保持在非全景视图时重载后仍为该视图布局
|
||||||
restoreView() || fitView();
|
restoreView() || fitView();
|
||||||
@@ -462,6 +537,25 @@
|
|||||||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
||||||
return h;
|
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) {
|
function visible(n) {
|
||||||
if (!G.filters.types[n.type]) return false;
|
if (!G.filters.types[n.type]) return false;
|
||||||
if (G.viewMode === "timeline" && G.activeNodes && !G.activeNodes.has(n.id)) return false;
|
if (G.viewMode === "timeline" && G.activeNodes && !G.activeNodes.has(n.id)) return false;
|
||||||
@@ -471,6 +565,9 @@
|
|||||||
if (G.soloJob) { // 岗位子图:只保留该岗位与一跳邻居
|
if (G.soloJob) { // 岗位子图:只保留该岗位与一跳邻居
|
||||||
if (n.id !== G.soloJob && !neighborsOf(G.soloJob).has(n.id)) return false;
|
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 (n.type === "Job") {
|
||||||
if (G.filters.category && n.category !== G.filters.category) return false;
|
if (G.filters.category && n.category !== G.filters.category) return false;
|
||||||
if (G.filters.level && jobLevel(n) !== G.filters.level) return false;
|
if (G.filters.level && jobLevel(n) !== G.filters.level) return false;
|
||||||
@@ -501,8 +598,20 @@
|
|||||||
G.raf = requestAnimationFrame(() => { G.raf = null; draw(); });
|
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) {
|
function nodeRadius(n) {
|
||||||
return n.type === "Job" ? 13 : n.type === "Scenario" ? 8 : n.type === "Industry" ? 10 : 6.5;
|
const base = n.type === "Job" ? 13 : n.type === "Scenario" ? 8 : n.type === "Industry" ? 10 : 6.5;
|
||||||
|
if (n.type !== "Skill") return base;
|
||||||
|
const d = G.deg[n.id] || 0;
|
||||||
|
return base + (d >= 2 ? Math.min(5.5, Math.log2(d) * 1.6) : 0); // 度数 ≥2 起对数放大,封顶接近岗位尺寸
|
||||||
}
|
}
|
||||||
|
|
||||||
function draw() {
|
function draw() {
|
||||||
@@ -549,6 +658,33 @@
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
} else if (G.viewMode === "tech" && G.catAnchors) {
|
} else if (G.viewMode === "tech" && G.catAnchors) {
|
||||||
ctx.save();
|
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) => {
|
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 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 n = G.nodes.filter((x) => x.type === "Job" && (x.category || "未分类") === a.cat).length;
|
||||||
@@ -782,6 +918,42 @@
|
|||||||
return best;
|
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() {
|
function bindCanvas() {
|
||||||
const c = canvas();
|
const c = canvas();
|
||||||
c.addEventListener("mousedown", (e) => {
|
c.addEventListener("mousedown", (e) => {
|
||||||
@@ -800,9 +972,12 @@
|
|||||||
const h = pickNode(sx, sy);
|
const h = pickNode(sx, sy);
|
||||||
const id = h ? h.id : null;
|
const id = h ? h.id : null;
|
||||||
if (id !== G.hover) { G.hover = id; requestDraw(); }
|
if (id !== G.hover) { G.hover = id; requestDraw(); }
|
||||||
c.classList.toggle("hovering", !!h);
|
c.classList.toggle("hovering", !!h || !!pickEdge(sx, sy));
|
||||||
|
const inCanvas = sx >= 0 && sy >= 0 && sx <= c.clientWidth && sy <= c.clientHeight;
|
||||||
|
updateEdgeTip(!h && inCanvas ? pickEdge(sx, sy) : null, sx, sy);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
updateEdgeTip(null);
|
||||||
G.drag.moved = true;
|
G.drag.moved = true;
|
||||||
if (G.drag.mode === "pan") {
|
if (G.drag.mode === "pan") {
|
||||||
G.cam.x = G.drag.camX + (sx - G.drag.sx);
|
G.cam.x = G.drag.camX + (sx - G.drag.sx);
|
||||||
@@ -822,9 +997,29 @@
|
|||||||
c.addEventListener("click", (e) => {
|
c.addEventListener("click", (e) => {
|
||||||
if (G.drag && G.drag.moved) return;
|
if (G.drag && G.drag.moved) return;
|
||||||
const rect = c.getBoundingClientRect();
|
const rect = c.getBoundingClientRect();
|
||||||
const node = pickNode(e.clientX - rect.left, e.clientY - rect.top);
|
const sx = e.clientX - rect.left, sy = e.clientY - rect.top;
|
||||||
selectNode(node ? node.id : null);
|
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) => {
|
c.addEventListener("contextmenu", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -894,15 +1089,7 @@
|
|||||||
$("#match-job-select").dispatchEvent(new Event("change"));
|
$("#match-job-select").dispatchEvent(new Event("change"));
|
||||||
toast("已带入目标岗位,请上传或载入示例简历", "success");
|
toast("已带入目标岗位,请上传或载入示例简历", "success");
|
||||||
} else if (key === "solo") {
|
} else if (key === "solo") {
|
||||||
G.soloJob = G.soloJob === n.id ? null : n.id;
|
toggleSolo(n);
|
||||||
G._defaultApplied = true;
|
|
||||||
updateSoloBadge();
|
|
||||||
updateRecentChange();
|
|
||||||
if (G.soloJob) {
|
|
||||||
applySoloLayout();
|
|
||||||
fitSoloView();
|
|
||||||
toast(`已进入「${n.title || n.id}」岗位子图(同心分层:中心岗位 → 必备 → 加分 → 场景/行业)`, "success");
|
|
||||||
} else fitView();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1222,6 +1409,13 @@
|
|||||||
e.currentTarget.classList.toggle("active", G.showLabels);
|
e.currentTarget.classList.toggle("active", G.showLabels);
|
||||||
requestDraw();
|
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");
|
$("#tool-filter").onclick = () => $("#graph-filter-panel").classList.toggle("hidden");
|
||||||
document.addEventListener("click", (e) => {
|
document.addEventListener("click", (e) => {
|
||||||
if (!e.target.closest("#graph-filter-panel") && !e.target.closest("#tool-filter")) {
|
if (!e.target.closest("#graph-filter-panel") && !e.target.closest("#tool-filter")) {
|
||||||
@@ -1481,20 +1675,16 @@
|
|||||||
function updateSoloBadge() {
|
function updateSoloBadge() {
|
||||||
const badge = $("#solo-badge");
|
const badge = $("#solo-badge");
|
||||||
if (!badge) return;
|
if (!badge) return;
|
||||||
badge.classList.toggle("hidden", !G.soloJob);
|
const fid = G.soloJob || G.focusSkill;
|
||||||
if (G.soloJob) {
|
badge.classList.toggle("hidden", !fid);
|
||||||
const n = G.nodes[G.idx[G.soloJob]];
|
if (fid) {
|
||||||
$("#solo-badge-text").textContent = `岗位子图:${(n && (n.title || n.id)) || G.soloJob}`;
|
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");
|
const soloExit = $("#solo-exit");
|
||||||
if (soloExit) soloExit.onclick = () => {
|
if (soloExit) soloExit.onclick = () => exitFocus();
|
||||||
G.soloJob = null;
|
|
||||||
updateSoloBadge();
|
|
||||||
updateRecentChange();
|
|
||||||
fitView();
|
|
||||||
toast("已退出子图,当前为岗位生态总览(全量图)", "info");
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ================= 备注(localStorage) ================= */
|
/* ================= 备注(localStorage) ================= */
|
||||||
const getNotes = () => JSON.parse(localStorage.getItem(NOTES_KEY) || "{}");
|
const getNotes = () => JSON.parse(localStorage.getItem(NOTES_KEY) || "{}");
|
||||||
@@ -1549,7 +1739,11 @@
|
|||||||
kv.push(["类别", n.category || "—"], ["期望年限", n.expected_years != null ? `${n.expected_years} 年` : "—"]);
|
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.source_count != null) kv.push(["支撑 JD", `${n.source_count} 条 / ${n.company_count} 家企业`]);
|
||||||
}
|
}
|
||||||
if (n.type === "Skill") kv.push(["类别", n.category || "通用技能"]);
|
if (n.type === "Skill") {
|
||||||
|
kv.push(["类别", n.category || "通用技能"]);
|
||||||
|
const refs = G.edges.filter((e) => e.target === id && e.rel === "REQUIRES").length;
|
||||||
|
if (refs) kv.push(["枢纽度", `被 ${refs} 个岗位引用`]);
|
||||||
|
}
|
||||||
kv.push(["级别", n.level || (n.type === "Job" ? jobLevel(n) || "—" : "—")]);
|
kv.push(["级别", n.level || (n.type === "Job" ? jobLevel(n) || "—" : "—")]);
|
||||||
if (n.weight != null) kv.push(["需求指数", Number(n.weight).toFixed(2)]);
|
if (n.weight != null) kv.push(["需求指数", Number(n.weight).toFixed(2)]);
|
||||||
if (n.importance) kv.push(["重要性", n.importance === "required" ? "必备" : n.importance]);
|
if (n.importance) kv.push(["重要性", n.importance === "required" ? "必备" : n.importance]);
|
||||||
@@ -1723,17 +1917,7 @@
|
|||||||
$("#evo-job-select").dispatchEvent(new Event("change"));
|
$("#evo-job-select").dispatchEvent(new Event("change"));
|
||||||
};
|
};
|
||||||
const soloBtn = $("#dt-solo");
|
const soloBtn = $("#dt-solo");
|
||||||
if (soloBtn) soloBtn.onclick = () => {
|
if (soloBtn) soloBtn.onclick = () => toggleSolo(n);
|
||||||
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");
|
const matchBtn = $("#dt-match");
|
||||||
if (matchBtn) matchBtn.onclick = () => {
|
if (matchBtn) matchBtn.onclick = () => {
|
||||||
CN.switchView("match");
|
CN.switchView("match");
|
||||||
@@ -1874,6 +2058,7 @@
|
|||||||
async reload() {
|
async reload() {
|
||||||
await load();
|
await load();
|
||||||
if (G.soloJob && G.idx[G.soloJob]) { applySoloLayout(); fitSoloView(); } // 子图模式下重排同心分层
|
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.compare && G.compare.b) updateCompare(); // 对比模式随数据重算
|
||||||
if (G.selected && !(G.selected in G.idx)) selectNode(null, false);
|
if (G.selected && !(G.selected in G.idx)) selectNode(null, false);
|
||||||
else if (G.selected) renderDetail(G.selected);
|
else if (G.selected) renderDetail(G.selected);
|
||||||
|
|||||||
+14
-6
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>CareerNexus 职联 · 岗位与能力图谱平台</title>
|
<title>CareerNexus 职联 · 岗位与能力图谱平台</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=20260828">
|
<link rel="stylesheet" href="/static/style.css?v=20260828-2">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- ============ 顶部栏 56px ============ -->
|
<!-- ============ 顶部栏 56px ============ -->
|
||||||
@@ -220,11 +220,14 @@
|
|||||||
<button id="solo-exit" title="退出岗位子图">×</button>
|
<button id="solo-exit" title="退出岗位子图">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 动态时序视图:季度时间轴(仅该视图显示) -->
|
<!-- 动态时序视图:季度时间轴(仅该视图显示),下方带每季新增关系迷你柱图 -->
|
||||||
<div class="graph-timeline hidden" id="graph-timeline">
|
<div class="graph-timeline hidden" id="graph-timeline">
|
||||||
<button class="btn btn-secondary btn-sm" id="gt-play">播放</button>
|
<div class="gt-row">
|
||||||
<input type="range" id="gt-slider" min="0" max="0" step="1" value="0">
|
<button class="btn btn-secondary btn-sm" id="gt-play">播放</button>
|
||||||
<span class="gt-window" id="gt-label">—</span>
|
<input type="range" id="gt-slider" min="0" max="0" step="1" value="0">
|
||||||
|
<span class="gt-window" id="gt-label">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="gtb-bars" id="gt-bars" title="图谱生长曲线:截至各季度的生效关系数,点击跳转"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 动态时序视图:当季解说面板(仅该视图显示,随滑块刷新) -->
|
<!-- 动态时序视图:当季解说面板(仅该视图显示,随滑块刷新) -->
|
||||||
@@ -259,8 +262,13 @@
|
|||||||
<button class="tool-btn" id="tool-filter" title="筛选"><svg viewBox="0 0 24 24"><path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z"/></svg></button>
|
<button class="tool-btn" id="tool-filter" title="筛选"><svg viewBox="0 0 24 24"><path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z"/></svg></button>
|
||||||
<button class="tool-btn" id="tool-labels" title="关系标签开关"><svg viewBox="0 0 24 24"><path d="M20.59 13.41L11 3H4v7l9.59 9.59a2 2 0 002.82 0l4.18-4.18a2 2 0 000-2.82z"/><circle cx="7.5" cy="7.5" r="1" fill="currentColor"/></svg></button>
|
<button class="tool-btn" id="tool-labels" title="关系标签开关"><svg viewBox="0 0 24 24"><path d="M20.59 13.41L11 3H4v7l9.59 9.59a2 2 0 002.82 0l4.18-4.18a2 2 0 000-2.82z"/><circle cx="7.5" cy="7.5" r="1" fill="currentColor"/></svg></button>
|
||||||
<button class="tool-btn" id="tool-relayout" title="一键整理布局"><svg viewBox="0 0 24 24"><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.2 2.2M16.2 16.2l2.2 2.2M5.6 18.4l2.2-2.2M16.2 7.8l2.2-2.2"/></svg></button>
|
<button class="tool-btn" id="tool-relayout" title="一键整理布局"><svg viewBox="0 0 24 24"><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.2 2.2M16.2 16.2l2.2 2.2M5.6 18.4l2.2-2.2M16.2 7.8l2.2-2.2"/></svg></button>
|
||||||
|
<span class="tool-sep"></span>
|
||||||
|
<button class="tool-btn" id="tool-fullscreen" title="全屏演示"><svg viewBox="0 0 24 24"><path d="M4 9V5a1 1 0 011-1h4M15 4h4a1 1 0 011 1v4M20 15v4a1 1 0 01-1 1h-4M9 20H5a1 1 0 01-1-1v-4"/></svg></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 边悬停提示(跟随鼠标的 DOM tooltip) -->
|
||||||
|
<div class="edge-tip hidden" id="edge-tip"></div>
|
||||||
|
|
||||||
<!-- 筛选面板 -->
|
<!-- 筛选面板 -->
|
||||||
<div class="graph-filter-panel hidden" id="graph-filter-panel">
|
<div class="graph-filter-panel hidden" id="graph-filter-panel">
|
||||||
<div class="fp-head">图谱筛选 <button class="link-btn" id="fp-reset">重置</button></div>
|
<div class="fp-head">图谱筛选 <button class="link-btn" id="fp-reset">重置</button></div>
|
||||||
@@ -697,6 +705,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/app.js?v=20260827"></script>
|
<script src="/static/app.js?v=20260827"></script>
|
||||||
<script src="/static/graph.js?v=20260828"></script>
|
<script src="/static/graph.js?v=20260828-2"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+25
-4
@@ -311,15 +311,36 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width:
|
|||||||
.graph-actions { display: flex; gap: 8px; }
|
.graph-actions { display: flex; gap: 8px; }
|
||||||
.graph-view-tabs { margin: 0 auto; }
|
.graph-view-tabs { margin: 0 auto; }
|
||||||
|
|
||||||
/* 动态时序视图:画布顶部居中的季度时间轴 */
|
/* 动态时序视图:画布顶部居中的季度时间轴(含趋势柱图) */
|
||||||
.graph-timeline {
|
.graph-timeline {
|
||||||
position: absolute; top: 12px; left: 50%; transform: translateX(-50%);
|
position: absolute; top: 12px; left: 50%; transform: translateX(-50%);
|
||||||
display: flex; align-items: center; gap: 10px; z-index: 6;
|
display: flex; flex-direction: column; align-items: stretch; gap: 5px; z-index: 6;
|
||||||
background: rgba(255,255,255,0.94); border: 1px solid var(--border); border-radius: 999px;
|
background: rgba(255,255,255,0.94); border: 1px solid var(--border); border-radius: 16px;
|
||||||
padding: 6px 14px; box-shadow: var(--shadow-pop);
|
padding: 8px 14px; box-shadow: var(--shadow-pop);
|
||||||
}
|
}
|
||||||
|
.gt-row { display: flex; align-items: center; gap: 10px; }
|
||||||
.graph-timeline input[type="range"] { width: 240px; accent-color: var(--primary); }
|
.graph-timeline input[type="range"] { width: 240px; accent-color: var(--primary); }
|
||||||
.gt-window { font-size: 12px; color: var(--text-1); font-weight: 600; white-space: nowrap; }
|
.gt-window { font-size: 12px; color: var(--text-1); font-weight: 600; white-space: nowrap; }
|
||||||
|
/* Timebar:每季度新增关系数迷你柱图,与滑块等宽对齐,点击跳转(类名加 b 前缀避免与学习路径甘特条 .gt-bar 冲突) */
|
||||||
|
.gtb-bars { display: flex; align-items: flex-end; gap: 2px; height: 20px; margin-left: 46px; width: 240px; }
|
||||||
|
.gtb-bar {
|
||||||
|
flex: 1; min-width: 3px; border-radius: 1.5px 1.5px 0 0; cursor: pointer;
|
||||||
|
background: rgba(24,144,255,0.22); transition: background .12s;
|
||||||
|
}
|
||||||
|
.gtb-bar:hover { background: rgba(24,144,255,0.5); }
|
||||||
|
.gtb-bar.active { background: var(--primary); }
|
||||||
|
|
||||||
|
/* 边悬停提示 */
|
||||||
|
.edge-tip {
|
||||||
|
position: absolute; z-index: 30; pointer-events: none; max-width: 280px;
|
||||||
|
background: rgba(29,33,41,0.92); color: #fff; border-radius: 6px;
|
||||||
|
font-size: 11px; line-height: 1.55; padding: 6px 10px; box-shadow: var(--shadow-pop);
|
||||||
|
}
|
||||||
|
.edge-tip .et-title { font-weight: 600; margin-bottom: 1px; }
|
||||||
|
.edge-tip .et-sub { color: rgba(255,255,255,0.72); }
|
||||||
|
|
||||||
|
/* 全屏演示模式 */
|
||||||
|
#graph-canvas-wrap:fullscreen { border-radius: 0; background: #fff; }
|
||||||
|
|
||||||
/* 图谱搜索定位(头部) */
|
/* 图谱搜索定位(头部) */
|
||||||
.graph-search { position: relative; display: flex; align-items: center; }
|
.graph-search { position: relative; display: flex; align-items: center; }
|
||||||
|
|||||||
Reference in New Issue
Block a user