From 8e87349b1c9490c7f6ce5b5a0b2850b1bc0acecb Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Sun, 9 Nov 2025 23:13:27 -0800 Subject: [PATCH 01/16] Support multiple projects --- docs/source/conf.py | 5 +- sphinx_visualized/__init__.py | 273 +++++++++++++++++++++- sphinx_visualized/static/js/link-graph.js | 25 +- 3 files changed, 299 insertions(+), 4 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 6dcd96b..f4c7d8b 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -72,9 +72,12 @@ pygments_style = "sphinx" intersphinx_mapping = { - "sphinx": ("https://www.sphinx-doc.org/en/master/", None), + # "sphinx": ("https://www.sphinx-doc.org/en/master/", None), + "sphinx": ("../../../sphinx/build/sphinx/html", None), } +visualised_projects = ['sphinx'] + # -- Options for HTML output ------------------------------------------------- diff --git a/sphinx_visualized/__init__.py b/sphinx_visualized/__init__.py index 64e9ff3..7c9b9ff 100644 --- a/sphinx_visualized/__init__.py +++ b/sphinx_visualized/__init__.py @@ -13,13 +13,19 @@ from docutils import nodes as docutils_nodes from multiprocessing import Manager, Queue from fnmatch import fnmatch +import urllib.request +import urllib.error +from sphinx.util import logging __version__ = "0.8.1" +logger = logging.getLogger(__name__) + def setup(app): app.add_config_value("visualized_clusters", [], "html") app.add_config_value("visualized_auto_cluster", False, "html") + app.add_config_value("visualised_projects", [], "html") app.connect("builder-inited", create_objects) app.connect("doctree-resolved", get_links) app.connect("build-finished", create_json) @@ -325,6 +331,8 @@ def create_graphson(nodes, links, page_list, clusters_config): vertex_label = "intersphinx" elif node.get("is_external"): vertex_label = "external" + elif node.get("is_external_project"): + vertex_label = "external_project" else: vertex_label = "page" @@ -349,6 +357,15 @@ def create_graphson(nodes, links, page_list, clusters_config): if node.get("is_intersphinx"): vertex["properties"]["is_intersphinx"] = True + # Mark external project nodes + if node.get("is_external_project"): + vertex["properties"]["is_external_project"] = True + vertex["properties"]["external_project_name"] = node.get("external_project_name") + + # Mark whether this external node connects to home project + if node.get("has_home_connection"): + vertex["properties"]["has_home_connection"] = True + vertices.append(vertex) # Create edges (links) @@ -370,10 +387,25 @@ def create_graphson(nodes, links, page_list, clusters_config): # Collect all unique cluster names from nodes cluster_names = set() + external_project_clusters = {} # Track external project clusters and their connection status + for node in nodes: if node.get("cluster") is not None: cluster_names.add(node["cluster"]) + # Track external project clusters + if node.get("is_external_project"): + cluster = node["cluster"] + if cluster not in external_project_clusters: + external_project_clusters[cluster] = { + "has_any_home_connection": False, + "project_name": node.get("external_project_name") + } + + # Update if any node in this cluster connects to home + if node.get("has_home_connection"): + external_project_clusters[cluster]["has_any_home_connection"] = True + # Build complete cluster list: manual configs + auto-generated clusters all_clusters = list(clusters_config) if clusters_config else [] manual_cluster_names = {c.get("name") for c in clusters_config} if clusters_config else set() @@ -381,10 +413,22 @@ def create_graphson(nodes, links, page_list, clusters_config): # Add auto-generated clusters that aren't already in manual config for cluster_name in cluster_names: if cluster_name not in manual_cluster_names: - all_clusters.append({ + cluster_config = { "name": cluster_name, "patterns": [] # Auto-generated clusters don't have patterns - }) + } + + # For external project clusters, set default visibility based on home connections + if cluster_name in external_project_clusters: + cluster_info = external_project_clusters[cluster_name] + cluster_config["is_external_project"] = True + cluster_config["external_project_name"] = cluster_info["project_name"] + + # Default to hidden if no connections to home project + if not cluster_info["has_any_home_connection"]: + cluster_config["default_hidden"] = True + + all_clusters.append(cluster_config) # Include cluster configuration metadata graphson = { @@ -398,6 +442,219 @@ def create_graphson(nodes, links, page_list, clusters_config): return graphson +def fetch_external_project_data(app, project_name): + """ + Fetch graphson.json data from an external intersphinx project. + + Args: + app: Sphinx application object + project_name: Name of the project in intersphinx_mapping + + Returns: + Dictionary with graphson data if successful, None otherwise + """ + intersphinx_mapping = getattr(app.config, 'intersphinx_mapping', {}) + + if project_name not in intersphinx_mapping: + logger.warning(f"Project '{project_name}' not found in intersphinx_mapping") + return None + + project_info = intersphinx_mapping[project_name] + + # Extract base URL from intersphinx_mapping format + base_url = None + if isinstance(project_info, tuple): + if len(project_info) >= 2 and isinstance(project_info[1], tuple): + # Processed format: ('sphinx', ('https://...', (None,))) + base_url = project_info[1][0] if len(project_info[1]) > 0 else None + elif len(project_info) >= 1: + # Original format: ('https://...', None) + base_url = project_info[0] + else: + base_url = project_info + + if not base_url: + logger.warning(f"Could not extract URL for project '{project_name}'") + return None + + # Normalize URL and construct graphson.json path + base_url = base_url.rstrip('/') + + # Check if base_url is a local path or URL + is_local_path = not base_url.startswith(('http://', 'https://', 'file://')) + + if is_local_path: + # Handle local file path + # Convert relative path to absolute based on the conf.py location + if not os.path.isabs(base_url): + # Get the source directory (where conf.py is located) + confdir = app.confdir + base_url = os.path.abspath(os.path.join(confdir, base_url)) + + graphson_path = os.path.join(base_url, '_static', 'sphinx-visualized', 'graphson.json') + + try: + if not os.path.exists(graphson_path): + logger.warning(f"Could not find graphson.json for '{project_name}' at {graphson_path}. " + f"The project may not have sphinx-visualized extension installed or not built yet.") + return None + + with open(graphson_path, 'r', encoding='utf-8') as f: + data = json.load(f) + return { + 'data': data, + 'base_url': base_url, + 'project_name': project_name + } + except json.JSONDecodeError as e: + logger.warning(f"Invalid JSON data for '{project_name}': {e}") + return None + except Exception as e: + logger.warning(f"Error reading local file for '{project_name}': {e}") + return None + else: + # Handle remote URL + graphson_url = f"{base_url}/_static/sphinx-visualized/graphson.json" + + try: + with urllib.request.urlopen(graphson_url, timeout=10) as response: + data = json.loads(response.read().decode('utf-8')) + return { + 'data': data, + 'base_url': base_url, + 'project_name': project_name + } + except urllib.error.HTTPError as e: + logger.warning(f"Could not fetch graphson.json for '{project_name}' (HTTP {e.code}). " + f"The project may not have sphinx-visualized extension installed.") + return None + except urllib.error.URLError as e: + logger.warning(f"Network error fetching data for '{project_name}': {e.reason}") + return None + except json.JSONDecodeError as e: + logger.warning(f"Invalid JSON data for '{project_name}': {e}") + return None + except Exception as e: + logger.warning(f"Unexpected error fetching data for '{project_name}': {e}") + return None + + +def merge_external_project_data(home_nodes, home_links, external_project_data, home_page_list): + """ + Merge external project data into the home project's graph. + + Args: + home_nodes: List of nodes from the home project + home_links: List of links from the home project + external_project_data: Dictionary with external project data + home_page_list: List of page identifiers from home project + + Returns: + Tuple of (merged_nodes, merged_links, node_id_offset) + """ + if not external_project_data or 'data' not in external_project_data: + return home_nodes, home_links, len(home_nodes) + + graphson_data = external_project_data['data'] + base_url = external_project_data['base_url'] + project_name = external_project_data['project_name'] + + # Offset for external node IDs to avoid conflicts + node_id_offset = len(home_nodes) + + # Track which external nodes connect to home project + external_nodes_with_home_connections = set() + + # First pass: identify connections between external project and home project + # Check if any home nodes link to external project URLs + for home_node in home_nodes: + if home_node.get('is_intersphinx') and home_node.get('path', '').startswith(base_url): + # This home node is actually a reference to the external project + # Mark it for later matching + external_nodes_with_home_connections.add(home_node['path']) + + # Create mapping of external URLs to new node IDs + external_url_to_node = {} + merged_nodes = list(home_nodes) + + # Add external project nodes with offset IDs + for vertex in graphson_data.get('vertices', []): + original_id = vertex['id'] + new_id = original_id + node_id_offset + + # Construct full URL for this external node + node_path = vertex['properties'].get('path', '') + + # Handle relative paths from external project + if node_path.startswith('../../../'): + # Convert relative path to absolute URL + relative_path = node_path.replace('../../../', '') + full_url = f"{base_url}/{relative_path}" + else: + full_url = node_path + + # Check if this external node connects to home project + has_home_connection = full_url in external_nodes_with_home_connections + + # Add cluster prefix to indicate external project + original_cluster = vertex['properties'].get('cluster') + if original_cluster: + cluster_name = f"{project_name}:{original_cluster}" + else: + cluster_name = project_name + + merged_nodes.append({ + 'id': new_id, + 'label': vertex['properties'].get('name', ''), + 'path': full_url, + 'cluster': cluster_name, + 'is_external_project': True, + 'external_project_name': project_name, + 'has_home_connection': has_home_connection, + }) + + external_url_to_node[full_url] = new_id + + # Update existing home nodes that reference the external project + for home_node in merged_nodes: + if home_node.get('is_intersphinx') and home_node.get('path', '').startswith(base_url): + # Try to find matching external node + if home_node['path'] in external_url_to_node: + # Mark that this exists in external project + home_node['external_project_node_id'] = external_url_to_node[home_node['path']] + + # Add external project edges with offset IDs + merged_links = list(home_links) + for edge in graphson_data.get('edges', []): + # Check if both nodes are in external project (not connected to home) + source_id = edge['outV'] + node_id_offset + target_id = edge['inV'] + node_id_offset + + # Find if either endpoint connects to home + source_node = next((n for n in merged_nodes if n['id'] == source_id), None) + target_node = next((n for n in merged_nodes if n['id'] == target_id), None) + + has_home_connection = False + if source_node and source_node.get('has_home_connection'): + has_home_connection = True + if target_node and target_node.get('has_home_connection'): + has_home_connection = True + + merged_links.append({ + 'source': source_id, + 'target': target_id, + 'strength': edge['properties'].get('strength', 1), + 'reference_count': edge['properties'].get('reference_count', 1), + 'type': edge['label'], + 'types': edge['properties'].get('types', [edge['label']]), + 'is_external_project': True, + 'external_project_name': project_name, + 'has_home_connection': has_home_connection, + }) + + return merged_nodes, merged_links, node_id_offset + + def create_json(app, exception): """ Create and copy static files for visualizations @@ -563,6 +820,18 @@ def create_json(app, exception): with open(filename, "w") as json_file: json_file.write(f'var toctree = {json.dumps(build_toctree_hierarchy(app), indent=4)};') + # Fetch and merge external project data if configured + visualised_projects = getattr(app.config, 'visualised_projects', []) + if visualised_projects: + logger.info(f"sphinx-vizualised: Fetching data for {len(visualised_projects)} external project(s): {', '.join(visualised_projects)}") + + for project_name in visualised_projects: + external_data = fetch_external_project_data(app, project_name) + if external_data: + nodes, links, _ = merge_external_project_data(nodes, links, external_data, page_list) + else: + logger.info(f"sphinx-vizualised: Skipping '{project_name}' - data could not be fetched") + # Create GraphSON format graphson = create_graphson(nodes, links, page_list, clusters_config) filename = Path(app.outdir) / "_static" / "sphinx-visualized" / "graphson.json" diff --git a/sphinx_visualized/static/js/link-graph.js b/sphinx_visualized/static/js/link-graph.js index dc545f0..b5f7585 100644 --- a/sphinx_visualized/static/js/link-graph.js +++ b/sphinx_visualized/static/js/link-graph.js @@ -438,7 +438,8 @@ window.addEventListener('DOMContentLoaded', async () => { // Track which clusters are visible const visibleClusters = {}; clusterConfig.forEach(cluster => { - visibleClusters[cluster.name] = true; + // Default to hidden if cluster is marked as default_hidden + visibleClusters[cluster.name] = !cluster.default_hidden; }); const updateGraph = () => { @@ -775,6 +776,28 @@ window.addEventListener('DOMContentLoaded', async () => { renderLinkTypesPanel(); } + // Apply initial visibility for clusters with default_hidden flag + // This must be done after updateEdgeVisibilityByLinkType is defined + if (clusterConfig.length > 0 && legendContainer) { + // Find the updateGraph function from the cluster panel scope + // Since it's in a closure, we need to trigger it by checking visibility + graph.forEachNode((node) => { + const nodeData = graph.getNodeAttributes(node); + const cluster = nodeData.cluster; + + // Check if this cluster should be hidden by default + const clusterInfo = clusterConfig.find(c => c.name === cluster); + if (clusterInfo && clusterInfo.default_hidden) { + graph.setNodeAttribute(node, 'hidden', true); + } + }); + + // Update edge visibility after hiding nodes + if (typeof updateEdgeVisibilityByLinkType === 'function') { + updateEdgeVisibilityByLinkType(); + } + } + // Search functionality const searchInput = document.getElementById('search'); searchInput.addEventListener('input', (e) => { From 8ee24f35fb0bf7d5b1aec824677bbc136ba71b9b Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Sun, 9 Nov 2025 23:52:07 -0800 Subject: [PATCH 02/16] fix link between clusters --- docs/source/getting-started.rst | 3 +- sphinx_visualized/__init__.py | 147 ++++++++++++++++++++++++-------- 2 files changed, 115 insertions(+), 35 deletions(-) diff --git a/docs/source/getting-started.rst b/docs/source/getting-started.rst index a998cb2..1c23d70 100644 --- a/docs/source/getting-started.rst +++ b/docs/source/getting-started.rst @@ -10,7 +10,8 @@ Directly install via pip by using: pip install sphinx-visualized -Add ``sphinx_visualized`` to the `extensions `_ array in your Sphinx **conf.py**. +Add ``sphinx_visualized`` to the :confval:`extensions ` array in your Sphinx **conf.py**. + For example: .. code-block:: python diff --git a/sphinx_visualized/__init__.py b/sphinx_visualized/__init__.py index 7c9b9ff..6096851 100644 --- a/sphinx_visualized/__init__.py +++ b/sphinx_visualized/__init__.py @@ -539,11 +539,12 @@ def fetch_external_project_data(app, project_name): return None -def merge_external_project_data(home_nodes, home_links, external_project_data, home_page_list): +def merge_external_project_data(app, home_nodes, home_links, external_project_data, home_page_list): """ Merge external project data into the home project's graph. Args: + app: Sphinx application object home_nodes: List of nodes from the home project home_links: List of links from the home project external_project_data: Dictionary with external project data @@ -568,17 +569,40 @@ def merge_external_project_data(home_nodes, home_links, external_project_data, h # First pass: identify connections between external project and home project # Check if any home nodes link to external project URLs for home_node in home_nodes: - if home_node.get('is_intersphinx') and home_node.get('path', '').startswith(base_url): - # This home node is actually a reference to the external project - # Mark it for later matching - external_nodes_with_home_connections.add(home_node['path']) + if home_node.get('is_intersphinx'): + node_path = home_node.get('path', '') + + # Normalize the path to absolute if it's relative + if not node_path.startswith(('http://', 'https://', 'file://')): + # It's a relative path - convert to absolute + if not os.path.isabs(node_path): + confdir = app.confdir + # Remove leading ../../../ and make absolute + node_path = os.path.abspath(os.path.join(confdir, node_path)) + + # Check if this path is under the base_url directory + # Strip fragments for comparison + node_path_base = node_path.split('#')[0] + base_url_normalized = base_url.split('#')[0] + + if node_path_base.startswith(base_url_normalized): + # This home node is actually a reference to the external project + # Mark it for later matching (store with fragment for specific matching) + external_nodes_with_home_connections.add(node_path) # Create mapping of external URLs to new node IDs external_url_to_node = {} merged_nodes = list(home_nodes) - # Add external project nodes with offset IDs + # Add external project nodes with offset IDs (only internal pages, not their external references) for vertex in graphson_data.get('vertices', []): + # Skip external/intersphinx nodes from the external project + # We only want their internal documentation pages + if vertex.get('label') in ['external', 'intersphinx', 'external_project']: + continue + if vertex.get('properties', {}).get('is_external') or vertex.get('properties', {}).get('is_intersphinx'): + continue + original_id = vertex['id'] new_id = original_id + node_id_offset @@ -594,14 +618,17 @@ def merge_external_project_data(home_nodes, home_links, external_project_data, h full_url = node_path # Check if this external node connects to home project - has_home_connection = full_url in external_nodes_with_home_connections + # Need to check both exact match and base path match (without fragment) + full_url_base = full_url.split('#')[0] + has_home_connection = any( + ref == full_url or ref.split('#')[0] == full_url_base + for ref in external_nodes_with_home_connections + ) - # Add cluster prefix to indicate external project - original_cluster = vertex['properties'].get('cluster') - if original_cluster: - cluster_name = f"{project_name}:{original_cluster}" - else: - cluster_name = project_name + # All nodes from external project go into single cluster named after the project + # (ignore the external project's internal cluster structure) + # Use "(external)" suffix to match existing intersphinx node pattern + cluster_name = f"{project_name} (external)" merged_nodes.append({ 'id': new_id, @@ -613,18 +640,64 @@ def merge_external_project_data(home_nodes, home_links, external_project_data, h 'has_home_connection': has_home_connection, }) + # Store both with and without fragments for matching external_url_to_node[full_url] = new_id + external_url_to_node[full_url_base] = new_id + + # Create mapping from old intersphinx node IDs to new external project node IDs + node_id_redirect = {} + intersphinx_node_ids_to_remove = set() - # Update existing home nodes that reference the external project for home_node in merged_nodes: - if home_node.get('is_intersphinx') and home_node.get('path', '').startswith(base_url): - # Try to find matching external node - if home_node['path'] in external_url_to_node: - # Mark that this exists in external project - home_node['external_project_node_id'] = external_url_to_node[home_node['path']] - - # Add external project edges with offset IDs - merged_links = list(home_links) + # ONLY process nodes that are explicitly marked as intersphinx + # This ensures we don't accidentally remove home project's internal nodes + if not home_node.get('is_intersphinx'): + continue + + node_path = home_node.get('path', '') + + # Normalize the path to absolute if it's relative + if not node_path.startswith(('http://', 'https://', 'file://')): + if not os.path.isabs(node_path): + confdir = app.confdir + node_path = os.path.abspath(os.path.join(confdir, node_path)) + + # Check if this path matches the base_url + node_path_base = node_path.split('#')[0] + if node_path_base.startswith(base_url.split('#')[0]): + # Try to find matching external node (try both with and without fragment) + matched_id = external_url_to_node.get(node_path) or external_url_to_node.get(node_path_base) + if matched_id: + # Map the old intersphinx stub node ID to the actual external project node ID + old_id = home_node['id'] + new_id = matched_id + node_id_redirect[old_id] = new_id + # Mark this intersphinx stub node for removal (we'll use the full external node instead) + intersphinx_node_ids_to_remove.add(old_id) + + # Remove intersphinx stub nodes that have been replaced by full external project nodes + # Only remove nodes that are in the removal set (these should only be intersphinx stubs) + merged_nodes = [n for n in merged_nodes if n['id'] not in intersphinx_node_ids_to_remove] + + # Update home links to redirect edges from intersphinx stubs to actual external nodes + merged_links = [] + for link in home_links: + source_id = link['source'] + target_id = link['target'] + + # Redirect if either endpoint is an intersphinx stub that now has a real external node + if source_id in node_id_redirect: + source_id = node_id_redirect[source_id] + if target_id in node_id_redirect: + target_id = node_id_redirect[target_id] + + merged_links.append({ + **link, + 'source': source_id, + 'target': target_id, + }) + + # Add external project edges with offset IDs (only edges between internal nodes) for edge in graphson_data.get('edges', []): # Check if both nodes are in external project (not connected to home) source_id = edge['outV'] + node_id_offset @@ -634,6 +707,10 @@ def merge_external_project_data(home_nodes, home_links, external_project_data, h source_node = next((n for n in merged_nodes if n['id'] == source_id), None) target_node = next((n for n in merged_nodes if n['id'] == target_id), None) + # Skip edges where either node was filtered out (external/intersphinx nodes) + if not source_node or not target_node: + continue + has_home_connection = False if source_node and source_node.get('has_home_connection'): has_home_connection = True @@ -808,6 +885,20 @@ def create_json(app, exception): "types": link_types, # New field: all link types for this edge }) + # Fetch and merge external project data if configured + # This must happen BEFORE writing nodes.js and links.js + visualised_projects = getattr(app.config, 'visualised_projects', []) + if visualised_projects: + logger.info(f"Fetching data for {len(visualised_projects)} external project(s): {', '.join(visualised_projects)}") + + for project_name in visualised_projects: + external_data = fetch_external_project_data(app, project_name) + if external_data: + logger.info(f"Successfully fetched data for '{project_name}', merging into graph...") + nodes, links, _ = merge_external_project_data(app, nodes, links, external_data, page_list) + else: + logger.info(f"Skipping '{project_name}' - data could not be fetched") + filename = Path(app.outdir) / "_static" / "sphinx-visualized" / "js" / "links.js" with open(filename, "w") as json_file: json_file.write(f'var links_data = {json.dumps(links, indent=4)};') @@ -820,18 +911,6 @@ def create_json(app, exception): with open(filename, "w") as json_file: json_file.write(f'var toctree = {json.dumps(build_toctree_hierarchy(app), indent=4)};') - # Fetch and merge external project data if configured - visualised_projects = getattr(app.config, 'visualised_projects', []) - if visualised_projects: - logger.info(f"sphinx-vizualised: Fetching data for {len(visualised_projects)} external project(s): {', '.join(visualised_projects)}") - - for project_name in visualised_projects: - external_data = fetch_external_project_data(app, project_name) - if external_data: - nodes, links, _ = merge_external_project_data(nodes, links, external_data, page_list) - else: - logger.info(f"sphinx-vizualised: Skipping '{project_name}' - data could not be fetched") - # Create GraphSON format graphson = create_graphson(nodes, links, page_list, clusters_config) filename = Path(app.outdir) / "_static" / "sphinx-visualized" / "graphson.json" From b861197e21c2b829c3aa6e8f9189a5295870f9e2 Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Sat, 15 Nov 2025 17:30:14 -0800 Subject: [PATCH 03/16] fix cross cluster links --- sphinx_visualized/__init__.py | 6 +- sphinx_visualized/static/js/link-graph.js | 84 ++++++++++++++++++++--- 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/sphinx_visualized/__init__.py b/sphinx_visualized/__init__.py index 6096851..a4fbcdc 100644 --- a/sphinx_visualized/__init__.py +++ b/sphinx_visualized/__init__.py @@ -424,7 +424,11 @@ def create_graphson(nodes, links, page_list, clusters_config): cluster_config["is_external_project"] = True cluster_config["external_project_name"] = cluster_info["project_name"] - # Default to hidden if no connections to home project + # For external projects, only show nodes with home connections by default + # Unlinked nodes can be revealed with a checkbox + cluster_config["show_only_connected_by_default"] = True + + # Default to completely hidden if no connections to home project at all if not cluster_info["has_any_home_connection"]: cluster_config["default_hidden"] = True diff --git a/sphinx_visualized/static/js/link-graph.js b/sphinx_visualized/static/js/link-graph.js index b5f7585..2d59af0 100644 --- a/sphinx_visualized/static/js/link-graph.js +++ b/sphinx_visualized/static/js/link-graph.js @@ -113,6 +113,7 @@ window.addEventListener('DOMContentLoaded', async () => { originalColor: nodeColor, isExternal: vertex.properties.is_external, isIntersphinx: isIntersphinx, + has_home_connection: vertex.properties.has_home_connection || false, x: Math.random() * 100, y: Math.random() * 100 }); @@ -437,9 +438,14 @@ window.addEventListener('DOMContentLoaded', async () => { if (legendContainer) { // Track which clusters are visible const visibleClusters = {}; + // Track whether to show unlinked nodes for external project clusters + const showUnlinkedNodes = {}; + clusterConfig.forEach(cluster => { // Default to hidden if cluster is marked as default_hidden visibleClusters[cluster.name] = !cluster.default_hidden; + // Default to NOT showing unlinked nodes for external projects + showUnlinkedNodes[cluster.name] = false; }); const updateGraph = () => { @@ -447,12 +453,35 @@ window.addEventListener('DOMContentLoaded', async () => { const nodeData = graph.getNodeAttributes(node); const cluster = nodeData.cluster; - if (!cluster || visibleClusters[cluster]) { + // Nodes without a cluster are always visible + if (!cluster) { graph.setNodeAttribute(node, 'color', nodeData.originalColor); graph.setNodeAttribute(node, 'hidden', false); - } else { + return; + } + + // Check if cluster is visible + if (!visibleClusters[cluster]) { graph.setNodeAttribute(node, 'hidden', true); + return; } + + // Cluster is visible - now check if this is an external project node without home connection + const clusterConfig_item = clusterConfig.find(c => c.name === cluster); + if (clusterConfig_item && clusterConfig_item.show_only_connected_by_default) { + // This is an external project cluster that should only show connected nodes by default + const hasHomeConnection = nodeData.has_home_connection; + + if (!hasHomeConnection && !showUnlinkedNodes[cluster]) { + // Node is not connected to home and we're not showing unlinked nodes + graph.setNodeAttribute(node, 'hidden', true); + return; + } + } + + // Node should be visible + graph.setNodeAttribute(node, 'color', nodeData.originalColor); + graph.setNodeAttribute(node, 'hidden', false); }); // Update edge visibility based on link types after node visibility changes @@ -506,7 +535,9 @@ window.addEventListener('DOMContentLoaded', async () => { const li = document.createElement('li'); li.className = 'caption-row'; li.title = `${count} page${count !== 1 ? 's' : ''}`; - li.innerHTML = ` + + // Build the main cluster checkbox + let html = ` `; - li.querySelector('input').addEventListener('change', (e) => { + // Add secondary checkbox for external projects with linked/unlinked toggle + if (cluster.show_only_connected_by_default) { + const unlinkedChecked = showUnlinkedNodes[cluster.name]; + html += ` + + + `; + } + + li.innerHTML = html; + + // Main cluster checkbox handler + li.querySelector(`#cluster-${index}`).addEventListener('change', (e) => { visibleClusters[cluster.name] = e.target.checked; updateGraph(); renderLegend(); }); + // Unlinked nodes checkbox handler (if present) + if (cluster.show_only_connected_by_default) { + const unlinkedCheckbox = li.querySelector(`#cluster-unlinked-${index}`); + if (unlinkedCheckbox) { + unlinkedCheckbox.addEventListener('change', (e) => { + showUnlinkedNodes[cluster.name] = e.target.checked; + updateGraph(); + }); + } + } + list.appendChild(li); }); @@ -776,19 +832,31 @@ window.addEventListener('DOMContentLoaded', async () => { renderLinkTypesPanel(); } - // Apply initial visibility for clusters with default_hidden flag + // Apply initial visibility for clusters with default_hidden flag and show_only_connected_by_default // This must be done after updateEdgeVisibilityByLinkType is defined if (clusterConfig.length > 0 && legendContainer) { - // Find the updateGraph function from the cluster panel scope - // Since it's in a closure, we need to trigger it by checking visibility graph.forEachNode((node) => { const nodeData = graph.getNodeAttributes(node); const cluster = nodeData.cluster; - // Check if this cluster should be hidden by default + if (!cluster) return; + + // Check cluster configuration const clusterInfo = clusterConfig.find(c => c.name === cluster); + + // Hide entire cluster if marked as default_hidden if (clusterInfo && clusterInfo.default_hidden) { graph.setNodeAttribute(node, 'hidden', true); + return; + } + + // For external project clusters, hide unlinked nodes by default + if (clusterInfo && clusterInfo.show_only_connected_by_default) { + const hasHomeConnection = nodeData.has_home_connection; + if (!hasHomeConnection) { + graph.setNodeAttribute(node, 'hidden', true); + return; + } } }); From 7b202068b4e7dce00047cb30cbc7bf6f93b3a783 Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Sat, 15 Nov 2025 17:34:09 -0800 Subject: [PATCH 04/16] fix show/hide unlinked cluster --- sphinx_visualized/static/js/link-graph.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx_visualized/static/js/link-graph.js b/sphinx_visualized/static/js/link-graph.js index 2d59af0..956860a 100644 --- a/sphinx_visualized/static/js/link-graph.js +++ b/sphinx_visualized/static/js/link-graph.js @@ -553,7 +553,7 @@ window.addEventListener('DOMContentLoaded', async () => { const unlinkedChecked = showUnlinkedNodes[cluster.name]; html += ` -