diff --git a/tests/test_frontend_logic.js b/tests/test_frontend_logic.js index b2e2170..7b15630 100644 --- a/tests/test_frontend_logic.js +++ b/tests/test_frontend_logic.js @@ -235,5 +235,14 @@ eq(dg.added.length, 1, "摘要:当季新增事件"); eq(dg.exited.length, 1, "摘要:当季退出事件"); 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`); process.exit(fail ? 1 : 0); diff --git a/webui/graph.js b/webui/graph.js index 541e855..2d5afe9 100644 --- a/webui/graph.js +++ b/webui/graph.js @@ -15,6 +15,9 @@ 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} 与集合缓存 @@ -209,8 +212,8 @@ if (G.levelJob) selectNode(G.levelJob, false); // 右侧面板同步展示岗位定义,信息闭环 } - /* 视野适配职级能力树(只按可见节点的包围盒) */ - function fitLevelView() { + /* 视野适配当前可见节点(职级能力树 / 技能聚焦等局部视图通用) */ + 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) => { @@ -222,11 +225,72 @@ if (!cnt) return fitView(); const w = maxX - minX || 1, h = maxY - minY || 1; const W = c.clientWidth, H = c.clientHeight; - G.cam.k = Math.max(0.2, Math.min(W / (w + 280), H / (h + 240), 1.4)); + G.cam.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() { @@ -241,6 +305,14 @@ 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) => + ``).join(""); + $$("#gt-bars .gtb-bar").forEach((el) => { + el.onclick = () => { G.timeIdx = +el.dataset.i; sl.value = G.timeIdx; applyTimeWindow(); }; + }); applyTimeWindow(); } function applyTimeWindow() { @@ -252,6 +324,7 @@ 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(); } @@ -269,6 +342,7 @@ 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"); @@ -338,6 +412,7 @@ }); buildFilterOptions(); + computeDegrees(); layout(); relayoutByView(); // 保持在非全景视图时重载后仍为该视图布局 restoreView() || fitView(); @@ -462,6 +537,25 @@ 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; @@ -471,6 +565,9 @@ 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; @@ -501,8 +598,20 @@ 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) { - 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() { @@ -549,6 +658,33 @@ 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; @@ -782,6 +918,42 @@ 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 = `