PBI-Measure-CallGraph/static/js/graph.js
2025-03-15 21:32:56 +08:00

278 lines
8.4 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.init();
}
/**
* 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');
// 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();
}
});
}
/**
* Set the graph data
* @param {Object} data - The graph data with nodes and links
*/
setData(data) {
this.data = data;
}
/**
* Render the graph
*/
render() {
if (!this.data) return;
// Clear previous graph
this.g.selectAll('*').remove();
// Create links
const links = this.g.append('g')
.attr('class', 'links')
.selectAll('line')
.data(this.data.links)
.enter().append('line')
.attr('class', 'link');
// 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', 8);
// 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);
});
// Create simulation
this.simulation = d3.forceSimulation(this.data.nodes)
.force('link', d3.forceLink(this.data.links).id(d => d.id).distance(100))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(this.width / 2, this.height / 2))
.force('collision', d3.forceCollide().radius(30))
.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|null} node - The node to select, or null to deselect
*/
selectNode(node) {
this.selectedNode = node;
// Show measure details
if (window.appFunctions && window.appFunctions.showMeasureDetails) {
window.appFunctions.showMeasureDetails(node);
}
// Reset all nodes and links
this.g.selectAll('.node').classed('selected', false).classed('highlighted', false);
this.g.selectAll('.link').classed('highlighted', false);
if (!node) return;
// Get connected nodes
const connectedNodes = new Set();
const connectedLinks = new Set();
// Find incoming links (where this node is the target)
this.data.links.forEach(link => {
if (link.target.id === node.id) {
connectedNodes.add(link.source.id);
connectedLinks.add(link);
}
});
// Find outgoing links (where this node is the source)
this.data.links.forEach(link => {
if (link.source.id === node.id) {
connectedNodes.add(link.target.id);
connectedLinks.add(link);
}
});
// Highlight the selected node
this.g.selectAll('.node')
.filter(d => d.id === node.id)
.classed('selected', true);
// Highlight connected nodes
this.g.selectAll('.node')
.filter(d => connectedNodes.has(d.id))
.classed('highlighted', true);
// Highlight connected links
this.g.selectAll('.link')
.filter(d => connectedLinks.has(d))
.classed('highlighted', true);
}
/**
* Search for nodes by name
* @param {string} query - The search query
*/
searchNodes(query) {
if (!query) {
// Reset all nodes if query is empty
this.g.selectAll('.node').style('opacity', 1);
return;
}
const lowerQuery = query.toLowerCase();
// Filter nodes based on the query
this.g.selectAll('.node').style('opacity', d => {
return d.id.toLowerCase().includes(lowerQuery) ? 1 : 0.2;
});
}
/**
* 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));
}
}