""" Generate interactive HTML visualization for all Power BI Semantic Models Scans the repository for .SemanticModel folders, parses TMDL relationship files, and creates an interactive HTML viewer with relationship diagrams. How to run: 1. Place this script anywhere in your repo (root, scripts/, tools/, etc.) 2. Open a terminal and run: python visualize_all_relationships.py # Scans repo for .SemanticModel folders python visualize_all_relationships.py --no-browser # Skip opening browser Quick tip: Type "python " then drag this file into your terminal to paste the full path. Using VS Code with Copilot? Just ask: "Run the visualize_all_relationships.py script" Requirements: Python 3.7+ (no external dependencies) """ import re import json import sys import argparse from pathlib import Path from collections import defaultdict import webbrowser def parse_tmdl_relationships(file_path): """Parse relationships.tmdl file and extract relationship definitions""" try: with open(file_path, 'r', encoding='utf-8') as f: content = f.read() except Exception as e: print(f" ā Error reading file {file_path}: {e}") return [] if not content.strip(): return [] relationships = [] # Split by relationship blocks blocks = re.split(r'(?:^|\n)relationship\s+[\w-]+\n', content, flags=re.MULTILINE) rel_ids = re.findall(r'(?:^|\n)relationship\s+([\w-]+)\n', content, flags=re.MULTILINE) for i, block in enumerate(blocks[1:], 0): # Skip first empty block rel = {'id': rel_ids[i] if i < len(rel_ids) else f'rel_{i}'} # Extract properties from_col_match = re.search(r'fromColumn:\s*(.+)', block) to_col_match = re.search(r'toColumn:\s*(.+)', block) from_card_match = re.search(r'fromCardinality:\s*(\w+)', block) to_card_match = re.search(r'toCardinality:\s*(\w+)', block) cross_filter_match = re.search(r'crossFilteringBehavior:\s*(\w+)', block) is_active_match = re.search(r'isActive:\s*(\w+)', block) if from_col_match and to_col_match: from_full = from_col_match.group(1).strip() to_full = to_col_match.group(1).strip() # Parse table.column format from_parts = from_full.split('.') to_parts = to_full.split('.') # Handle quoted table names from_table = from_parts[0].strip("'\"") from_column = '.'.join(from_parts[1:]).strip("'\"") if len(from_parts) > 1 else '' to_table = to_parts[0].strip("'\"") to_column = '.'.join(to_parts[1:]).strip("'\"") if len(to_parts) > 1 else '' rel['from_table'] = from_table rel['from_column'] = from_column rel['to_table'] = to_table rel['to_column'] = to_column rel['from_cardinality'] = from_card_match.group(1) if from_card_match else 'many' rel['to_cardinality'] = to_card_match.group(1) if to_card_match else 'one' rel['cross_filtering'] = cross_filter_match.group(1) if cross_filter_match else 'oneDirection' rel['is_active'] = is_active_match.group(1).lower() != 'false' if is_active_match else True relationships.append(rel) return relationships def prepare_model_data(relationships, model_name): """Prepare visualization data for a model""" nodes = {} edges = [] table_stats = defaultdict(lambda: {'incoming': 0, 'outgoing': 0}) for rel in relationships: from_table = rel['from_table'] to_table = rel['to_table'] # Track statistics table_stats[from_table]['outgoing'] += 1 table_stats[to_table]['incoming'] += 1 # Add nodes if not exists if from_table not in nodes: nodes[from_table] = {'id': from_table, 'label': from_table, 'connections': []} if to_table not in nodes: nodes[to_table] = {'id': to_table, 'label': to_table, 'connections': []} # Track connections if to_table not in nodes[from_table]['connections']: nodes[from_table]['connections'].append(to_table) if from_table not in nodes[to_table]['connections']: nodes[to_table]['connections'].append(from_table) # Create edge # Note: In Power BI, filter direction flows FROM dimension (one-side) TO fact (many-side) # So we reverse the arrow direction from the TMDL definition cardinality = f"{rel['from_cardinality'][0].upper()}:{rel['to_cardinality'][0].upper()}" direction = 'ā' if rel['cross_filtering'] == 'bothDirections' else 'ā' active_text = '' if rel['is_active'] else ' (inactive)' # Reverse arrow direction: filter flows from 'to' (dimension) to 'from' (fact) edge = { 'from': to_table, 'to': from_table, 'label': f"{cardinality} {direction}{active_text}", 'title': f"{to_table}.{rel['to_column']} ā {from_table}.{rel['from_column']}", 'arrows': 'to' if rel['cross_filtering'] != 'bothDirections' else 'to, from', 'dashes': not rel['is_active'], 'color': '#8E44AD' if rel['cross_filtering'] == 'bothDirections' else ('#999999' if not rel['is_active'] else '#2C3E50'), 'width': 2.5 if rel['cross_filtering'] == 'bothDirections' else (1 if not rel['is_active'] else 2), 'from_column': rel['to_column'], 'to_column': rel['from_column'], 'cardinality': cardinality, 'cross_filtering': rel['cross_filtering'], 'is_active': rel['is_active'] } edges.append(edge) # Classify tables fact_tables = [t for t, stats in table_stats.items() if stats['outgoing'] > stats['incoming']] # Prepare nodes for vis.js vis_nodes = [] for table_name, node_data in nodes.items(): is_fact = table_name in fact_tables vis_node = { 'id': table_name, 'label': table_name, 'title': f"{table_name}\n{'Fact Table' if is_fact else 'Dimension Table'}\nConnections: {len(node_data['connections'])}", 'color': { 'background': '#FF6B6B' if is_fact else '#4ECDC4', 'border': '#C44545' if is_fact else '#3BA39F', 'highlight': { 'background': '#FF8787' if is_fact else '#6FE8DE', 'border': '#A03333' if is_fact else '#2A7A77' } }, 'shape': 'box' if is_fact else 'ellipse', 'font': {'size': 14, 'color': '#000000', 'bold': True}, 'connections': node_data['connections'] } vis_nodes.append(vis_node) return { 'nodes': vis_nodes, 'edges': edges, 'stats': { 'tables': len(nodes), 'relationships': len(relationships), 'facts': len(fact_tables), 'dimensions': len(nodes) - len(fact_tables) } } def create_multi_model_html(models_data, output_path): """Create an interactive HTML with dropdown to select models""" # Create JavaScript object with all models data models_json = json.dumps(models_data) html_content = f"""