715 lines
25 KiB
JavaScript
715 lines
25 KiB
JavaScript
/**
|
||
* 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));
|
||
}
|
||
}
|