diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e64e3f1..5f22d2c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,11 @@ Changelog ========= +0.9.0 +----- + +- Add support for integrating external Sphinx projects with :confval:`visualized_projects` + 0.8.2 ----- diff --git a/docs/source/advanced-configuration.rst b/docs/source/advanced-configuration.rst index 21a1e80..8aa2308 100644 --- a/docs/source/advanced-configuration.rst +++ b/docs/source/advanced-configuration.rst @@ -71,6 +71,24 @@ With auto-clustering enabled: - Root-level pages (like ``index.html``) remain unclustered - You can combine auto-clustering with manual clusters - manual patterns take precedence +.. _external_projects: + +Integrating External Projects +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Integrate documentation from other Sphinx projects that also use the **sphinx-visualized** extension: + +.. code-block:: python + + intersphinx_mapping = { + "sphinx": ("https://www.sphinx-doc.org/en/master/", None), + "python": ("https://docs.python.org/3/", None), + } + + visualized_projects = ['sphinx'] + +.. note:: Each project name must match an entry in your :confval:`intersphinx_mapping`. + GraphSON Export ^^^^^^^^^^^^^^^ diff --git a/docs/source/conf.py b/docs/source/conf.py index 6dcd96b..eab288c 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), } +visualized_projects = ['sphinx'] + # -- Options for HTML output ------------------------------------------------- diff --git a/docs/source/configuration-values.rst b/docs/source/configuration-values.rst index 13de82a..19fd342 100644 --- a/docs/source/configuration-values.rst +++ b/docs/source/configuration-values.rst @@ -22,3 +22,12 @@ For advanced usage, see :doc:`advanced-configuration`. See :ref:`auto_clustering` for more details. .. versionadded:: 0.6.0 + +.. confval:: visualized_projects + + - **Type**: list of strings + - **Default**: ``[]`` (empty list) + - **Description**: List of external Sphinx projects to integrate into the link graph visualization. + See :ref:`external_projects` for detailed usage examples. + + .. versionadded:: 0.9.0 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 dbfda0c..005b92b 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 +from sphinx.util import logging -__version__ = "0.8.2" +from .external import fetch_external_project_data, merge_external_project_data + +__version__ = "0.9.0" + +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("visualized_projects", [], "html") app.connect("builder-inited", create_objects) app.connect("doctree-resolved", get_links) app.connect("doctree-resolved", track_includes) @@ -412,6 +418,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" @@ -436,6 +444,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) @@ -457,10 +474,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() @@ -468,10 +500,26 @@ 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"] + + # 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 + + all_clusters.append(cluster_config) # Include cluster configuration metadata graphson = { @@ -638,6 +686,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 + visualized_projects = getattr(app.config, 'visualized_projects', []) + if visualized_projects: + logger.info(f"Fetching data for {len(visualized_projects)} external project(s): {', '.join(visualized_projects)}") + + for project_name in visualized_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)};') diff --git a/sphinx_visualized/external.py b/sphinx_visualized/external.py new file mode 100644 index 0000000..25647bd --- /dev/null +++ b/sphinx_visualized/external.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +External project integration for sphinx-visualized. + +This module handles fetching and merging graph data from external Sphinx projects +that also use the sphinx-visualized extension. +""" + +import json +import os +import urllib.request +import urllib.error +from sphinx.util import logging + +logger = logging.getLogger(__name__) + + +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(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 + 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 + # Use max ID + 1 to handle sparse/non-sequential IDs + node_id_offset = max([n['id'] for n in home_nodes]) + 1 if home_nodes else 0 + + # 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'): + 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 (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 + + # 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 + # 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 + ) + + # 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, + '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, + }) + + # 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() + + for home_node in merged_nodes: + # 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 + 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) + + # 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 + 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 diff --git a/sphinx_visualized/static/css/graph-panels.css b/sphinx_visualized/static/css/graph-panels.css index 46da49c..2aedf63 100644 --- a/sphinx_visualized/static/css/graph-panels.css +++ b/sphinx_visualized/static/css/graph-panels.css @@ -112,7 +112,7 @@ display: none; } -/* Show real checkboxes for link types panel */ +/* Show real checkboxes for link types panel (left side) */ #link-types-panel .caption-row input[type="checkbox"] { display: inline-block; width: 1rem; @@ -124,18 +124,48 @@ accent-color: hsl(215.4 16.3% 46.9%); } -#link-types-panel .caption-row { +/* Link toggle icon for cluster panel (right side) */ +#cluster-panel .link-toggle-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + margin-left: 0.5rem; + margin-top: 0.125rem; + cursor: pointer; + flex-shrink: 0; + transition: opacity 0.2s ease, transform 0.1s ease; + opacity: 0.7; +} + +#cluster-panel .link-toggle-icon:hover { + opacity: 1; + transform: scale(1.1); +} + +#cluster-panel .link-toggle-icon svg { + width: 100%; + height: 100%; + stroke: hsl(215.4 16.3% 46.9%); + fill: none; +} + +#link-types-panel .caption-row, +#cluster-panel .caption-row { display: flex; align-items: flex-start; } -#link-types-panel .caption-row label { +#link-types-panel .caption-row label, +#cluster-panel .caption-row label { flex: 1; min-width: 0; } -/* For non-link-types panels: dim unchecked items */ -.caption-row input[type="checkbox"]:not(:checked) + label { +/* For non-link-types, non-cluster panels: dim unchecked items */ +#type-panel .caption-row input[type="checkbox"]:not(:checked) + label, +#category-panel .caption-row input[type="checkbox"]:not(:checked) + label { color: hsl(215.4 16.3% 46.9%); opacity: 0.7; } @@ -150,19 +180,32 @@ background-color: var(--active-color, hsl(222.2 47.4% 11.2%)) !important; } -/* For other panels (type-panel, category-panel, cluster-panel): fill circles based on checked state */ +/* For other panels (type-panel, category-panel): fill circles based on checked state */ #type-panel .caption-row input[type="checkbox"]:checked + label .circle, -#category-panel .caption-row input[type="checkbox"]:checked + label .circle, -#cluster-panel .caption-row input[type="checkbox"]:checked + label .circle { +#category-panel .caption-row input[type="checkbox"]:checked + label .circle { /* Background color is already set inline from JS, just ensure it's visible */ } #type-panel .caption-row input[type="checkbox"]:not(:checked) + label .circle, -#category-panel .caption-row input[type="checkbox"]:not(:checked) + label .circle, -#cluster-panel .caption-row input[type="checkbox"]:not(:checked) + label .circle { +#category-panel .caption-row input[type="checkbox"]:not(:checked) + label .circle { background-color: white !important; } +/* For cluster panel: fill circles based on cluster visibility (not checkbox state) */ +#cluster-panel .caption-row.cluster-visible label .circle { + /* Background color is already set inline from JS, just ensure it's visible */ +} + +#cluster-panel .caption-row:not(.cluster-visible) label .circle { + background-color: white !important; +} + +/* For cluster panel: dim hidden clusters */ +#cluster-panel .caption-row:not(.cluster-visible) { + color: hsl(215.4 16.3% 46.9%); + opacity: 0.7; +} + .caption-row label { display: flex; flex-direction: row; diff --git a/sphinx_visualized/static/js/link-graph.js b/sphinx_visualized/static/js/link-graph.js index 4b69d01..c8e2c91 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 }); @@ -465,8 +466,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 => { - visibleClusters[cluster.name] = true; + // 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 = () => { @@ -474,12 +481,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 @@ -491,9 +521,16 @@ window.addEventListener('DOMContentLoaded', async () => { const renderLegend = async () => { const visibleCount = Object.values(visibleClusters).filter(v => v).length; - // Load chevron and cube-transparent SVGs + // Load SVGs const chevronResponse = await fetch('../svg/chevron-down.svg'); const chevronSvg = await chevronResponse.text(); + + const linkResponse = await fetch('../svg/link.svg'); + const linkSvg = await linkResponse.text(); + + const linkSlashResponse = await fetch('../svg/link-slash.svg'); + const linkSlashSvg = await linkSlashResponse.text(); + const cubeResponse = await fetch('../svg/cube-transparent.svg'); const cubeSvg = await cubeResponse.text(); @@ -534,21 +571,62 @@ window.addEventListener('DOMContentLoaded', async () => { const barWidth = (100 * count) / maxNodesPerCluster; const li = document.createElement('li'); - li.className = 'caption-row'; + li.className = `caption-row ${isChecked ? 'cluster-visible' : ''}`; li.title = `${count} page${count !== 1 ? 's' : ''}`; - li.innerHTML = ` - - - `; - - li.querySelector('input').addEventListener('change', (e) => { - visibleClusters[cluster.name] = e.target.checked; + + // Build the cluster UI + // For external projects: checkbox toggles unlinked nodes, circle toggles cluster + // For regular clusters: hide checkbox, circle toggles cluster + let html = ''; + + if (cluster.show_only_connected_by_default) { + const showingUnlinked = showUnlinkedNodes[cluster.name]; + const iconSvg = showingUnlinked ? linkSlashSvg : linkSvg; + html = ` + + ${iconSvg} + `; + } else { + // Regular cluster - no icon needed + html = ` + + `; + } + + li.innerHTML = html; + + // Link toggle icon handler (for external projects only - toggles unlinked nodes) + if (cluster.show_only_connected_by_default) { + const linkToggle = li.querySelector('.link-toggle-icon'); + if (linkToggle) { + linkToggle.addEventListener('click', (e) => { + e.stopPropagation(); + showUnlinkedNodes[cluster.name] = !showUnlinkedNodes[cluster.name]; + updateGraph(); + renderLegend(); + }); + } + } + + // Circle/label click handler - toggles entire cluster visibility + const label = li.querySelector('label'); + label.addEventListener('click', (e) => { + e.preventDefault(); + + // Toggle cluster visibility + visibleClusters[cluster.name] = !visibleClusters[cluster.name]; updateGraph(); renderLegend(); }); @@ -817,6 +895,40 @@ window.addEventListener('DOMContentLoaded', async () => { } } + // 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) { + graph.forEachNode((node) => { + const nodeData = graph.getNodeAttributes(node); + const cluster = nodeData.cluster; + + 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; + } + } + }); + + // Update edge visibility after hiding nodes + if (typeof updateEdgeVisibilityByLinkType === 'function') { + updateEdgeVisibilityByLinkType(); + } + } + // Search functionality const searchInput = document.getElementById('search'); searchInput.addEventListener('input', (e) => { diff --git a/sphinx_visualized/static/svg/link-slash.svg b/sphinx_visualized/static/svg/link-slash.svg new file mode 100644 index 0000000..b2bbb3f --- /dev/null +++ b/sphinx_visualized/static/svg/link-slash.svg @@ -0,0 +1,4 @@ + + + + diff --git a/sphinx_visualized/static/svg/link.svg b/sphinx_visualized/static/svg/link.svg new file mode 100644 index 0000000..32fd3ba --- /dev/null +++ b/sphinx_visualized/static/svg/link.svg @@ -0,0 +1,4 @@ + + + +