278 lines
8.4 KiB
JavaScript
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));
|
|
}
|
|
}
|