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