Skip to content

Layout Algorithms

Network layout and positioning algorithms.

Overview

The layout.algorithms module provides various network layout algorithms:

  • Spring: Force-directed layout
  • Circular: Circular arrangement
  • Kamada-Kawai: Energy minimization
  • Spectral: Eigenvalue-based

Modules

pypopart.layout.algorithms

Layout algorithms for network visualization in PyPopART.

Provides various layout algorithms for positioning nodes in haplotype networks, including force-directed, hierarchical, spectral, and custom layouts.

Algorithm Selection Guide

For small networks (<50 nodes): - KamadaKawaiLayout: Best quality, slow - ForceDirectedLayout: Good quality, moderate speed

For medium networks (50-500 nodes): - ForceDirectedLayout: Default choice, good balance - SpectralLayout: Faster alternative, good quality - HierarchicalLayout: Very fast, tree-like structure

For large networks (>500 nodes): - SpectralLayout: Fast, maintains structure - HierarchicalLayout: Fastest option - CircularLayout: Simple, very fast

Special purposes: - RadialLayout: Emphasize central node - CircularLayout: Show connectivity patterns

LayoutAlgorithm

Base class for layout algorithms.

Provides interface for computing node positions in network visualizations.

Source code in src/pypopart/layout/algorithms.py
class LayoutAlgorithm:
    """
    Base class for layout algorithms.

    Provides interface for computing node positions in network visualizations.
    """

    def __init__(self, network: HaplotypeNetwork):
        """
        Initialize layout algorithm with a network.

        Parameters
        ----------
        network :
            HaplotypeNetwork object.
        """
        self.network = network
        self.graph = network._graph

    def compute(self, **kwargs) -> Dict[str, Tuple[float, float]]:
        """
            Compute node positions.

        Parameters
        ----------
            **kwargs :
                Algorithm-specific parameters.

        Returns
        -------
            Dictionary mapping node IDs to (x, y) positions.
        """
        raise NotImplementedError('Subclasses must implement compute()')

    def save_layout(
        self, layout: Dict[str, Tuple[float, float]], filename: str
    ) -> None:
        """
        Save layout to a JSON file.

        Parameters
        ----------
        layout :
            Node positions dictionary.
        filename :
            Output filename.
        """
        # Convert tuples to lists for JSON serialization
        layout_serializable = {node: list(pos) for node, pos in layout.items()}

        with open(filename, 'w') as f:
            json.dump(layout_serializable, f, indent=2)

    @staticmethod
    def load_layout(filename: str) -> Dict[str, Tuple[float, float]]:
        """
            Load layout from a JSON file.

        Parameters
        ----------
            filename :
                Input filename.

        Returns
        -------
            Dictionary mapping node IDs to (x, y) positions.
        """
        with open(filename, 'r') as f:
            layout_data = json.load(f)

        # Convert lists back to tuples
        return {node: tuple(pos) for node, pos in layout_data.items()}
__init__
__init__(network: HaplotypeNetwork)

Initialize layout algorithm with a network.

Parameters:

Name Type Description Default
network HaplotypeNetwork

HaplotypeNetwork object.

required
Source code in src/pypopart/layout/algorithms.py
def __init__(self, network: HaplotypeNetwork):
    """
    Initialize layout algorithm with a network.

    Parameters
    ----------
    network :
        HaplotypeNetwork object.
    """
    self.network = network
    self.graph = network._graph
compute
compute(**kwargs) -> Dict[str, Tuple[float, float]]
Compute node positions.

Returns:

Type Description
Dictionary mapping node IDs to (x, y) positions.
Source code in src/pypopart/layout/algorithms.py
def compute(self, **kwargs) -> Dict[str, Tuple[float, float]]:
    """
        Compute node positions.

    Parameters
    ----------
        **kwargs :
            Algorithm-specific parameters.

    Returns
    -------
        Dictionary mapping node IDs to (x, y) positions.
    """
    raise NotImplementedError('Subclasses must implement compute()')
save_layout
save_layout(
    layout: Dict[str, Tuple[float, float]], filename: str
) -> None

Save layout to a JSON file.

Parameters:

Name Type Description Default
layout Dict[str, Tuple[float, float]]

Node positions dictionary.

required
filename str

Output filename.

required
Source code in src/pypopart/layout/algorithms.py
def save_layout(
    self, layout: Dict[str, Tuple[float, float]], filename: str
) -> None:
    """
    Save layout to a JSON file.

    Parameters
    ----------
    layout :
        Node positions dictionary.
    filename :
        Output filename.
    """
    # Convert tuples to lists for JSON serialization
    layout_serializable = {node: list(pos) for node, pos in layout.items()}

    with open(filename, 'w') as f:
        json.dump(layout_serializable, f, indent=2)
load_layout staticmethod
load_layout(
    filename: str,
) -> Dict[str, Tuple[float, float]]
Load layout from a JSON file.

Returns:

Type Description
Dictionary mapping node IDs to (x, y) positions.
Source code in src/pypopart/layout/algorithms.py
@staticmethod
def load_layout(filename: str) -> Dict[str, Tuple[float, float]]:
    """
        Load layout from a JSON file.

    Parameters
    ----------
        filename :
            Input filename.

    Returns
    -------
        Dictionary mapping node IDs to (x, y) positions.
    """
    with open(filename, 'r') as f:
        layout_data = json.load(f)

    # Convert lists back to tuples
    return {node: tuple(pos) for node, pos in layout_data.items()}

ForceDirectedLayout

Bases: LayoutAlgorithm

Force-directed layout using spring algorithm.

Simulates physical spring forces between connected nodes to create aesthetically pleasing layouts. Uses the Fruchterman-Reingold algorithm implemented in NetworkX's spring_layout.

Performance
  • Time complexity: O(iterations * N^2) where N is number of nodes
  • Typical runtime: ~25ms for 100 nodes, 50 iterations
  • Best for: Networks with 10-500 nodes
  • Quality: Good balance between speed and aesthetic quality
Notes

For very large networks (>500 nodes), consider using: - HierarchicalLayout (fastest, ~0.1ms for 100 nodes) - CircularLayout (very fast, ~0.2ms for 100 nodes) - SpectralLayout (faster alternative to force-directed)

Source code in src/pypopart/layout/algorithms.py
class ForceDirectedLayout(LayoutAlgorithm):
    """
    Force-directed layout using spring algorithm.

    Simulates physical spring forces between connected nodes to create
    aesthetically pleasing layouts. Uses the Fruchterman-Reingold algorithm
    implemented in NetworkX's spring_layout.

    Performance
    -----------
    - Time complexity: O(iterations * N^2) where N is number of nodes
    - Typical runtime: ~25ms for 100 nodes, 50 iterations
    - Best for: Networks with 10-500 nodes
    - Quality: Good balance between speed and aesthetic quality

    Notes
    -----
    For very large networks (>500 nodes), consider using:
    - HierarchicalLayout (fastest, ~0.1ms for 100 nodes)
    - CircularLayout (very fast, ~0.2ms for 100 nodes)
    - SpectralLayout (faster alternative to force-directed)
    """

    def compute(
        self,
        k: Optional[float] = None,
        iterations: int = 50,
        seed: Optional[int] = None,
        **kwargs,
    ) -> Dict[str, Tuple[float, float]]:
        """
            Compute force-directed layout.

        Parameters
        ----------
            k :
                Optimal distance between nodes (None for auto).
                Smaller values bring nodes closer together.
            iterations :
                Number of iterations for optimization.
                More iterations = better quality but slower.
                Default 50 is good for most networks.
            seed :
                Random seed for reproducibility.
            **kwargs :
                Additional parameters passed to spring_layout.

        Returns
        -------
            Node positions dictionary.
        """
        layout = nx.spring_layout(
            self.graph, k=k, iterations=iterations, seed=seed, **kwargs
        )
        # Convert numpy arrays to tuples
        return {node: tuple(pos) for node, pos in layout.items()}
compute
compute(
    k: Optional[float] = None,
    iterations: int = 50,
    seed: Optional[int] = None,
    **kwargs
) -> Dict[str, Tuple[float, float]]
Compute force-directed layout.

Returns:

Type Description
Node positions dictionary.
Source code in src/pypopart/layout/algorithms.py
def compute(
    self,
    k: Optional[float] = None,
    iterations: int = 50,
    seed: Optional[int] = None,
    **kwargs,
) -> Dict[str, Tuple[float, float]]:
    """
        Compute force-directed layout.

    Parameters
    ----------
        k :
            Optimal distance between nodes (None for auto).
            Smaller values bring nodes closer together.
        iterations :
            Number of iterations for optimization.
            More iterations = better quality but slower.
            Default 50 is good for most networks.
        seed :
            Random seed for reproducibility.
        **kwargs :
            Additional parameters passed to spring_layout.

    Returns
    -------
        Node positions dictionary.
    """
    layout = nx.spring_layout(
        self.graph, k=k, iterations=iterations, seed=seed, **kwargs
    )
    # Convert numpy arrays to tuples
    return {node: tuple(pos) for node, pos in layout.items()}

CircularLayout

Bases: LayoutAlgorithm

Circular layout arranging nodes in a circle.

Places nodes evenly spaced around a circle, useful for showing connectivity patterns.

Source code in src/pypopart/layout/algorithms.py
class CircularLayout(LayoutAlgorithm):
    """
    Circular layout arranging nodes in a circle.

    Places nodes evenly spaced around a circle, useful for showing
    connectivity patterns.
    """

    def compute(
        self, scale: float = 1.0, center: Optional[Tuple[float, float]] = None, **kwargs
    ) -> Dict[str, Tuple[float, float]]:
        """
            Compute circular layout.

        Parameters
        ----------
            scale :
                Scale factor for the layout.
            center :
                Center position (x, y).
            **kwargs :
                Additional parameters passed to circular_layout.

        Returns
        -------
            Node positions dictionary.
        """
        layout = nx.circular_layout(self.graph, scale=scale, center=center, **kwargs)
        # Convert numpy arrays to tuples
        return {node: tuple(pos) for node, pos in layout.items()}
compute
compute(
    scale: float = 1.0,
    center: Optional[Tuple[float, float]] = None,
    **kwargs
) -> Dict[str, Tuple[float, float]]
Compute circular layout.

Returns:

Type Description
Node positions dictionary.
Source code in src/pypopart/layout/algorithms.py
def compute(
    self, scale: float = 1.0, center: Optional[Tuple[float, float]] = None, **kwargs
) -> Dict[str, Tuple[float, float]]:
    """
        Compute circular layout.

    Parameters
    ----------
        scale :
            Scale factor for the layout.
        center :
            Center position (x, y).
        **kwargs :
            Additional parameters passed to circular_layout.

    Returns
    -------
        Node positions dictionary.
    """
    layout = nx.circular_layout(self.graph, scale=scale, center=center, **kwargs)
    # Convert numpy arrays to tuples
    return {node: tuple(pos) for node, pos in layout.items()}

RadialLayout

Bases: LayoutAlgorithm

Radial layout with center node and concentric rings.

Places a central node at the origin and arranges other nodes in concentric circles based on distance from center.

Source code in src/pypopart/layout/algorithms.py
class RadialLayout(LayoutAlgorithm):
    """
    Radial layout with center node and concentric rings.

    Places a central node at the origin and arranges other nodes
    in concentric circles based on distance from center.
    """

    def compute(
        self, center_node: Optional[str] = None, scale: float = 1.0, **kwargs
    ) -> Dict[str, Tuple[float, float]]:
        """
            Compute radial layout.

        Parameters
        ----------
            center_node :
                Node to place at center (most connected if None).
            scale :
                Scale factor for the layout.
            **kwargs :
                Additional parameters.

        Returns
        -------
            Node positions dictionary.
        """
        if not self.graph.nodes():
            return {}

        # Find center node if not specified
        if center_node is None:
            # Use node with highest degree
            center_node = max(self.graph.nodes(), key=lambda n: self.graph.degree(n))

        # Calculate distances from center using shortest path
        try:
            distances = nx.single_source_shortest_path_length(self.graph, center_node)
            # Add disconnected nodes at max distance + 1
            max_dist = max(distances.values()) if distances else 0
            for node in self.graph.nodes():
                if node not in distances:
                    distances[node] = max_dist + 1
        except nx.NetworkXError:
            # If graph is disconnected, use all nodes at max distance
            distances = dict.fromkeys(self.graph.nodes(), 1)
            distances[center_node] = 0

        # Group nodes by distance
        max_distance = max(distances.values()) if distances else 0
        rings: Dict[int, List[str]] = {i: [] for i in range(max_distance + 1)}

        for node, dist in distances.items():
            rings[dist].append(node)

        # Position nodes
        positions = {}
        positions[center_node] = (0.0, 0.0)

        for ring_idx, nodes in rings.items():
            if ring_idx == 0:
                continue

            radius = scale * ring_idx
            n_nodes = len(nodes)

            for i, node in enumerate(nodes):
                angle = 2 * np.pi * i / n_nodes
                x = radius * np.cos(angle)
                y = radius * np.sin(angle)
                positions[node] = (x, y)

        return positions
compute
compute(
    center_node: Optional[str] = None,
    scale: float = 1.0,
    **kwargs
) -> Dict[str, Tuple[float, float]]
Compute radial layout.

Returns:

Type Description
Node positions dictionary.
Source code in src/pypopart/layout/algorithms.py
def compute(
    self, center_node: Optional[str] = None, scale: float = 1.0, **kwargs
) -> Dict[str, Tuple[float, float]]:
    """
        Compute radial layout.

    Parameters
    ----------
        center_node :
            Node to place at center (most connected if None).
        scale :
            Scale factor for the layout.
        **kwargs :
            Additional parameters.

    Returns
    -------
        Node positions dictionary.
    """
    if not self.graph.nodes():
        return {}

    # Find center node if not specified
    if center_node is None:
        # Use node with highest degree
        center_node = max(self.graph.nodes(), key=lambda n: self.graph.degree(n))

    # Calculate distances from center using shortest path
    try:
        distances = nx.single_source_shortest_path_length(self.graph, center_node)
        # Add disconnected nodes at max distance + 1
        max_dist = max(distances.values()) if distances else 0
        for node in self.graph.nodes():
            if node not in distances:
                distances[node] = max_dist + 1
    except nx.NetworkXError:
        # If graph is disconnected, use all nodes at max distance
        distances = dict.fromkeys(self.graph.nodes(), 1)
        distances[center_node] = 0

    # Group nodes by distance
    max_distance = max(distances.values()) if distances else 0
    rings: Dict[int, List[str]] = {i: [] for i in range(max_distance + 1)}

    for node, dist in distances.items():
        rings[dist].append(node)

    # Position nodes
    positions = {}
    positions[center_node] = (0.0, 0.0)

    for ring_idx, nodes in rings.items():
        if ring_idx == 0:
            continue

        radius = scale * ring_idx
        n_nodes = len(nodes)

        for i, node in enumerate(nodes):
            angle = 2 * np.pi * i / n_nodes
            x = radius * np.cos(angle)
            y = radius * np.sin(angle)
            positions[node] = (x, y)

    return positions

HierarchicalLayout

Bases: LayoutAlgorithm

Hierarchical layout arranging nodes in levels.

Creates a tree-like structure with nodes arranged in horizontal levels based on distance from a root node.

Source code in src/pypopart/layout/algorithms.py
class HierarchicalLayout(LayoutAlgorithm):
    """
    Hierarchical layout arranging nodes in levels.

    Creates a tree-like structure with nodes arranged in horizontal
    levels based on distance from a root node.
    """

    def compute(
        self,
        root_node: Optional[str] = None,
        vertical: bool = True,
        width: float = 2.0,
        height: float = 2.0,
        **kwargs,
    ) -> Dict[str, Tuple[float, float]]:
        """
            Compute hierarchical layout.

        Parameters
        ----------
            root_node :
                Root node for hierarchy (most connected if None).
            vertical :
                If True, levels are horizontal; if False, levels are vertical.
            width :
                Total width of the layout.
            height :
                Total height of the layout.
            **kwargs :
                Additional parameters.

        Returns
        -------
            Node positions dictionary.
        """
        if not self.graph.nodes():
            return {}

        # Find root node if not specified
        if root_node is None:
            root_node = max(self.graph.nodes(), key=lambda n: self.graph.degree(n))

        # Calculate distances from root using BFS
        try:
            distances = nx.single_source_shortest_path_length(self.graph, root_node)
            # Add disconnected nodes at max level + 1
            max_dist = max(distances.values()) if distances else 0
            for node in self.graph.nodes():
                if node not in distances:
                    distances[node] = max_dist + 1
        except nx.NetworkXError:
            # If graph is disconnected, use default distances
            distances = dict.fromkeys(self.graph.nodes(), 1)
            distances[root_node] = 0

        # Group nodes by level
        max_level = max(distances.values()) if distances else 0
        levels: Dict[int, List[str]] = {i: [] for i in range(max_level + 1)}

        for node, level in distances.items():
            levels[level].append(node)

        # Position nodes
        positions = {}

        for level_idx, nodes in levels.items():
            n_nodes = len(nodes)

            if vertical:
                # Horizontal levels (top to bottom)
                y = height * (1 - level_idx / max(max_level, 1))

                for i, node in enumerate(nodes):
                    if n_nodes == 1:
                        x = width / 2
                    else:
                        x = width * i / (n_nodes - 1)
                    positions[node] = (x, y)
            else:
                # Vertical levels (left to right)
                x = width * level_idx / max(max_level, 1)

                for i, node in enumerate(nodes):
                    if n_nodes == 1:
                        y = height / 2
                    else:
                        y = height * (1 - i / (n_nodes - 1))
                    positions[node] = (x, y)

        return positions
compute
compute(
    root_node: Optional[str] = None,
    vertical: bool = True,
    width: float = 2.0,
    height: float = 2.0,
    **kwargs
) -> Dict[str, Tuple[float, float]]
Compute hierarchical layout.

Returns:

Type Description
Node positions dictionary.
Source code in src/pypopart/layout/algorithms.py
def compute(
    self,
    root_node: Optional[str] = None,
    vertical: bool = True,
    width: float = 2.0,
    height: float = 2.0,
    **kwargs,
) -> Dict[str, Tuple[float, float]]:
    """
        Compute hierarchical layout.

    Parameters
    ----------
        root_node :
            Root node for hierarchy (most connected if None).
        vertical :
            If True, levels are horizontal; if False, levels are vertical.
        width :
            Total width of the layout.
        height :
            Total height of the layout.
        **kwargs :
            Additional parameters.

    Returns
    -------
        Node positions dictionary.
    """
    if not self.graph.nodes():
        return {}

    # Find root node if not specified
    if root_node is None:
        root_node = max(self.graph.nodes(), key=lambda n: self.graph.degree(n))

    # Calculate distances from root using BFS
    try:
        distances = nx.single_source_shortest_path_length(self.graph, root_node)
        # Add disconnected nodes at max level + 1
        max_dist = max(distances.values()) if distances else 0
        for node in self.graph.nodes():
            if node not in distances:
                distances[node] = max_dist + 1
    except nx.NetworkXError:
        # If graph is disconnected, use default distances
        distances = dict.fromkeys(self.graph.nodes(), 1)
        distances[root_node] = 0

    # Group nodes by level
    max_level = max(distances.values()) if distances else 0
    levels: Dict[int, List[str]] = {i: [] for i in range(max_level + 1)}

    for node, level in distances.items():
        levels[level].append(node)

    # Position nodes
    positions = {}

    for level_idx, nodes in levels.items():
        n_nodes = len(nodes)

        if vertical:
            # Horizontal levels (top to bottom)
            y = height * (1 - level_idx / max(max_level, 1))

            for i, node in enumerate(nodes):
                if n_nodes == 1:
                    x = width / 2
                else:
                    x = width * i / (n_nodes - 1)
                positions[node] = (x, y)
        else:
            # Vertical levels (left to right)
            x = width * level_idx / max(max_level, 1)

            for i, node in enumerate(nodes):
                if n_nodes == 1:
                    y = height / 2
                else:
                    y = height * (1 - i / (n_nodes - 1))
                positions[node] = (x, y)

    return positions

KamadaKawaiLayout

Bases: LayoutAlgorithm

Kamada-Kawai layout algorithm.

Uses energy minimization to position nodes based on graph-theoretic distances. Produces high-quality layouts but is computationally expensive for large networks.

Performance
  • Time complexity: O(N^3) where N is number of nodes
  • Typical runtime: ~190ms for 100 nodes
  • Best for: Small networks (<50 nodes) where layout quality is critical
  • Quality: Excellent, minimizes stress based on graph distances
Notes

For large networks, use ForceDirectedLayout or SpectralLayout instead. Kamada-Kawai can be very slow for networks with >100 nodes.

Source code in src/pypopart/layout/algorithms.py
class KamadaKawaiLayout(LayoutAlgorithm):
    """
    Kamada-Kawai layout algorithm.

    Uses energy minimization to position nodes based on graph-theoretic
    distances. Produces high-quality layouts but is computationally expensive
    for large networks.

    Performance
    -----------
    - Time complexity: O(N^3) where N is number of nodes
    - Typical runtime: ~190ms for 100 nodes
    - Best for: Small networks (<50 nodes) where layout quality is critical
    - Quality: Excellent, minimizes stress based on graph distances

    Notes
    -----
    For large networks, use ForceDirectedLayout or SpectralLayout instead.
    Kamada-Kawai can be very slow for networks with >100 nodes.
    """

    def compute(
        self, scale: float = 1.0, center: Optional[Tuple[float, float]] = None, **kwargs
    ) -> Dict[str, Tuple[float, float]]:
        """
            Compute Kamada-Kawai layout.

        Parameters
        ----------
            scale :
                Scale factor for the layout.
            center :
                Center position (x, y).
            **kwargs :
                Additional parameters passed to kamada_kawai_layout.

        Returns
        -------
            Node positions dictionary.

        Warnings
        --------
        This algorithm can be very slow for large networks (>100 nodes).
        Consider using ForceDirectedLayout or SpectralLayout as faster alternatives.
        """
        layout = nx.kamada_kawai_layout(
            self.graph, scale=scale, center=center, **kwargs
        )
        # Convert numpy arrays to tuples
        return {node: tuple(pos) for node, pos in layout.items()}
compute
compute(
    scale: float = 1.0,
    center: Optional[Tuple[float, float]] = None,
    **kwargs
) -> Dict[str, Tuple[float, float]]
Compute Kamada-Kawai layout.

Returns:

Type Description
Node positions dictionary.
Warnings

This algorithm can be very slow for large networks (>100 nodes). Consider using ForceDirectedLayout or SpectralLayout as faster alternatives.

Source code in src/pypopart/layout/algorithms.py
def compute(
    self, scale: float = 1.0, center: Optional[Tuple[float, float]] = None, **kwargs
) -> Dict[str, Tuple[float, float]]:
    """
        Compute Kamada-Kawai layout.

    Parameters
    ----------
        scale :
            Scale factor for the layout.
        center :
            Center position (x, y).
        **kwargs :
            Additional parameters passed to kamada_kawai_layout.

    Returns
    -------
        Node positions dictionary.

    Warnings
    --------
    This algorithm can be very slow for large networks (>100 nodes).
    Consider using ForceDirectedLayout or SpectralLayout as faster alternatives.
    """
    layout = nx.kamada_kawai_layout(
        self.graph, scale=scale, center=center, **kwargs
    )
    # Convert numpy arrays to tuples
    return {node: tuple(pos) for node, pos in layout.items()}

SpectralLayout

Bases: LayoutAlgorithm

Spectral layout using graph Laplacian eigenvectors.

Uses the eigenvectors of the graph Laplacian matrix to position nodes. This is a fast alternative to force-directed layouts that works well for large networks.

Performance
  • Time complexity: O(N^2) where N is number of nodes
  • Typical runtime: ~5-10ms for 100 nodes
  • Best for: Large networks (100-1000+ nodes)
  • Quality: Good, respects graph structure efficiently
Notes

Spectral layout is much faster than Kamada-Kawai and comparable to force-directed layouts while maintaining good quality. Particularly effective for networks with clear clustering structure.

Source code in src/pypopart/layout/algorithms.py
class SpectralLayout(LayoutAlgorithm):
    """
    Spectral layout using graph Laplacian eigenvectors.

    Uses the eigenvectors of the graph Laplacian matrix to position nodes.
    This is a fast alternative to force-directed layouts that works well
    for large networks.

    Performance
    -----------
    - Time complexity: O(N^2) where N is number of nodes
    - Typical runtime: ~5-10ms for 100 nodes
    - Best for: Large networks (100-1000+ nodes)
    - Quality: Good, respects graph structure efficiently

    Notes
    -----
    Spectral layout is much faster than Kamada-Kawai and comparable
    to force-directed layouts while maintaining good quality.
    Particularly effective for networks with clear clustering structure.
    """

    def compute(
        self,
        scale: float = 1.0,
        center: Optional[Tuple[float, float]] = None,
        dim: int = 2,
        **kwargs,
    ) -> Dict[str, Tuple[float, float]]:
        """
        Compute spectral layout using graph Laplacian.

        Parameters
        ----------
        scale :
            Scale factor for the layout.
        center :
            Center position (x, y).
        dim :
            Dimensionality of layout (default 2 for 2D visualization).
        **kwargs :
            Additional parameters passed to spectral_layout.

        Returns
        -------
        Node positions dictionary.
        """
        layout = nx.spectral_layout(
            self.graph, scale=scale, center=center, dim=dim, **kwargs
        )
        # Convert numpy arrays to tuples
        return {node: tuple(pos) for node, pos in layout.items()}
compute
compute(
    scale: float = 1.0,
    center: Optional[Tuple[float, float]] = None,
    dim: int = 2,
    **kwargs
) -> Dict[str, Tuple[float, float]]

Compute spectral layout using graph Laplacian.

Parameters:

Name Type Description Default
scale float

Scale factor for the layout.

1.0
center Optional[Tuple[float, float]]

Center position (x, y).

None
dim int

Dimensionality of layout (default 2 for 2D visualization).

2
**kwargs

Additional parameters passed to spectral_layout.

{}

Returns:

Type Description
Node positions dictionary.
Source code in src/pypopart/layout/algorithms.py
def compute(
    self,
    scale: float = 1.0,
    center: Optional[Tuple[float, float]] = None,
    dim: int = 2,
    **kwargs,
) -> Dict[str, Tuple[float, float]]:
    """
    Compute spectral layout using graph Laplacian.

    Parameters
    ----------
    scale :
        Scale factor for the layout.
    center :
        Center position (x, y).
    dim :
        Dimensionality of layout (default 2 for 2D visualization).
    **kwargs :
        Additional parameters passed to spectral_layout.

    Returns
    -------
    Node positions dictionary.
    """
    layout = nx.spectral_layout(
        self.graph, scale=scale, center=center, dim=dim, **kwargs
    )
    # Convert numpy arrays to tuples
    return {node: tuple(pos) for node, pos in layout.items()}

ManualLayout

Bases: LayoutAlgorithm

Manual layout with user-specified positions.

Allows manual positioning of nodes or adjustment of existing layouts.

Source code in src/pypopart/layout/algorithms.py
class ManualLayout(LayoutAlgorithm):
    """
    Manual layout with user-specified positions.

    Allows manual positioning of nodes or adjustment of existing layouts.
    """

    def __init__(
        self,
        network: HaplotypeNetwork,
        initial_positions: Optional[Dict[str, Tuple[float, float]]] = None,
    ):
        """
        Initialize manual layout.

        Parameters
        ----------
        network :
            HaplotypeNetwork object.
        initial_positions :
            Starting positions for nodes.
        """
        super().__init__(network)
        self.positions = initial_positions or {}

    def compute(self, **kwargs) -> Dict[str, Tuple[float, float]]:
        """
            Return current manual positions.

        Parameters
        ----------
            **kwargs :
                Ignored.

        Returns
        -------
            Node positions dictionary.
        """
        # Fill in missing nodes with default layout
        if len(self.positions) < len(self.graph.nodes()):
            default_layout = nx.spring_layout(self.graph)
            for node in self.graph.nodes():
                if node not in self.positions:
                    self.positions[node] = default_layout[node]

        return self.positions

    def set_position(self, node: str, position: Tuple[float, float]) -> None:
        """
        Set position for a specific node.

        Parameters
        ----------
        node :
            Node ID.
        position :
            (x, y) coordinates.
        """
        if node not in self.graph.nodes():
            raise ValueError(f"Node '{node}' not in network")

        self.positions[node] = position

    def move_node(self, node: str, dx: float, dy: float) -> None:
        """
        Move a node by a relative offset.

        Parameters
        ----------
        node :
            Node ID.
        dx :
            X offset.
        dy :
            Y offset.
        """
        if node not in self.positions:
            raise ValueError(f"Node '{node}' has no position set")

        x, y = self.positions[node]
        self.positions[node] = (x + dx, y + dy)
__init__
__init__(
    network: HaplotypeNetwork,
    initial_positions: Optional[
        Dict[str, Tuple[float, float]]
    ] = None,
)

Initialize manual layout.

Parameters:

Name Type Description Default
network HaplotypeNetwork

HaplotypeNetwork object.

required
initial_positions Optional[Dict[str, Tuple[float, float]]]

Starting positions for nodes.

None
Source code in src/pypopart/layout/algorithms.py
def __init__(
    self,
    network: HaplotypeNetwork,
    initial_positions: Optional[Dict[str, Tuple[float, float]]] = None,
):
    """
    Initialize manual layout.

    Parameters
    ----------
    network :
        HaplotypeNetwork object.
    initial_positions :
        Starting positions for nodes.
    """
    super().__init__(network)
    self.positions = initial_positions or {}
compute
compute(**kwargs) -> Dict[str, Tuple[float, float]]
Return current manual positions.

Returns:

Type Description
Node positions dictionary.
Source code in src/pypopart/layout/algorithms.py
def compute(self, **kwargs) -> Dict[str, Tuple[float, float]]:
    """
        Return current manual positions.

    Parameters
    ----------
        **kwargs :
            Ignored.

    Returns
    -------
        Node positions dictionary.
    """
    # Fill in missing nodes with default layout
    if len(self.positions) < len(self.graph.nodes()):
        default_layout = nx.spring_layout(self.graph)
        for node in self.graph.nodes():
            if node not in self.positions:
                self.positions[node] = default_layout[node]

    return self.positions
set_position
set_position(
    node: str, position: Tuple[float, float]
) -> None

Set position for a specific node.

Parameters:

Name Type Description Default
node str

Node ID.

required
position Tuple[float, float]

(x, y) coordinates.

required
Source code in src/pypopart/layout/algorithms.py
def set_position(self, node: str, position: Tuple[float, float]) -> None:
    """
    Set position for a specific node.

    Parameters
    ----------
    node :
        Node ID.
    position :
        (x, y) coordinates.
    """
    if node not in self.graph.nodes():
        raise ValueError(f"Node '{node}' not in network")

    self.positions[node] = position
move_node
move_node(node: str, dx: float, dy: float) -> None

Move a node by a relative offset.

Parameters:

Name Type Description Default
node str

Node ID.

required
dx float

X offset.

required
dy float

Y offset.

required
Source code in src/pypopart/layout/algorithms.py
def move_node(self, node: str, dx: float, dy: float) -> None:
    """
    Move a node by a relative offset.

    Parameters
    ----------
    node :
        Node ID.
    dx :
        X offset.
    dy :
        Y offset.
    """
    if node not in self.positions:
        raise ValueError(f"Node '{node}' has no position set")

    x, y = self.positions[node]
    self.positions[node] = (x + dx, y + dy)

LayoutManager

Manager for network layout computation and persistence.

Provides high-level interface for computing, saving, and loading network layouts with optional caching.

Parameters:

Name Type Description Default
network HaplotypeNetwork

The haplotype network to compute layouts for.

required
enable_cache bool

If True, cache layout results for repeated calls with same parameters. Default is True. Disable if network structure changes between calls.

True

Examples:

>>> manager = LayoutManager(network)
>>> layout = manager.compute_layout('spring', iterations=50, seed=42)
>>> # Second call with same parameters uses cached result
>>> layout2 = manager.compute_layout('spring', iterations=50, seed=42)
Source code in src/pypopart/layout/algorithms.py
class LayoutManager:
    """
    Manager for network layout computation and persistence.

    Provides high-level interface for computing, saving, and loading
    network layouts with optional caching.

    Parameters
    ----------
    network : HaplotypeNetwork
        The haplotype network to compute layouts for.
    enable_cache : bool, optional
        If True, cache layout results for repeated calls with same parameters.
        Default is True. Disable if network structure changes between calls.

    Examples
    --------
    >>> manager = LayoutManager(network)
    >>> layout = manager.compute_layout('spring', iterations=50, seed=42)
    >>> # Second call with same parameters uses cached result
    >>> layout2 = manager.compute_layout('spring', iterations=50, seed=42)
    """

    def __init__(self, network: HaplotypeNetwork, enable_cache: bool = True):
        """
        Initialize layout manager.

        Parameters
        ----------
        network :
            HaplotypeNetwork object.
        enable_cache :
            Enable caching of layout computations. Default True.
        """
        self.network = network
        self._enable_cache = enable_cache
        self._cache: Dict[str, Dict[str, Tuple[float, float]]] = {}
        self._algorithms = {
            'force_directed': ForceDirectedLayout,
            'spring': ForceDirectedLayout,  # Alias
            'circular': CircularLayout,
            'radial': RadialLayout,
            'hierarchical': HierarchicalLayout,
            'kamada_kawai': KamadaKawaiLayout,
            'spectral': SpectralLayout,
            'manual': ManualLayout,
        }

    def compute_layout(
        self, algorithm: str = 'force_directed', use_cache: bool = True, **kwargs
    ) -> Dict[str, Tuple[float, float]]:
        """
        Compute network layout using specified algorithm.

        Parameters
        ----------
        algorithm :
            Layout algorithm name.
        use_cache :
            If True and caching is enabled, return cached result if available.
            Default True.
        **kwargs :
            Algorithm-specific parameters.

        Returns
        -------
        Node positions dictionary.

        Raises
        ------
        ValueError :
            If algorithm not recognized.

        Notes
        -----
        Results are cached based on algorithm name and parameters. To force
        recomputation, set use_cache=False or clear_cache().
        """
        if algorithm not in self._algorithms:
            available = ', '.join(self._algorithms.keys())
            raise ValueError(f"Unknown algorithm '{algorithm}'. Available: {available}")

        # Check cache if enabled
        if self._enable_cache and use_cache:
            cache_key = self._make_cache_key(algorithm, kwargs)
            if cache_key in self._cache:
                return self._cache[cache_key]

        # Compute layout
        layout_class = self._algorithms[algorithm]
        layout_algo = layout_class(self.network)
        result = layout_algo.compute(**kwargs)

        # Store in cache if enabled
        if self._enable_cache:
            cache_key = self._make_cache_key(algorithm, kwargs)
            self._cache[cache_key] = result

        return result

    def _make_cache_key(self, algorithm: str, kwargs: Dict) -> str:
        """
        Create a cache key from algorithm name and parameters.

        Parameters
        ----------
        algorithm : str
            Algorithm name.
        kwargs : dict
            Algorithm parameters.

        Returns
        -------
        str
            Cache key string.
        """
        import json

        # Sort kwargs for consistent keys
        sorted_kwargs = json.dumps(kwargs, sort_keys=True, default=str)
        return f'{algorithm}:{sorted_kwargs}'

    def clear_cache(self) -> None:
        """
        Clear the layout cache.

        Use this after the network structure has changed to ensure
        fresh layout computations.
        """
        self._cache.clear()

    def save_layout(
        self, layout: Dict[str, Tuple[float, float]], filename: str
    ) -> None:
        """
        Save layout to file.

        Parameters
        ----------
        layout :
            Node positions dictionary.
        filename :
            Output filename (JSON format).
        """
        algo = LayoutAlgorithm(self.network)
        algo.save_layout(layout, filename)

    def load_layout(self, filename: str) -> Dict[str, Tuple[float, float]]:
        """
            Load layout from file.

        Parameters
        ----------
            filename :
                Input filename (JSON format).

        Returns
        -------
            Node positions dictionary.
        """
        return LayoutAlgorithm.load_layout(filename)

    def get_available_algorithms(self) -> List[str]:
        """
        Get list of available layout algorithms.

        Returns
        -------
            List of algorithm names.
        """
        return list(self._algorithms.keys())
__init__
__init__(
    network: HaplotypeNetwork, enable_cache: bool = True
)

Initialize layout manager.

Parameters:

Name Type Description Default
network HaplotypeNetwork

HaplotypeNetwork object.

required
enable_cache bool

Enable caching of layout computations. Default True.

True
Source code in src/pypopart/layout/algorithms.py
def __init__(self, network: HaplotypeNetwork, enable_cache: bool = True):
    """
    Initialize layout manager.

    Parameters
    ----------
    network :
        HaplotypeNetwork object.
    enable_cache :
        Enable caching of layout computations. Default True.
    """
    self.network = network
    self._enable_cache = enable_cache
    self._cache: Dict[str, Dict[str, Tuple[float, float]]] = {}
    self._algorithms = {
        'force_directed': ForceDirectedLayout,
        'spring': ForceDirectedLayout,  # Alias
        'circular': CircularLayout,
        'radial': RadialLayout,
        'hierarchical': HierarchicalLayout,
        'kamada_kawai': KamadaKawaiLayout,
        'spectral': SpectralLayout,
        'manual': ManualLayout,
    }
compute_layout
compute_layout(
    algorithm: str = "force_directed",
    use_cache: bool = True,
    **kwargs
) -> Dict[str, Tuple[float, float]]

Compute network layout using specified algorithm.

Parameters:

Name Type Description Default
algorithm str

Layout algorithm name.

'force_directed'
use_cache bool

If True and caching is enabled, return cached result if available. Default True.

True
**kwargs

Algorithm-specific parameters.

{}

Returns:

Type Description
Node positions dictionary.

Raises:

Type Description
ValueError :

If algorithm not recognized.

Notes

Results are cached based on algorithm name and parameters. To force recomputation, set use_cache=False or clear_cache().

Source code in src/pypopart/layout/algorithms.py
def compute_layout(
    self, algorithm: str = 'force_directed', use_cache: bool = True, **kwargs
) -> Dict[str, Tuple[float, float]]:
    """
    Compute network layout using specified algorithm.

    Parameters
    ----------
    algorithm :
        Layout algorithm name.
    use_cache :
        If True and caching is enabled, return cached result if available.
        Default True.
    **kwargs :
        Algorithm-specific parameters.

    Returns
    -------
    Node positions dictionary.

    Raises
    ------
    ValueError :
        If algorithm not recognized.

    Notes
    -----
    Results are cached based on algorithm name and parameters. To force
    recomputation, set use_cache=False or clear_cache().
    """
    if algorithm not in self._algorithms:
        available = ', '.join(self._algorithms.keys())
        raise ValueError(f"Unknown algorithm '{algorithm}'. Available: {available}")

    # Check cache if enabled
    if self._enable_cache and use_cache:
        cache_key = self._make_cache_key(algorithm, kwargs)
        if cache_key in self._cache:
            return self._cache[cache_key]

    # Compute layout
    layout_class = self._algorithms[algorithm]
    layout_algo = layout_class(self.network)
    result = layout_algo.compute(**kwargs)

    # Store in cache if enabled
    if self._enable_cache:
        cache_key = self._make_cache_key(algorithm, kwargs)
        self._cache[cache_key] = result

    return result
clear_cache
clear_cache() -> None

Clear the layout cache.

Use this after the network structure has changed to ensure fresh layout computations.

Source code in src/pypopart/layout/algorithms.py
def clear_cache(self) -> None:
    """
    Clear the layout cache.

    Use this after the network structure has changed to ensure
    fresh layout computations.
    """
    self._cache.clear()
save_layout
save_layout(
    layout: Dict[str, Tuple[float, float]], filename: str
) -> None

Save layout to file.

Parameters:

Name Type Description Default
layout Dict[str, Tuple[float, float]]

Node positions dictionary.

required
filename str

Output filename (JSON format).

required
Source code in src/pypopart/layout/algorithms.py
def save_layout(
    self, layout: Dict[str, Tuple[float, float]], filename: str
) -> None:
    """
    Save layout to file.

    Parameters
    ----------
    layout :
        Node positions dictionary.
    filename :
        Output filename (JSON format).
    """
    algo = LayoutAlgorithm(self.network)
    algo.save_layout(layout, filename)
load_layout
load_layout(
    filename: str,
) -> Dict[str, Tuple[float, float]]
Load layout from file.

Returns:

Type Description
Node positions dictionary.
Source code in src/pypopart/layout/algorithms.py
def load_layout(self, filename: str) -> Dict[str, Tuple[float, float]]:
    """
        Load layout from file.

    Parameters
    ----------
        filename :
            Input filename (JSON format).

    Returns
    -------
        Node positions dictionary.
    """
    return LayoutAlgorithm.load_layout(filename)
get_available_algorithms
get_available_algorithms() -> List[str]

Get list of available layout algorithms.

Returns:

Type Description
List of algorithm names.
Source code in src/pypopart/layout/algorithms.py
def get_available_algorithms(self) -> List[str]:
    """
    Get list of available layout algorithms.

    Returns
    -------
        List of algorithm names.
    """
    return list(self._algorithms.keys())