/** * 项目事实图渲染(Cytoscape + ELK),供项目管理页使用。 * 节点采用 SVG 卡片背景(图标 + 多行文字),避免 Cytoscape 原生 label 定位问题。 */ (function (global) { 'use strict'; let _cy = null; let _graphData = null; let _onNodeSelect = null; let _onEdgeSelect = null; let _resizeObs = null; const EDGE_COLORS = { discovered_on: '#4F46E5', leads_to: '#64748B', enables: '#E11D48', exploits: '#DC2626', depends_on: '#0D9488', contains: '#6366F1', part_of: '#6366F1', supports: '#94A3B8', links_vuln: '#BE123C', }; const CARD_PAD = 14; const CARD_TEXT_PAD_RIGHT = 12; const CARD_ICON = 36; const CARD_ICON_GAP = 12; const CARD_TEXT_X = CARD_PAD + CARD_ICON + CARD_ICON_GAP; const CARD_MIN_W = 300; const CARD_TARGET_W = 360; const CARD_MIN_H = 88; const CARD_MAX_H = 176; const CARD_HEADER_FS = 11; const CARD_HEADER_LH = 16; const CARD_KEY_FS = 10; const CARD_KEY_LH = 14; const CARD_SUMMARY_FS = 13; const CARD_SUMMARY_LH = 18; const CARD_SECTION_GAP = 6; const CARD_FONT = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif'; const CARD_KEY_FONT = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace'; function nodeTheme(type) { switch (type) { case 'target': return { typeLabel: '目标', typeEn: 'TARGET', accent: '#4F46E5', bgEnd: '#F5F3FF', icon: 'target' }; case 'finding': return { typeLabel: '发现', typeEn: 'FINDING', accent: '#E11D48', bgEnd: '#FFF1F2', icon: 'finding', cardStyle: 'default' }; case 'exploit': return { typeLabel: '利用', typeEn: 'EXPLOIT', accent: '#B45309', bgEnd: '#FFFBEB', icon: 'vulnerability', cardStyle: 'default' }; case 'vulnerability': return { typeLabel: '漏洞', typeEn: 'VULN', accent: '#9333EA', bgEnd: '#F5F3FF', icon: 'vuln', cardStyle: 'default' }; case 'auth': return { typeLabel: '认证', typeEn: 'AUTH', accent: '#0D9488', bgEnd: '#F0FDFA', icon: 'default' }; case 'infra': return { typeLabel: '基础设施', typeEn: 'INFRA', accent: '#64748B', bgEnd: '#F8FAFC', icon: 'default' }; case 'chain': return { typeLabel: '攻击链', typeEn: 'CHAIN', accent: '#7C3AED', bgEnd: '#F5F3FF', icon: 'vulnerability' }; case 'poc': return { typeLabel: 'POC', typeEn: 'POC', accent: '#C2410C', bgEnd: '#FFEDD5', icon: 'vulnerability' }; case 'business': return { typeLabel: '业务', typeEn: 'BUSINESS', accent: '#0369A1', bgEnd: '#F0F9FF', icon: 'default' }; case 'missing': return { typeLabel: '缺失', typeEn: 'MISSING', accent: '#CBD5E1', bgEnd: '#F1F5F9', icon: 'default' }; default: return { typeLabel: '备注', typeEn: 'NOTE', accent: '#94A3B8', bgEnd: '#F8FAFC', icon: 'default' }; } } function escapeXml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function escapeHtml(str) { return escapeXml(str); } function buildStatusBadge(confidence) { const conf = (confidence || '').toLowerCase(); if (conf === 'tentative') return '待确认'; if (conf === 'deprecated') return '已废弃'; return ''; } function buildHeaderText(theme, statusBadge) { const line = (theme.typeEn || '') + ' · ' + (theme.typeLabel || ''); return statusBadge ? line + ' · ' + statusBadge : line; } function isWideChar(ch) { const code = ch.codePointAt(0) || 0; if (code >= 0x4e00 && code <= 0x9fff) return true; if (code >= 0x3400 && code <= 0x4dbf) return true; if (code >= 0xf900 && code <= 0xfaff) return true; if (code >= 0xff00 && code <= 0xffef) return true; return /[·:,。;!?【】()《》、「」]/.test(ch); } function charWidth(ch, fontSize, bold) { const scale = bold ? 1.05 : 1; if (ch === ' ') return fontSize * 0.3 * scale; if (isWideChar(ch)) return fontSize * scale; return fontSize * 0.58 * scale; } function lineWidth(text, fontSize, bold) { let width = 0; for (const ch of text) width += charWidth(ch, fontSize, bold); return width; } function wrapTextLines(text, maxWidth, fontSize, maxLines, bold) { const raw = String(text || '').replace(/\s+/g, ' ').trim(); if (!raw) return ['—']; const safeWidth = Math.max(40, maxWidth - 4); const chars = [...raw]; const lines = []; let index = 0; while (index < chars.length && lines.length < maxLines) { let line = ''; let width = 0; while (index < chars.length) { const ch = chars[index]; const nextWidth = charWidth(ch, fontSize, bold); if (line && width + nextWidth > safeWidth) break; line += ch; width += nextWidth; index += 1; if (width >= safeWidth) break; } if (line) lines.push(line); } if (index < chars.length && lines.length) { let last = lines[lines.length - 1]; while (last.length > 1 && lineWidth(last + '…', fontSize, bold) > safeWidth) { last = last.slice(0, -1); } lines[lines.length - 1] = last + '…'; } return lines.length ? lines : ['—']; } function cardTextWidth(nodeWidth) { return nodeWidth - CARD_TEXT_X - CARD_PAD - CARD_TEXT_PAD_RIGHT; } function computeNodeLayout(type, summary, statusBadge, theme, factKey) { const width = type === 'target' ? CARD_TARGET_W : CARD_MIN_W; const textW = cardTextWidth(width); const t = theme || nodeTheme(type); const headerLines = wrapTextLines(buildHeaderText(t, statusBadge), textW, CARD_HEADER_FS, 2, true); const keyText = String(factKey || '').trim(); const keyLines = keyText ? wrapTextLines(keyText, textW, CARD_KEY_FS, 2, false) : []; const summaryLines = wrapTextLines(summary, textW, CARD_SUMMARY_FS, keyLines.length ? 3 : 4, true); const keyBlockHeight = keyLines.length ? CARD_SECTION_GAP + keyLines.length * CARD_KEY_LH + CARD_SECTION_GAP : CARD_SECTION_GAP; const height = Math.min( CARD_MAX_H, Math.max( CARD_MIN_H, CARD_PAD + headerLines.length * CARD_HEADER_LH + keyBlockHeight + summaryLines.length * CARD_SUMMARY_LH + CARD_PAD, ), ); return { width, height, headerLines, keyLines, summaryLines, searchLabel: [headerLines.join(' '), keyLines.join(' '), summaryLines.join(' ')] .filter(Boolean) .join('\n'), }; } function svgIconGroup(kind, color, x, y) { const scale = (CARD_ICON / 24).toFixed(3); if (kind === 'target') { return ( `` + `` + `` ); } if (kind === 'finding') { return ( `` + `` + `` ); } if (kind === 'vuln') { return ( `` + `` + `` + `` ); } if (kind === 'vulnerability') { return ( `` + `` + `` + `` ); } return ( `` + `` ); } function buildNodeCardSvgUrl(theme, layout, confidence) { const { width, height, headerLines, keyLines, summaryLines } = layout; const accent = theme.accent; const bgEnd = theme.bgEnd; const conf = (confidence || '').toLowerCase(); const isTentative = conf === 'tentative'; const isDeprecated = conf === 'deprecated'; const iconX = CARD_PAD; const iconY = (height - CARD_ICON) / 2; const headerY = CARD_PAD + CARD_HEADER_FS; const keyY = CARD_PAD + headerLines.length * CARD_HEADER_LH + CARD_SECTION_GAP + CARD_KEY_FS; const summaryY = CARD_PAD + headerLines.length * CARD_HEADER_LH + (keyLines.length ? CARD_SECTION_GAP + keyLines.length * CARD_KEY_LH + CARD_SECTION_GAP : CARD_SECTION_GAP) + CARD_SUMMARY_FS; const stroke = isTentative ? `stroke="${accent}" stroke-width="1.5" stroke-dasharray="8 5" stroke-opacity="0.9"` : `stroke="${accent}" stroke-width="1.5" stroke-opacity="0.72"`; const headerSvg = headerLines .map( (line, i) => `${escapeXml(line)}`, ) .join(''); const keySvg = keyLines .map( (line, i) => `${escapeXml(line)}`, ) .join(''); const summarySvg = summaryLines .map( (line, i) => `${escapeXml(line)}`, ) .join(''); const textClipW = width - CARD_TEXT_X - CARD_PAD - 2; const textClipH = height - CARD_PAD * 2 + 4; const svg = `` + `` + `` + `` + `` + `` + svgIconGroup(theme.icon, accent, iconX, iconY) + `${headerSvg}${keySvg}${summarySvg}` + ``; try { return 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg))); } catch (e) { return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); } } function destroy() { if (_resizeObs) { _resizeObs.disconnect(); _resizeObs = null; } if (_cy) { _cy.destroy(); _cy = null; } _graphData = null; } function observeContainerResize(container) { if (_resizeObs) { _resizeObs.disconnect(); _resizeObs = null; } if (!container || typeof ResizeObserver === 'undefined') return; _resizeObs = new ResizeObserver(() => { if (_cy) { try { _cy.resize(); } catch (e) { console.warn('graph resize', e); } } }); _resizeObs.observe(container); } function centerGraph() { if (!_cy) return; try { _cy.resize(); _cy.fit(undefined, 56); if (_cy.zoom() < 0.65) { _cy.zoom(0.65); _cy.center(); } } catch (e) { console.warn('centerGraph', e); } } // ELK 分层(仅影响节点纵向位置,不修改边的 source/target) function pathGraphNodeLayer(type, factKey) { const key = (factKey || '').toLowerCase(); if (key.startsWith('vuln:')) return '4'; const t = (type || '').toLowerCase(); if (t === 'target') return '0'; if (t === 'infra' || t === 'auth' || t === 'business') return '1'; if (t === 'exploit' || t === 'poc') return '3'; if (t === 'vulnerability' || t === 'vuln') return '3'; if (t === 'chain' || t === 'finding') return '2'; if (t === 'note') return '2'; return '2'; } function applyElkLayout(validEdges, isComplex) { const layoutOptions = { name: 'breadthfirst', directed: true, spacingFactor: isComplex ? 3.0 : 2.5, padding: 40, }; const elkInstance = typeof ELK !== 'undefined' ? new ELK() : null; if (!elkInstance) { const layout = _cy.layout(layoutOptions); layout.one('layoutstop', () => setTimeout(centerGraph, 100)); layout.run(); return; } const nodeGap = isComplex ? 45 : 60; const layerGap = isComplex ? 70 : 95; const elkGraph = { id: 'root', layoutOptions: { 'elk.algorithm': 'layered', 'elk.direction': 'DOWN', 'elk.spacing.nodeNode': String(nodeGap), 'elk.layered.spacing.nodeNodeBetweenLayers': String(layerGap), 'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF', }, children: (_graphData.nodes || []).map((node) => { const n = _cy ? _cy.getElementById(node.id) : null; const w = n.length ? n.data('nodeWidth') : node.type === 'target' ? CARD_TARGET_W : CARD_MIN_W; const h = n.length ? n.data('nodeHeight') : CARD_MIN_H; const nodeKey = node.fact_key || node.id; return { id: node.id, width: w, height: h, layoutOptions: { 'org.eclipse.elk.layered.layering.layerId': pathGraphNodeLayer(node.type, nodeKey), }, }; }), edges: validEdges.map((edge) => ({ id: edge.id, sources: [edge.source], targets: [edge.target], })), }; elkInstance .layout(elkGraph) .then((laidOut) => { (laidOut.children || []).forEach((elkNode) => { const cyNode = _cy.getElementById(elkNode.id); if (cyNode.length && elkNode.x != null) { cyNode.position({ x: elkNode.x + (elkNode.width || 0) / 2, y: elkNode.y + (elkNode.height || 0) / 2, }); } }); setTimeout(centerGraph, 120); }) .catch(() => { const layout = _cy.layout(layoutOptions); layout.one('layoutstop', () => setTimeout(centerGraph, 100)); layout.run(); }); } function render(container, graphData, options) { if (!container || typeof cytoscape === 'undefined') { if (container) { container.innerHTML = '
Cytoscape 未加载
'; } return null; } destroy(); _graphData = graphData || { nodes: [], edges: [] }; _onNodeSelect = options && options.onNodeSelect; _onEdgeSelect = options && options.onEdgeSelect; const nodes = _graphData.nodes || []; const edges = _graphData.edges || []; if (!nodes.length) { const title = (options && options.emptyTitle) || ''; const hint = (options && options.emptyText) || '暂无事实关系'; const steps = (options && options.emptySteps) || []; const actionLabel = options && options.emptyActionLabel; const stepsHtml = steps.length ? '
    ' + steps.map((s) => '
  1. ' + escapeHtml(String(s)) + '
  2. ').join('') + '
' : ''; const actionHtml = actionLabel && options.onEmptyAction ? '' : ''; container.innerHTML = '
' + '' + (title ? '

' + escapeHtml(title) + '

' : '') + '

' + escapeHtml(hint) + '

' + stepsHtml + actionHtml + '
'; const cta = container.querySelector('.project-fact-graph-empty-cta'); if (cta && typeof options.onEmptyAction === 'function') { cta.addEventListener('click', options.onEmptyAction); } return null; } container.innerHTML = ''; const isComplex = nodes.length > 15 || edges.length > 25; const elements = []; const nodeIds = new Set(); nodes.forEach((node) => { nodeIds.add(node.id); const visualType = resolveGraphNodeType(node); const theme = nodeTheme(visualType); const factKey = node.fact_key || node.id; const summary = (node.summary || node.label || '').trim() || '—'; const statusBadge = buildStatusBadge(node.confidence); const layout = computeNodeLayout(visualType, summary, statusBadge, theme, factKey); elements.push({ data: { id: node.id, label: layout.searchLabel, factKey: node.fact_key || node.id, category: node.category || '', type: visualType, typeLabel: theme.typeLabel, typeEn: theme.typeEn, accentColor: theme.accent, statusBadge: statusBadge, confidence: node.confidence || '', nodeWidth: layout.width, nodeHeight: layout.height, cardSvgUrl: buildNodeCardSvgUrl(theme, layout, node.confidence), }, }); }); const validEdges = []; edges.forEach((edge, idx) => { if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) return; const id = edge.id || 'e-' + idx; validEdges.push({ ...edge, id }); elements.push({ data: { id, source: edge.source, target: edge.target, type: edge.type || 'leads_to', confidence: edge.confidence || 'confirmed', }, }); }); _cy = cytoscape({ container, elements, style: [ { selector: 'node', style: { label: '', width: (ele) => ele.data('nodeWidth') || CARD_MIN_W, height: (ele) => ele.data('nodeHeight') || CARD_MIN_H, shape: 'round-rectangle', 'background-color': '#ffffff', 'background-image': (ele) => ele.data('cardSvgUrl') || 'none', 'background-width': (ele) => (ele.data('nodeWidth') || CARD_MIN_W) + 'px', 'background-height': (ele) => (ele.data('nodeHeight') || CARD_MIN_H) + 'px', 'background-position-x': '50%', 'background-position-y': '50%', 'background-fit': 'none', 'border-width': 0, 'background-opacity': 1, }, }, { selector: 'edge', style: { width: 2.2, 'line-color': (ele) => EDGE_COLORS[ele.data('type')] || '#CBD5E1', 'target-arrow-color': (ele) => EDGE_COLORS[ele.data('type')] || '#CBD5E1', 'target-arrow-shape': 'triangle', 'curve-style': 'bezier', opacity: (ele) => (ele.data('confidence') === 'tentative' ? 0.55 : 0.9), 'line-style': (ele) => (ele.data('confidence') === 'tentative' ? 'dashed' : 'solid'), }, }, { selector: 'edge:selected', style: { width: 3.5, opacity: 1, 'line-color': '#4F46E5', 'target-arrow-color': '#4F46E5', }, }, { selector: 'node:selected', style: { 'border-width': 3, 'border-color': '#4F46E5', 'border-opacity': 1, }, }, ], minZoom: 0.35, maxZoom: 3, }); _cy.on('tap', 'node', (evt) => { const d = evt.target.data(); const key = d.factKey || d.id; if (_connectMode && _connectPick) { _connectPick(key); return; } if (typeof _onNodeSelect === 'function') { _onNodeSelect(key, d); } }); _cy.on('tap', 'edge', (evt) => { if (_connectMode && _connectPick) return; const d = evt.target.data(); if (typeof _onEdgeSelect === 'function') { _onEdgeSelect(d.id, d); } }); _cy.on('tap', (evt) => { if (evt.target === _cy) { clearEdgeSelection(); } }); applyElkLayout(validEdges, isComplex); observeContainerResize(container); return _cy; } function filterBySearch(query) { if (!_cy) return; const q = (query || '').trim().toLowerCase(); _cy.nodes().forEach((n) => { if (!q) { n.style('opacity', 1); return; } const text = ( (n.data('label') || '') + ' ' + (n.data('factKey') || '') + ' ' + (n.data('typeLabel') || '') ).toLowerCase(); n.style('opacity', text.includes(q) ? 1 : 0.15); }); _cy.edges().forEach((e) => { e.style('opacity', q ? 0.12 : 0.9); }); } let _connectMode = false; let _connectPick = null; function selectEdge(edgeId) { if (!_cy || !edgeId) return; _cy.elements().unselect(); const edge = _cy.getElementById(edgeId); if (edge.length) edge.select(); } function clearEdgeSelection() { if (!_cy) return; _cy.elements().unselect(); } function setConnectMode(enabled, onPick) { _connectMode = !!enabled; _connectPick = typeof onPick === 'function' ? onPick : null; if (_cy) { _cy.userPanningEnabled(!_connectMode); } } /** 与后端 GraphNodeType 一致:优先 category,vuln: 合成节点例外;无 category 时回退 type/key。 */ function resolveGraphNodeType(node) { if (!node) return 'note'; const key = String(node.fact_key || node.id || '').toLowerCase(); if (key.startsWith('vuln:')) return 'vulnerability'; const cat = String(node.category || '').toLowerCase(); if (cat) { if (cat === 'vuln') return 'vulnerability'; if (cat === 'missing') return 'missing'; return cat; } const t = String(node.type || '').toLowerCase(); if (t === 'vuln') return 'vulnerability'; if (t) return t; if (key.startsWith('target/')) return 'target'; if (key.startsWith('exploit/') || key.startsWith('evidence/')) return 'exploit'; if (key.startsWith('poc/')) return 'poc'; if (key.startsWith('chain/')) return 'chain'; if (key.startsWith('finding/')) return 'finding'; if (key.startsWith('auth/')) return 'auth'; if (key.startsWith('infra/') || key.startsWith('business/')) return 'infra'; return 'note'; } global.ProjectFactGraph = { render, destroy, center: centerGraph, filterBySearch, setConnectMode, selectEdge, clearEdgeSelection, nodeTheme, resolveGraphNodeType, }; })(typeof window !== 'undefined' ? window : globalThis);