/** * Graph visualization using D3.js for the Power BI Measure Call Graph */ class Graph { /** * Initialize the graph * @param {string} selector - CSS selector for the container element */ constructor(selector) { this.container = d3.select(selector); this.width = this.container.node().getBoundingClientRect().width; this.height = this.container.node().getBoundingClientRect().height; this.data = null; this.simulation = null; this.svg = null; this.zoom = null; this.tooltip = null; this.selectedNode = null; this.detailGraph = null; // 详情图实例 this.init(); // 初始化详情图 this.initDetailGraph(); } /** * Initialize the SVG and D3 components */ init() { // Create SVG this.svg = this.container.append('svg') .attr('width', this.width) .attr('height', this.height) .attr('class', 'graph-svg'); // 添加箭头定义 const defs = this.svg.append('defs'); // 普通箭头 defs.append('marker') .attr('id', 'arrow') .attr('viewBox', '0 -5 10 10') .attr('refX', 20) .attr('refY', 0) .attr('markerWidth', 6) .attr('markerHeight', 6) .attr('orient', 'auto') .append('path') .attr('d', 'M0,-5L10,0L0,5') .attr('class', 'arrow'); // 高亮箭头 defs.append('marker') .attr('id', 'arrow-highlighted') .attr('viewBox', '0 -5 10 10') .attr('refX', 20) .attr('refY', 0) .attr('markerWidth', 6) .attr('markerHeight', 6) .attr('orient', 'auto') .append('path') .attr('d', 'M0,-5L10,0L0,5') .attr('class', 'arrow highlighted'); // 暗淡箭头 defs.append('marker') .attr('id', 'arrow-dimmed') .attr('viewBox', '0 -5 10 10') .attr('refX', 20) .attr('refY', 0) .attr('markerWidth', 6) .attr('markerHeight', 6) .attr('orient', 'auto') .append('path') .attr('d', 'M0,-5L10,0L0,5') .attr('class', 'arrow dimmed'); // Create zoom behavior this.zoom = d3.zoom() .scaleExtent([0.1, 4]) .on('zoom', (event) => { this.g.attr('transform', event.transform); }); // Apply zoom to SVG this.svg.call(this.zoom); // Create a group for the graph elements this.g = this.svg.append('g'); // Create tooltip this.tooltip = d3.select('body').append('div') .attr('class', 'tooltip') .style('opacity', 0); // Handle window resize window.addEventListener('resize', () => { this.width = this.container.node().getBoundingClientRect().width; this.height = this.container.node().getBoundingClientRect().height; this.svg.attr('width', this.width).attr('height', this.height); if (this.simulation) { this.simulation.alpha(0.3).restart(); } }); } /** * 初始化详情图 */ initDetailGraph() { const detailContainer = d3.select('#graph-container-detail'); const width = detailContainer.node().getBoundingClientRect().width; const height = detailContainer.node().getBoundingClientRect().height; // 创建SVG this.detailSvg = detailContainer.append('svg') .attr('width', width) .attr('height', height) .attr('class', 'graph-svg'); // 添加箭头定义 const defs = this.detailSvg.append('defs'); defs.append('marker') .attr('id', 'detail-arrow') .attr('viewBox', '0 -5 10 10') .attr('refX', 20) .attr('refY', 0) .attr('markerWidth', 6) .attr('markerHeight', 6) .attr('orient', 'auto') .append('path') .attr('d', 'M0,-5L10,0L0,5') .attr('class', 'arrow'); // 创建详情图的缩放行为 this.detailZoom = d3.zoom() .scaleExtent([0.1, 4]) .on('zoom', (event) => { this.detailG.attr('transform', event.transform); }); // 应用缩放到SVG this.detailSvg.call(this.detailZoom); // 创建详情图的容器组 this.detailG = this.detailSvg.append('g'); // 添加标题 detailContainer.select('h3') .text('Selected Measure Details'); // 处理窗口大小调整 window.addEventListener('resize', () => { const newWidth = detailContainer.node().getBoundingClientRect().width; const newHeight = detailContainer.node().getBoundingClientRect().height; this.detailSvg.attr('width', newWidth).attr('height', newHeight); if (this.detailSimulation) { this.detailSimulation.alpha(0.3).restart(); } }); // 初始化时调用resetDetailView方法,确保详情图在可视范围内 // 使用setTimeout确保DOM已完全渲染 setTimeout(() => { this.resetDetailView(); }, 300); } /** * Set the graph data * @param {Object} data - The graph data with nodes and links */ setData(data) { this.data = data; // 计算每个节点的引用次数 this.data.nodes.forEach(node => { node.referenceCount = 0; // 被引用次数 }); this.data.links.forEach(link => { // 对目标节点(被引用的节点)的引用次数+1 const targetNode = this.data.nodes.find(node => node.id === link.target); if (targetNode) { targetNode.referenceCount = (targetNode.referenceCount || 0) + 1; } }); } /** * Render the graph */ render() { if (!this.data) return; // Clear previous graph this.g.selectAll('*').remove(); // 排序节点:被引用次数多的在左侧 this.data.nodes.sort((a, b) => b.referenceCount - a.referenceCount); // Create links with arrows const links = this.g.append('g') .attr('class', 'links') .selectAll('line') .data(this.data.links) .enter().append('line') .attr('class', 'link') .attr('marker-end', 'url(#arrow)'); // 添加箭头 // Create nodes const nodes = this.g.append('g') .attr('class', 'nodes') .selectAll('.node') .data(this.data.nodes) .enter().append('g') .attr('class', 'node') .call(d3.drag() .on('start', this.dragStarted.bind(this)) .on('drag', this.dragged.bind(this)) .on('end', this.dragEnded.bind(this))); // Add circles to nodes nodes.append('circle') .attr('r', d => Math.max(8, Math.min(12, d.referenceCount + 6))); // 根据引用次数调整大小 // Add text labels to nodes nodes.append('text') .attr('dx', 12) .attr('dy', '.35em') .text(d => d.id); // Add event listeners to nodes nodes .on('mouseover', (event, d) => { // Show tooltip this.tooltip.transition() .duration(200) .style('opacity', .9); this.tooltip.html(d.expression) .style('left', (event.pageX + 10) + 'px') .style('top', (event.pageY - 28) + 'px'); }) .on('mouseout', () => { // Hide tooltip this.tooltip.transition() .duration(500) .style('opacity', 0); }) .on('click', (event, d) => { event.stopPropagation(); this.selectNode(d); }); // 布局力模型 - 调整参数使节点更分散 this.simulation = d3.forceSimulation(this.data.nodes) .force('link', d3.forceLink(this.data.links).id(d => d.id).distance(150)) // 增加连接距离 .force('charge', d3.forceManyBody().strength(-500)) // 增加排斥力 .force('center', d3.forceCenter(this.width / 2, this.height / 2)) .force('collision', d3.forceCollide().radius(40)) // 增加碰撞半径 // 水平定位力:根据引用次数排列,增加分散度 .force('x', d3.forceX().x(d => { // 引用次数越多,越靠左 const maxCount = Math.max(...this.data.nodes.map(n => n.referenceCount || 0)); const scaleFactor = this.width * 0.9; // 增加宽度因子 return this.width / 2 - (scaleFactor * (d.referenceCount / maxCount)) + (scaleFactor / 2); }).strength(0.2)) // 减小力强度,允许更自由的布局 .on('tick', () => { links .attr('x1', d => d.source.x) .attr('y1', d => d.source.y) .attr('x2', d => d.target.x) .attr('y2', d => d.target.y); nodes.attr('transform', d => `translate(${d.x},${d.y})`); }); // Add click handler to SVG to deselect nodes this.svg.on('click', () => { this.selectNode(null); }); // Center the graph initially this.resetView(); } /** * Handle drag start event */ dragStarted(event, d) { if (!event.active) this.simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; } /** * Handle drag event */ dragged(event, d) { d.fx = event.x; d.fy = event.y; } /** * Handle drag end event */ dragEnded(event, d) { if (!event.active) this.simulation.alphaTarget(0); d.fx = null; d.fy = null; } /** * Select a node and highlight its connections * @param {Object} node - The selected node */ selectNode(node) { this.selectedNode = node; // Update node classes based on selection this.g.selectAll('.node') .classed('selected', d => d === node) .classed('highlighted', d => { if (!node) return false; // Check if this node is connected to the selected node return this.data.links.some(link => (link.source.id === node.id && link.target.id === d.id) || (link.target.id === node.id && link.source.id === d.id) ); }) .classed('dimmed', d => { if (!node) return false; if (d === node) return false; // Dim nodes that are not connected to the selected node return !this.data.links.some(link => (link.source.id === node.id && link.target.id === d.id) || (link.target.id === node.id && link.source.id === d.id) ); }); // Update link classes based on selection this.g.selectAll('.link') .classed('highlighted', link => node && (link.source.id === node.id || link.target.id === node.id) ) .classed('dimmed', link => node && !(link.source.id === node.id || link.target.id === node.id) ) .attr('marker-end', link => { if (!node) return 'url(#arrow)'; if (link.source.id === node.id || link.target.id === node.id) { return 'url(#arrow-highlighted)'; } return 'url(#arrow-dimmed)'; }); // Display measure details if (window.appFunctions && window.appFunctions.showMeasureDetails) { window.appFunctions.showMeasureDetails(node); } // 更新详情图 this.renderDetailGraph(node); } /** * 渲染选中节点相关的详情图 * @param {Object} selectedNode - 选中的节点 */ renderDetailGraph(selectedNode) { // 清除之前的详情图 this.detailG.selectAll('*').remove(); if (!selectedNode || !this.data) { d3.select('#graph-container-detail h3').text('Selected Measure Details'); return; } d3.select('#graph-container-detail h3').text(`"${selectedNode.id}" Details`); // 找出与选中节点相关的所有节点和连接 const relatedNodes = []; const relatedLinks = []; // 添加选中的节点 relatedNodes.push({ ...selectedNode, type: 'selected' }); // 找出引用这个节点的其他节点(出现在source位置) this.data.links.forEach(link => { if (link.target.id === selectedNode.id) { // 找到引用这个节点的其他节点 const sourceNode = this.data.nodes.find(n => n.id === link.source.id); if (sourceNode) { relatedNodes.push({ ...sourceNode, type: 'reference' // 引用节点 }); relatedLinks.push({ source: link.source.id, target: selectedNode.id }); } } else if (link.source.id === selectedNode.id) { // 找到被这个节点引用的其他节点 const targetNode = this.data.nodes.find(n => n.id === link.target.id); if (targetNode) { relatedNodes.push({ ...targetNode, type: 'referenced' // 被引用节点 }); relatedLinks.push({ source: selectedNode.id, target: link.target.id }); } } }); // 获取详情图容器的边界 const containerBounds = d3.select('#graph-container-detail').node().getBoundingClientRect(); const width = containerBounds.width; const height = containerBounds.height; // 绘制连接线 const links = this.detailG.append('g') .attr('class', 'detail-links') .selectAll('line') .data(relatedLinks) .enter().append('line') .attr('class', 'detail-link') .attr('marker-end', 'url(#detail-arrow)'); // 绘制节点 const nodes = this.detailG.append('g') .attr('class', 'detail-nodes') .selectAll('g') .data(relatedNodes) .enter().append('g') .attr('class', d => `detail-node ${d.type}`) .call(d3.drag() // 添加拖拽功能 .on('start', this.detailDragStarted.bind(this)) .on('drag', this.detailDragged.bind(this)) .on('end', this.detailDragEnded.bind(this))); // 添加节点圆形 nodes.append('circle') .attr('r', 10) .attr('class', d => d.type); // 添加节点文本,根据节点类型调整文本位置 nodes.append('text') .attr('dx', d => { if (d.type === 'referenced') return -12; // 被引用节点文本在左侧 return 12; // 选中节点和引用节点文本在右侧 }) .attr('dy', '.35em') .attr('text-anchor', d => { if (d.type === 'referenced') return 'end'; // 被引用节点文本右对齐 return 'start'; // 选中节点和引用节点文本左对齐 }) .text(d => d.id); // 设置布局 - 被引用节点在左侧,引用节点在右侧 this.detailSimulation = d3.forceSimulation(relatedNodes) .force('link', d3.forceLink(relatedLinks).id(d => d.id).distance(120)) // 减小连接距离 .force('charge', d3.forceManyBody().strength(-300)) // 减小排斥力 .force('center', d3.forceCenter(width / 2, height / 2)) .force('collision', d3.forceCollide().radius(30)) // 减小碰撞半径 // 水平布局力 - 被引用节点在左侧,引用节点在右侧 .force('x', d3.forceX().x(d => { if (d.type === 'selected') return width / 2; // 选中节点在中间 if (d.type === 'referenced') return width * 0.25; // 被引用节点在左侧 if (d.type === 'reference') return width * 0.75; // 引用节点在右侧 return width / 2; }).strength(0.5)) // 减小力度让节点可以更自由分布 .on('tick', () => { links .attr('x1', d => d.source.x) .attr('y1', d => d.source.y) .attr('x2', d => d.target.x) .attr('y2', d => d.target.y); nodes.attr('transform', d => `translate(${d.x},${d.y})`); }); // 添加双击事件来重置详情图视图 this.detailSvg.on('dblclick', () => { this.resetDetailView(); }); // 添加点击事件到详情图中的节点 nodes.on('click', (event, d) => { // 防止事件冒泡 event.stopPropagation(); // 在主图中查找该节点 const mainNode = this.data.nodes.find(n => n.id === d.id); if (mainNode) { // 在主图中选中该节点 this.selectNode(mainNode); // 将主图视图移动到该节点位置 this.centerViewOnNode(mainNode); } }) .on('mouseover', (event, d) => { // 显示点击提示 this.tooltip.transition() .duration(200) .style('opacity', .9); let tooltipText = '点击跳转到主图中的该节点'; if (d.type === 'selected') { tooltipText = '当前选中的节点'; } this.tooltip.html(tooltipText) .style('left', (event.pageX + 10) + 'px') .style('top', (event.pageY - 28) + 'px'); }) .on('mouseout', () => { // 隐藏提示 this.tooltip.transition() .duration(500) .style('opacity', 0); }); // 在力学模拟稳定后重置视图,确保等待图表完全渲染 this.detailSimulation.on('end', () => { // 延迟一点执行重置,确保图表完全渲染 setTimeout(() => { this.resetDetailView(); }, 200); }); } /** * 将主图的视图中心移动到指定节点 * @param {Object} node - 要居中显示的节点 */ centerViewOnNode(node) { if (!node || !node.x || !node.y) return; // 获取当前缩放级别 const currentTransform = d3.zoomTransform(this.svg.node()); const scale = currentTransform.k; // 计算需要的平移量,使节点位于视图中心 const x = node.x; const y = node.y; const translate = [this.width / 2 - scale * x, this.height / 2 - scale * y]; // 应用变换,添加动画效果 this.svg.transition() .duration(750) .call(this.zoom.transform, d3.zoomIdentity .translate(translate[0], translate[1]) .scale(scale)); } /** * 详情图拖拽开始事件处理 */ detailDragStarted(event, d) { if (!event.active) this.detailSimulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; // 添加拖动样式 d3.select(event.sourceEvent.target.parentNode).classed('dragging', true); } /** * 详情图拖拽中事件处理 */ detailDragged(event, d) { d.fx = event.x; d.fy = event.y; } /** * 详情图拖拽结束事件处理 */ detailDragEnded(event, d) { if (!event.active) this.detailSimulation.alphaTarget(0); d.fx = null; d.fy = null; // 移除拖动样式 d3.select(event.sourceEvent.target.parentNode).classed('dragging', false); } /** * 重置详情图视图 */ resetDetailView() { if (!this.detailG || !this.detailZoom) return; try { // 获取详情图容器的边界 const containerBounds = d3.select('#graph-container-detail').node().getBoundingClientRect(); const width = containerBounds.width; const height = containerBounds.height; // 获取图表内容的边界框 const bounds = this.detailG.node().getBBox(); // 如果边界框有效且有宽高 if (bounds && bounds.width > 0 && bounds.height > 0) { const dx = bounds.width; const dy = bounds.height; const x = bounds.x + dx / 2; const y = bounds.y + dy / 2; // 计算缩放比例以适应图形 const scale = 0.8 / Math.max(dx / width, dy / height); const translate = [width / 2 - scale * x, height / 2 - scale * y]; // 应用变换 this.detailSvg.transition() .duration(750) .call(this.detailZoom.transform, d3.zoomIdentity .translate(translate[0], translate[1]) .scale(scale)); } else { // 如果没有有效边界框,仅重置到中心 this.detailSvg.transition() .duration(750) .call(this.detailZoom.transform, d3.zoomIdentity .translate(width / 2, height / 2) .scale(1)); } } catch (error) { console.log('重置详情图视图出错:', error); // 发生错误时,尝试简单重置 try { const containerBounds = d3.select('#graph-container-detail').node().getBoundingClientRect(); const width = containerBounds.width; const height = containerBounds.height; this.detailSvg.transition() .duration(750) .call(this.detailZoom.transform, d3.zoomIdentity .translate(width / 2, height / 2) .scale(1)); } catch (e) { console.error('备用重置也失败:', e); } } } /** * Search for nodes by name * @param {string} query - The search query * @returns {Array} - Array of matching nodes */ searchNodes(query) { // 移除之前的搜索匹配标记 this.g.selectAll('.node').classed('search-match', false); if (!query) { // 如果查询为空,恢复所有节点的正常显示 this.g.selectAll('.node').style('opacity', 1); return []; } const lowerQuery = query.toLowerCase(); const matchingNodes = []; // 标记匹配的节点并收集匹配结果 this.g.selectAll('.node') .each((d, i, nodes) => { const node = d3.select(nodes[i]); const isMatch = d.id.toLowerCase().includes(lowerQuery); if (isMatch) { matchingNodes.push(d); } // 为匹配的节点添加特殊类并使其文本加粗 node.classed('search-match', isMatch); }); return matchingNodes; } /** * Reset the view to center the graph */ resetView() { const bounds = this.g.node().getBBox(); const dx = bounds.width; const dy = bounds.height; const x = bounds.x + dx / 2; const y = bounds.y + dy / 2; // Calculate the scale to fit the graph const scale = 0.9 / Math.max(dx / this.width, dy / this.height); const translate = [this.width / 2 - scale * x, this.height / 2 - scale * y]; // Apply the transform this.svg.transition() .duration(750) .call(this.zoom.transform, d3.zoomIdentity .translate(translate[0], translate[1]) .scale(scale)); } }