PBI-Measure-CallGraph/static/js/graph.js
2025-03-19 22:36:40 +08:00

715 lines
25 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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));
}
}