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 = `
${esc((a && a.title) || e.source)} → ${esc((b && b.title) || e.target)}
+
${relLabel}${e.weight != null ? ` · 需求指数 ${Number(e.weight).toFixed(2)}` : ""} · 证据 ${(e.sources || []).length} 条 JD${firstWin ? ` · 自 ${esc(firstWin)}` : ""}
`; + 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) => { @@ -800,9 +972,12 @@ const h = pickNode(sx, sy); const id = h ? h.id : null; 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; } + updateEdgeTip(null); G.drag.moved = true; if (G.drag.mode === "pan") { G.cam.x = G.drag.camX + (sx - G.drag.sx); @@ -822,9 +997,29 @@ c.addEventListener("click", (e) => { if (G.drag && G.drag.moved) return; const rect = c.getBoundingClientRect(); - const node = pickNode(e.clientX - rect.left, e.clientY - rect.top); - selectNode(node ? node.id : null); + 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(); @@ -894,15 +1089,7 @@ $("#match-job-select").dispatchEvent(new Event("change")); toast("已带入目标岗位,请上传或载入示例简历", "success"); } else if (key === "solo") { - G.soloJob = G.soloJob === n.id ? null : n.id; - G._defaultApplied = true; - updateSoloBadge(); - updateRecentChange(); - if (G.soloJob) { - applySoloLayout(); - fitSoloView(); - toast(`已进入「${n.title || n.id}」岗位子图(同心分层:中心岗位 → 必备 → 加分 → 场景/行业)`, "success"); - } else fitView(); + toggleSolo(n); } } @@ -1222,6 +1409,13 @@ 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")) { @@ -1481,20 +1675,16 @@ function updateSoloBadge() { const badge = $("#solo-badge"); if (!badge) return; - badge.classList.toggle("hidden", !G.soloJob); - if (G.soloJob) { - const n = G.nodes[G.idx[G.soloJob]]; - $("#solo-badge-text").textContent = `岗位子图:${(n && (n.title || n.id)) || G.soloJob}`; + const 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 = () => { - G.soloJob = null; - updateSoloBadge(); - updateRecentChange(); - fitView(); - toast("已退出子图,当前为岗位生态总览(全量图)", "info"); - }; + if (soloExit) soloExit.onclick = () => exitFocus(); /* ================= 备注(localStorage) ================= */ const getNotes = () => JSON.parse(localStorage.getItem(NOTES_KEY) || "{}"); @@ -1549,7 +1739,11 @@ 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 || "通用技能"]); + 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]); @@ -1723,17 +1917,7 @@ $("#evo-job-select").dispatchEvent(new Event("change")); }; const soloBtn = $("#dt-solo"); - if (soloBtn) soloBtn.onclick = () => { - G.soloJob = G.soloJob === id ? null : id; - G._defaultApplied = true; - updateSoloBadge(); - updateRecentChange(); - if (G.soloJob) { - applySoloLayout(); - fitSoloView(); - toast(`已进入「${n.title || id}」岗位子图(同心分层:中心岗位 → 必备 → 加分 → 场景/行业)`, "success"); - } else fitView(); - }; + if (soloBtn) soloBtn.onclick = () => toggleSolo(n); const matchBtn = $("#dt-match"); if (matchBtn) matchBtn.onclick = () => { CN.switchView("match"); @@ -1874,6 +2058,7 @@ 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); diff --git a/webui/index.html b/webui/index.html index e7f8698..2c3d97f 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4,7 +4,7 @@ CareerNexus 职联 · 岗位与能力图谱平台 - + @@ -220,11 +220,14 @@ - + @@ -259,8 +262,13 @@ + + + + + - + diff --git a/webui/style.css b/webui/style.css index e3c750d..98cf09f 100644 --- a/webui/style.css +++ b/webui/style.css @@ -311,15 +311,36 @@ svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: .graph-actions { display: flex; gap: 8px; } .graph-view-tabs { margin: 0 auto; } -/* 动态时序视图:画布顶部居中的季度时间轴 */ +/* 动态时序视图:画布顶部居中的季度时间轴(含趋势柱图) */ .graph-timeline { position: absolute; top: 12px; left: 50%; transform: translateX(-50%); - display: flex; align-items: center; gap: 10px; z-index: 6; - background: rgba(255,255,255,0.94); border: 1px solid var(--border); border-radius: 999px; - padding: 6px 14px; box-shadow: var(--shadow-pop); + 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: 16px; + 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); } .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; }