Source code for neofoam.framework.graph.visualization

# SPDX-License-Identifier: GPL-3.0-or-later
#
# SPDX-FileCopyrightText: 2026 NeoFOAM authors

"""Visualization helpers for graph structures.

Public entry points are :func:`digraph_to_pyvis_html` (HTML rendering) and
:func:`dependency_dag` (compose per-domain operation DAGs into a single graph
for rendering or diagnostics).
"""

import json
from typing import Mapping

import networkx as nx  # type: ignore[import-untyped]
from pyvis.network import Network  # type: ignore[import-untyped]

from neofoam.framework.operations import OperationCollection, Operations
from neofoam.framework.types import OperationMetadata

from .sorter import NetworkxTopologicalSorter


def _build_dag(nodes: list[OperationMetadata]) -> nx.DiGraph:
    """Build a DAG from a list of OperationMetadata objects."""
    graph = nx.DiGraph()
    for node in nodes:
        graph.add_node(
            node.name,
            meta=node,
            shape=node.shape,
            color=node.color,
            operation_number=node.operation_number,
        )
        for dependency in node.dependencies:
            graph.add_edge(dependency, node.name)
    return graph


def _build_global_dag(
    domains: Mapping[str, list[OperationMetadata]],
) -> nx.DiGraph:
    """Compose per-domain DAGs into a single global DAG."""
    graph = nx.DiGraph()
    for _, nodes in domains.items():
        sub_graph = _build_dag(nodes)
        graph = nx.compose(graph, sub_graph)
    return graph


[docs] def dependency_dag(domains: Mapping[str, list[OperationMetadata]]) -> nx.DiGraph: """Public entry point: build the global dependency DAG for ``domains``. Thin wrapper over :func:`_build_global_dag` so callers (e.g. :meth:`Simulation.dependency_graph`) bind to a stable public name. """ return _build_global_dag(domains)
def _compute_nodes_order(nodes: list[OperationMetadata]) -> list[str]: """Deterministic topological order over the node metadata list.""" graph = _build_dag(nodes) sorter = NetworkxTopologicalSorter( key=lambda node_name: ( graph.nodes[node_name].get("operation_number") is None, graph.nodes[node_name].get("operation_number"), node_name, ) ) return sorter.sort(graph) def _compute_steps_order(op_col: OperationCollection) -> Operations: """Order ``op_col``'s operations to match :func:`_compute_nodes_order`.""" nodes = [op.metadata for op in op_col.ops] sorted_names = _compute_nodes_order(nodes) sorted_ops = Operations() name_to_op = {op.operation_name: op for op in op_col.ops} for node_name in sorted_names: sorted_ops.add(name_to_op[node_name]) return sorted_ops
[docs] def digraph_to_pyvis_html(graph: nx.DiGraph, html_path: str = "dag.html") -> None: """Render a directed graph to a pyvis HTML file.""" net = Network(directed=True, notebook=False) for node, attrs in graph.nodes(data=True): shape = attrs.get("shape", "ellipse") net.add_node( node, label=str(node), shape=shape, color=attrs.get("color", "lightblue") ) for source, target in graph.edges: net.add_edge(source, target) options = { "layout": { "hierarchical": { "enabled": True, "direction": "UD", "sortMethod": "directed", "nodeSpacing": 180, "levelSeparation": 150, } }, "physics": {"enabled": True}, "edges": { "arrows": {"to": {"enabled": True}}, "smooth": False, "font": {"size": 14, "align": "middle"}, }, } net.set_options(json.dumps(options)) net.write_html(html_path)