commit 00392d28e3c5f15693045e3306da5c52817dbdd4 Author: Forrest Zhu Date: Sat Mar 15 21:32:56 2025 +0800 Initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b09cd78 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/MSNET/Microsoft.AnalysisServices.AdomdClient.dll b/MSNET/Microsoft.AnalysisServices.AdomdClient.dll new file mode 100644 index 0000000..ffc8c02 Binary files /dev/null and b/MSNET/Microsoft.AnalysisServices.AdomdClient.dll differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..10efa16 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# Power BI Measure Call Graph + +A Flask web application that visualizes the dependency relationships between measures in a Power BI / Analysis Services model. + +## Features + +- Connect to Analysis Services models +- Extract measures and their DAX expressions +- Parse DAX expressions to determine dependencies between measures +- Visualize the call graph with an interactive D3.js visualization +- Search for specific measures +- Click on measures to highlight dependencies +- Hover over measures to see their DAX expressions + +## Requirements + +- Python 3.7+ +- Microsoft Analysis Services ODBC driver (MSOLAP) +- Access to a Power BI / Analysis Services model + +## Installation + +1. Clone this repository: + ``` + git clone https://github.com/yourusername/pbi-measure-callgraph.git + cd pbi-measure-callgraph + ``` + +2. Create a virtual environment (optional but recommended): + ``` + python -m venv venv + venv\Scripts\activate # Windows + source venv/bin/activate # macOS/Linux + ``` + +3. Install the required packages: + ``` + pip install -r requirements.txt + ``` + +## Usage + +1. Start the Flask application: + ``` + python app.py + ``` + +2. Open your web browser and navigate to: + ``` + http://localhost:5000 + ``` + +3. Enter your Analysis Services server and database information and click "Connect" + +4. Once connected, the call graph will be displayed with all measures from your model + +5. Interact with the graph: + - Click on a measure to highlight its dependencies + - Hover over a measure to see its DAX expression + - Use the search box to find specific measures + - Use the mouse wheel to zoom in/out + - Drag the graph to pan + - Click the "Reset View" button to center the graph + +## How It Works + +1. **Connection**: The application connects to your Analysis Services model using the MSOLAP ODBC driver +2. **Measure Extraction**: It retrieves all visible measures and their DAX expressions +3. **Dependency Analysis**: The DAX parser analyzes each expression to identify references to other measures +4. **Visualization**: The D3.js force-directed graph visualizes the dependencies between measures + +## Troubleshooting + +- **Connection Issues**: Ensure you have the MSOLAP driver installed and that you have access to the specified server and database +- **Missing Measures**: Only visible measures are included in the graph +- **Incorrect Dependencies**: The DAX parser uses a simplified approach to identify measure references and may not catch all complex scenarios + +## License + +MIT diff --git a/__pycache__/dax_parser.cpython-313.pyc b/__pycache__/dax_parser.cpython-313.pyc new file mode 100644 index 0000000..57c2e5b Binary files /dev/null and b/__pycache__/dax_parser.cpython-313.pyc differ diff --git a/__pycache__/model_connector.cpython-313.pyc b/__pycache__/model_connector.cpython-313.pyc new file mode 100644 index 0000000..e6ca9f7 Binary files /dev/null and b/__pycache__/model_connector.cpython-313.pyc differ diff --git a/app.py b/app.py new file mode 100644 index 0000000..9e2e48e --- /dev/null +++ b/app.py @@ -0,0 +1,61 @@ +from flask import Flask, render_template, jsonify, request +import json +import os +from model_connector import ModelConnector +from dax_parser import DaxParser + +app = Flask(__name__) + +# Initialize the model connector and DAX parser +model_connector = None +dax_parser = DaxParser() + +@app.route('/') +def index(): + """Render the main page.""" + return render_template('index.html') + +@app.route('/connect', methods=['POST']) +def connect(): + """Connect to the Analysis Services model.""" + global model_connector + + data = request.json + server = data.get('server') + database = data.get('database') + + try: + model_connector = ModelConnector(server, database) + return jsonify({'success': True, 'message': 'Connected successfully'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}) + +@app.route('/get_measures') +def get_measures(): + """Get all measures from the connected model.""" + if not model_connector: + return jsonify({'success': False, 'message': 'Not connected to a model'}) + + try: + measures = model_connector.get_measures() + return jsonify({'success': True, 'measures': measures}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}) + +@app.route('/get_call_graph') +def get_call_graph(): + """Generate and return the call graph data.""" + if not model_connector: + return jsonify({'success': False, 'message': 'Not connected to a model'}) + + try: + measures_df = model_connector.get_measures() + measures = measures_df.to_dict(orient="records") + graph = dax_parser.build_call_graph(measures) + return jsonify({'success': True, 'graph': graph}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}) + +if __name__ == '__main__': + app.run(debug=True) + diff --git a/dax_parser.py b/dax_parser.py new file mode 100644 index 0000000..a0c79f1 --- /dev/null +++ b/dax_parser.py @@ -0,0 +1,70 @@ +import re + +class DaxParser: + """Class to parse DAX expressions and build dependency graphs.""" + + def __init__(self): + """Initialize the DAX parser.""" + # Regular expression to match measure references in DAX + # This is a simplified pattern and may need refinement for complex DAX + self.measure_ref_pattern = r'\[([^\]]+)\]' + + def extract_measure_references(self, dax_expression, all_measure_names): + """ + Extract references to other measures from a DAX expression. + + Args: + dax_expression (str): The DAX expression to parse + all_measure_names (list): List of all measure names in the model + + Returns: + list: Names of measures referenced in the expression + """ + if not dax_expression: + return [] + + # Find all potential measure references (anything in square brackets) + potential_refs = re.findall(self.measure_ref_pattern, dax_expression) + + # Filter to only include actual measure names + # This helps avoid false positives like column references + measure_refs = [ref for ref in potential_refs if ref in all_measure_names] + + return list(set(measure_refs)) # Remove duplicates + + def build_call_graph(self, measures): + """ + Build a call graph of measure dependencies. + + Args: + measures (list): List of measure dictionaries with 'name' and 'expression' keys + + Returns: + dict: A graph representation with nodes and links + """ + # Extract all measure names + all_measure_names = [measure['name'] for measure in measures] + + # Create nodes for the graph + nodes = [{'id': measure['name'], 'expression': measure['expression']} for measure in measures] + + # Create links (dependencies) for the graph + links = [] + for measure in measures: + source = measure['name'] + expression = measure['expression'] + + # Find references to other measures in this expression + references = self.extract_measure_references(expression, all_measure_names) + + # Add links for each reference + for target in references: + links.append({ + 'source': source, + 'target': target + }) + + return { + 'nodes': nodes, + 'links': links + } diff --git a/model_connector.py b/model_connector.py new file mode 100644 index 0000000..aa716f1 --- /dev/null +++ b/model_connector.py @@ -0,0 +1,89 @@ +from sys import path +path.append('D:\\Personal_Files\\document\\GitHub\\Test\\MSNET') + +import pyadomd +import pandas as pd + + +class ModelConnector: + """Class to connect to and retrieve data from Analysis Services models.""" + + def __init__(self, server, database): + """ + Initialize the connection to the Analysis Services model. + + Args: + server (str): The server name or IP address + database (str): The database name + """ + self.server = server + self.database = database + # self.connection_string = f"Provider=MSOLAP;Data Source={server}" + self.connection_string = f"Provider=MSOLAP;Data Source=localhost:56195" + self.connection = None + self.connect() + + def connect(self): + """Establish a connection to the Analysis Services model.""" + try: + self.connection = pyadomd.Pyadomd(self.connection_string) + self.connection.open() + return True + except Exception as e: + raise Exception(f"Failed to connect to the model: {str(e)}") + + def execute_query(self, query): + """ + Execute a DAX or MDX query against the model. + + Args: + query (str): The DAX or MDX query to execute + + Returns: + pandas.DataFrame: The query results + """ + if not self.connection: + self.connect() + + try: + with self.connection.cursor().execute(query) as conn: + rows = conn.fetchall() + df = pd.DataFrame(rows, columns=[col[0] for col in conn._description]) + return df + except Exception as e: + raise Exception(f"Failed to execute query: {str(e)}") + + def get_measures(self): + """ + Retrieve all measures from the model. + + Returns: + list: A list of dictionaries containing measure information + """ + # DMV query to get all measures + query = """ + SELECT + [MEASURE_NAME] as [name], + [EXPRESSION] as [expression], + [MEASURE_CAPTION] as [caption], + [MEASURE_DISPLAY_FOLDER] as [display_folder], + [DESCRIPTION] as [description] + FROM $SYSTEM.MDSCHEMA_MEASURES + WHERE MEASURE_IS_VISIBLE + """ + + try: + df = self.execute_query(query) + return df if not df.empty else [] + except Exception as e: + raise Exception(f"Failed to retrieve measures: {str(e)}") + + +if __name__ == '__main__': + # Example usage + server = 'localhost:56195' + database = 'Exteranl All Channel' + + model_connector = ModelConnector(server, database) + measures = model_connector.get_measures() + print(measures) # List of measure dictionaries diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c7a3cf0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask==2.3.3 +pyodbc==4.0.39 +pandas==2.2.3 diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..4543cb2 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,188 @@ +/* Global styles */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + line-height: 1.6; + color: #333; + background-color: #f5f5f5; +} + +.container { + max-width: 1400px; + margin: 0 auto; + padding: 20px; +} + +header { + text-align: center; + margin-bottom: 30px; +} + +h1 { + color: #0078d4; + margin-bottom: 10px; +} + +h2 { + color: #0078d4; + margin-bottom: 15px; + font-size: 1.5rem; +} + +/* Connection panel */ +.connection-panel { + background-color: white; + padding: 20px; + border-radius: 5px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + margin-bottom: 20px; +} + +.form-group { + margin-bottom: 15px; +} + +label { + display: block; + margin-bottom: 5px; + font-weight: 500; +} + +input[type="text"] { + width: 100%; + padding: 8px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; +} + +button { + background-color: #0078d4; + color: white; + border: none; + padding: 10px 15px; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + transition: background-color 0.3s; +} + +button:hover { + background-color: #005a9e; +} + +#connection-status { + margin-top: 10px; + font-weight: 500; +} + +.success { + color: #107c10; +} + +.error { + color: #d83b01; +} + +/* Visualization container */ +.visualization-container { + background-color: white; + padding: 20px; + border-radius: 5px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + margin-bottom: 20px; +} + +.controls { + display: flex; + justify-content: space-between; + margin-bottom: 15px; +} + +.search-box { + flex-grow: 1; + margin-left: 15px; +} + +#graph-container { + width: 100%; + height: 600px; + border: 1px solid #ddd; + border-radius: 4px; + overflow: hidden; +} + +/* Graph styles */ +.node circle { + fill: #0078d4; + stroke: #fff; + stroke-width: 2px; +} + +.node text { + font-size: 12px; + fill: #333; +} + +.node.highlighted circle { + fill: #ffb900; +} + +.node.selected circle { + fill: #107c10; +} + +.link { + stroke: #999; + stroke-opacity: 0.6; + stroke-width: 1px; +} + +.link.highlighted { + stroke: #ffb900; + stroke-opacity: 0.8; + stroke-width: 2px; +} + +/* Measure details */ +.measure-details { + background-color: white; + padding: 20px; + border-radius: 5px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); +} + +#measure-name { + font-size: 1.2rem; + font-weight: 600; + margin-bottom: 10px; +} + +#measure-expression { + background-color: #f8f8f8; + padding: 15px; + border-radius: 4px; + border: 1px solid #ddd; + white-space: pre-wrap; + font-family: Consolas, Monaco, 'Andale Mono', monospace; + font-size: 0.9rem; + overflow-x: auto; +} + +/* Tooltip */ +.tooltip { + position: absolute; + background-color: rgba(0, 0, 0, 0.8); + color: white; + padding: 10px; + border-radius: 4px; + font-size: 12px; + max-width: 300px; + z-index: 10; + pointer-events: none; +} diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..b1e5e42 --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,121 @@ +/** + * Main application JavaScript for the Power BI Measure Call Graph + */ + +// DOM elements +const serverInput = document.getElementById('server'); +const databaseInput = document.getElementById('database'); +const connectBtn = document.getElementById('connect-btn'); +const connectionStatus = document.getElementById('connection-status'); +const resetBtn = document.getElementById('reset-btn'); +const searchInput = document.getElementById('search-input'); +const measureName = document.getElementById('measure-name'); +const measureExpression = document.getElementById('measure-expression'); + +// Graph instance +let graph = null; + +// Event listeners +document.addEventListener('DOMContentLoaded', () => { + // Initialize the graph + graph = new Graph('#graph-container'); + + // Connect button + connectBtn.addEventListener('click', connectToModel); + + // Reset button + resetBtn.addEventListener('click', () => { + if (graph) { + graph.resetView(); + } + }); + + // Search input + searchInput.addEventListener('input', (e) => { + if (graph) { + graph.searchNodes(e.target.value); + } + }); +}); + +/** + * Connect to the Analysis Services model + */ +async function connectToModel() { + const server = serverInput.value.trim() || 'localhost'; + const database = databaseInput.value.trim(); + + if (!database) { + showConnectionStatus('Please enter a database name', 'error'); + return; + } + + try { + showConnectionStatus('Connecting...', ''); + + // Connect to the model + const connectResponse = await fetch('/connect', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ server, database }) + }); + + const connectData = await connectResponse.json(); + + if (!connectData.success) { + showConnectionStatus(`Connection failed: ${connectData.message}`, 'error'); + return; + } + + showConnectionStatus('Connected successfully. Loading measures...', 'success'); + + // Get the call graph data + const graphResponse = await fetch('/get_call_graph'); + const graphData = await graphResponse.json(); + + if (!graphData.success) { + showConnectionStatus(`Failed to load graph: ${graphData.message}`, 'error'); + return; + } + + // Initialize the graph with the data + graph.setData(graphData.graph); + graph.render(); + + showConnectionStatus('Graph loaded successfully', 'success'); + } catch (error) { + showConnectionStatus(`Error: ${error.message}`, 'error'); + } +} + +/** + * Show connection status message + * @param {string} message - The message to display + * @param {string} type - The message type (success, error, or empty for neutral) + */ +function showConnectionStatus(message, type) { + connectionStatus.textContent = message; + connectionStatus.className = type; +} + +/** + * Display measure details + * @param {Object} measure - The measure object + */ +function showMeasureDetails(measure) { + if (!measure) { + measureName.textContent = ''; + measureExpression.textContent = ''; + return; + } + + measureName.textContent = measure.id; + measureExpression.textContent = measure.expression || 'No expression available'; +} + +// Export functions for use in graph.js +window.appFunctions = { + showMeasureDetails +}; diff --git a/static/js/graph.js b/static/js/graph.js new file mode 100644 index 0000000..fc2249b --- /dev/null +++ b/static/js/graph.js @@ -0,0 +1,277 @@ +/** + * 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)); + } +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..7be1385 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,51 @@ + + + + + + Power BI Measure Call Graph + + + + + +
+
+

Power BI Measure Call Graph

+
+ +
+

Connect to Analysis Services

+
+ + +
+
+ + +
+ +
+
+ +
+
+ + +
+
+
+ +
+

Measure Details

+
+

+        
+
+ + + + +