From a9508437eeb7db0e41e25d9f0e715533d33b1f44 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Tue, 7 Oct 2025 13:57:56 +0000 Subject: [PATCH 01/44] refactor visualisations and fix/speed up matplotlib backend --- src/pathpyG/visualisations/__init__.py | 107 +--- .../visualisations/_matplotlib/__init__.py | 17 - .../visualisations/_matplotlib/backend.py | 205 +++++++ .../visualisations/_matplotlib/core.py | 50 -- .../_matplotlib/network_plots.py | 133 ----- src/pathpyG/visualisations/network_plot.py | 182 +++++++ src/pathpyG/visualisations/network_plots.py | 504 ------------------ src/pathpyG/visualisations/pathpy_plot.py | 24 + src/pathpyG/visualisations/plot.py | 109 ---- src/pathpyG/visualisations/plot_backend.py | 19 + src/pathpyG/visualisations/plot_function.py | 168 ++++++ .../visualisations/temporal_network_plot.py | 100 ++++ src/pathpyG/visualisations/utils.py | 33 +- 13 files changed, 709 insertions(+), 942 deletions(-) create mode 100644 src/pathpyG/visualisations/_matplotlib/backend.py delete mode 100644 src/pathpyG/visualisations/_matplotlib/core.py delete mode 100644 src/pathpyG/visualisations/_matplotlib/network_plots.py create mode 100644 src/pathpyG/visualisations/network_plot.py delete mode 100644 src/pathpyG/visualisations/network_plots.py create mode 100644 src/pathpyG/visualisations/pathpy_plot.py delete mode 100644 src/pathpyG/visualisations/plot.py create mode 100644 src/pathpyG/visualisations/plot_backend.py create mode 100644 src/pathpyG/visualisations/plot_function.py create mode 100644 src/pathpyG/visualisations/temporal_network_plot.py diff --git a/src/pathpyG/visualisations/__init__.py b/src/pathpyG/visualisations/__init__.py index a737a9364..5d18f0d1b 100644 --- a/src/pathpyG/visualisations/__init__.py +++ b/src/pathpyG/visualisations/__init__.py @@ -9,112 +9,9 @@ # # Copyright (c) 2016-2023 Pathpy Developers # ============================================================================= -# flake8: noqa -# pylint: disable=unused-import -from typing import Optional, Any - -from pathpyG.core.graph import Graph -from pathpyG.core.temporal_graph import TemporalGraph - -from pathpyG.visualisations.plot import PathPyPlot -from pathpyG.visualisations.network_plots import NetworkPlot -from pathpyG.visualisations.network_plots import StaticNetworkPlot -from pathpyG.visualisations.network_plots import TemporalNetworkPlot - -from pathpyG.visualisations.layout import layout - -PLOT_CLASSES: dict = { - "network": NetworkPlot, - "static": StaticNetworkPlot, - "temporal": TemporalNetworkPlot, -} - - -def plot(data: Graph, kind: Optional[str] = None, **kwargs: Any) -> PathPyPlot: - """Make plot of pathpyG objects. - - Creates and displays a plot for a given `pathpyG` object. This function can - generate different types of network plots based on the nature of the input - data and specified plot kind. - - The function dynamically determines the plot type if not explicitly - provided, based on the input data type. It supports static network plots - for `Graph` objects, temporal network plots for `TemporalGraph` objects, - and potentially other types if specified in `kind`. - - Args: - - data (Graph): A `pathpyG` object representing the network data. This can - be a `Graph` or `TemporalGraph` object, or other compatible types. - - kind (Optional[str], optional): A string keyword defining the type of - plot to generate. Options include: - - - 'static' : Generates a static (aggregated) network plot. Ideal - for `Graph` objects. - - - 'temporal' : Creates a temporal network plot, which includes time - components. Suitable for `TemporalGraph` objects. - - - 'hist' : Produces a histogram of network properties. (Note: - Implementation for 'hist' is not present in the given function - code, it's mentioned for possible extension.) - - The default behavior (when `kind` is None) is to infer the plot type from the data type. - - **kwargs (Any): Optional keyword arguments to customize the plot. These - arguments are passed directly to the plotting class. Common options - could include layout parameters, color schemes, and plot size. - - Returns: - - PathPyPlot: A `PathPyPlot` object representing the generated plot. - This could be an instance of a plot class from - `pathpyG.visualisations.network_plots`, depending on the kind of - plot generated. - - Raises: - - NotImplementedError: If the `kind` is not recognized or if the function - cannot infer the plot type from the `data` type. - - Example Usage: - - >>> import pathpyG as pp - >>> graph = Graph.from_edge_list([["a", "b"], ["b", "c"], ["a", "c"]]) - >>> plot(graph, kind='static', filename='graph.png') - - This will create a static network plot of the `graph` and save it to 'graph.png'. - - Note: - - - If a 'filename' is provided in `kwargs`, the plot will be saved to - that file. Otherwise, it will be displayed using `plt.show()`. - - - The function's behavior and the available options in `kwargs` might - change based on the type of plot being generated. - - Todo: - - - Cleanup the file and use `plt.show()` only in an interactive environment. - """ - if kind is None: - if isinstance(data, TemporalGraph): - kind = "temporal" - elif isinstance(data, Graph): - kind = "static" - else: - raise NotImplementedError - - filename = kwargs.pop("filename", None) - - plt = PLOT_CLASSES[kind](data, **kwargs) - if filename: - plt.save(filename, **kwargs) - else: - plt.show(**kwargs) - return plt +from pathpyG.visualisations.layout import layout # noqa: F401 +from pathpyG.visualisations.plot_function import plot # noqa: F401 # ============================================================================= # eof diff --git a/src/pathpyG/visualisations/_matplotlib/__init__.py b/src/pathpyG/visualisations/_matplotlib/__init__.py index 9d2023d47..397f5f636 100644 --- a/src/pathpyG/visualisations/_matplotlib/__init__.py +++ b/src/pathpyG/visualisations/_matplotlib/__init__.py @@ -9,23 +9,6 @@ # # Copyright (c) 2016-2023 Pathpy Developers # ============================================================================= -# flake8: noqa -# pylint: disable=unused-import -from typing import Any -from pathpyG.visualisations._matplotlib.network_plots import NetworkPlot -from pathpyG.visualisations._matplotlib.network_plots import StaticNetworkPlot -from pathpyG.visualisations._matplotlib.network_plots import TemporalNetworkPlot - -PLOT_CLASSES: dict = { - "network": NetworkPlot, - "static": StaticNetworkPlot, - "temporal": TemporalNetworkPlot, -} - - -def plot(data: dict, kind: str = "network", **kwargs: Any) -> Any: - """Plot function.""" - return PLOT_CLASSES[kind](data, **kwargs) # ============================================================================= diff --git a/src/pathpyG/visualisations/_matplotlib/backend.py b/src/pathpyG/visualisations/_matplotlib/backend.py new file mode 100644 index 000000000..64846c1b7 --- /dev/null +++ b/src/pathpyG/visualisations/_matplotlib/backend.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import logging + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.collections import LineCollection, PathCollection +from matplotlib.path import Path + +from pathpyG.visualisations.network_plot import NetworkPlot +from pathpyG.visualisations.plot_backend import PlotBackend + +logger = logging.getLogger("root") + +SUPPORTED_KINDS = { + NetworkPlot: "static", +} + + +class MatplotlibBackend(PlotBackend): + """Matplotlib plotting backend.""" + + def __init__(self, plot, show_labels=None): + super().__init__(plot) + self._kind = SUPPORTED_KINDS.get(type(plot), None) + self._show_labels = show_labels + if self._kind is None: + logger.error(f"Plot of type {type(plot)} not supported by Matplotlib backend.") + raise ValueError(f"Plot of type {type(plot)} not supported.") + + def save(self, filename: str) -> None: + """Save the plot to the hard drive.""" + fig, ax = self.to_fig() + fig.savefig(filename) + + def show(self) -> None: + """Show the plot on the device.""" + fig, ax = self.to_fig() + plt.show() + + def to_fig(self) -> tuple[plt.Figure, plt.Axes]: + """Convert data to figure.""" + fig, ax = plt.subplots() + ax.set_axis_off() + + # get source and target coordinates for edges + source_coords = self.data["nodes"].loc[self.data["edges"]["source"], ["x", "y"]].values + target_coords = self.data["nodes"].loc[self.data["edges"]["target"], ["x", "y"]].values + + if self.config["directed"]: + self.add_directed_edges(source_coords, target_coords, ax) + else: + self.add_undirected_edges(source_coords, target_coords, ax) + + # plot nodes + ax.scatter( + self.data["nodes"]["x"], + self.data["nodes"]["y"], + s=self.data["nodes"]["size"] * 15, + c=self.data["nodes"]["color"], + alpha=self.data["nodes"]["opacity"], + zorder=2, + ) + + # add node labels + if self._show_labels: + for label in self.data["nodes"].index: + x, y = self.data["nodes"].loc[label, ["x", "y"]] + # Annotate the node label with text above the node + ax.annotate( + label, + (x, y), + xytext=(0, 6 + 0.25 * self.data["nodes"].loc[label, "size"]), + textcoords="offset points", + fontsize=6 + 0.25 * self.data["nodes"].loc[label, "size"], + ) + return fig, ax + + def add_undirected_edges(self, source_coords, target_coords, ax): + """Add undirected edges to the plot based on LineCollection.""" + edge_lines = list(zip(source_coords, target_coords)) + ax.add_collection( + LineCollection( + edge_lines, + colors=self.data["edges"]["color"], + alpha=self.data["edges"]["opacity"], + linewidths=self.data["edges"]["size"], + zorder=1, + ) + ) + + def add_directed_edges(self, source_coords, target_coords, ax): + """Add directed edges with arrowheads to the plot based on Bezier curves.""" + # get bezier curve vertices and codes + vertices, codes = self.get_bezier_curve( + source_coords, + target_coords, + shorten=self.data["nodes"].loc[self.data["edges"]["target"], ["size"]].values * 0.001, + ) + ax.add_collection( + PathCollection( + [ + Path( + v, + codes, + ) + for v in zip(*vertices) + ], + facecolor="none", + edgecolor=self.data["edges"]["color"], + alpha=self.data["edges"]["opacity"], + linewidth=self.data["edges"]["size"], + zorder=1, + ) + ) + + # add arrowheads + arrow_vertices, arrow_codes = self.get_arrowhead(vertices, head_length=self.data["nodes"]["size"].max() * 0.001) + ax.add_collection( + PathCollection( + [Path(v, arrow_codes) for v in zip(*arrow_vertices)], + facecolor=self.data["edges"]["color"], + edgecolor=self.data["edges"]["color"], + alpha=self.data["edges"]["opacity"], + zorder=3, + ) + ) + + def get_bezier_curve(self, source_coords, target_coords, curvature=0.2, shorten=0.02): + """Calculates the vertices and codes for a quadratic Bézier curve path. + + Args: + source_coords (np.array): Start points (x, y) for all edges. + target_coords (np.array): End points (x, y) for all edges. + curvature (float): A value controlling the curve's bend. + shorten (float): Amount to shorten the curve at both ends to avoid overlap with nodes. + Will shorten double at the target end to make space for the arrowhead. + + Returns: + tuple: A tuple containing (vertices, codes) for the Path object. + """ + # Start and end points for the Bézier curve + P0 = source_coords + P2 = target_coords + + # Calculate distance and direction vector + mid_point = (P0 + P2) / 2 + vec = P2 - P0 + dist = np.linalg.norm(vec, axis=1, keepdims=True) + + # Perpendicular vector + perp_vec = np.array([-vec[:, 1], vec[:, 0]]).T / dist + + # Calculate control points + P1 = mid_point + perp_vec * dist * curvature + + # Shorten the curve to avoid overlap with nodes + direction_P0_P1 = (P1 - P0) / np.linalg.norm(P1 - P0, axis=1, keepdims=True) + direction_P2_P1 = (P1 - P2) / np.linalg.norm(P1 - P2, axis=1, keepdims=True) + P0 += direction_P0_P1 * shorten + P2 += direction_P2_P1 * shorten * 2 + + vertices = [P0, P1, P2] + codes = [ + Path.MOVETO, + Path.CURVE3, + Path.MOVETO, + ] + return vertices, codes + + def get_arrowhead(self, vertices, head_length=0.01, head_width=0.02): + """Calculates the vertices and codes for a triangular arrowhead path. + + Args: + vertices (list): List of vertices from the Bézier curve. + head_length (float): Length of the arrowhead. + head_width (float): Width of the arrowhead. + + Returns: + tuple: A tuple containing (vertices, codes) for the Path object. + """ + # Extract the last segment of the Bézier curve + P1, P2 = vertices[1], vertices[2] + # 1. Calculate the tangent vector (direction of the curve at the end) + # For a quadratic curve, this is the vector from the control point to the end point. + tangent = P2 - P1 + tangent /= np.linalg.norm(tangent, axis=1, keepdims=True) + + # 2. Calculate the perpendicular vector for the width + perp = np.array([-tangent[:, 1], tangent[:, 0]]).T + + # 3. Define the three points of the arrowhead triangle + base_center = P2 + tip = P2 + tangent * head_length + wing1 = base_center + perp * head_width / 2 + wing2 = base_center - perp * head_width / 2 + + vertices = [wing1, tip, wing2, wing1] + codes = [ + Path.MOVETO, + Path.LINETO, + Path.LINETO, + Path.CLOSEPOLY, # Close the shape to make it fillable + ] + return vertices, codes diff --git a/src/pathpyG/visualisations/_matplotlib/core.py b/src/pathpyG/visualisations/_matplotlib/core.py deleted file mode 100644 index 838937e06..000000000 --- a/src/pathpyG/visualisations/_matplotlib/core.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Generic matplotlib plot class.""" - -# ============================================================================= -# File : core.py -- Plots with matplotlib backend -# Author : Jürgen Hackl -# Time-stamp: -# -# Copyright (c) 2016-2023 Pathpy Developers -# ============================================================================= -from __future__ import annotations - -import logging - -from typing import Any - -from pathpyG.visualisations.plot import PathPyPlot - -# create logger -logger = logging.getLogger("root") - - -class MatplotlibPlot(PathPyPlot): - """Base class for plotting matplotlib objects.""" - - def generate(self) -> None: - """Generate the plot.""" - raise NotImplementedError - - def save(self, filename: str, **kwargs: Any) -> None: # type: ignore - """Save the plot to the hard drive.""" - self.to_fig().savefig(filename) - - def show(self, **kwargs: Any) -> None: # type: ignore - """Show the plot on the device.""" - self.to_fig().show() - - def to_fig(self) -> Any: # type: ignore - """Convert to matplotlib figure.""" - raise NotImplementedError - - -# ============================================================================= -# eof -# -# Local Variables: -# mode: python -# mode: linum -# mode: auto-fill -# fill-column: 79 -# End: diff --git a/src/pathpyG/visualisations/_matplotlib/network_plots.py b/src/pathpyG/visualisations/_matplotlib/network_plots.py deleted file mode 100644 index 9f250f865..000000000 --- a/src/pathpyG/visualisations/_matplotlib/network_plots.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Network plots with matplotlib.""" - -# ============================================================================= -# File : network_plots.py -- Network plots with matplotlib -# Author : Jürgen Hackl -# Time-stamp: -# -# Copyright (c) 2016-2023 Pathpy Developers -# ============================================================================= -from typing import Any - -import logging -from pathpyG.visualisations._matplotlib.core import MatplotlibPlot - -# create logger -logger = logging.getLogger("root") - - -class NetworkPlot(MatplotlibPlot): - """Network plot class for a static network.""" - - _kind = "network" - - def __init__(self, data: dict, **kwargs: Any) -> None: - """Initialize network plot class.""" - super().__init__() - self.data = data - self.config = kwargs - self.generate() - - def generate(self) -> None: - """Clen up data.""" - self._compute_node_data() - self._compute_edge_data() - - def _compute_node_data(self) -> None: - """Generate the data structure for the nodes.""" - default = { - "uid": None, - "x": 0, - "y": 0, - "size": 30, - "color": "blue", - "opacity": 1.0, - } - - nodes: dict = {key: [] for key in default} - - for node in self.data["nodes"]: - for key, value in default.items(): - nodes[key].append(node.get(key, value)) - - self.data["nodes"] = nodes - - def _compute_edge_data(self) -> None: - """Generate the data structure for the edges.""" - default = { - "uid": None, - "size": 5, - "color": "red", - "opacity": 1.0, - } - - edges: dict = {**{key: [] for key in default}, **{"line": []}} - - for edge in self.data["edges"]: - source = self.data["nodes"]["uid"].index(edge.get("source")) - target = self.data["nodes"]["uid"].index(edge.get("target")) - edges["line"].append( - [ - (self.data["nodes"]["x"][source], self.data["nodes"]["x"][target]), - (self.data["nodes"]["y"][source], self.data["nodes"]["y"][target]), - ] - ) - - for key, value in default.items(): - edges[key].append(edge.get(key, value)) - - self.data["edges"] = edges - - def to_fig(self) -> Any: - """Convert data to figure.""" - import matplotlib.pyplot as plt - - fig, ax = plt.subplots() - ax.set_axis_off() - - # plot edges - for i in range(len(self.data["edges"]["uid"])): - ax.plot( - *self.data["edges"]["line"][i], - color=self.data["edges"]["color"][i], - alpha=self.data["edges"]["opacity"][i], - zorder=1, - ) - - # plot nodes - ax.scatter( - self.data["nodes"]["x"], - self.data["nodes"]["y"], - s=self.data["nodes"]["size"], - c=self.data["nodes"]["color"], - alpha=self.data["nodes"]["opacity"], - zorder=2, - ) - return plt - - -class StaticNetworkPlot(NetworkPlot): - """Network plot class for a static network.""" - - _kind = "static" - - -class TemporalNetworkPlot(NetworkPlot): - """Network plot class for a static network.""" - - _kind = "temporal" - - def __init__(self, data: dict, **kwargs: Any) -> None: - """Initialize network plot class.""" - raise NotImplementedError - - -# ============================================================================= -# eof -# -# Local Variables: -# mode: python -# mode: linum -# mode: auto-fill -# fill-column: 79 -# End: diff --git a/src/pathpyG/visualisations/network_plot.py b/src/pathpyG/visualisations/network_plot.py new file mode 100644 index 000000000..056588c5e --- /dev/null +++ b/src/pathpyG/visualisations/network_plot.py @@ -0,0 +1,182 @@ +"""Network plot classes.""" + +# !/usr/bin/python -tt +# -*- coding: utf-8 -*- +# ============================================================================= +# File : network_plots.py -- Network plots +# Author : Jürgen Hackl +# Time-stamp: +# +# Copyright (c) 2016-2023 Pathpy Developers +# ============================================================================= +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +import matplotlib.pyplot as plt +import pandas as pd + +from pathpyG.visualisations.layout import layout as network_layout +from pathpyG.visualisations.pathpy_plot import PathPyPlot +from pathpyG.visualisations.utils import rgb_to_hex + +# pseudo load class for type checking +if TYPE_CHECKING: + from pathpyG.core.graph import Graph + + +# create logger +logger = logging.getLogger("root") + + +class NetworkPlot(PathPyPlot): + """Network plot class for a static network.""" + + _kind = "network" + + def __init__(self, network: Graph, **kwargs: Any) -> None: + """Initialize network plot class.""" + super().__init__() + self.network = network + self.node_args = {} + self.edge_args = {} + self.attributes = ["color", "size", "opacity"] + # extract node and edge specific arguments from kwargs + for key in kwargs.keys(): + if key.startswith("node_"): + self.node_args[key[5:]] = kwargs.get(key) + elif key.startswith("edge_"): + self.edge_args[key[5:]] = kwargs.get(key) + # remove node_ and edge_ arguments from kwargs and update config with remaining kwargs + for node_arg in self.node_args.keys(): + kwargs.pop(f"node_{node_arg}") + for edge_arg in self.edge_args.keys(): + kwargs.pop(f"edge_{edge_arg}") + self.config.update(kwargs) + # generate plot data + self.generate() + + def generate(self) -> None: + """Generate the plot.""" + self._compute_edge_data() + self._compute_node_data() + self._compute_layout() + self._compute_config() + + def _compute_node_data(self) -> None: + """Generate the data structure for the nodes.""" + # initialize values + nodes: pd.DataFrame = pd.DataFrame(index=self.network.nodes) + for attribute in self.attributes: + # set default value for each attribute based on the pathpyG.toml config + nodes[attribute] = self.config.get("node").get(attribute) # type: ignore[union-attr] + # check if attribute is given as argument + if attribute in self.node_args: + if isinstance(self.node_args[attribute], dict): + nodes[attribute] = nodes.index.map(self.node_args[attribute]) + else: + nodes[attribute] = self.node_args[attribute] + # check if attribute is given as node attribute + elif attribute in self.network.node_attrs(): + nodes[attribute] = self.network.data[f"node_{attribute}"] + + # convert needed attributes to useful values + nodes["color"] = self._convert_to_rgb_tuple(nodes["color"]) + nodes["color"] = nodes["color"].map(self._convert_color) + + # save node data + self.data["nodes"] = nodes + + def _compute_edge_data(self) -> None: + """Generate the data structure for the edges.""" + # initialize values + edges: pd.DataFrame = pd.DataFrame(index=pd.MultiIndex.from_tuples(self.network.edges, names=["source", "target"])) + for attribute in self.attributes: + # set default value for each attribute based on the pathpyG.toml config + edges[attribute] = self.config.get("edge").get(attribute) # type: ignore[union-attr] + # check if attribute is given as argument + if attribute in self.edge_args: + if isinstance(self.edge_args[attribute], dict): + edges[attribute] = edges.index.map(lambda x: f"{x[0]}-{x[1]}").map(self.edge_args[attribute]) + else: + edges[attribute] = self.edge_args[attribute] + # check if attribute is given as edge attribute + elif f"edge_{attribute}" in self.network.edge_attrs(): + edges[attribute] = self.network.data[f"edge_{attribute}"] + # special case for size: If no edge_size is given use edge_weight if available + elif attribute == "size": + if "edge_weight" in self.network.edge_attrs(): + edges[attribute] = self.network.data["edge_weight"] + elif "weight" in self.edge_args: + if isinstance(self.edge_args["weight"], dict): + edges[attribute] = edges.index.map(lambda x: f"{x[0]}-{x[1]}").map(self.edge_args["weight"]) + else: + edges[attribute] = self.edge_args["weight"] + + # convert needed attributes to useful values + edges["color"] = self._convert_to_rgb_tuple(edges["color"]) + edges["color"] = edges["color"].map(self._convert_color) + edges["source"] = edges.index.map(lambda x: x[0]) + edges["target"] = edges.index.map(lambda x: x[1]) + + # save edge data + self.data["edges"] = edges + + def _convert_to_rgb_tuple(self, colors: pd.Series) -> dict: + """Convert colors to rgb colormap if given as numerical values.""" + # check if colors are given as numerical values + if pd.api.types.is_numeric_dtype(colors): + # load colormap to map numerical values to color + cmap_name = self.config.get("cmap") + cmap = plt.get_cmap(cmap_name) + # normalize values to [0,1] + norm = plt.Normalize(vmin=colors.min(), vmax=colors.max()) + # map values to colors + colors = colors.map(lambda x: cmap(norm(x))) + return colors + + def _convert_color(self, color: tuple[int, int, int]) -> str: + """Convert color rgb tuple to hex.""" + if isinstance(color, tuple): + return rgb_to_hex(color[:3]) + elif isinstance(color, str): + return color + else: + logger.error(f"The provided color {color} is not valid!") + raise AttributeError + + def _compute_layout(self) -> None: + """Create layout.""" + # get layout form the config + layout = self.config.get("layout") + + # if no layout is considered stop this process + if layout is None: + return + + # get layout dict for each node + if isinstance(layout, str): + layout = network_layout(self.network, layout=layout) + elif not isinstance(layout, dict): + logger.error("The provided layout is not valid!") + raise AttributeError + + # update x,y position of the nodes + layout_df = pd.DataFrame.from_dict(layout, orient="index", columns=["x", "y"]) + self.data["nodes"] = self.data["nodes"].join(layout_df, how="left") + + def _compute_config(self) -> None: + """Add additional configs.""" + self.config["directed"] = self.network.is_directed() + + +# ============================================================================= +# eof +# +# Local Variables: +# mode: python +# mode: linum +# mode: auto-fill +# fill-column: 79 +# End: diff --git a/src/pathpyG/visualisations/network_plots.py b/src/pathpyG/visualisations/network_plots.py deleted file mode 100644 index cd89d8f18..000000000 --- a/src/pathpyG/visualisations/network_plots.py +++ /dev/null @@ -1,504 +0,0 @@ -"""Network plot classes.""" - -# !/usr/bin/python -tt -# -*- coding: utf-8 -*- -# ============================================================================= -# File : network_plots.py -- Network plots -# Author : Jürgen Hackl -# Time-stamp: -# -# Copyright (c) 2016-2023 Pathpy Developers -# ============================================================================= -from __future__ import annotations - -import logging - -from collections import defaultdict -from typing import TYPE_CHECKING, Any -from pathpyG.visualisations.plot import PathPyPlot -from pathpyG.visualisations.utils import rgb_to_hex, Colormap -from pathpyG.visualisations.layout import layout as network_layout - -# pseudo load class for type checking -if TYPE_CHECKING: - from pathpyG.core.graph import Graph - from pathpyG.core.temporal_graph import TemporalGraph - - -# create logger -logger = logging.getLogger("root") - - -def network_plot(network: Graph, **kwargs: Any) -> NetworkPlot: - """Plot a static network. - - This function generates a static plot of the network with various output - formats including interactive HTML with d3js, tex file with tikz code, PDF - from the tex source, and PNG based on matplotlib. The appearance of the - plot can be modified using keyword arguments. - - Args: - network (Graph): A `Graph` object to be plotted. - **kwargs (Any): Keyword arguments to modify the appearance of the - plot. Defaults to no attributes. For details see below. - - Returns: - A plot object, the type of which depends on the output format chosen. - - - # Keyword Arguments to modify the appearance of the plot - **Nodes:** - - - `node_size` : diameter of the node - - - `node_color` : The fill color of the node. Possible values are: - - - A single color string referred to by name, RGB or RGBA code, for - instance `red` or `#a98d19` or `(12,34,102)`. - - - A sequence of color strings referred to by name, RGB or RGBA code, - which will be used for each point's color recursively. For - instance `['green', 'yellow']` all points will be filled in green or - yellow, alternatively. - - - A column name or position whose values will be used to color the - marker points according to a colormap. - - - `node_cmap` : Colormap for node colors. If node colors are given as int - or float values the color will be assigned based on a colormap. Per - default the color map goes from red to green. Matplotlib colormaps or - seaborn color palettes can be used to style the node colors. - - - `node_opacity` : fill opacity of the node. The default is 1. The range - of the number lies between 0 and 1. Where 0 represents a fully - transparent fill and 1 a solid fill. - - - **Edges** - - - `edge_size` : width of the edge - - - `edge_color` : The line color of the edge. Possible values are: - - - A single color string referred to by name, RGB or RGBA code, for - instance `red` or `#a98d19` or `(12,34,102)`. - - - A sequence of color strings referred to by name, RGB or RGBA - code, which will be used for each point's color recursively. For - instance `['green','yellow']` all points will be filled in green or - yellow, alternatively. - - - A column name or position whose values will be used to color the - marker points according to a colormap. - - - `edge_cmap` : Colormap for edge colors. If node colors are given as int - or float values the color will be assigned based on a colormap. Per - default the color map goes from red to green. Matplotlib colormaps or - seaborn color palettes can be used to style the edge colors. - - - `edge_opacity` : line opacity of the edge. The default is 1. The range - of the number lies between 0 and 1. Where 0 represents a fully - transparent fill and 1 a solid fill. - - - **General** - - - `keep_aspect_ratio` - - - `margin` - - - `layout` - - """ - return NetworkPlot(network, **kwargs) - - -class NetworkPlot(PathPyPlot): - """Network plot class for a static network.""" - - _kind = "network" - - def __init__(self, network: Graph, **kwargs: Any) -> None: - """Initialize network plot class.""" - super().__init__() - self.network = network - self.config = kwargs - self.generate() - - def generate(self) -> None: - """Generate the plot.""" - self._compute_edge_data() - self._compute_node_data() - self._compute_layout() - self._cleanup_config() - self._cleanup_data() - - def _compute_node_data(self) -> None: - """Generate the data structure for the nodes.""" - # initialize values - nodes: dict = {} - attributes: set = {"color", "size", "opacity", "label"} - attr: defaultdict = defaultdict(dict) - - # get attributes categories from pathpyg - categories = {a.replace("node_", "") for a in self.network.node_attrs()}.intersection(attributes) - - # add node data to data dict - self._get_node_data(nodes, attributes, attr, categories) - nodes = {str(k): v for k, v in nodes.items()} - - # convert needed attributes to useful values - attr["color"] = self._convert_color(attr["color"], mode="node") - attr["opacity"] = self._convert_opacity(attr["opacity"], mode="node") - attr["size"] = self._convert_size(attr["size"], mode="node") - attr["label"] = self._convert_label(attr["label"], mode="node") - - # update data dict with converted attributes - for attribute in attr: - attr[attribute] = {str(k): v for k, v in attr[attribute].items()} - for key, value in attr[attribute].items(): - nodes[key][attribute] = value - - # save node data - self.data["nodes"] = nodes - - def _get_node_data( - self, - nodes: dict, - attributes: set, - attr: defaultdict, - categories: set, - ) -> None: - """Extract node data from network.""" - for uid in self.network.nodes: - str_uid = str(uid) - nodes[uid] = {"uid": str_uid} - - # add edge attributes if needed - for attribute in attributes: - attr[attribute][str_uid] = ( - self.network[f"node_{attribute}", uid].item() if attribute in categories else None - ) - - def _compute_edge_data(self) -> None: - """Generate the data structure for the edges.""" - # initialize values - edges: dict = {} - attributes: set = {"weight", "color", "size", "opacity"} - attr: defaultdict = defaultdict(dict) - - # get attributes categories from pathpyg - categories: set = {a.replace("edge_", "") for a in self.network.edge_attrs()}.intersection(attributes) - - # add edge data to data dict - self._get_edge_data(edges, attributes, attr, categories) - - # convert needed attributes to useful values - attr["weight"] = self._convert_weight(attr["weight"], mode="edge") - attr["color"] = self._convert_color(attr["color"], mode="edge") - attr["opacity"] = self._convert_opacity(attr["opacity"], mode="edge") - attr["size"] = self._convert_size(attr["size"], mode="edge") - - # update data dict with converted attributes - for attribute in attr: - for key, value in attr[attribute].items(): - edges[key][attribute] = value - - # save edge data - self.data["edges"] = edges - - def _get_edge_data( - self, - edges: dict, - attributes: set, - attr: defaultdict, - categories: set, - ) -> None: - """Extract edge data from network.""" - for u, v in self.network.edges: - uid = f"{u}-{v}" - edges[uid] = { - "uid": uid, - "source": str(u), - "target": str(v), - } - # add edge attributes if needed - for attribute in attributes: - attr[attribute][uid] = ( - self.network[f"edge_{attribute}", u, v].item() if attribute in categories else None - ) - - def _convert_weight(self, weight: dict, mode: str = "node") -> dict: - """Convert weight to float.""" - # get style from the config - style = self.config.get(f"{mode}_weight") - - # check if new attribute is a single object - if isinstance(style, (int, float)): - weight = {k: style for k in weight} - - # check if new attribute is a dict - elif isinstance(style, dict): - weight.update(**{k: v for k, v in style.items() if k in weight}) - - # return all weights which are not None - return {k: v if v is not None else 1 for k, v in weight.items()} - - def _convert_color(self, color: dict, mode: str = "node") -> dict: - """Convert colors to hex if rgb.""" - # get style from the config - style = self.config.get(f"{mode}_color") - color = {str(k): v for k, v in color.items()} - - # check if new attribute is a single object - if isinstance(style, (str, int, float, tuple)): - color = {k: style for k in color} - - # check if new attribute is a dict - elif isinstance(style, dict): - color.update(**{k: v for k, v in style.items() if k in color}) - - # check if new attribute is a list - elif isinstance(style, list): - for i, k in enumerate(color): - try: - color[k] = style[i] - except IndexError: - pass - - # check if numerical values are given - values = [v for v in color.values() if isinstance(v, (int, float))] - - if values: - # load colormap to map numerical values to color - cmap = self.config.get(f"{mode}_cmap", Colormap()) - cdict = {values[i]: tuple(c[:3]) for i, c in enumerate(cmap(values, bytes=True))} - - # convert colors to hex if not already string - for key, value in color.items(): - if isinstance(value, tuple): - color[key] = rgb_to_hex(value) - elif isinstance(value, (int, float)): - color[key] = rgb_to_hex(cdict[value]) - - # return all colors wich are not None - return {k: v for k, v in color.items() if v is not None} - - def _convert_opacity(self, opacity: dict, mode: str = "node") -> dict: - """Convert opacity to float.""" - # get style from the config - style = self.config.get(f"{mode}_opacity") - - # check if new attribute is a single object - if isinstance(style, (int, float)): - opacity = {k: style for k in opacity} - - # check if new attribute is a dict - elif isinstance(style, dict): - opacity.update(**{k: v for k, v in style.items() if k in opacity}) - - # return all colors wich are not None - return {k: v for k, v in opacity.items() if v is not None} - - def _convert_size(self, size: dict, mode: str = "node") -> dict: - """Convert size to float.""" - # get style from the config - style = self.config.get(f"{mode}_size") - - # check if new attribute is a single object - if isinstance(style, (int, float)): - size = {k: style for k in size} - - # check if new attribute is a dict - elif isinstance(style, dict): - size.update(**{k: v for k, v in style.items() if k in size}) - - # return all colors wich are not None - return {k: v for k, v in size.items() if v is not None} - - def _convert_label(self, label: dict, mode: str = "node") -> dict: - """Convert label to string.""" - # get style from the config - style = self.config.get(f"{mode}_label") - - # check if new attribute is a single object - if isinstance(style, str): - label = {k: style for k in label} - - # check if new attribute is a dict - elif isinstance(style, dict): - label.update(**{k: v for k, v in style.items() if k in label}) - - # check if new attribute is a list - elif isinstance(style, list): - for i, k in enumerate(label): - try: - label[k] = style[i] - except IndexError: - pass - - # return all labels wich are not None - return {k: v for k, v in label.items() if v is not None} - - def _compute_layout(self) -> None: - """Create layout.""" - # get layout form the config - layout = self.config.get("layout", "rand") - - # if no layout is considered stop this process - if layout is None: - return - - # get layout dict for each node - if isinstance(layout, str): - layout = network_layout(self.network, layout=layout) - elif not isinstance(layout, dict): - logger.error("The provided layout is not valid!") - raise AttributeError - - # update x,y position of the nodes - for uid, (_x, _y) in layout.items(): - self.data["nodes"][str(uid)]["x"] = _x - self.data["nodes"][str(uid)]["y"] = _y - - def _cleanup_config(self) -> None: - """Clean up final config file.""" - try: - directed = self.network.is_directed() - except NotImplementedError: - directed = False - - if not self.config.get("directed", None): - self.config["directed"] = directed - - if not self.config.get("curved", None): - self.config["curved"] = directed - - def _cleanup_data(self) -> None: - """Clean up final data structure.""" - self.data["nodes"] = list(self.data["nodes"].values()) - self.data["edges"] = list(self.data["edges"].values()) - - -def static_plot(network: Graph, **kwargs: Any) -> NetworkPlot: - """Plot a static network.""" - return StaticNetworkPlot(network, **kwargs) - - -class StaticNetworkPlot(NetworkPlot): - """Network plot class for a static network.""" - - _kind = "static" - - -def temporal_plot(network: TemporalGraph, **kwargs: Any) -> NetworkPlot: - """Plot a temporal network. - - **Temporal properties:** - - - ``start`` : start time of the simulation - - - ``end`` : end time of the simulation - - - ``delta`` : time needed for progressing one time step - - - ``intervals`` : number of numeric intervals - - """ - return TemporalNetworkPlot(network, **kwargs) - - -class TemporalNetworkPlot(NetworkPlot): - """Network plot class for a temporal network.""" - - _kind = "temporal" - network: TemporalGraph - - def __init__(self, network: TemporalGraph, **kwargs: Any) -> None: - """Initialize network plot class.""" - super().__init__(network, **kwargs) - - def _get_edge_data(self, edges: dict, attributes: set, attr: defaultdict, categories: set) -> None: - """Extract edge data from temporal network.""" - for u, v, t in self.network.temporal_edges: - uid = f"{u}-{v}-{t}" - edges[uid] = { - "uid": uid, - "source": str(u), - "target": str(v), - "start": int(t), - "end": int(t) + 1, - } - # add edge attributes if needed - for attribute in attributes: - attr[attribute][uid] = ( - self.network[f"edge_{attribute}", u, v].item() if attribute in categories else None - ) - - def _compute_node_data(self): - """_summary_""" - super()._compute_node_data() - - raw_color_attr = self.config.get("node_color", {}) - if not isinstance(raw_color_attr, dict): - return - - color_changes_by_node = defaultdict(list) - for key, color in raw_color_attr.items(): - if "-" not in key: - continue - - try: - node_id, time_str = key.rsplit("-", 1) - time = float(time_str) - except ValueError as exc: - raise ValueError(f"Invalid time-encoded node_color key: '{key}'") from exc - - if isinstance(color, (int, float)): - cmap = self.config.get("node_cmap", Colormap()) - rgb = cmap([color])[0] - color = rgb_to_hex(rgb[:3]) - - elif isinstance(color, tuple): - color = rgb_to_hex(color) - - color_changes_by_node[node_id].append({"time": time, "color": color}) - - for node_id, changes in color_changes_by_node.items(): - if node_id in self.data.get("nodes", {}): - self.data["nodes"][node_id]["color_change"] = sorted(changes, key=lambda x: x["time"]) - - def _get_node_data(self, nodes: dict, attributes: set, attr: defaultdict, categories: set) -> None: - """Extract node data from temporal network.""" - - time = {e[2] for e in self.network.temporal_edges} - - if self.config.get("end", None) is None: - self.config["end"] = int(max(time) + 1) - - if self.config.get("start", None) is None: - self.config["start"] = int(min(time) - 1) - - for uid in self.network.nodes: - nodes[uid] = { - "uid": str(uid), - "start": int(min(time) - 1), - "end": int(max(time) + 1), - } - - # add edge attributes if needed - for attribute in attributes: - attr[attribute][uid] = ( - self.network[f"node_{attribute}", uid].item() if attribute in categories else None - ) - - -# ============================================================================= -# eof -# -# Local Variables: -# mode: python -# mode: linum -# mode: auto-fill -# fill-column: 79 -# End: diff --git a/src/pathpyG/visualisations/pathpy_plot.py b/src/pathpyG/visualisations/pathpy_plot.py new file mode 100644 index 000000000..863a904e0 --- /dev/null +++ b/src/pathpyG/visualisations/pathpy_plot.py @@ -0,0 +1,24 @@ +import logging + +from pathpyG import config + +logger = logging.getLogger("root") + + +class PathPyPlot: + """Abstract class for assembling plots. + + Attributes: + data (pd.DataFrame): Data of the plot object. + config (dict): Configuration for the plot. + """ + + def __init__(self) -> None: + """Initialize plot class.""" + self.data: dict = {} + self.config: dict = config.get("visualisation", {}).copy() + logger.debug(f"Intialising PathpyPlot with config: {self.config}") + + def generate(self) -> None: + """Generate the plot.""" + raise NotImplementedError diff --git a/src/pathpyG/visualisations/plot.py b/src/pathpyG/visualisations/plot.py deleted file mode 100644 index 7e85b20d5..000000000 --- a/src/pathpyG/visualisations/plot.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Class to plot pathpy networks.""" - -# !/usr/bin/python -tt -# -*- coding: utf-8 -*- -# ============================================================================= -# File : plot.py -- Module to plot pathpyG networks -# Author : Jürgen Hackl -# Time-stamp: -# -# Copyright (c) 2016-2023 Pathpy Developers -# ============================================================================= -import os -import logging -import importlib - -from copy import deepcopy -from typing import Optional, Any - - -# create logger -logger = logging.getLogger("root") - -# supported backends -BACKENDS: set = {"d3js", "tikz", "matplotlib", "manim"} - -# supported file formats -FORMATS: dict = { - ".html": "d3js", - ".tex": "tikz", - ".pdf": "tikz", - ".svg": "tikz", - ".png": "matplotlib", - ".mp4": "manim", - ".gif": "manim", -} - - -def _get_plot_backend( - backend: Optional[str] = None, - filename: Optional[str] = None, - default: str = "d3js", -) -> Any: - """Return the plotting backend to use.""" - # use default backend per default - _backend: str = default - - # Get file ending and infere backend - if isinstance(filename, str): - _backend = FORMATS.get(os.path.splitext(filename)[1], default) - - # if no backend was found use the backend suggested for the file format - if backend is not None and backend not in BACKENDS and filename is not None: - logger.error(f"The backend <{backend}> was not found.") - raise KeyError - - # if no backend was given use the backend suggested for the file format - elif isinstance(backend, str) and backend in BACKENDS: - _backend = backend - - # try to load backend or return error - try: - module = importlib.import_module(f"pathpyG.visualisations._{_backend}") - except ImportError: - logger.error(f"The <{_backend}> backend could not be imported.") - raise ImportError from None - - return module - - -class PathPyPlot: - """Abstract class for assemblig plots. - - Attributes - ---------- - data : dict - data of the plot object - config : dict - configuration for the plot - - """ - - def __init__(self) -> None: - """Initialize plot class.""" - logger.debug("Initalize PathPyPlot class") - self.data: dict = {} - self.config: dict = {} - - @property - def _kind(self) -> str: - """Specify kind str. Must be overridden in child class.""" - raise NotImplementedError - - def generate(self) -> None: - """Generate the plot.""" - raise NotImplementedError - - def save(self, filename: str, **kwargs: Any) -> None: - """Save the plot to the hard drive.""" - _backend: str = kwargs.pop("backend", self.config.get("backend", None)) - - plot_backend = _get_plot_backend(_backend, filename) - plot_backend.plot(deepcopy(self.data), self._kind, **deepcopy(self.config)).save(filename, **kwargs) - - def show(self, **kwargs: Any) -> None: - """Show the plot on the device.""" - _backend: str = kwargs.pop("backend", self.config.get("backend", None)) - - plot_backend = _get_plot_backend(_backend, None) - plot_backend.plot(deepcopy(self.data), self._kind, **deepcopy(self.config)).show(**kwargs) diff --git a/src/pathpyG/visualisations/plot_backend.py b/src/pathpyG/visualisations/plot_backend.py new file mode 100644 index 000000000..96639c22e --- /dev/null +++ b/src/pathpyG/visualisations/plot_backend.py @@ -0,0 +1,19 @@ +"""Base class for all plot backends.""" + +from pathpyG.visualisations.pathpy_plot import PathPyPlot + + +class PlotBackend: + """Base class for all plot backends.""" + def __init__(self, plot: PathPyPlot): + """Initialize the backend with a plot.""" + self.data = plot.data + self.config = plot.config + + def save(self, filename: str) -> None: + """Save the plot to the hard drive.""" + raise NotImplementedError("Subclasses should implement this method.") + + def show(self) -> None: + """Show the plot on the device.""" + raise NotImplementedError("Subclasses should implement this method.") diff --git a/src/pathpyG/visualisations/plot_function.py b/src/pathpyG/visualisations/plot_function.py new file mode 100644 index 000000000..b9a70d03a --- /dev/null +++ b/src/pathpyG/visualisations/plot_function.py @@ -0,0 +1,168 @@ +"""Class to plot pathpy networks.""" + +# !/usr/bin/python -tt +# -*- coding: utf-8 -*- +# ============================================================================= +# File : plot.py -- Module to plot pathpyG networks +# Author : Jürgen Hackl +# Time-stamp: +# +# Copyright (c) 2016-2023 Pathpy Developers +# ============================================================================= +import importlib +import logging +import os +from enum import Enum +from typing import Any, Optional + +from pathpyG import config +from pathpyG.core.graph import Graph +from pathpyG.core.temporal_graph import TemporalGraph +from pathpyG.visualisations.network_plot import NetworkPlot +from pathpyG.visualisations.pathpy_plot import PathPyPlot +from pathpyG.visualisations.plot_backend import PlotBackend +from pathpyG.visualisations.temporal_network_plot import TemporalNetworkPlot + +# create logger +logger = logging.getLogger("root") + +# supported backends +class Backends(str, Enum): + """Supported backends.""" + d3js = "d3js" + matplotlib = "matplotlib" + tikz = "tikz" + manim = "manim" + + @staticmethod + def is_backend(backend: str) -> bool: + """Check if value is a valid backend.""" + return backend in Backends.__members__.values() + +# supported file formats +FORMATS: dict = { + ".html": Backends.d3js, + ".tex": Backends.tikz, + ".pdf": Backends.tikz, + ".svg": Backends.tikz, + ".png": Backends.matplotlib, + ".mp4": Backends.manim, + ".gif": Backends.manim, +} + +# Supported Plot Classes +PLOT_CLASSES: dict = { + "static": NetworkPlot, + "temporal": TemporalNetworkPlot, +} + +def _get_plot_backend(backend: Optional[str], filename: Optional[str], default: str) -> PlotBackend: + """Return the plotting backend to use.""" + # check if backend is valid backend type based on enum + if backend is not None and not Backends.is_backend(backend): + logger.error(f"The backend <{backend}> was not found.") + raise KeyError + # use given backend if valid + elif isinstance(backend, str) and Backends.is_backend(backend): + logger.debug(f"Using backend <{backend}>.") + _backend = backend + # if no backend was given use the backend suggested for the file format + else: + # Get file ending and try to infer backend + if isinstance(filename, str): + _backend = FORMATS.get(os.path.splitext(filename)[1], default) + logger.debug(f"Using backend <{_backend}> inferred from file ending.") + else: + # use default backend per default + _backend = default + logger.debug(f"Using default backend <{_backend}>.") + + # try to load backend class or return error + try: + module = importlib.import_module(f"pathpyG.visualisations._{_backend}.backend") + except ImportError as e: + logger.error(f"The <{_backend}> backend could not be imported.") + raise ImportError from e + + return getattr(module, f"{_backend.capitalize()}Backend") # type: ignore[return-value] + + +def plot(graph: Graph, kind: Optional[str] = None, show_labels=None, **kwargs: Any) -> PathPyPlot: + """Make plot of pathpyG objects. + + Creates and displays a plot for a given `pathpyG` object. This function can + generate different types of network plots based on the nature of the input + data and specified plot kind. + + The function dynamically determines the plot type if not explicitly + provided, based on the input data type. It supports static network plots + for `Graph` objects, temporal network plots for `TemporalGraph` objects, + and potentially other types if specified in `kind`. + + Args: + graph (Graph): A `pathpyG` object representing the network data. This can + be a `Graph` or `TemporalGraph` object, or other compatible types. + kind (Optional[str], optional): A string keyword defining the type of + plot to generate. Options include: + - 'static' : Generates a static (aggregated) network plot. Ideal + for `Graph` objects. + - 'temporal' : Creates a temporal network plot, which includes time + components. Suitable for `TemporalGraph` objects. + - 'hist' : Produces a histogram of network properties. (Note: + Implementation for 'hist' is not present in the given function + code, it's mentioned for possible extension.) + The default behavior (when `kind` is None) is to infer the plot type from the graph type. + show_labels (Optional[bool], optional): Whether to display node labels + on the plot. If None, the function will decide based on the IndexMap. + **kwargs (Any): Optional keyword arguments to customize the plot. These + arguments are passed directly to the plotting class. Common options + could include layout parameters, color schemes, and plot size. + + Returns: + PathPyPlot: A `PathPyPlot` object representing the generated plot. + This could be an instance of a plot class from + `pathpyG.visualisations.network_plots`, depending on the kind of + plot generated. + + Raises: + NotImplementedError: If the `kind` is not recognized or if the function + cannot infer the plot type from the `graph` type. + + Examples: + This will create a static network plot of the `graph` and save it to 'graph.png'. + + >>> import pathpyG as pp + >>> graph = Graph.from_edge_list([["a", "b"], ["b", "c"], ["a", "c"]]) + >>> plot(graph, kind="static", filename="graph.png") + + Note: + - If a 'filename' is provided in `kwargs`, the plot will be saved to + that file. Otherwise, it will be displayed using `plt.show()`. + - The function's behavior and the available options in `kwargs` might + change based on the type of plot being generated. + """ + if kind is None: + if isinstance(graph, TemporalGraph): + kind = "temporal" + elif isinstance(graph, Graph): + kind = "static" + else: + raise NotImplementedError + + if show_labels is None: + show_labels = graph.mapping.has_ids + + filename = kwargs.pop("filename", None) + _backend: str = kwargs.pop("backend", None) + + plt = PLOT_CLASSES[kind](graph, **kwargs) + plot_backend_class = _get_plot_backend( + backend=_backend, filename=filename, default=config.get("visualisation").get("default_backend") # type: ignore[union-attr] + ) + plot_backend = plot_backend_class(plt, show_labels=show_labels) + if filename: + plot_backend.save(filename) + else: + if config["environment"]["interactive"]: + plot_backend.show() + return plot_backend diff --git a/src/pathpyG/visualisations/temporal_network_plot.py b/src/pathpyG/visualisations/temporal_network_plot.py new file mode 100644 index 000000000..32845827f --- /dev/null +++ b/src/pathpyG/visualisations/temporal_network_plot.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import TYPE_CHECKING, Any + +from pathpyG.visualisations.network_plot import NetworkPlot +from pathpyG.visualisations.utils import Colormap, rgb_to_hex + +# pseudo load class for type checking +if TYPE_CHECKING: + from pathpyG.core.temporal_graph import TemporalGraph + + +class TemporalNetworkPlot(NetworkPlot): + """Network plot class for a temporal network.""" + + _kind = "temporal" + network: TemporalGraph + + def __init__(self, network: TemporalGraph, **kwargs: Any) -> None: + """Initialize network plot class.""" + super().__init__(network, **kwargs) + + def _get_edge_data(self, edges: dict, attributes: set, attr: defaultdict, categories: set) -> None: + """Extract edge data from temporal network.""" + for u, v, t in self.network.temporal_edges: + uid = f"{u}-{v}-{t}" + edges[uid] = { + "uid": uid, + "source": str(u), + "target": str(v), + "start": int(t), + "end": int(t) + 1, + } + # add edge attributes if needed + for attribute in attributes: + attr[attribute][uid] = ( + self.network[f"edge_{attribute}", u, v].item() if attribute in categories else None + ) + + def _compute_node_data(self): + """_summary_""" + super()._compute_node_data() + + raw_color_attr = self.config.get("node_color", {}) + if not isinstance(raw_color_attr, dict): + return + + color_changes_by_node = defaultdict(list) + for key, color in raw_color_attr.items(): + if "-" not in key: + continue + + try: + node_id, time_str = key.rsplit("-", 1) + time = float(time_str) + except ValueError as exc: + raise ValueError(f"Invalid time-encoded node_color key: '{key}'") from exc + + if isinstance(color, (int, float)): + cmap = self.config.get("node_cmap", Colormap()) + rgb = cmap([color])[0] + color = rgb_to_hex(rgb[:3]) + + elif isinstance(color, tuple): + color = rgb_to_hex(color) + + color_changes_by_node[node_id].append({"time": time, "color": color}) + + for node_id, changes in color_changes_by_node.items(): + if node_id in self.data.get("nodes", {}): + self.data["nodes"][node_id]["color_change"] = sorted(changes, key=lambda x: x["time"]) + + def _get_node_data(self, nodes: dict, attributes: set, attr: defaultdict, categories: set) -> None: + """Extract node data from temporal network.""" + + time = {e[2] for e in self.network.temporal_edges} + + if self.config.get("end", None) is None: + self.config["end"] = int(max(time) + 1) + + if self.config.get("start", None) is None: + self.config["start"] = int(min(time) - 1) + + for uid in self.network.nodes: + nodes[uid] = { + "uid": str(uid), + "start": int(min(time) - 1), + "end": int(max(time) + 1), + } + + # add edge attributes if needed + for attribute in attributes: + attr[attribute][uid] = ( + self.network[f"node_{attribute}", uid].item() if attribute in categories else None + ) + + def _compute_config(self) -> None: + """Add additional configs.""" + pass \ No newline at end of file diff --git a/src/pathpyG/visualisations/utils.py b/src/pathpyG/visualisations/utils.py index 9f5252f2d..48d22fdb6 100644 --- a/src/pathpyG/visualisations/utils.py +++ b/src/pathpyG/visualisations/utils.py @@ -7,11 +7,18 @@ # # Copyright (c) 2016-2023 Pathpy Developers # ============================================================================= -from typing import Optional def rgb_to_hex(rgb: tuple) -> str: - """Convert rgb color tuple to hex string.""" + """Convert rgb color tuple to hex string. + + Args: + rgb (tuple): RGB color tuple either in range 0-1 or 0-255. + """ + if all(0.0 <= val <= 1.0 for val in rgb): + rgb = tuple(int(val * 255) for val in rgb) + elif not all(0 <= val <= 255 for val in rgb): + raise ValueError("RGB values must be in range 0-1 or 0-255.") return "#%02x%02x%02x" % rgb @@ -22,28 +29,6 @@ def hex_to_rgb(value: str) -> tuple: return tuple(int(value[i : i + _l // 3], 16) for i in range(0, _l, _l // 3)) -class Colormap: - """Very simple colormap class.""" - - def __call__( - self, - values: list, - alpha: Optional[float] = None, - bytes: bool = False, - ) -> list: - """Return color value.""" - vmin, vmax = min(values), max(values) - if vmin == vmax: - vmin -= 1 - vmax += 1 - return [self.color_tuple(v) for v in ((x - vmin) / (vmax - vmin) * 100 for x in values)] - - @staticmethod - def color_tuple(n: float) -> tuple: - """Return color ramp from green to red.""" - return (int((255 * n) * 0.01), int((255 * (100 - n)) * 0.01), 0, 255) - - # ============================================================================= # eof # From 99a1a3ed942be0e8663cb1a07b351a6bd6ff5a05 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Wed, 8 Oct 2025 09:38:15 +0000 Subject: [PATCH 02/44] update matplotlib and tikz --- src/pathpyG/pathpyG.toml | 31 ++- .../visualisations/_matplotlib/backend.py | 92 +++++--- src/pathpyG/visualisations/_tikz/__init__.py | 17 -- .../_tikz/{core.py => backend.py} | 118 ++++++---- .../visualisations/_tikz/network_plots.py | 211 ------------------ .../_tikz/templates/network.tex | 12 - .../visualisations/_tikz/templates/static.tex | 4 +- .../_tikz/templates/temporal.tex | 12 - src/pathpyG/visualisations/network_plot.py | 14 +- src/pathpyG/visualisations/pathpy_plot.py | 4 + src/pathpyG/visualisations/plot_backend.py | 3 +- src/pathpyG/visualisations/plot_function.py | 4 +- src/pathpyG/visualisations/utils.py | 17 ++ 13 files changed, 199 insertions(+), 340 deletions(-) rename src/pathpyG/visualisations/_tikz/{core.py => backend.py} (59%) delete mode 100644 src/pathpyG/visualisations/_tikz/network_plots.py delete mode 100644 src/pathpyG/visualisations/_tikz/templates/network.tex delete mode 100644 src/pathpyG/visualisations/_tikz/templates/temporal.tex diff --git a/src/pathpyG/pathpyG.toml b/src/pathpyG/pathpyG.toml index 89de00073..9e27e1df6 100644 --- a/src/pathpyG/pathpyG.toml +++ b/src/pathpyG/pathpyG.toml @@ -15,15 +15,6 @@ frequency = "frequency" [object] separator ="," -[node] -color = "CornFlowerBlue" -size = 15 - -[edge] -color = "darkgray" -width = 2 -curved = true - [path] separator = "|" replace = "_" @@ -33,7 +24,25 @@ max_name_length = 5 separator = "=" replace = "_" -[temporal] +[visualisation] +default_backend = "d3js" +cmap = "cividis" +layout = "spring_layout" +width = "12cm" +height = "12cm" +latex_class_options = "" + +[visualisation.node] +color = [36, 74, 92] +size = 15 +opacity = 0.75 + +[visualisation.edge] +color = [76, 112, 123] +size = 2 +opacity = 0.5 + +[visualisation.temporal] start = "start" end = "end" timestamp = "timestamp" @@ -45,4 +54,4 @@ timestamp_synonyms = ['time', "t"] duration_synonyms = ["delta", "dt"] active = "active" is_active = true -unit = "s" \ No newline at end of file +unit = "s" diff --git a/src/pathpyG/visualisations/_matplotlib/backend.py b/src/pathpyG/visualisations/_matplotlib/backend.py index 64846c1b7..e9ec51be0 100644 --- a/src/pathpyG/visualisations/_matplotlib/backend.py +++ b/src/pathpyG/visualisations/_matplotlib/backend.py @@ -4,11 +4,12 @@ import matplotlib.pyplot as plt import numpy as np -from matplotlib.collections import LineCollection, PathCollection +from matplotlib.collections import EllipseCollection, LineCollection, PathCollection from matplotlib.path import Path from pathpyG.visualisations.network_plot import NetworkPlot from pathpyG.visualisations.plot_backend import PlotBackend +from pathpyG.visualisations.utils import unit_str_to_float logger = logging.getLogger("root") @@ -20,10 +21,9 @@ class MatplotlibBackend(PlotBackend): """Matplotlib plotting backend.""" - def __init__(self, plot, show_labels=None): - super().__init__(plot) + def __init__(self, plot, show_labels: bool): + super().__init__(plot, show_labels=show_labels) self._kind = SUPPORTED_KINDS.get(type(plot), None) - self._show_labels = show_labels if self._kind is None: logger.error(f"Plot of type {type(plot)} not supported by Matplotlib backend.") raise ValueError(f"Plot of type {type(plot)} not supported.") @@ -40,7 +40,11 @@ def show(self) -> None: def to_fig(self) -> tuple[plt.Figure, plt.Axes]: """Convert data to figure.""" - fig, ax = plt.subplots() + size_factor = 1 / 200 # scale node size to reasonable values + fig, ax = plt.subplots( + figsize=(unit_str_to_float(self.config["width"], "in"), unit_str_to_float(self.config["height"], "in")), + dpi=150, + ) ax.set_axis_off() # get source and target coordinates for edges @@ -48,36 +52,57 @@ def to_fig(self) -> tuple[plt.Figure, plt.Axes]: target_coords = self.data["nodes"].loc[self.data["edges"]["target"], ["x", "y"]].values if self.config["directed"]: - self.add_directed_edges(source_coords, target_coords, ax) + self.add_directed_edges(source_coords, target_coords, ax, size_factor) else: - self.add_undirected_edges(source_coords, target_coords, ax) + self.add_undirected_edges(source_coords, target_coords, ax, size_factor) # plot nodes - ax.scatter( - self.data["nodes"]["x"], - self.data["nodes"]["y"], - s=self.data["nodes"]["size"] * 15, - c=self.data["nodes"]["color"], - alpha=self.data["nodes"]["opacity"], - zorder=2, + # We use EllipseCollection instead of scatter because there you can specify the radius of each circle in the unit of the data coordinates + # https://stackoverflow.com/a/33095224 + ax.add_collection( + EllipseCollection( + widths=self.data["nodes"]["size"] * size_factor, + heights=self.data["nodes"]["size"] * size_factor, + angles=0, + units="xy", + offsets=self.data["nodes"][["x", "y"]].values, + transOffset=ax.transData, + facecolors=self.data["nodes"]["color"], + edgecolors="black", + linewidths=0.5, + alpha=self.data["nodes"]["opacity"], + zorder=2, + ) ) # add node labels - if self._show_labels: + if self.show_labels: for label in self.data["nodes"].index: x, y = self.data["nodes"].loc[label, ["x", "y"]] # Annotate the node label with text above the node ax.annotate( label, (x, y), - xytext=(0, 6 + 0.25 * self.data["nodes"].loc[label, "size"]), + xytext=(0, 0.75 * self.data["nodes"].loc[label, "size"]), textcoords="offset points", - fontsize=6 + 0.25 * self.data["nodes"].loc[label, "size"], + fontsize=0.75 * self.data["nodes"]["size"].mean(), ) + + # set limits + ax.set_xlim(-0.1, 1.1) + ax.set_ylim(-0.1, 1.1) return fig, ax - def add_undirected_edges(self, source_coords, target_coords, ax): + def add_undirected_edges(self, source_coords, target_coords, ax, size_factor): """Add undirected edges to the plot based on LineCollection.""" + # shorten edges so they don't overlap with nodes + vec = target_coords - source_coords + dist = np.linalg.norm(vec, axis=1, keepdims=True) + direction = vec / dist + source_coords += direction * (self.data["nodes"].loc[self.data["edges"]["source"], ["size"]].values * (size_factor / 2)) # /2 because we use radius instead of diameter + target_coords -= direction * (self.data["nodes"].loc[self.data["edges"]["target"], ["size"]].values * (size_factor / 2)) + + # create and add lines edge_lines = list(zip(source_coords, target_coords)) ax.add_collection( LineCollection( @@ -89,13 +114,18 @@ def add_undirected_edges(self, source_coords, target_coords, ax): ) ) - def add_directed_edges(self, source_coords, target_coords, ax): + def add_directed_edges(self, source_coords, target_coords, ax, size_factor): """Add directed edges with arrowheads to the plot based on Bezier curves.""" # get bezier curve vertices and codes + head_length = 0.02 vertices, codes = self.get_bezier_curve( source_coords, target_coords, - shorten=self.data["nodes"].loc[self.data["edges"]["target"], ["size"]].values * 0.001, + source_node_size=self.data["nodes"].loc[self.data["edges"]["source"], ["size"]].values + * (size_factor / 2), # /2 because we use radius instead of diameter + target_node_size=self.data["nodes"].loc[self.data["edges"]["target"], ["size"]].values + * (size_factor / 2), + head_length=head_length, ) ax.add_collection( PathCollection( @@ -115,23 +145,35 @@ def add_directed_edges(self, source_coords, target_coords, ax): ) # add arrowheads - arrow_vertices, arrow_codes = self.get_arrowhead(vertices, head_length=self.data["nodes"]["size"].max() * 0.001) + arrow_vertices, arrow_codes = self.get_arrowhead(vertices, head_length=head_length) ax.add_collection( PathCollection( [Path(v, arrow_codes) for v in zip(*arrow_vertices)], facecolor=self.data["edges"]["color"], edgecolor=self.data["edges"]["color"], alpha=self.data["edges"]["opacity"], - zorder=3, + zorder=1, ) ) - def get_bezier_curve(self, source_coords, target_coords, curvature=0.2, shorten=0.02): + def get_bezier_curve( + self, + source_coords, + target_coords, + source_node_size, + target_node_size, + head_length, + curvature=0.2, + shorten=0.005, + ): """Calculates the vertices and codes for a quadratic Bézier curve path. Args: source_coords (np.array): Start points (x, y) for all edges. target_coords (np.array): End points (x, y) for all edges. + source_node_size (np.array): Size of the source nodes to adjust the curve shortening. + target_node_size (np.array): Size of the target nodes to adjust the curve shortening. + head_length (float): Length of the arrowhead to adjust the curve shortening. curvature (float): A value controlling the curve's bend. shorten (float): Amount to shorten the curve at both ends to avoid overlap with nodes. Will shorten double at the target end to make space for the arrowhead. @@ -157,8 +199,8 @@ def get_bezier_curve(self, source_coords, target_coords, curvature=0.2, shorten= # Shorten the curve to avoid overlap with nodes direction_P0_P1 = (P1 - P0) / np.linalg.norm(P1 - P0, axis=1, keepdims=True) direction_P2_P1 = (P1 - P2) / np.linalg.norm(P1 - P2, axis=1, keepdims=True) - P0 += direction_P0_P1 * shorten - P2 += direction_P2_P1 * shorten * 2 + P0 += direction_P0_P1 * (shorten + source_node_size) + P2 += direction_P2_P1 * (shorten + target_node_size + head_length) vertices = [P0, P1, P2] codes = [ diff --git a/src/pathpyG/visualisations/_tikz/__init__.py b/src/pathpyG/visualisations/_tikz/__init__.py index 0ec92cb9b..8d233e496 100644 --- a/src/pathpyG/visualisations/_tikz/__init__.py +++ b/src/pathpyG/visualisations/_tikz/__init__.py @@ -9,23 +9,6 @@ # # Copyright (c) 2016-2023 Pathpy Developers # ============================================================================= -# flake8: noqa -# pylint: disable=unused-import -from typing import Any -from pathpyG.visualisations._tikz.network_plots import NetworkPlot -from pathpyG.visualisations._tikz.network_plots import StaticNetworkPlot -from pathpyG.visualisations._tikz.network_plots import TemporalNetworkPlot - -PLOT_CLASSES: dict = { - "network": NetworkPlot, - "static": StaticNetworkPlot, - "temporal": TemporalNetworkPlot, -} - - -def plot(data: dict, kind: str = "network", **kwargs: Any) -> Any: - """Plot function.""" - return PLOT_CLASSES[kind](data, **kwargs) # ============================================================================= diff --git a/src/pathpyG/visualisations/_tikz/core.py b/src/pathpyG/visualisations/_tikz/backend.py similarity index 59% rename from src/pathpyG/visualisations/_tikz/core.py rename to src/pathpyG/visualisations/_tikz/backend.py index 73c7f7c90..b278a77c1 100644 --- a/src/pathpyG/visualisations/_tikz/core.py +++ b/src/pathpyG/visualisations/_tikz/backend.py @@ -1,46 +1,40 @@ -#!/usr/bin/python -tt -# -*- coding: utf-8 -*- -# ============================================================================= -# File : core.py -- Plots with tikz -# Author : Jürgen Hackl -# Time-stamp: -# -# Copyright (c) 2016-2021 Pathpy Developers -# ============================================================================= from __future__ import annotations +import logging import os -import time import shutil -import logging -import tempfile import subprocess +import tempfile +import time import webbrowser - -from typing import Any from string import Template -from pathpyG.utils.config import config -from pathpyG.visualisations.plot import PathPyPlot +from pathpyG import config +from pathpyG.visualisations.network_plot import NetworkPlot +from pathpyG.visualisations.pathpy_plot import PathPyPlot +from pathpyG.visualisations.plot_backend import PlotBackend +from pathpyG.visualisations.utils import unit_str_to_float, hex_to_rgb # create logger logger = logging.getLogger("root") +SUPPORTED_KINDS = { + NetworkPlot: "static", +} -class TikzPlot(PathPyPlot): - """Base class for plotting tikz objects.""" - def __init__(self, **kwargs: Any) -> None: - """Initialize plot class.""" - super().__init__() - if kwargs: - self.config = kwargs +class TikzBackend(PlotBackend): + """Backend for tikz/latex output.""" - def generate(self) -> None: - """Generate the plot.""" - raise NotImplementedError + def __init__(self, plot: PathPyPlot, show_labels: bool): + """Initialize the backend with a plot.""" + super().__init__(plot, show_labels=show_labels) + self._kind = SUPPORTED_KINDS.get(type(plot), None) + if self._kind is None: + logger.error(f"Plot of type {type(plot)} not supported by Tikz backend.") + raise ValueError(f"Plot of type {type(plot)} not supported.") - def save(self, filename: str, **kwargs: Any) -> None: + def save(self, filename: str) -> None: """Save the plot to the hard drive.""" if filename.endswith("tex"): with open(filename, "w+") as new: @@ -62,7 +56,7 @@ def save(self, filename: str, **kwargs: Any) -> None: else: raise NotImplementedError - def show(self, **kwargs: Any) -> None: + def show(self) -> None: """Show the plot on the device.""" # compile temporary pdf temp_file, temp_dir = self.compile_svg() @@ -101,7 +95,7 @@ def compile_svg(self) -> tuple: except subprocess.CalledProcessError as e: logger.error("latexmk compiler failed with output:\n%s", e.output.decode()) raise AttributeError from e - + # dvisvgm command command = [ "dvisvgm", @@ -180,25 +174,59 @@ def to_tex(self) -> str: # fill template with data tex = Template(tex_template).substitute( - classoptions=self.config.get("latex_class_options", ""), - width=self.config.get("width", "12cm"), - height=self.config.get("height", "12cm"), + classoptions=self.config.get("latex_class_options"), + width=self.config.get("width"), + height=self.config.get("height"), tikz=data, ) return tex def to_tikz(self) -> str: - """Convert data to tikz.""" - raise NotImplementedError - - -# ============================================================================= -# eof -# -# Local Variables: -# mode: python -# mode: linum -# mode: auto-fill -# fill-column: 79 -# End: + """Convert to Tex.""" + tikz = "" + # generate node strings + node_strings = "\\Vertex[" + # show labels if specified + if self.show_labels: + node_strings += "label=" + self.data["nodes"].index.astype(str) + "," + node_strings += ( + "fontsize=\\fontsize{" + str(int(0.75 * self.data["nodes"]["size"].mean())) + "}{10}\selectfont," + ) + # Convert hex colors to rgb if necessary + if self.data["nodes"]["color"].str.startswith("#").all(): + self.data["nodes"]["color"] = self.data["nodes"]["color"].map(hex_to_rgb) + node_strings += "RGB,color={" + self.data["nodes"]["color"].astype(str).str.strip("()") + "}," + else: + node_strings += "color=" + self.data["nodes"]["color"] + "," + # add other options + node_strings += "size=" + (self.data["nodes"]["size"] * 0.05).astype(str) + "," + node_strings += "opacity=" + self.data["nodes"]["opacity"].astype(str) + "," + # add position + node_strings += ( + "x=" + ((self.data["nodes"]["x"] - 0.5) * unit_str_to_float(self.config["width"], "cm")).astype(str) + "," + ) + node_strings += ( + "y=" + ((self.data["nodes"]["y"] - 0.5) * unit_str_to_float(self.config["height"], "cm")).astype(str) + "]" + ) + # add node name + node_strings += "{" + self.data["nodes"].index.astype(str) + "}\n" + tikz += node_strings.str.cat() + + # generate edge strings + edge_strings = "\\Edge[" + if self.config["directed"]: + edge_strings += "bend=15,Direct," + if self.data["edges"]["color"].str.startswith("#").all(): + self.data["edges"]["color"] = self.data["edges"]["color"].map(hex_to_rgb) + edge_strings += "RGB,color={" + self.data["edges"]["color"].astype(str).str.strip("()") + "}," + else: + edge_strings += "color=" + self.data["edges"]["color"] + "," + edge_strings += "lw=" + self.data["edges"]["size"].astype(str) + "," + edge_strings += "opacity=" + self.data["edges"]["opacity"].astype(str) + "]" + edge_strings += ( + "(" + self.data["edges"]["source"].astype(str) + ")(" + self.data["edges"]["target"].astype(str) + ")\n" + ) + tikz += edge_strings.str.cat() + + return tikz diff --git a/src/pathpyG/visualisations/_tikz/network_plots.py b/src/pathpyG/visualisations/_tikz/network_plots.py deleted file mode 100644 index 1aed15856..000000000 --- a/src/pathpyG/visualisations/_tikz/network_plots.py +++ /dev/null @@ -1,211 +0,0 @@ -"""Network plots with tikz.""" - -# !/usr/bin/python -tt -# -*- coding: utf-8 -*- -# ============================================================================= -# File : network_plots.py -- Network plots with tikz -# Author : Jürgen Hackl -# Time-stamp: -# -# Copyright (c) 2016-2021 Pathpy Developers -# ============================================================================= -from __future__ import annotations - -from typing import Any - -# import logging - -from pathpyG.visualisations.utils import hex_to_rgb -from pathpyG.visualisations._tikz.core import TikzPlot - -# create logger -# logger = logging.getLogger("root") - - -class NetworkPlot(TikzPlot): - """Network plot class for a static network.""" - - _kind = "network" - - def __init__(self, data: dict, **kwargs: Any) -> None: - """Initialize network plot class.""" - super().__init__() - self.data = data - self.config = kwargs - self.generate() - - def generate(self) -> None: - """Clean up data.""" - self._compute_node_data() - self._compute_edge_data() - self._update_layout() - - def _compute_node_data(self) -> None: - """Generate the data structure for the nodes.""" - default: set = {"uid", "x", "y", "size", "color", "opacity"} - mapping: dict = {} - - for node in self.data["nodes"]: - for key in list(node): - if key in mapping: - node[mapping[key]] = node.pop(key) - if key not in default: - node.pop(key, None) - - color = node.get("color", None) - if isinstance(color, str) and "#" in color: - color = hex_to_rgb(color) - node["color"] = f"{{{color[0]},{color[1]},{color[2]}}}" - node["RGB"] = True - - def _compute_edge_data(self) -> None: - """Generate the data structure for the edges.""" - default: set = {"uid", "source", "target", "lw", "color", "opacity"} - mapping: dict = {"size": "lw"} - - for edge in self.data["edges"]: - for key in list(edge): - if key in mapping: - edge[mapping[key]] = edge.pop(key) - if key not in default: - edge.pop(key, None) - - color = edge.get("color", None) - if isinstance(color, str) and "#" in color: - color = hex_to_rgb(color) - edge["color"] = f"{{{color[0]},{color[1]},{color[2]}}}" - edge["RGB"] = True - - def _update_layout(self, default_size: float = 0.6) -> None: - """Update the layout.""" - layout = self.config.get("layout") - - if layout is None: - return - - # get data - layout = {n["uid"]: (n["x"], n["y"]) for n in self.data["nodes"]} - sizes = {n["uid"]: n.get("size", default_size) for n in self.data["nodes"]} - - # get config values - width = self.config["width"] - height = self.config["height"] - keep_aspect_ratio = self.config.get("keep_aspect_ratio", True) - margin = self.config.get("margin", 0.0) - margins = {"top": margin, "left": margin, "bottom": margin, "right": margin} - - # calculate the scaling ratio - x_ratio = float("inf") - y_ratio = float("inf") - - # calculate absolute min and max coordinates - x_absolute = [] - y_absolute = [] - for uid, (_x, _y) in layout.items(): - _s = sizes[uid] / 2 - x_absolute.extend([_x - _s, _x + _s]) - y_absolute.extend([_y - _s, _y + _s]) - - # calculate min and max center coordinates - x_values, y_values = zip(*layout.values()) - x_min, x_max = min(x_values), max(x_values) - y_min, y_max = min(y_values), max(y_values) - - # change margins - margins["left"] += abs(x_min - min(x_absolute)) - margins["bottom"] += abs(y_min - min(y_absolute)) - margins["top"] += abs(y_max - max(y_absolute)) - margins["right"] += abs(x_max - max(x_absolute)) - - if x_max - x_min > 0: - x_ratio = (width - margins["left"] - margins["right"]) / (x_max - x_min) - if y_max - y_min > 0: - y_ratio = (height - margins["top"] - margins["bottom"]) / (y_max - y_min) - - if keep_aspect_ratio: - scaling = (min(x_ratio, y_ratio), min(x_ratio, y_ratio)) - else: - scaling = (x_ratio, y_ratio) - - if scaling[0] == float("inf"): - scaling = (1, scaling[1]) - if scaling[1] == float("inf"): - scaling = (scaling[0], 1) - - x_values = [] - y_values = [] - - # apply scaling to the points - _layout = {n: (x * scaling[0], y * scaling[1]) for n, (x, y) in layout.items()} - - # find min and max values of the points - x_values, y_values = zip(*_layout.values()) - x_min, x_max = min(x_values), max(x_values) - y_min, y_max = min(y_values), max(y_values) - - # calculate the translation - translation = ( - ((width - margins["left"] - margins["right"]) / 2 + margins["left"]) - ((x_max - x_min) / 2 + x_min), - ((height - margins["top"] - margins["bottom"]) / 2 + margins["bottom"]) - ((y_max - y_min) / 2 + y_min), - ) - - # apply translation to the points - _layout = {n: (x + translation[0], y + translation[1]) for n, (x, y) in _layout.items()} - - # update node position for the plot - for node in self.data["nodes"]: - node["x"], node["y"] = _layout[node["uid"]] - - def to_tikz(self) -> str: - """Convert to Tex.""" - - def _add_args(args: dict): - string = "" - for key, value in args.items(): - string += f",{key}" if value is True else f",{key}={value}" - return string - - tikz = "" - for node in self.data["nodes"]: - uid = node.pop("uid") - string = "\\Vertex[" - string += _add_args(node) - string += "]{{{}}}\n".format(uid) - tikz += string - - for edge in self.data["edges"]: - uid = edge.pop("uid") - source = edge.pop("source") - target = edge.pop("target") - string = "\\Edge[" - string += _add_args(edge) - string += "]({})({})\n".format(source, target) - tikz += string - return tikz - - -class StaticNetworkPlot(NetworkPlot): - """Network plot class for a static network.""" - - _kind = "static" - - -class TemporalNetworkPlot(NetworkPlot): - """Network plot class for a static network.""" - - _kind = "temporal" - - def __init__(self, data: dict, **kwargs: Any) -> None: - """Initialize network plot class.""" - raise NotImplementedError - - -# ============================================================================= -# eof -# -# Local Variables: -# mode: python -# mode: linum -# mode: auto-fill -# fill-column: 79 -# End: diff --git a/src/pathpyG/visualisations/_tikz/templates/network.tex b/src/pathpyG/visualisations/_tikz/templates/network.tex deleted file mode 100644 index 5ce11fd7c..000000000 --- a/src/pathpyG/visualisations/_tikz/templates/network.tex +++ /dev/null @@ -1,12 +0,0 @@ -\documentclass[$classoptions]{standalone} -\usepackage[dvipsnames]{xcolor} -\usepackage{tikz-network} -\newcommand{\width}{$width} -\newcommand{\height}{$height} -\begin{document} -\begin{tikzpicture} -\tikzset{every node}=[font=\sffamily\bfseries] -\clip (-0.5*\width,-0.5*\height) rectangle (0.5*\width,0.5*\height); -$tikz -\end{tikzpicture} -\end{document} \ No newline at end of file diff --git a/src/pathpyG/visualisations/_tikz/templates/static.tex b/src/pathpyG/visualisations/_tikz/templates/static.tex index e926806cf..07d9a88c7 100644 --- a/src/pathpyG/visualisations/_tikz/templates/static.tex +++ b/src/pathpyG/visualisations/_tikz/templates/static.tex @@ -6,8 +6,8 @@ \begin{document} \begin{tikzpicture} \tikzset{every node}=[font=\sffamily\bfseries] -\clip (-0.5*\width,-0.5*\height) rectangle (0.5*\width,0.5*\height); -\draw[draw,opacity=0] (-0.5*\width,-0.5*\height) rectangle (0.5*\width,0.5*\height); +\clip (-0.6*\width,-0.6*\height) rectangle (0.6*\width,0.6*\height); +\draw[draw,opacity=0] (-0.6*\width,-0.6*\height) rectangle (0.6*\width,0.6*\height); $tikz \end{tikzpicture} \end{document} \ No newline at end of file diff --git a/src/pathpyG/visualisations/_tikz/templates/temporal.tex b/src/pathpyG/visualisations/_tikz/templates/temporal.tex deleted file mode 100644 index 5ce11fd7c..000000000 --- a/src/pathpyG/visualisations/_tikz/templates/temporal.tex +++ /dev/null @@ -1,12 +0,0 @@ -\documentclass[$classoptions]{standalone} -\usepackage[dvipsnames]{xcolor} -\usepackage{tikz-network} -\newcommand{\width}{$width} -\newcommand{\height}{$height} -\begin{document} -\begin{tikzpicture} -\tikzset{every node}=[font=\sffamily\bfseries] -\clip (-0.5*\width,-0.5*\height) rectangle (0.5*\width,0.5*\height); -$tikz -\end{tikzpicture} -\end{document} \ No newline at end of file diff --git a/src/pathpyG/visualisations/network_plot.py b/src/pathpyG/visualisations/network_plot.py index 056588c5e..0f2cc4e56 100644 --- a/src/pathpyG/visualisations/network_plot.py +++ b/src/pathpyG/visualisations/network_plot.py @@ -70,7 +70,10 @@ def _compute_node_data(self) -> None: nodes: pd.DataFrame = pd.DataFrame(index=self.network.nodes) for attribute in self.attributes: # set default value for each attribute based on the pathpyG.toml config - nodes[attribute] = self.config.get("node").get(attribute) # type: ignore[union-attr] + if isinstance(self.config.get("node").get(attribute), list | tuple): # type: ignore[union-attr] + nodes[attribute] = [self.config.get("node").get(attribute)] * len(nodes) # type: ignore[union-attr] + else: + nodes[attribute] = self.config.get("node").get(attribute) # type: ignore[union-attr] # check if attribute is given as argument if attribute in self.node_args: if isinstance(self.node_args[attribute], dict): @@ -94,7 +97,10 @@ def _compute_edge_data(self) -> None: edges: pd.DataFrame = pd.DataFrame(index=pd.MultiIndex.from_tuples(self.network.edges, names=["source", "target"])) for attribute in self.attributes: # set default value for each attribute based on the pathpyG.toml config - edges[attribute] = self.config.get("edge").get(attribute) # type: ignore[union-attr] + if isinstance(self.config.get("edge").get(attribute), list | tuple): # type: ignore[union-attr] + edges[attribute] = [self.config.get("edge").get(attribute)] * len(edges) # type: ignore[union-attr] + else: + edges[attribute] = self.config.get("edge").get(attribute) # type: ignore[union-attr] # check if attribute is given as argument if attribute in self.edge_args: if isinstance(self.edge_args[attribute], dict): @@ -164,6 +170,10 @@ def _compute_layout(self) -> None: # update x,y position of the nodes layout_df = pd.DataFrame.from_dict(layout, orient="index", columns=["x", "y"]) + # scale x and y to [0,1] + layout_df["x"] = (layout_df["x"] - layout_df["x"].min()) / (layout_df["x"].max() - layout_df["x"].min()) + layout_df["y"] = (layout_df["y"] - layout_df["y"].min()) / (layout_df["y"].max() - layout_df["y"].min()) + # join layout with node data self.data["nodes"] = self.data["nodes"].join(layout_df, how="left") def _compute_config(self) -> None: diff --git a/src/pathpyG/visualisations/pathpy_plot.py b/src/pathpyG/visualisations/pathpy_plot.py index 863a904e0..99af1fd52 100644 --- a/src/pathpyG/visualisations/pathpy_plot.py +++ b/src/pathpyG/visualisations/pathpy_plot.py @@ -17,6 +17,10 @@ def __init__(self) -> None: """Initialize plot class.""" self.data: dict = {} self.config: dict = config.get("visualisation", {}).copy() + if isinstance(self.config["node"]["color"], list): + self.config["node"]["color"] = tuple(self.config["node"]["color"]) + if isinstance(self.config["edge"]["color"], list): + self.config["edge"]["color"] = tuple(self.config["edge"]["color"]) logger.debug(f"Intialising PathpyPlot with config: {self.config}") def generate(self) -> None: diff --git a/src/pathpyG/visualisations/plot_backend.py b/src/pathpyG/visualisations/plot_backend.py index 96639c22e..4924ba3cc 100644 --- a/src/pathpyG/visualisations/plot_backend.py +++ b/src/pathpyG/visualisations/plot_backend.py @@ -5,10 +5,11 @@ class PlotBackend: """Base class for all plot backends.""" - def __init__(self, plot: PathPyPlot): + def __init__(self, plot: PathPyPlot, show_labels: bool) -> None: """Initialize the backend with a plot.""" self.data = plot.data self.config = plot.config + self.show_labels = show_labels def save(self, filename: str) -> None: """Save the plot to the hard drive.""" diff --git a/src/pathpyG/visualisations/plot_function.py b/src/pathpyG/visualisations/plot_function.py index b9a70d03a..a1991545a 100644 --- a/src/pathpyG/visualisations/plot_function.py +++ b/src/pathpyG/visualisations/plot_function.py @@ -21,7 +21,7 @@ from pathpyG.visualisations.network_plot import NetworkPlot from pathpyG.visualisations.pathpy_plot import PathPyPlot from pathpyG.visualisations.plot_backend import PlotBackend -from pathpyG.visualisations.temporal_network_plot import TemporalNetworkPlot +# from pathpyG.visualisations.temporal_network_plot import TemporalNetworkPlot # create logger logger = logging.getLogger("root") @@ -53,7 +53,7 @@ def is_backend(backend: str) -> bool: # Supported Plot Classes PLOT_CLASSES: dict = { "static": NetworkPlot, - "temporal": TemporalNetworkPlot, + # "temporal": TemporalNetworkPlot, } def _get_plot_backend(backend: Optional[str], filename: Optional[str], default: str) -> PlotBackend: diff --git a/src/pathpyG/visualisations/utils.py b/src/pathpyG/visualisations/utils.py index 48d22fdb6..e60e8af08 100644 --- a/src/pathpyG/visualisations/utils.py +++ b/src/pathpyG/visualisations/utils.py @@ -29,6 +29,23 @@ def hex_to_rgb(value: str) -> tuple: return tuple(int(value[i : i + _l // 3], 16) for i in range(0, _l, _l // 3)) +def cm_to_inch(value: float) -> float: + """Convert cm to inch.""" + return value / 2.54 + +def inch_to_cm(value: float) -> float: + """Convert inch to cm.""" + return value * 2.54 + +def unit_str_to_float(value: str, unit: str) -> float: + """Convert string with unit to float in `unit`.""" + if value.endswith("cm"): + return float(value[:-2]) if unit == "cm" else cm_to_inch(float(value[:-2])) + elif value.endswith("in"): + return inch_to_cm(float(value[:-2])) if unit == "cm" else float(value[:-2]) + else: + raise ValueError("Value must end with 'cm' or 'in'.") + # ============================================================================= # eof # From 94bf4f9efdae100c61b9743154b541fb7e2cace3 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Thu, 9 Oct 2025 12:58:33 +0000 Subject: [PATCH 03/44] finalise static d3js --- src/pathpyG/visualisations/_d3js/__init__.py | 17 - src/pathpyG/visualisations/_d3js/backend.py | 162 +++++ src/pathpyG/visualisations/_d3js/core.py | 145 ----- .../visualisations/_d3js/network_plots.py | 82 --- .../_d3js/templates/d3.v5.min.js | 2 - .../_d3js/templates/d3.v7.min.js | 2 + .../visualisations/_d3js/templates/network.js | 561 ++++++++++++++---- .../visualisations/_d3js/templates/setup.html | 17 - .../visualisations/_d3js/templates/static.js | 211 +------ .../visualisations/_d3js/templates/styles.css | 21 +- .../_d3js/templates/temporal.js | 455 ++++---------- src/pathpyG/visualisations/network_plot.py | 16 +- src/pathpyG/visualisations/utils.py | 28 +- 13 files changed, 781 insertions(+), 938 deletions(-) create mode 100644 src/pathpyG/visualisations/_d3js/backend.py delete mode 100644 src/pathpyG/visualisations/_d3js/core.py delete mode 100644 src/pathpyG/visualisations/_d3js/network_plots.py delete mode 100644 src/pathpyG/visualisations/_d3js/templates/d3.v5.min.js create mode 100644 src/pathpyG/visualisations/_d3js/templates/d3.v7.min.js delete mode 100644 src/pathpyG/visualisations/_d3js/templates/setup.html diff --git a/src/pathpyG/visualisations/_d3js/__init__.py b/src/pathpyG/visualisations/_d3js/__init__.py index debc56622..c33b18fff 100644 --- a/src/pathpyG/visualisations/_d3js/__init__.py +++ b/src/pathpyG/visualisations/_d3js/__init__.py @@ -9,23 +9,6 @@ # # Copyright (c) 2016-2023 Pathpy Developers # ============================================================================= -# flake8: noqa -# pylint: disable=unused-import -from typing import Any -from pathpyG.visualisations._d3js.network_plots import NetworkPlot -from pathpyG.visualisations._d3js.network_plots import StaticNetworkPlot -from pathpyG.visualisations._d3js.network_plots import TemporalNetworkPlot - -PLOT_CLASSES: dict = { - "network": NetworkPlot, - "static": StaticNetworkPlot, - "temporal": TemporalNetworkPlot, -} - - -def plot(data: dict, kind: str = "network", **kwargs: Any) -> Any: - """Plot function.""" - return PLOT_CLASSES[kind](data, **kwargs) # ============================================================================= diff --git a/src/pathpyG/visualisations/_d3js/backend.py b/src/pathpyG/visualisations/_d3js/backend.py new file mode 100644 index 000000000..5cb64d841 --- /dev/null +++ b/src/pathpyG/visualisations/_d3js/backend.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import json +import logging +import os +import tempfile +import uuid +import webbrowser +from string import Template +from copy import deepcopy + +from pathpyG.utils.config import config +from pathpyG.visualisations.network_plot import NetworkPlot +from pathpyG.visualisations.plot_backend import PlotBackend +from pathpyG.visualisations.utils import rgb_to_hex, unit_str_to_float +# from pathpyG.visualisations.temporal_network_plot import TemporalNetworkPlot + +# create logger +logger = logging.getLogger("root") + +SUPPORTED_KINDS = { + NetworkPlot: "static", + # TemporalNetworkPlot: "temporal", +} + + +class D3jsBackend(PlotBackend): + """D3js plotting backend.""" + + def __init__(self, plot, show_labels: bool): + super().__init__(plot, show_labels) + self._kind = SUPPORTED_KINDS.get(type(plot), None) + if self._kind is None: + logger.error( + f"Plot of type {type(plot)} not supported by D3js backend." + ) + raise ValueError(f"Plot of type {type(plot)} not supported.") + + def save(self, filename: str) -> None: + """Save the plot to the hard drive.""" + with open(filename, "w+") as new: + new.write(self.to_html()) + + def show(self) -> None: + """Show the plot on the device.""" + if config["environment"]["interactive"]: + from IPython.display import display_html, HTML # noqa I001 + + display_html(HTML(self.to_html())) + else: + # create temporal file + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + # save html + self.save(filename=temp_file.name) + # open the file + webbrowser.open(r"file:///" + temp_file.name) + + def _prepare_data(self) -> dict: + """Prepare the data for json conversion.""" + node_data = self.data["nodes"].copy() + node_data["uid"] = self.data["nodes"].index + node_data = node_data.rename(columns={"x": "xpos", "y": "ypos"}) + edge_data = self.data["edges"].copy() + edge_data["uid"] = self.data["edges"].index.map(lambda x: f"{x[0]}-{x[1]}") + data_dict = { + "nodes": node_data.to_dict(orient="records"), + "edges": edge_data.to_dict(orient="records"), + } + return data_dict + + def _prepare_config(self) -> dict: + """Prepare the config for json conversion.""" + config = deepcopy(self.config) + config["node"]["color"] = rgb_to_hex(self.config["node"]["color"]) + config["edge"]["color"] = rgb_to_hex(self.config["edge"]["color"]) + config["width"] = unit_str_to_float(self.config["width"], "px") + config["height"] = unit_str_to_float(self.config["height"], "px") + config["show_labels"] = self.show_labels + return config + + def to_json(self) -> tuple[str,str]: + """Convert data and config to json.""" + data_dict = self._prepare_data() + config_dict = self._prepare_config() + return json.dumps(data_dict), json.dumps(config_dict) + + def to_html(self) -> str: + """Convert data to html.""" + # generate unique dom uids + dom_id = "#x" + uuid.uuid4().hex + + # get path to the pathpy templates + template_dir = os.path.join( + os.path.dirname(os.path.dirname(__file__)), + os.path.normpath("_d3js/templates"), + ) + + # get d3js version + local = self.config.get("d3js_local", False) + if local: + d3js = os.path.join(template_dir, "d3.v7.min.js") + else: + d3js = "https://d3js.org/d3.v7.min.js" + + js_template = self.get_template(template_dir) + + with open(os.path.join(template_dir, "setup.js")) as template: + setup_template = template.read() + + with open(os.path.join(template_dir, "styles.css")) as template: + css_template = template.read() + + # update config + self.config["selector"] = dom_id + data_json, config_json = self.to_json() + + # generate html file + html = "\n" + + # div environment for the plot object + html += f'\n
\n' + + # add d3js library + html += f'\n' + + # start JavaScript + html += '" + + return html + + def get_template(self, template_dir: str) -> str: + """Get the JavaScript template for the specific plot type.""" + js_template = "" + with open(os.path.join(template_dir, "network.js")) as template: + js_template += template.read() + + with open(os.path.join(template_dir, f"{self._kind}.js")) as template: + js_template += template.read() + + return js_template \ No newline at end of file diff --git a/src/pathpyG/visualisations/_d3js/core.py b/src/pathpyG/visualisations/_d3js/core.py deleted file mode 100644 index 236572c74..000000000 --- a/src/pathpyG/visualisations/_d3js/core.py +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/python -tt -# -*- coding: utf-8 -*- -# ============================================================================= -# File : core.py -- Plots with d3js -# Author : Jürgen Hackl -# Time-stamp: -# -# Copyright (c) 2016-2021 Pathpy Developers -# ============================================================================= -from __future__ import annotations - -import os -import json -import uuid -import logging -import tempfile -import webbrowser - -from typing import Any -from string import Template - -from pathpyG.utils.config import config -from pathpyG.visualisations.plot import PathPyPlot - -# create logger -logger = logging.getLogger("root") - - -class D3jsPlot(PathPyPlot): - """Base class for plotting d3js objects.""" - - def generate(self) -> None: - """Generate the plot.""" - raise NotImplementedError - - def save(self, filename: str, **kwargs: Any) -> None: - """Save the plot to the hard drive.""" - with open(filename, "w+") as new: - new.write(self.to_html()) - - def show(self, **kwargs: Any) -> None: - """Show the plot on the device.""" - if config["environment"]["interactive"]: - from IPython.display import display_html, HTML - - display_html(HTML(self.to_html())) - else: - # create temporal file - with tempfile.NamedTemporaryFile(delete=False) as temp_file: - # save html - self.save(temp_file.name) - # open the file - webbrowser.open(r"file:///" + temp_file.name) - - def to_json(self) -> str: - """Convert data to json.""" - raise NotImplementedError - - def to_html(self) -> str: - """Convert data to html.""" - # generate unique dom uids - dom_id = "#x" + uuid.uuid4().hex - - # get path to the pathpy templates - template_dir = os.path.join( - os.path.dirname(os.path.dirname(__file__)), - os.path.normpath("_d3js/templates"), - ) - - # get d3js version - local = self.config.get("d3js_local", False) - if local: - d3js = os.path.join(template_dir, "d3.v5.min.js") - else: - d3js = "https://d3js.org/d3.v5.min.js" - - # get template files - with open(os.path.join(template_dir, f"{self._kind}.js")) as template: - js_template = template.read() - - with open(os.path.join(template_dir, "setup.js")) as template: - setup_template = template.read() - - with open(os.path.join(template_dir, "styles.css")) as template: - css_template = template.read() - - # load custom template - _template = self.config.get("template", None) - if _template and os.path.isfile(_template): - with open(_template) as template: - js_template = template.read() - - # load custom css template - _template = self.config.get("css", None) - if _template and os.path.isfile(_template): - with open(_template) as template: - css_template += template.read() - - # update config - self.config["selector"] = dom_id - data = self.to_json() - - # generate html file - html = "\n" - - # div environment for the plot object - html += f'\n
\n' - - # add d3js library - html += f'\n' - - # start JavaScript - html += '" - - return html - - -# ============================================================================= -# eof -# -# Local Variables: -# mode: python -# mode: linum -# mode: auto-fill -# fill-column: 79 -# End: diff --git a/src/pathpyG/visualisations/_d3js/network_plots.py b/src/pathpyG/visualisations/_d3js/network_plots.py deleted file mode 100644 index 0adb6753b..000000000 --- a/src/pathpyG/visualisations/_d3js/network_plots.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Network plots with d3js.""" - -# !/usr/bin/python -tt -# -*- coding: utf-8 -*- -# ============================================================================= -# File : network_plots.py -- Network plots with d3js -# Author : Jürgen Hackl -# Time-stamp: -# -# Copyright (c) 2016-2023 Pathpy Developers -# ============================================================================= -from __future__ import annotations - -import json -import numpy as np - -# import logging - -from typing import Any - -from pathpyG.visualisations._d3js.core import D3jsPlot - -# create logger -# logger = logging.getLogger("root") - -class NpEncoder(json.JSONEncoder): - """Encode np values to python for json export.""" - def default(self, obj): - if isinstance(obj, np.integer): - return int(obj) - if isinstance(obj, np.floating): - return float(obj) - if isinstance(obj, np.ndarray): - return obj.tolist() - return super(NpEncoder, self).default(obj) - -class NetworkPlot(D3jsPlot): - """Network plot class for a static network.""" - - _kind = "network" - - def __init__(self, data: dict, **kwargs: Any) -> None: - """Initialize network plot class.""" - super().__init__() - self.data = data - self.config = kwargs - self.generate() - - def generate(self) -> None: - """Clen up data.""" - self.config.pop("node_cmap", None) - self.config.pop("edge_cmap", None) - for node in self.data["nodes"]: - node.pop("x", None) - node.pop("y", None) - - def to_json(self) -> Any: - """Convert data to json.""" - return json.dumps(self.data, cls=NpEncoder) - - -class StaticNetworkPlot(NetworkPlot): - """Network plot class for a temporal network.""" - - _kind = "static" - - -class TemporalNetworkPlot(NetworkPlot): - """Network plot class for a temporal network.""" - - _kind = "temporal" - - -# ============================================================================= -# eof -# -# Local Variables: -# mode: python -# mode: linum -# mode: auto-fill -# fill-column: 79 -# End: diff --git a/src/pathpyG/visualisations/_d3js/templates/d3.v5.min.js b/src/pathpyG/visualisations/_d3js/templates/d3.v5.min.js deleted file mode 100644 index 2cb07035a..000000000 --- a/src/pathpyG/visualisations/_d3js/templates/d3.v5.min.js +++ /dev/null @@ -1,2 +0,0 @@ -// https://d3js.org v5.16.0 Copyright 2020 Mike Bostock -!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?n(exports):"function"==typeof define&&define.amd?define(["exports"],n):n((t=t||self).d3=t.d3||{})}(this,function(t){"use strict";function n(t,n){return tn?1:t>=n?0:NaN}function e(t){var e;return 1===t.length&&(e=t,t=function(t,r){return n(e(t),r)}),{left:function(n,e,r,i){for(null==r&&(r=0),null==i&&(i=n.length);r>>1;t(n[o],e)<0?r=o+1:i=o}return r},right:function(n,e,r,i){for(null==r&&(r=0),null==i&&(i=n.length);r>>1;t(n[o],e)>0?i=o:r=o+1}return r}}}var r=e(n),i=r.right,o=r.left;function a(t,n){return[t,n]}function u(t){return null===t?NaN:+t}function c(t,n){var e,r,i=t.length,o=0,a=-1,c=0,f=0;if(null==n)for(;++a1)return f/(o-1)}function f(t,n){var e=c(t,n);return e?Math.sqrt(e):e}function s(t,n){var e,r,i,o=t.length,a=-1;if(null==n){for(;++a=e)for(r=i=e;++ae&&(r=e),i=e)for(r=i=e;++ae&&(r=e),i0)return[t];if((r=n0)for(t=Math.ceil(t/a),n=Math.floor(n/a),o=new Array(i=Math.ceil(n-t+1));++u=0?(o>=y?10:o>=_?5:o>=b?2:1)*Math.pow(10,i):-Math.pow(10,-i)/(o>=y?10:o>=_?5:o>=b?2:1)}function w(t,n,e){var r=Math.abs(n-t)/Math.max(0,e),i=Math.pow(10,Math.floor(Math.log(r)/Math.LN10)),o=r/i;return o>=y?i*=10:o>=_?i*=5:o>=b&&(i*=2),n=1)return+e(t[r-1],r-1,t);var r,i=(r-1)*n,o=Math.floor(i),a=+e(t[o],o,t);return a+(+e(t[o+1],o+1,t)-a)*(i-o)}}function T(t,n){var e,r,i=t.length,o=-1;if(null==n){for(;++o=e)for(r=e;++or&&(r=e)}else for(;++o=e)for(r=e;++or&&(r=e);return r}function A(t){for(var n,e,r,i=t.length,o=-1,a=0;++o=0;)for(n=(r=t[i]).length;--n>=0;)e[--a]=r[n];return e}function S(t,n){var e,r,i=t.length,o=-1;if(null==n){for(;++o=e)for(r=e;++oe&&(r=e)}else for(;++o=e)for(r=e;++oe&&(r=e);return r}function k(t){if(!(i=t.length))return[];for(var n=-1,e=S(t,E),r=new Array(e);++n=0&&(e=t.slice(r+1),t=t.slice(0,r)),t&&!n.hasOwnProperty(t))throw new Error("unknown type: "+t);return{type:t,name:e}})}function X(t,n){for(var e,r=0,i=t.length;r0)for(var e,r,i=new Array(e),o=0;o=0&&"xmlns"!==(n=t.slice(0,e))&&(t=t.slice(e+1)),$.hasOwnProperty(n)?{space:$[n],local:t}:t}function Z(t){var n=W(t);return(n.local?function(t){return function(){return this.ownerDocument.createElementNS(t.space,t.local)}}:function(t){return function(){var n=this.ownerDocument,e=this.namespaceURI;return e===G&&n.documentElement.namespaceURI===G?n.createElement(t):n.createElementNS(e,t)}})(n)}function Q(){}function K(t){return null==t?Q:function(){return this.querySelector(t)}}function J(){return[]}function tt(t){return null==t?J:function(){return this.querySelectorAll(t)}}function nt(t){return function(){return this.matches(t)}}function et(t){return new Array(t.length)}function rt(t,n){this.ownerDocument=t.ownerDocument,this.namespaceURI=t.namespaceURI,this._next=null,this._parent=t,this.__data__=n}rt.prototype={constructor:rt,appendChild:function(t){return this._parent.insertBefore(t,this._next)},insertBefore:function(t,n){return this._parent.insertBefore(t,n)},querySelector:function(t){return this._parent.querySelector(t)},querySelectorAll:function(t){return this._parent.querySelectorAll(t)}};var it="$";function ot(t,n,e,r,i,o){for(var a,u=0,c=n.length,f=o.length;un?1:t>=n?0:NaN}function ct(t){return t.ownerDocument&&t.ownerDocument.defaultView||t.document&&t||t.defaultView}function ft(t,n){return t.style.getPropertyValue(n)||ct(t).getComputedStyle(t,null).getPropertyValue(n)}function st(t){return t.trim().split(/^|\s+/)}function lt(t){return t.classList||new ht(t)}function ht(t){this._node=t,this._names=st(t.getAttribute("class")||"")}function dt(t,n){for(var e=lt(t),r=-1,i=n.length;++r=0&&(this._names.splice(n,1),this._node.setAttribute("class",this._names.join(" ")))},contains:function(t){return this._names.indexOf(t)>=0}};var Mt={};(t.event=null,"undefined"!=typeof document)&&("onmouseenter"in document.documentElement||(Mt={mouseenter:"mouseover",mouseleave:"mouseout"}));function Nt(t,n,e){return t=Tt(t,n,e),function(n){var e=n.relatedTarget;e&&(e===this||8&e.compareDocumentPosition(this))||t.call(this,n)}}function Tt(n,e,r){return function(i){var o=t.event;t.event=i;try{n.call(this,this.__data__,e,r)}finally{t.event=o}}}function At(t){return function(){var n=this.__on;if(n){for(var e,r=0,i=-1,o=n.length;r=m&&(m=b+1);!(_=g[m])&&++m=0;)(r=i[o])&&(a&&4^r.compareDocumentPosition(a)&&a.parentNode.insertBefore(r,a),a=r);return this},sort:function(t){function n(n,e){return n&&e?t(n.__data__,e.__data__):!n-!e}t||(t=ut);for(var e=this._groups,r=e.length,i=new Array(r),o=0;o1?this.each((null==n?function(t){return function(){this.style.removeProperty(t)}}:"function"==typeof n?function(t,n,e){return function(){var r=n.apply(this,arguments);null==r?this.style.removeProperty(t):this.style.setProperty(t,r,e)}}:function(t,n,e){return function(){this.style.setProperty(t,n,e)}})(t,n,null==e?"":e)):ft(this.node(),t)},property:function(t,n){return arguments.length>1?this.each((null==n?function(t){return function(){delete this[t]}}:"function"==typeof n?function(t,n){return function(){var e=n.apply(this,arguments);null==e?delete this[t]:this[t]=e}}:function(t,n){return function(){this[t]=n}})(t,n)):this.node()[t]},classed:function(t,n){var e=st(t+"");if(arguments.length<2){for(var r=lt(this.node()),i=-1,o=e.length;++i=0&&(n=t.slice(e+1),t=t.slice(0,e)),{type:t,name:n}})}(t+""),a=o.length;if(!(arguments.length<2)){for(u=n?St:At,null==e&&(e=!1),r=0;r>8&15|n>>4&240,n>>4&15|240&n,(15&n)<<4|15&n,1):8===e?gn(n>>24&255,n>>16&255,n>>8&255,(255&n)/255):4===e?gn(n>>12&15|n>>8&240,n>>8&15|n>>4&240,n>>4&15|240&n,((15&n)<<4|15&n)/255):null):(n=on.exec(t))?new bn(n[1],n[2],n[3],1):(n=an.exec(t))?new bn(255*n[1]/100,255*n[2]/100,255*n[3]/100,1):(n=un.exec(t))?gn(n[1],n[2],n[3],n[4]):(n=cn.exec(t))?gn(255*n[1]/100,255*n[2]/100,255*n[3]/100,n[4]):(n=fn.exec(t))?Mn(n[1],n[2]/100,n[3]/100,1):(n=sn.exec(t))?Mn(n[1],n[2]/100,n[3]/100,n[4]):ln.hasOwnProperty(t)?vn(ln[t]):"transparent"===t?new bn(NaN,NaN,NaN,0):null}function vn(t){return new bn(t>>16&255,t>>8&255,255&t,1)}function gn(t,n,e,r){return r<=0&&(t=n=e=NaN),new bn(t,n,e,r)}function yn(t){return t instanceof Jt||(t=pn(t)),t?new bn((t=t.rgb()).r,t.g,t.b,t.opacity):new bn}function _n(t,n,e,r){return 1===arguments.length?yn(t):new bn(t,n,e,null==r?1:r)}function bn(t,n,e,r){this.r=+t,this.g=+n,this.b=+e,this.opacity=+r}function mn(){return"#"+wn(this.r)+wn(this.g)+wn(this.b)}function xn(){var t=this.opacity;return(1===(t=isNaN(t)?1:Math.max(0,Math.min(1,t)))?"rgb(":"rgba(")+Math.max(0,Math.min(255,Math.round(this.r)||0))+", "+Math.max(0,Math.min(255,Math.round(this.g)||0))+", "+Math.max(0,Math.min(255,Math.round(this.b)||0))+(1===t?")":", "+t+")")}function wn(t){return((t=Math.max(0,Math.min(255,Math.round(t)||0)))<16?"0":"")+t.toString(16)}function Mn(t,n,e,r){return r<=0?t=n=e=NaN:e<=0||e>=1?t=n=NaN:n<=0&&(t=NaN),new An(t,n,e,r)}function Nn(t){if(t instanceof An)return new An(t.h,t.s,t.l,t.opacity);if(t instanceof Jt||(t=pn(t)),!t)return new An;if(t instanceof An)return t;var n=(t=t.rgb()).r/255,e=t.g/255,r=t.b/255,i=Math.min(n,e,r),o=Math.max(n,e,r),a=NaN,u=o-i,c=(o+i)/2;return u?(a=n===o?(e-r)/u+6*(e0&&c<1?0:a,new An(a,u,c,t.opacity)}function Tn(t,n,e,r){return 1===arguments.length?Nn(t):new An(t,n,e,null==r?1:r)}function An(t,n,e,r){this.h=+t,this.s=+n,this.l=+e,this.opacity=+r}function Sn(t,n,e){return 255*(t<60?n+(e-n)*t/60:t<180?e:t<240?n+(e-n)*(240-t)/60:n)}Qt(Jt,pn,{copy:function(t){return Object.assign(new this.constructor,this,t)},displayable:function(){return this.rgb().displayable()},hex:hn,formatHex:hn,formatHsl:function(){return Nn(this).formatHsl()},formatRgb:dn,toString:dn}),Qt(bn,_n,Kt(Jt,{brighter:function(t){return t=null==t?1/.7:Math.pow(1/.7,t),new bn(this.r*t,this.g*t,this.b*t,this.opacity)},darker:function(t){return t=null==t?.7:Math.pow(.7,t),new bn(this.r*t,this.g*t,this.b*t,this.opacity)},rgb:function(){return this},displayable:function(){return-.5<=this.r&&this.r<255.5&&-.5<=this.g&&this.g<255.5&&-.5<=this.b&&this.b<255.5&&0<=this.opacity&&this.opacity<=1},hex:mn,formatHex:mn,formatRgb:xn,toString:xn})),Qt(An,Tn,Kt(Jt,{brighter:function(t){return t=null==t?1/.7:Math.pow(1/.7,t),new An(this.h,this.s,this.l*t,this.opacity)},darker:function(t){return t=null==t?.7:Math.pow(.7,t),new An(this.h,this.s,this.l*t,this.opacity)},rgb:function(){var t=this.h%360+360*(this.h<0),n=isNaN(t)||isNaN(this.s)?0:this.s,e=this.l,r=e+(e<.5?e:1-e)*n,i=2*e-r;return new bn(Sn(t>=240?t-240:t+120,i,r),Sn(t,i,r),Sn(t<120?t+240:t-120,i,r),this.opacity)},displayable:function(){return(0<=this.s&&this.s<=1||isNaN(this.s))&&0<=this.l&&this.l<=1&&0<=this.opacity&&this.opacity<=1},formatHsl:function(){var t=this.opacity;return(1===(t=isNaN(t)?1:Math.max(0,Math.min(1,t)))?"hsl(":"hsla(")+(this.h||0)+", "+100*(this.s||0)+"%, "+100*(this.l||0)+"%"+(1===t?")":", "+t+")")}}));var kn=Math.PI/180,En=180/Math.PI,Cn=.96422,Pn=1,zn=.82521,Rn=4/29,Dn=6/29,qn=3*Dn*Dn,Ln=Dn*Dn*Dn;function Un(t){if(t instanceof Bn)return new Bn(t.l,t.a,t.b,t.opacity);if(t instanceof Vn)return Gn(t);t instanceof bn||(t=yn(t));var n,e,r=Hn(t.r),i=Hn(t.g),o=Hn(t.b),a=Fn((.2225045*r+.7168786*i+.0606169*o)/Pn);return r===i&&i===o?n=e=a:(n=Fn((.4360747*r+.3850649*i+.1430804*o)/Cn),e=Fn((.0139322*r+.0971045*i+.7141733*o)/zn)),new Bn(116*a-16,500*(n-a),200*(a-e),t.opacity)}function On(t,n,e,r){return 1===arguments.length?Un(t):new Bn(t,n,e,null==r?1:r)}function Bn(t,n,e,r){this.l=+t,this.a=+n,this.b=+e,this.opacity=+r}function Fn(t){return t>Ln?Math.pow(t,1/3):t/qn+Rn}function Yn(t){return t>Dn?t*t*t:qn*(t-Rn)}function In(t){return 255*(t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055)}function Hn(t){return(t/=255)<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4)}function jn(t){if(t instanceof Vn)return new Vn(t.h,t.c,t.l,t.opacity);if(t instanceof Bn||(t=Un(t)),0===t.a&&0===t.b)return new Vn(NaN,0=1?(e=1,n-1):Math.floor(e*n),i=t[r],o=t[r+1],a=r>0?t[r-1]:2*i-o,u=r180||e<-180?e-360*Math.round(e/360):e):ue(isNaN(t)?n:t)}function se(t){return 1==(t=+t)?le:function(n,e){return e-n?function(t,n,e){return t=Math.pow(t,e),n=Math.pow(n,e)-t,e=1/e,function(r){return Math.pow(t+r*n,e)}}(n,e,t):ue(isNaN(n)?e:n)}}function le(t,n){var e=n-t;return e?ce(t,e):ue(isNaN(t)?n:t)}Qt(re,ee,Kt(Jt,{brighter:function(t){return t=null==t?1/.7:Math.pow(1/.7,t),new re(this.h,this.s,this.l*t,this.opacity)},darker:function(t){return t=null==t?.7:Math.pow(.7,t),new re(this.h,this.s,this.l*t,this.opacity)},rgb:function(){var t=isNaN(this.h)?0:(this.h+120)*kn,n=+this.l,e=isNaN(this.s)?0:this.s*n*(1-n),r=Math.cos(t),i=Math.sin(t);return new bn(255*(n+e*($n*r+Wn*i)),255*(n+e*(Zn*r+Qn*i)),255*(n+e*(Kn*r)),this.opacity)}}));var he=function t(n){var e=se(n);function r(t,n){var r=e((t=_n(t)).r,(n=_n(n)).r),i=e(t.g,n.g),o=e(t.b,n.b),a=le(t.opacity,n.opacity);return function(n){return t.r=r(n),t.g=i(n),t.b=o(n),t.opacity=a(n),t+""}}return r.gamma=t,r}(1);function de(t){return function(n){var e,r,i=n.length,o=new Array(i),a=new Array(i),u=new Array(i);for(e=0;eo&&(i=n.slice(o,i),u[a]?u[a]+=i:u[++a]=i),(e=e[0])===(r=r[0])?u[a]?u[a]+=r:u[++a]=r:(u[++a]=null,c.push({i:a,x:me(e,r)})),o=Me.lastIndex;return o180?n+=360:n-t>180&&(t+=360),o.push({i:e.push(i(e)+"rotate(",null,r)-2,x:me(t,n)})):n&&e.push(i(e)+"rotate("+n+r)}(o.rotate,a.rotate,u,c),function(t,n,e,o){t!==n?o.push({i:e.push(i(e)+"skewX(",null,r)-2,x:me(t,n)}):n&&e.push(i(e)+"skewX("+n+r)}(o.skewX,a.skewX,u,c),function(t,n,e,r,o,a){if(t!==e||n!==r){var u=o.push(i(o)+"scale(",null,",",null,")");a.push({i:u-4,x:me(t,e)},{i:u-2,x:me(n,r)})}else 1===e&&1===r||o.push(i(o)+"scale("+e+","+r+")")}(o.scaleX,o.scaleY,a.scaleX,a.scaleY,u,c),o=a=null,function(t){for(var n,e=-1,r=c.length;++e=0&&n._call.call(null,t),n=n._next;--tr}function pr(){or=(ir=ur.now())+ar,tr=nr=0;try{dr()}finally{tr=0,function(){var t,n,e=Ke,r=1/0;for(;e;)e._call?(r>e._time&&(r=e._time),t=e,e=e._next):(n=e._next,e._next=null,e=t?t._next=n:Ke=n);Je=t,gr(r)}(),or=0}}function vr(){var t=ur.now(),n=t-ir;n>rr&&(ar-=n,ir=t)}function gr(t){tr||(nr&&(nr=clearTimeout(nr)),t-or>24?(t<1/0&&(nr=setTimeout(pr,t-ur.now()-ar)),er&&(er=clearInterval(er))):(er||(ir=ur.now(),er=setInterval(vr,rr)),tr=1,cr(pr)))}function yr(t,n,e){var r=new lr;return n=null==n?0:+n,r.restart(function(e){r.stop(),t(e+n)},n,e),r}lr.prototype=hr.prototype={constructor:lr,restart:function(t,n,e){if("function"!=typeof t)throw new TypeError("callback is not a function");e=(null==e?fr():+e)+(null==n?0:+n),this._next||Je===this||(Je?Je._next=this:Ke=this,Je=this),this._call=t,this._time=e,gr()},stop:function(){this._call&&(this._call=null,this._time=1/0,gr())}};var _r=I("start","end","cancel","interrupt"),br=[],mr=0,xr=1,wr=2,Mr=3,Nr=4,Tr=5,Ar=6;function Sr(t,n,e,r,i,o){var a=t.__transition;if(a){if(e in a)return}else t.__transition={};!function(t,n,e){var r,i=t.__transition;function o(c){var f,s,l,h;if(e.state!==xr)return u();for(f in i)if((h=i[f]).name===e.name){if(h.state===Mr)return yr(o);h.state===Nr?(h.state=Ar,h.timer.stop(),h.on.call("interrupt",t,t.__data__,h.index,h.group),delete i[f]):+fmr)throw new Error("too late; already scheduled");return e}function Er(t,n){var e=Cr(t,n);if(e.state>Mr)throw new Error("too late; already running");return e}function Cr(t,n){var e=t.__transition;if(!e||!(e=e[n]))throw new Error("transition not found");return e}function Pr(t,n){var e,r,i,o=t.__transition,a=!0;if(o){for(i in n=null==n?null:n+"",o)(e=o[i]).name===n?(r=e.state>wr&&e.state=0&&(t=t.slice(0,n)),!t||"start"===t})}(n)?kr:Er;return function(){var a=o(this,t),u=a.on;u!==r&&(i=(r=u).copy()).on(n,e),a.on=i}}(e,t,n))},attr:function(t,n){var e=W(t),r="transform"===e?Le:Rr;return this.attrTween(t,"function"==typeof n?(e.local?function(t,n,e){var r,i,o;return function(){var a,u,c=e(this);if(null!=c)return(a=this.getAttributeNS(t.space,t.local))===(u=c+"")?null:a===r&&u===i?o:(i=u,o=n(r=a,c));this.removeAttributeNS(t.space,t.local)}}:function(t,n,e){var r,i,o;return function(){var a,u,c=e(this);if(null!=c)return(a=this.getAttribute(t))===(u=c+"")?null:a===r&&u===i?o:(i=u,o=n(r=a,c));this.removeAttribute(t)}})(e,r,zr(this,"attr."+t,n)):null==n?(e.local?function(t){return function(){this.removeAttributeNS(t.space,t.local)}}:function(t){return function(){this.removeAttribute(t)}})(e):(e.local?function(t,n,e){var r,i,o=e+"";return function(){var a=this.getAttributeNS(t.space,t.local);return a===o?null:a===r?i:i=n(r=a,e)}}:function(t,n,e){var r,i,o=e+"";return function(){var a=this.getAttribute(t);return a===o?null:a===r?i:i=n(r=a,e)}})(e,r,n))},attrTween:function(t,n){var e="attr."+t;if(arguments.length<2)return(e=this.tween(e))&&e._value;if(null==n)return this.tween(e,null);if("function"!=typeof n)throw new Error;var r=W(t);return this.tween(e,(r.local?function(t,n){var e,r;function i(){var i=n.apply(this,arguments);return i!==r&&(e=(r=i)&&function(t,n){return function(e){this.setAttributeNS(t.space,t.local,n.call(this,e))}}(t,i)),e}return i._value=n,i}:function(t,n){var e,r;function i(){var i=n.apply(this,arguments);return i!==r&&(e=(r=i)&&function(t,n){return function(e){this.setAttribute(t,n.call(this,e))}}(t,i)),e}return i._value=n,i})(r,n))},style:function(t,n,e){var r="transform"==(t+="")?qe:Rr;return null==n?this.styleTween(t,function(t,n){var e,r,i;return function(){var o=ft(this,t),a=(this.style.removeProperty(t),ft(this,t));return o===a?null:o===e&&a===r?i:i=n(e=o,r=a)}}(t,r)).on("end.style."+t,qr(t)):"function"==typeof n?this.styleTween(t,function(t,n,e){var r,i,o;return function(){var a=ft(this,t),u=e(this),c=u+"";return null==u&&(this.style.removeProperty(t),c=u=ft(this,t)),a===c?null:a===r&&c===i?o:(i=c,o=n(r=a,u))}}(t,r,zr(this,"style."+t,n))).each(function(t,n){var e,r,i,o,a="style."+n,u="end."+a;return function(){var c=Er(this,t),f=c.on,s=null==c.value[a]?o||(o=qr(n)):void 0;f===e&&i===s||(r=(e=f).copy()).on(u,i=s),c.on=r}}(this._id,t)):this.styleTween(t,function(t,n,e){var r,i,o=e+"";return function(){var a=ft(this,t);return a===o?null:a===r?i:i=n(r=a,e)}}(t,r,n),e).on("end.style."+t,null)},styleTween:function(t,n,e){var r="style."+(t+="");if(arguments.length<2)return(r=this.tween(r))&&r._value;if(null==n)return this.tween(r,null);if("function"!=typeof n)throw new Error;return this.tween(r,function(t,n,e){var r,i;function o(){var o=n.apply(this,arguments);return o!==i&&(r=(i=o)&&function(t,n,e){return function(r){this.style.setProperty(t,n.call(this,r),e)}}(t,o,e)),r}return o._value=n,o}(t,n,null==e?"":e))},text:function(t){return this.tween("text","function"==typeof t?function(t){return function(){var n=t(this);this.textContent=null==n?"":n}}(zr(this,"text",t)):function(t){return function(){this.textContent=t}}(null==t?"":t+""))},textTween:function(t){var n="text";if(arguments.length<1)return(n=this.tween(n))&&n._value;if(null==t)return this.tween(n,null);if("function"!=typeof t)throw new Error;return this.tween(n,function(t){var n,e;function r(){var r=t.apply(this,arguments);return r!==e&&(n=(e=r)&&function(t){return function(n){this.textContent=t.call(this,n)}}(r)),n}return r._value=t,r}(t))},remove:function(){return this.on("end.remove",function(t){return function(){var n=this.parentNode;for(var e in this.__transition)if(+e!==t)return;n&&n.removeChild(this)}}(this._id))},tween:function(t,n){var e=this._id;if(t+="",arguments.length<2){for(var r,i=Cr(this.node(),e).tween,o=0,a=i.length;o0&&(r=o-P),M<0?d=p-z:M>0&&(u=c-z),x=Mi,B.attr("cursor",Pi.selection),I());break;default:return}xi()},!0).on("keyup.brush",function(){switch(t.event.keyCode){case 16:R&&(g=y=R=!1,I());break;case 18:x===Ti&&(w<0?f=h:w>0&&(r=o),M<0?d=p:M>0&&(u=c),x=Ni,I());break;case 32:x===Mi&&(t.event.altKey?(w&&(f=h-P*w,r=o+P*w),M&&(d=p-z*M,u=c+z*M),x=Ti):(w<0?f=h:w>0&&(r=o),M<0?d=p:M>0&&(u=c),x=Ni),B.attr("cursor",Pi[m]),I());break;default:return}xi()},!0),Ht(t.event.view)}mi(),Pr(b),s.call(b),U.start()}function Y(){var t=D(b);!R||g||y||(Math.abs(t[0]-L[0])>Math.abs(t[1]-L[1])?y=!0:g=!0),L=t,v=!0,xi(),I()}function I(){var t;switch(P=L[0]-q[0],z=L[1]-q[1],x){case Mi:case wi:w&&(P=Math.max(S-r,Math.min(E-f,P)),o=r+P,h=f+P),M&&(z=Math.max(k-u,Math.min(C-d,z)),c=u+z,p=d+z);break;case Ni:w<0?(P=Math.max(S-r,Math.min(E-r,P)),o=r+P,h=f):w>0&&(P=Math.max(S-f,Math.min(E-f,P)),o=r,h=f+P),M<0?(z=Math.max(k-u,Math.min(C-u,z)),c=u+z,p=d):M>0&&(z=Math.max(k-d,Math.min(C-d,z)),c=u,p=d+z);break;case Ti:w&&(o=Math.max(S,Math.min(E,r-P*w)),h=Math.max(S,Math.min(E,f+P*w))),M&&(c=Math.max(k,Math.min(C,u-z*M)),p=Math.max(k,Math.min(C,d+z*M)))}h1e-6)if(Math.abs(s*u-c*f)>1e-6&&i){var h=e-o,d=r-a,p=u*u+c*c,v=h*h+d*d,g=Math.sqrt(p),y=Math.sqrt(l),_=i*Math.tan((Qi-Math.acos((p+l-v)/(2*g*y)))/2),b=_/y,m=_/g;Math.abs(b-1)>1e-6&&(this._+="L"+(t+b*f)+","+(n+b*s)),this._+="A"+i+","+i+",0,0,"+ +(s*h>f*d)+","+(this._x1=t+m*u)+","+(this._y1=n+m*c)}else this._+="L"+(this._x1=t)+","+(this._y1=n);else;},arc:function(t,n,e,r,i,o){t=+t,n=+n,o=!!o;var a=(e=+e)*Math.cos(r),u=e*Math.sin(r),c=t+a,f=n+u,s=1^o,l=o?r-i:i-r;if(e<0)throw new Error("negative radius: "+e);null===this._x1?this._+="M"+c+","+f:(Math.abs(this._x1-c)>1e-6||Math.abs(this._y1-f)>1e-6)&&(this._+="L"+c+","+f),e&&(l<0&&(l=l%Ki+Ki),l>Ji?this._+="A"+e+","+e+",0,1,"+s+","+(t-a)+","+(n-u)+"A"+e+","+e+",0,1,"+s+","+(this._x1=c)+","+(this._y1=f):l>1e-6&&(this._+="A"+e+","+e+",0,"+ +(l>=Qi)+","+s+","+(this._x1=t+e*Math.cos(i))+","+(this._y1=n+e*Math.sin(i))))},rect:function(t,n,e,r){this._+="M"+(this._x0=this._x1=+t)+","+(this._y0=this._y1=+n)+"h"+ +e+"v"+ +r+"h"+-e+"Z"},toString:function(){return this._}};function uo(){}function co(t,n){var e=new uo;if(t instanceof uo)t.each(function(t,n){e.set(n,t)});else if(Array.isArray(t)){var r,i=-1,o=t.length;if(null==n)for(;++ir!=d>r&&e<(h-f)*(r-s)/(d-s)+f&&(i=-i)}return i}function wo(t,n,e){var r,i,o,a;return function(t,n,e){return(n[0]-t[0])*(e[1]-t[1])==(e[0]-t[0])*(n[1]-t[1])}(t,n,e)&&(i=t[r=+(t[0]===n[0])],o=e[r],a=n[r],i<=o&&o<=a||a<=o&&o<=i)}function Mo(){}var No=[[],[[[1,1.5],[.5,1]]],[[[1.5,1],[1,1.5]]],[[[1.5,1],[.5,1]]],[[[1,.5],[1.5,1]]],[[[1,1.5],[.5,1]],[[1,.5],[1.5,1]]],[[[1,.5],[1,1.5]]],[[[1,.5],[.5,1]]],[[[.5,1],[1,.5]]],[[[1,1.5],[1,.5]]],[[[.5,1],[1,.5]],[[1.5,1],[1,1.5]]],[[[1.5,1],[1,.5]]],[[[.5,1],[1.5,1]]],[[[1,1.5],[1.5,1]]],[[[.5,1],[1,1.5]]],[]];function To(){var t=1,n=1,e=M,r=u;function i(t){var n=e(t);if(Array.isArray(n))n=n.slice().sort(_o);else{var r=s(t),i=r[0],a=r[1];n=w(i,a,n),n=g(Math.floor(i/n)*n,Math.floor(a/n)*n,n)}return n.map(function(n){return o(t,n)})}function o(e,i){var o=[],u=[];return function(e,r,i){var o,u,c,f,s,l,h=new Array,d=new Array;o=u=-1,f=e[0]>=r,No[f<<1].forEach(p);for(;++o=r,No[c|f<<1].forEach(p);No[f<<0].forEach(p);for(;++u=r,s=e[u*t]>=r,No[f<<1|s<<2].forEach(p);++o=r,l=s,s=e[u*t+o+1]>=r,No[c|f<<1|s<<2|l<<3].forEach(p);No[f|s<<3].forEach(p)}o=-1,s=e[u*t]>=r,No[s<<2].forEach(p);for(;++o=r,No[s<<2|l<<3].forEach(p);function p(t){var n,e,r=[t[0][0]+o,t[0][1]+u],c=[t[1][0]+o,t[1][1]+u],f=a(r),s=a(c);(n=d[f])?(e=h[s])?(delete d[n.end],delete h[e.start],n===e?(n.ring.push(c),i(n.ring)):h[n.start]=d[e.end]={start:n.start,end:e.end,ring:n.ring.concat(e.ring)}):(delete d[n.end],n.ring.push(c),d[n.end=s]=n):(n=h[s])?(e=d[f])?(delete h[n.start],delete d[e.end],n===e?(n.ring.push(c),i(n.ring)):h[e.start]=d[n.end]={start:e.start,end:n.end,ring:e.ring.concat(n.ring)}):(delete h[n.start],n.ring.unshift(r),h[n.start=f]=n):h[f]=d[s]={start:f,end:s,ring:[r,c]}}No[s<<3].forEach(p)}(e,i,function(t){r(t,e,i),function(t){for(var n=0,e=t.length,r=t[e-1][1]*t[0][0]-t[e-1][0]*t[0][1];++n0?o.push([t]):u.push(t)}),u.forEach(function(t){for(var n,e=0,r=o.length;e0&&a0&&u0&&o>0))throw new Error("invalid size");return t=r,n=o,i},i.thresholds=function(t){return arguments.length?(e="function"==typeof t?t:Array.isArray(t)?bo(yo.call(t)):bo(t),i):e},i.smooth=function(t){return arguments.length?(r=t?u:Mo,i):r===u},i}function Ao(t,n,e){for(var r=t.width,i=t.height,o=1+(e<<1),a=0;a=e&&(u>=o&&(c-=t.data[u-o+a*r]),n.data[u-e+a*r]=c/Math.min(u+1,r-1+o-u,o))}function So(t,n,e){for(var r=t.width,i=t.height,o=1+(e<<1),a=0;a=e&&(u>=o&&(c-=t.data[a+(u-o)*r]),n.data[a+(u-e)*r]=c/Math.min(u+1,i-1+o-u,o))}function ko(t){return t[0]}function Eo(t){return t[1]}function Co(){return 1}var Po={},zo={},Ro=34,Do=10,qo=13;function Lo(t){return new Function("d","return {"+t.map(function(t,n){return JSON.stringify(t)+": d["+n+'] || ""'}).join(",")+"}")}function Uo(t){var n=Object.create(null),e=[];return t.forEach(function(t){for(var r in t)r in n||e.push(n[r]=r)}),e}function Oo(t,n){var e=t+"",r=e.length;return r9999?"+"+Oo(t,6):Oo(t,4)}(t.getUTCFullYear())+"-"+Oo(t.getUTCMonth()+1,2)+"-"+Oo(t.getUTCDate(),2)+(i?"T"+Oo(n,2)+":"+Oo(e,2)+":"+Oo(r,2)+"."+Oo(i,3)+"Z":r?"T"+Oo(n,2)+":"+Oo(e,2)+":"+Oo(r,2)+"Z":e||n?"T"+Oo(n,2)+":"+Oo(e,2)+"Z":"")}function Fo(t){var n=new RegExp('["'+t+"\n\r]"),e=t.charCodeAt(0);function r(t,n){var r,i=[],o=t.length,a=0,u=0,c=o<=0,f=!1;function s(){if(c)return zo;if(f)return f=!1,Po;var n,r,i=a;if(t.charCodeAt(i)===Ro){for(;a++=o?c=!0:(r=t.charCodeAt(a++))===Do?f=!0:r===qo&&(f=!0,t.charCodeAt(a)===Do&&++a),t.slice(i+1,n-1).replace(/""/g,'"')}for(;a=(o=(v+y)/2))?v=o:y=o,(s=e>=(a=(g+_)/2))?g=a:_=a,i=d,!(d=d[l=s<<1|f]))return i[l]=p,t;if(u=+t._x.call(null,d.data),c=+t._y.call(null,d.data),n===u&&e===c)return p.next=d,i?i[l]=p:t._root=p,t;do{i=i?i[l]=new Array(4):t._root=new Array(4),(f=n>=(o=(v+y)/2))?v=o:y=o,(s=e>=(a=(g+_)/2))?g=a:_=a}while((l=s<<1|f)==(h=(c>=a)<<1|u>=o));return i[h]=d,i[l]=p,t}function ba(t,n,e,r,i){this.node=t,this.x0=n,this.y0=e,this.x1=r,this.y1=i}function ma(t){return t[0]}function xa(t){return t[1]}function wa(t,n,e){var r=new Ma(null==n?ma:n,null==e?xa:e,NaN,NaN,NaN,NaN);return null==t?r:r.addAll(t)}function Ma(t,n,e,r,i,o){this._x=t,this._y=n,this._x0=e,this._y0=r,this._x1=i,this._y1=o,this._root=void 0}function Na(t){for(var n={data:t.data},e=n;t=t.next;)e=e.next={data:t.data};return n}var Ta=wa.prototype=Ma.prototype;function Aa(t){return t.x+t.vx}function Sa(t){return t.y+t.vy}function ka(t){return t.index}function Ea(t,n){var e=t.get(n);if(!e)throw new Error("missing: "+n);return e}function Ca(t){return t.x}function Pa(t){return t.y}Ta.copy=function(){var t,n,e=new Ma(this._x,this._y,this._x0,this._y0,this._x1,this._y1),r=this._root;if(!r)return e;if(!r.length)return e._root=Na(r),e;for(t=[{source:r,target:e._root=new Array(4)}];r=t.pop();)for(var i=0;i<4;++i)(n=r.source[i])&&(n.length?t.push({source:n,target:r.target[i]=new Array(4)}):r.target[i]=Na(n));return e},Ta.add=function(t){var n=+this._x.call(null,t),e=+this._y.call(null,t);return _a(this.cover(n,e),n,e,t)},Ta.addAll=function(t){var n,e,r,i,o=t.length,a=new Array(o),u=new Array(o),c=1/0,f=1/0,s=-1/0,l=-1/0;for(e=0;es&&(s=r),il&&(l=i));if(c>s||f>l)return this;for(this.cover(c,f).cover(s,l),e=0;et||t>=i||r>n||n>=o;)switch(u=(nh||(o=c.y0)>d||(a=c.x1)=y)<<1|t>=g)&&(c=p[p.length-1],p[p.length-1]=p[p.length-1-f],p[p.length-1-f]=c)}else{var _=t-+this._x.call(null,v.data),b=n-+this._y.call(null,v.data),m=_*_+b*b;if(m=(u=(p+g)/2))?p=u:g=u,(s=a>=(c=(v+y)/2))?v=c:y=c,n=d,!(d=d[l=s<<1|f]))return this;if(!d.length)break;(n[l+1&3]||n[l+2&3]||n[l+3&3])&&(e=n,h=l)}for(;d.data!==t;)if(r=d,!(d=d.next))return this;return(i=d.next)&&delete d.next,r?(i?r.next=i:delete r.next,this):n?(i?n[l]=i:delete n[l],(d=n[0]||n[1]||n[2]||n[3])&&d===(n[3]||n[2]||n[1]||n[0])&&!d.length&&(e?e[h]=d:this._root=d),this):(this._root=i,this)},Ta.removeAll=function(t){for(var n=0,e=t.length;n1?r[0]+r.slice(2):r,+t.slice(e+1)]}function qa(t){return(t=Da(Math.abs(t)))?t[1]:NaN}var La,Ua=/^(?:(.)?([<>=^]))?([+\-( ])?([$#])?(0)?(\d+)?(,)?(\.\d+)?(~)?([a-z%])?$/i;function Oa(t){if(!(n=Ua.exec(t)))throw new Error("invalid format: "+t);var n;return new Ba({fill:n[1],align:n[2],sign:n[3],symbol:n[4],zero:n[5],width:n[6],comma:n[7],precision:n[8]&&n[8].slice(1),trim:n[9],type:n[10]})}function Ba(t){this.fill=void 0===t.fill?" ":t.fill+"",this.align=void 0===t.align?">":t.align+"",this.sign=void 0===t.sign?"-":t.sign+"",this.symbol=void 0===t.symbol?"":t.symbol+"",this.zero=!!t.zero,this.width=void 0===t.width?void 0:+t.width,this.comma=!!t.comma,this.precision=void 0===t.precision?void 0:+t.precision,this.trim=!!t.trim,this.type=void 0===t.type?"":t.type+""}function Fa(t,n){var e=Da(t,n);if(!e)return t+"";var r=e[0],i=e[1];return i<0?"0."+new Array(-i).join("0")+r:r.length>i+1?r.slice(0,i+1)+"."+r.slice(i+1):r+new Array(i-r.length+2).join("0")}Oa.prototype=Ba.prototype,Ba.prototype.toString=function(){return this.fill+this.align+this.sign+this.symbol+(this.zero?"0":"")+(void 0===this.width?"":Math.max(1,0|this.width))+(this.comma?",":"")+(void 0===this.precision?"":"."+Math.max(0,0|this.precision))+(this.trim?"~":"")+this.type};var Ya={"%":function(t,n){return(100*t).toFixed(n)},b:function(t){return Math.round(t).toString(2)},c:function(t){return t+""},d:function(t){return Math.round(t).toString(10)},e:function(t,n){return t.toExponential(n)},f:function(t,n){return t.toFixed(n)},g:function(t,n){return t.toPrecision(n)},o:function(t){return Math.round(t).toString(8)},p:function(t,n){return Fa(100*t,n)},r:Fa,s:function(t,n){var e=Da(t,n);if(!e)return t+"";var r=e[0],i=e[1],o=i-(La=3*Math.max(-8,Math.min(8,Math.floor(i/3))))+1,a=r.length;return o===a?r:o>a?r+new Array(o-a+1).join("0"):o>0?r.slice(0,o)+"."+r.slice(o):"0."+new Array(1-o).join("0")+Da(t,Math.max(0,n+o-1))[0]},X:function(t){return Math.round(t).toString(16).toUpperCase()},x:function(t){return Math.round(t).toString(16)}};function Ia(t){return t}var Ha,ja=Array.prototype.map,Xa=["y","z","a","f","p","n","µ","m","","k","M","G","T","P","E","Z","Y"];function Va(t){var n,e,r=void 0===t.grouping||void 0===t.thousands?Ia:(n=ja.call(t.grouping,Number),e=t.thousands+"",function(t,r){for(var i=t.length,o=[],a=0,u=n[0],c=0;i>0&&u>0&&(c+u+1>r&&(u=Math.max(1,r-c)),o.push(t.substring(i-=u,i+u)),!((c+=u+1)>r));)u=n[a=(a+1)%n.length];return o.reverse().join(e)}),i=void 0===t.currency?"":t.currency[0]+"",o=void 0===t.currency?"":t.currency[1]+"",a=void 0===t.decimal?".":t.decimal+"",u=void 0===t.numerals?Ia:function(t){return function(n){return n.replace(/[0-9]/g,function(n){return t[+n]})}}(ja.call(t.numerals,String)),c=void 0===t.percent?"%":t.percent+"",f=void 0===t.minus?"-":t.minus+"",s=void 0===t.nan?"NaN":t.nan+"";function l(t){var n=(t=Oa(t)).fill,e=t.align,l=t.sign,h=t.symbol,d=t.zero,p=t.width,v=t.comma,g=t.precision,y=t.trim,_=t.type;"n"===_?(v=!0,_="g"):Ya[_]||(void 0===g&&(g=12),y=!0,_="g"),(d||"0"===n&&"="===e)&&(d=!0,n="0",e="=");var b="$"===h?i:"#"===h&&/[boxX]/.test(_)?"0"+_.toLowerCase():"",m="$"===h?o:/[%p]/.test(_)?c:"",x=Ya[_],w=/[defgprs%]/.test(_);function M(t){var i,o,c,h=b,M=m;if("c"===_)M=x(t)+M,t="";else{var N=(t=+t)<0||1/t<0;if(t=isNaN(t)?s:x(Math.abs(t),g),y&&(t=function(t){t:for(var n,e=t.length,r=1,i=-1;r0&&(i=0)}return i>0?t.slice(0,i)+t.slice(n+1):t}(t)),N&&0==+t&&"+"!==l&&(N=!1),h=(N?"("===l?l:f:"-"===l||"("===l?"":l)+h,M=("s"===_?Xa[8+La/3]:"")+M+(N&&"("===l?")":""),w)for(i=-1,o=t.length;++i(c=t.charCodeAt(i))||c>57){M=(46===c?a+t.slice(i+1):t.slice(i))+M,t=t.slice(0,i);break}}v&&!d&&(t=r(t,1/0));var T=h.length+t.length+M.length,A=T>1)+h+t+M+A.slice(T);break;default:t=A+h+t+M}return u(t)}return g=void 0===g?6:/[gprs]/.test(_)?Math.max(1,Math.min(21,g)):Math.max(0,Math.min(20,g)),M.toString=function(){return t+""},M}return{format:l,formatPrefix:function(t,n){var e=l(((t=Oa(t)).type="f",t)),r=3*Math.max(-8,Math.min(8,Math.floor(qa(n)/3))),i=Math.pow(10,-r),o=Xa[8+r/3];return function(t){return e(i*t)+o}}}}function Ga(n){return Ha=Va(n),t.format=Ha.format,t.formatPrefix=Ha.formatPrefix,Ha}function $a(t){return Math.max(0,-qa(Math.abs(t)))}function Wa(t,n){return Math.max(0,3*Math.max(-8,Math.min(8,Math.floor(qa(n)/3)))-qa(Math.abs(t)))}function Za(t,n){return t=Math.abs(t),n=Math.abs(n)-t,Math.max(0,qa(n)-qa(t))+1}function Qa(){return new Ka}function Ka(){this.reset()}Ga({decimal:".",thousands:",",grouping:[3],currency:["$",""],minus:"-"}),Ka.prototype={constructor:Ka,reset:function(){this.s=this.t=0},add:function(t){tu(Ja,t,this.t),tu(this,Ja.s,this.s),this.s?this.t+=Ja.t:this.s=Ja.t},valueOf:function(){return this.s}};var Ja=new Ka;function tu(t,n,e){var r=t.s=n+e,i=r-n,o=r-i;t.t=n-o+(e-i)}var nu=1e-6,eu=1e-12,ru=Math.PI,iu=ru/2,ou=ru/4,au=2*ru,uu=180/ru,cu=ru/180,fu=Math.abs,su=Math.atan,lu=Math.atan2,hu=Math.cos,du=Math.ceil,pu=Math.exp,vu=Math.log,gu=Math.pow,yu=Math.sin,_u=Math.sign||function(t){return t>0?1:t<0?-1:0},bu=Math.sqrt,mu=Math.tan;function xu(t){return t>1?0:t<-1?ru:Math.acos(t)}function wu(t){return t>1?iu:t<-1?-iu:Math.asin(t)}function Mu(t){return(t=yu(t/2))*t}function Nu(){}function Tu(t,n){t&&Su.hasOwnProperty(t.type)&&Su[t.type](t,n)}var Au={Feature:function(t,n){Tu(t.geometry,n)},FeatureCollection:function(t,n){for(var e=t.features,r=-1,i=e.length;++r=0?1:-1,i=r*e,o=hu(n=(n*=cu)/2+ou),a=yu(n),u=qu*a,c=Du*o+u*hu(i),f=u*r*yu(i);Lu.add(lu(f,c)),Ru=t,Du=o,qu=a}function Hu(t){return[lu(t[1],t[0]),wu(t[2])]}function ju(t){var n=t[0],e=t[1],r=hu(e);return[r*hu(n),r*yu(n),yu(e)]}function Xu(t,n){return t[0]*n[0]+t[1]*n[1]+t[2]*n[2]}function Vu(t,n){return[t[1]*n[2]-t[2]*n[1],t[2]*n[0]-t[0]*n[2],t[0]*n[1]-t[1]*n[0]]}function Gu(t,n){t[0]+=n[0],t[1]+=n[1],t[2]+=n[2]}function $u(t,n){return[t[0]*n,t[1]*n,t[2]*n]}function Wu(t){var n=bu(t[0]*t[0]+t[1]*t[1]+t[2]*t[2]);t[0]/=n,t[1]/=n,t[2]/=n}var Zu,Qu,Ku,Ju,tc,nc,ec,rc,ic,oc,ac,uc,cc,fc,sc,lc,hc,dc,pc,vc,gc,yc,_c,bc,mc,xc,wc=Qa(),Mc={point:Nc,lineStart:Ac,lineEnd:Sc,polygonStart:function(){Mc.point=kc,Mc.lineStart=Ec,Mc.lineEnd=Cc,wc.reset(),Ou.polygonStart()},polygonEnd:function(){Ou.polygonEnd(),Mc.point=Nc,Mc.lineStart=Ac,Mc.lineEnd=Sc,Lu<0?(Zu=-(Ku=180),Qu=-(Ju=90)):wc>nu?Ju=90:wc<-nu&&(Qu=-90),oc[0]=Zu,oc[1]=Ku},sphere:function(){Zu=-(Ku=180),Qu=-(Ju=90)}};function Nc(t,n){ic.push(oc=[Zu=t,Ku=t]),nJu&&(Ju=n)}function Tc(t,n){var e=ju([t*cu,n*cu]);if(rc){var r=Vu(rc,e),i=Vu([r[1],-r[0],0],r);Wu(i),i=Hu(i);var o,a=t-tc,u=a>0?1:-1,c=i[0]*uu*u,f=fu(a)>180;f^(u*tcJu&&(Ju=o):f^(u*tc<(c=(c+360)%360-180)&&cJu&&(Ju=n)),f?tPc(Zu,Ku)&&(Ku=t):Pc(t,Ku)>Pc(Zu,Ku)&&(Zu=t):Ku>=Zu?(tKu&&(Ku=t)):t>tc?Pc(Zu,t)>Pc(Zu,Ku)&&(Ku=t):Pc(t,Ku)>Pc(Zu,Ku)&&(Zu=t)}else ic.push(oc=[Zu=t,Ku=t]);nJu&&(Ju=n),rc=e,tc=t}function Ac(){Mc.point=Tc}function Sc(){oc[0]=Zu,oc[1]=Ku,Mc.point=Nc,rc=null}function kc(t,n){if(rc){var e=t-tc;wc.add(fu(e)>180?e+(e>0?360:-360):e)}else nc=t,ec=n;Ou.point(t,n),Tc(t,n)}function Ec(){Ou.lineStart()}function Cc(){kc(nc,ec),Ou.lineEnd(),fu(wc)>nu&&(Zu=-(Ku=180)),oc[0]=Zu,oc[1]=Ku,rc=null}function Pc(t,n){return(n-=t)<0?n+360:n}function zc(t,n){return t[0]-n[0]}function Rc(t,n){return t[0]<=t[1]?t[0]<=n&&n<=t[1]:nru?t+Math.round(-t/au)*au:t,n]}function $c(t,n,e){return(t%=au)?n||e?Vc(Zc(t),Qc(n,e)):Zc(t):n||e?Qc(n,e):Gc}function Wc(t){return function(n,e){return[(n+=t)>ru?n-au:n<-ru?n+au:n,e]}}function Zc(t){var n=Wc(t);return n.invert=Wc(-t),n}function Qc(t,n){var e=hu(t),r=yu(t),i=hu(n),o=yu(n);function a(t,n){var a=hu(n),u=hu(t)*a,c=yu(t)*a,f=yu(n),s=f*e+u*r;return[lu(c*i-s*o,u*e-f*r),wu(s*i+c*o)]}return a.invert=function(t,n){var a=hu(n),u=hu(t)*a,c=yu(t)*a,f=yu(n),s=f*i-c*o;return[lu(c*i+f*o,u*e+s*r),wu(s*e-u*r)]},a}function Kc(t){function n(n){return(n=t(n[0]*cu,n[1]*cu))[0]*=uu,n[1]*=uu,n}return t=$c(t[0]*cu,t[1]*cu,t.length>2?t[2]*cu:0),n.invert=function(n){return(n=t.invert(n[0]*cu,n[1]*cu))[0]*=uu,n[1]*=uu,n},n}function Jc(t,n,e,r,i,o){if(e){var a=hu(n),u=yu(n),c=r*e;null==i?(i=n+r*au,o=n-c/2):(i=tf(a,i),o=tf(a,o),(r>0?io)&&(i+=r*au));for(var f,s=i;r>0?s>o:s1&&n.push(n.pop().concat(n.shift()))},result:function(){var e=n;return n=[],t=null,e}}}function ef(t,n){return fu(t[0]-n[0])=0;--o)i.point((s=f[o])[0],s[1]);else r(h.x,h.p.x,-1,i);h=h.p}f=(h=h.o).z,d=!d}while(!h.v);i.lineEnd()}}}function af(t){if(n=t.length){for(var n,e,r=0,i=t[0];++r=0?1:-1,T=N*M,A=T>ru,S=v*x;if(uf.add(lu(S*N*yu(T),g*w+S*hu(T))),a+=A?M+N*au:M,A^d>=e^b>=e){var k=Vu(ju(h),ju(_));Wu(k);var E=Vu(o,k);Wu(E);var C=(A^M>=0?-1:1)*wu(E[2]);(r>C||r===C&&(k[0]||k[1]))&&(u+=A^M>=0?1:-1)}}return(a<-nu||a0){for(l||(i.polygonStart(),l=!0),i.lineStart(),t=0;t1&&2&c&&h.push(h.pop().concat(h.shift())),a.push(h.filter(lf))}return h}}function lf(t){return t.length>1}function hf(t,n){return((t=t.x)[0]<0?t[1]-iu-nu:iu-t[1])-((n=n.x)[0]<0?n[1]-iu-nu:iu-n[1])}var df=sf(function(){return!0},function(t){var n,e=NaN,r=NaN,i=NaN;return{lineStart:function(){t.lineStart(),n=1},point:function(o,a){var u=o>0?ru:-ru,c=fu(o-e);fu(c-ru)0?iu:-iu),t.point(i,r),t.lineEnd(),t.lineStart(),t.point(u,r),t.point(o,r),n=0):i!==u&&c>=ru&&(fu(e-i)nu?su((yu(n)*(o=hu(r))*yu(e)-yu(r)*(i=hu(n))*yu(t))/(i*o*a)):(n+r)/2}(e,r,o,a),t.point(i,r),t.lineEnd(),t.lineStart(),t.point(u,r),n=0),t.point(e=o,r=a),i=u},lineEnd:function(){t.lineEnd(),e=r=NaN},clean:function(){return 2-n}}},function(t,n,e,r){var i;if(null==t)i=e*iu,r.point(-ru,i),r.point(0,i),r.point(ru,i),r.point(ru,0),r.point(ru,-i),r.point(0,-i),r.point(-ru,-i),r.point(-ru,0),r.point(-ru,i);else if(fu(t[0]-n[0])>nu){var o=t[0]0,i=fu(n)>nu;function o(t,e){return hu(t)*hu(e)>n}function a(t,e,r){var i=[1,0,0],o=Vu(ju(t),ju(e)),a=Xu(o,o),u=o[0],c=a-u*u;if(!c)return!r&&t;var f=n*a/c,s=-n*u/c,l=Vu(i,o),h=$u(i,f);Gu(h,$u(o,s));var d=l,p=Xu(h,d),v=Xu(d,d),g=p*p-v*(Xu(h,h)-1);if(!(g<0)){var y=bu(g),_=$u(d,(-p-y)/v);if(Gu(_,h),_=Hu(_),!r)return _;var b,m=t[0],x=e[0],w=t[1],M=e[1];x0^_[1]<(fu(_[0]-m)ru^(m<=_[0]&&_[0]<=x)){var A=$u(d,(-p+y)/v);return Gu(A,h),[_,Hu(A)]}}}function u(n,e){var i=r?t:ru-t,o=0;return n<-i?o|=1:n>i&&(o|=2),e<-i?o|=4:e>i&&(o|=8),o}return sf(o,function(t){var n,e,c,f,s;return{lineStart:function(){f=c=!1,s=1},point:function(l,h){var d,p=[l,h],v=o(l,h),g=r?v?0:u(l,h):v?u(l+(l<0?ru:-ru),h):0;if(!n&&(f=c=v)&&t.lineStart(),v!==c&&(!(d=a(n,p))||ef(n,d)||ef(p,d))&&(p[0]+=nu,p[1]+=nu,v=o(p[0],p[1])),v!==c)s=0,v?(t.lineStart(),d=a(p,n),t.point(d[0],d[1])):(d=a(n,p),t.point(d[0],d[1]),t.lineEnd()),n=d;else if(i&&n&&r^v){var y;g&e||!(y=a(p,n,!0))||(s=0,r?(t.lineStart(),t.point(y[0][0],y[0][1]),t.point(y[1][0],y[1][1]),t.lineEnd()):(t.point(y[1][0],y[1][1]),t.lineEnd(),t.lineStart(),t.point(y[0][0],y[0][1])))}!v||n&&ef(n,p)||t.point(p[0],p[1]),n=p,c=v,e=g},lineEnd:function(){c&&t.lineEnd(),n=null},clean:function(){return s|(f&&c)<<1}}},function(n,r,i,o){Jc(o,t,e,i,n,r)},r?[0,-t]:[-ru,t-ru])}var vf=1e9,gf=-vf;function yf(t,n,e,r){function i(i,o){return t<=i&&i<=e&&n<=o&&o<=r}function o(i,o,u,f){var s=0,l=0;if(null==i||(s=a(i,u))!==(l=a(o,u))||c(i,o)<0^u>0)do{f.point(0===s||3===s?t:e,s>1?r:n)}while((s=(s+u+4)%4)!==l);else f.point(o[0],o[1])}function a(r,i){return fu(r[0]-t)0?0:3:fu(r[0]-e)0?2:1:fu(r[1]-n)0?1:0:i>0?3:2}function u(t,n){return c(t.x,n.x)}function c(t,n){var e=a(t,1),r=a(n,1);return e!==r?e-r:0===e?n[1]-t[1]:1===e?t[0]-n[0]:2===e?t[1]-n[1]:n[0]-t[0]}return function(a){var c,f,s,l,h,d,p,v,g,y,_,b=a,m=nf(),x={point:w,lineStart:function(){x.point=M,f&&f.push(s=[]);y=!0,g=!1,p=v=NaN},lineEnd:function(){c&&(M(l,h),d&&g&&m.rejoin(),c.push(m.result()));x.point=w,g&&b.lineEnd()},polygonStart:function(){b=m,c=[],f=[],_=!0},polygonEnd:function(){var n=function(){for(var n=0,e=0,i=f.length;er&&(h-o)*(r-a)>(d-a)*(t-o)&&++n:d<=r&&(h-o)*(r-a)<(d-a)*(t-o)&&--n;return n}(),e=_&&n,i=(c=A(c)).length;(e||i)&&(a.polygonStart(),e&&(a.lineStart(),o(null,null,1,a),a.lineEnd()),i&&of(c,u,n,o,a),a.polygonEnd());b=a,c=f=s=null}};function w(t,n){i(t,n)&&b.point(t,n)}function M(o,a){var u=i(o,a);if(f&&s.push([o,a]),y)l=o,h=a,d=u,y=!1,u&&(b.lineStart(),b.point(o,a));else if(u&&g)b.point(o,a);else{var c=[p=Math.max(gf,Math.min(vf,p)),v=Math.max(gf,Math.min(vf,v))],m=[o=Math.max(gf,Math.min(vf,o)),a=Math.max(gf,Math.min(vf,a))];!function(t,n,e,r,i,o){var a,u=t[0],c=t[1],f=0,s=1,l=n[0]-u,h=n[1]-c;if(a=e-u,l||!(a>0)){if(a/=l,l<0){if(a0){if(a>s)return;a>f&&(f=a)}if(a=i-u,l||!(a<0)){if(a/=l,l<0){if(a>s)return;a>f&&(f=a)}else if(l>0){if(a0)){if(a/=h,h<0){if(a0){if(a>s)return;a>f&&(f=a)}if(a=o-c,h||!(a<0)){if(a/=h,h<0){if(a>s)return;a>f&&(f=a)}else if(h>0){if(a0&&(t[0]=u+f*l,t[1]=c+f*h),s<1&&(n[0]=u+s*l,n[1]=c+s*h),!0}}}}}(c,m,t,n,e,r)?u&&(b.lineStart(),b.point(o,a),_=!1):(g||(b.lineStart(),b.point(c[0],c[1])),b.point(m[0],m[1]),u||b.lineEnd(),_=!1)}p=o,v=a,g=u}return x}}var _f,bf,mf,xf=Qa(),wf={sphere:Nu,point:Nu,lineStart:function(){wf.point=Nf,wf.lineEnd=Mf},lineEnd:Nu,polygonStart:Nu,polygonEnd:Nu};function Mf(){wf.point=wf.lineEnd=Nu}function Nf(t,n){_f=t*=cu,bf=yu(n*=cu),mf=hu(n),wf.point=Tf}function Tf(t,n){t*=cu;var e=yu(n*=cu),r=hu(n),i=fu(t-_f),o=hu(i),a=r*yu(i),u=mf*e-bf*r*o,c=bf*e+mf*r*o;xf.add(lu(bu(a*a+u*u),c)),_f=t,bf=e,mf=r}function Af(t){return xf.reset(),Cu(t,wf),+xf}var Sf=[null,null],kf={type:"LineString",coordinates:Sf};function Ef(t,n){return Sf[0]=t,Sf[1]=n,Af(kf)}var Cf={Feature:function(t,n){return zf(t.geometry,n)},FeatureCollection:function(t,n){for(var e=t.features,r=-1,i=e.length;++r0&&(i=Ef(t[o],t[o-1]))>0&&e<=i&&r<=i&&(e+r-i)*(1-Math.pow((e-r)/i,2))nu}).map(c)).concat(g(du(o/d)*d,i,d).filter(function(t){return fu(t%v)>nu}).map(f))}return _.lines=function(){return b().map(function(t){return{type:"LineString",coordinates:t}})},_.outline=function(){return{type:"Polygon",coordinates:[s(r).concat(l(a).slice(1),s(e).reverse().slice(1),l(u).reverse().slice(1))]}},_.extent=function(t){return arguments.length?_.extentMajor(t).extentMinor(t):_.extentMinor()},_.extentMajor=function(t){return arguments.length?(r=+t[0][0],e=+t[1][0],u=+t[0][1],a=+t[1][1],r>e&&(t=r,r=e,e=t),u>a&&(t=u,u=a,a=t),_.precision(y)):[[r,u],[e,a]]},_.extentMinor=function(e){return arguments.length?(n=+e[0][0],t=+e[1][0],o=+e[0][1],i=+e[1][1],n>t&&(e=n,n=t,t=e),o>i&&(e=o,o=i,i=e),_.precision(y)):[[n,o],[t,i]]},_.step=function(t){return arguments.length?_.stepMajor(t).stepMinor(t):_.stepMinor()},_.stepMajor=function(t){return arguments.length?(p=+t[0],v=+t[1],_):[p,v]},_.stepMinor=function(t){return arguments.length?(h=+t[0],d=+t[1],_):[h,d]},_.precision=function(h){return arguments.length?(y=+h,c=Of(o,i,90),f=Bf(n,t,y),s=Of(u,a,90),l=Bf(r,e,y),_):y},_.extentMajor([[-180,-90+nu],[180,90-nu]]).extentMinor([[-180,-80-nu],[180,80+nu]])}function Yf(t){return t}var If,Hf,jf,Xf,Vf=Qa(),Gf=Qa(),$f={point:Nu,lineStart:Nu,lineEnd:Nu,polygonStart:function(){$f.lineStart=Wf,$f.lineEnd=Kf},polygonEnd:function(){$f.lineStart=$f.lineEnd=$f.point=Nu,Vf.add(fu(Gf)),Gf.reset()},result:function(){var t=Vf/2;return Vf.reset(),t}};function Wf(){$f.point=Zf}function Zf(t,n){$f.point=Qf,If=jf=t,Hf=Xf=n}function Qf(t,n){Gf.add(Xf*t-jf*n),jf=t,Xf=n}function Kf(){Qf(If,Hf)}var Jf=1/0,ts=Jf,ns=-Jf,es=ns,rs={point:function(t,n){tns&&(ns=t);nes&&(es=n)},lineStart:Nu,lineEnd:Nu,polygonStart:Nu,polygonEnd:Nu,result:function(){var t=[[Jf,ts],[ns,es]];return ns=es=-(ts=Jf=1/0),t}};var is,os,as,us,cs=0,fs=0,ss=0,ls=0,hs=0,ds=0,ps=0,vs=0,gs=0,ys={point:_s,lineStart:bs,lineEnd:ws,polygonStart:function(){ys.lineStart=Ms,ys.lineEnd=Ns},polygonEnd:function(){ys.point=_s,ys.lineStart=bs,ys.lineEnd=ws},result:function(){var t=gs?[ps/gs,vs/gs]:ds?[ls/ds,hs/ds]:ss?[cs/ss,fs/ss]:[NaN,NaN];return cs=fs=ss=ls=hs=ds=ps=vs=gs=0,t}};function _s(t,n){cs+=t,fs+=n,++ss}function bs(){ys.point=ms}function ms(t,n){ys.point=xs,_s(as=t,us=n)}function xs(t,n){var e=t-as,r=n-us,i=bu(e*e+r*r);ls+=i*(as+t)/2,hs+=i*(us+n)/2,ds+=i,_s(as=t,us=n)}function ws(){ys.point=_s}function Ms(){ys.point=Ts}function Ns(){As(is,os)}function Ts(t,n){ys.point=As,_s(is=as=t,os=us=n)}function As(t,n){var e=t-as,r=n-us,i=bu(e*e+r*r);ls+=i*(as+t)/2,hs+=i*(us+n)/2,ds+=i,ps+=(i=us*t-as*n)*(as+t),vs+=i*(us+n),gs+=3*i,_s(as=t,us=n)}function Ss(t){this._context=t}Ss.prototype={_radius:4.5,pointRadius:function(t){return this._radius=t,this},polygonStart:function(){this._line=0},polygonEnd:function(){this._line=NaN},lineStart:function(){this._point=0},lineEnd:function(){0===this._line&&this._context.closePath(),this._point=NaN},point:function(t,n){switch(this._point){case 0:this._context.moveTo(t,n),this._point=1;break;case 1:this._context.lineTo(t,n);break;default:this._context.moveTo(t+this._radius,n),this._context.arc(t,n,this._radius,0,au)}},result:Nu};var ks,Es,Cs,Ps,zs,Rs=Qa(),Ds={point:Nu,lineStart:function(){Ds.point=qs},lineEnd:function(){ks&&Ls(Es,Cs),Ds.point=Nu},polygonStart:function(){ks=!0},polygonEnd:function(){ks=null},result:function(){var t=+Rs;return Rs.reset(),t}};function qs(t,n){Ds.point=Ls,Es=Ps=t,Cs=zs=n}function Ls(t,n){Ps-=t,zs-=n,Rs.add(bu(Ps*Ps+zs*zs)),Ps=t,zs=n}function Us(){this._string=[]}function Os(t){return"m0,"+t+"a"+t+","+t+" 0 1,1 0,"+-2*t+"a"+t+","+t+" 0 1,1 0,"+2*t+"z"}function Bs(t){return function(n){var e=new Fs;for(var r in t)e[r]=t[r];return e.stream=n,e}}function Fs(){}function Ys(t,n,e){var r=t.clipExtent&&t.clipExtent();return t.scale(150).translate([0,0]),null!=r&&t.clipExtent(null),Cu(e,t.stream(rs)),n(rs.result()),null!=r&&t.clipExtent(r),t}function Is(t,n,e){return Ys(t,function(e){var r=n[1][0]-n[0][0],i=n[1][1]-n[0][1],o=Math.min(r/(e[1][0]-e[0][0]),i/(e[1][1]-e[0][1])),a=+n[0][0]+(r-o*(e[1][0]+e[0][0]))/2,u=+n[0][1]+(i-o*(e[1][1]+e[0][1]))/2;t.scale(150*o).translate([a,u])},e)}function Hs(t,n,e){return Is(t,[[0,0],n],e)}function js(t,n,e){return Ys(t,function(e){var r=+n,i=r/(e[1][0]-e[0][0]),o=(r-i*(e[1][0]+e[0][0]))/2,a=-i*e[0][1];t.scale(150*i).translate([o,a])},e)}function Xs(t,n,e){return Ys(t,function(e){var r=+n,i=r/(e[1][1]-e[0][1]),o=-i*e[0][0],a=(r-i*(e[1][1]+e[0][1]))/2;t.scale(150*i).translate([o,a])},e)}Us.prototype={_radius:4.5,_circle:Os(4.5),pointRadius:function(t){return(t=+t)!==this._radius&&(this._radius=t,this._circle=null),this},polygonStart:function(){this._line=0},polygonEnd:function(){this._line=NaN},lineStart:function(){this._point=0},lineEnd:function(){0===this._line&&this._string.push("Z"),this._point=NaN},point:function(t,n){switch(this._point){case 0:this._string.push("M",t,",",n),this._point=1;break;case 1:this._string.push("L",t,",",n);break;default:null==this._circle&&(this._circle=Os(this._radius)),this._string.push("M",t,",",n,this._circle)}},result:function(){if(this._string.length){var t=this._string.join("");return this._string=[],t}return null}},Fs.prototype={constructor:Fs,point:function(t,n){this.stream.point(t,n)},sphere:function(){this.stream.sphere()},lineStart:function(){this.stream.lineStart()},lineEnd:function(){this.stream.lineEnd()},polygonStart:function(){this.stream.polygonStart()},polygonEnd:function(){this.stream.polygonEnd()}};var Vs=16,Gs=hu(30*cu);function $s(t,n){return+n?function(t,n){function e(r,i,o,a,u,c,f,s,l,h,d,p,v,g){var y=f-r,_=s-i,b=y*y+_*_;if(b>4*n&&v--){var m=a+h,x=u+d,w=c+p,M=bu(m*m+x*x+w*w),N=wu(w/=M),T=fu(fu(w)-1)n||fu((y*E+_*C)/b-.5)>.3||a*h+u*d+c*p2?t[2]%360*cu:0,E()):[g*uu,y*uu,_*uu]},S.angle=function(t){return arguments.length?(b=t%360*cu,E()):b*uu},S.reflectX=function(t){return arguments.length?(m=t?-1:1,E()):m<0},S.reflectY=function(t){return arguments.length?(x=t?-1:1,E()):x<0},S.precision=function(t){return arguments.length?(a=$s(u,A=t*t),C()):bu(A)},S.fitExtent=function(t,n){return Is(S,t,n)},S.fitSize=function(t,n){return Hs(S,t,n)},S.fitWidth=function(t,n){return js(S,t,n)},S.fitHeight=function(t,n){return Xs(S,t,n)},function(){return n=t.apply(this,arguments),S.invert=n.invert&&k,E()}}function Js(t){var n=0,e=ru/3,r=Ks(t),i=r(n,e);return i.parallels=function(t){return arguments.length?r(n=t[0]*cu,e=t[1]*cu):[n*uu,e*uu]},i}function tl(t,n){var e=yu(t),r=(e+yu(n))/2;if(fu(r)0?n<-iu+nu&&(n=-iu+nu):n>iu-nu&&(n=iu-nu);var e=i/gu(fl(n),r);return[e*yu(r*t),i-e*hu(r*t)]}return o.invert=function(t,n){var e=i-n,o=_u(r)*bu(t*t+e*e),a=lu(t,fu(e))*_u(e);return e*r<0&&(a-=ru*_u(t)*_u(e)),[a/r,2*su(gu(i/o,1/r))-iu]},o}function ll(t,n){return[t,n]}function hl(t,n){var e=hu(t),r=t===n?yu(t):(e-hu(n))/(n-t),i=e/r+t;if(fu(r)=0;)n+=e[r].value;else n=1;t.value=n}function kl(t,n){var e,r,i,o,a,u=new zl(t),c=+t.value&&(u.value=t.value),f=[u];for(null==n&&(n=El);e=f.pop();)if(c&&(e.value=+e.data.value),(i=n(e.data))&&(a=i.length))for(e.children=new Array(a),o=a-1;o>=0;--o)f.push(r=e.children[o]=new zl(i[o])),r.parent=e,r.depth=e.depth+1;return u.eachBefore(Pl)}function El(t){return t.children}function Cl(t){t.data=t.data.data}function Pl(t){var n=0;do{t.height=n}while((t=t.parent)&&t.height<++n)}function zl(t){this.data=t,this.depth=this.height=0,this.parent=null}_l.invert=function(t,n){for(var e,r=n,i=r*r,o=i*i*i,a=0;a<12&&(o=(i=(r-=e=(r*(dl+pl*i+o*(vl+gl*i))-n)/(dl+3*pl*i+o*(7*vl+9*gl*i)))*r)*i*i,!(fu(e)nu&&--i>0);return[t/(.8707+(o=r*r)*(o*(o*o*o*(.003971-.001529*o)-.013791)-.131979)),r]},xl.invert=il(wu),wl.invert=il(function(t){return 2*su(t)}),Ml.invert=function(t,n){return[-n,2*su(pu(t))-iu]},zl.prototype=kl.prototype={constructor:zl,count:function(){return this.eachAfter(Sl)},each:function(t){var n,e,r,i,o=this,a=[o];do{for(n=a.reverse(),a=[];o=n.pop();)if(t(o),e=o.children)for(r=0,i=e.length;r=0;--e)i.push(n[e]);return this},sum:function(t){return this.eachAfter(function(n){for(var e=+t(n.data)||0,r=n.children,i=r&&r.length;--i>=0;)e+=r[i].value;n.value=e})},sort:function(t){return this.eachBefore(function(n){n.children&&n.children.sort(t)})},path:function(t){for(var n=this,e=function(t,n){if(t===n)return t;var e=t.ancestors(),r=n.ancestors(),i=null;for(t=e.pop(),n=r.pop();t===n;)i=t,t=e.pop(),n=r.pop();return i}(n,t),r=[n];n!==e;)n=n.parent,r.push(n);for(var i=r.length;t!==e;)r.splice(i,0,t),t=t.parent;return r},ancestors:function(){for(var t=this,n=[t];t=t.parent;)n.push(t);return n},descendants:function(){var t=[];return this.each(function(n){t.push(n)}),t},leaves:function(){var t=[];return this.eachBefore(function(n){n.children||t.push(n)}),t},links:function(){var t=this,n=[];return t.each(function(e){e!==t&&n.push({source:e.parent,target:e})}),n},copy:function(){return kl(this).eachBefore(Cl)}};var Rl=Array.prototype.slice;function Dl(t){for(var n,e,r=0,i=(t=function(t){for(var n,e,r=t.length;r;)e=Math.random()*r--|0,n=t[r],t[r]=t[e],t[e]=n;return t}(Rl.call(t))).length,o=[];r0&&e*e>r*r+i*i}function Ol(t,n){for(var e=0;e(a*=a)?(r=(f+a-i)/(2*f),o=Math.sqrt(Math.max(0,a/f-r*r)),e.x=t.x-r*u-o*c,e.y=t.y-r*c+o*u):(r=(f+i-a)/(2*f),o=Math.sqrt(Math.max(0,i/f-r*r)),e.x=n.x+r*u-o*c,e.y=n.y+r*c+o*u)):(e.x=n.x+e.r,e.y=n.y)}function Hl(t,n){var e=t.r+n.r-1e-6,r=n.x-t.x,i=n.y-t.y;return e>0&&e*e>r*r+i*i}function jl(t){var n=t._,e=t.next._,r=n.r+e.r,i=(n.x*e.r+e.x*n.r)/r,o=(n.y*e.r+e.y*n.r)/r;return i*i+o*o}function Xl(t){this._=t,this.next=null,this.previous=null}function Vl(t){if(!(i=t.length))return 0;var n,e,r,i,o,a,u,c,f,s,l;if((n=t[0]).x=0,n.y=0,!(i>1))return n.r;if(e=t[1],n.x=-e.r,e.x=n.r,e.y=0,!(i>2))return n.r+e.r;Il(e,n,r=t[2]),n=new Xl(n),e=new Xl(e),r=new Xl(r),n.next=r.previous=e,e.next=n.previous=r,r.next=e.previous=n;t:for(u=3;uh&&(h=u),g=s*s*v,(d=Math.max(h/g,g/l))>p){s-=u;break}p=d}y.push(a={value:s,dice:c1?n:1)},e}(vh);var _h=function t(n){function e(t,e,r,i,o){if((a=t._squarify)&&a.ratio===n)for(var a,u,c,f,s,l=-1,h=a.length,d=t.value;++l1?n:1)},e}(vh);function bh(t,n,e){return(n[0]-t[0])*(e[1]-t[1])-(n[1]-t[1])*(e[0]-t[0])}function mh(t,n){return t[0]-n[0]||t[1]-n[1]}function xh(t){for(var n=t.length,e=[0,1],r=2,i=2;i1&&bh(t[e[r-2]],t[e[r-1]],t[i])<=0;)--r;e[r++]=i}return e.slice(0,r)}function wh(){return Math.random()}var Mh=function t(n){function e(t,e){return t=null==t?0:+t,e=null==e?1:+e,1===arguments.length?(e=t,t=0):e-=t,function(){return n()*e+t}}return e.source=t,e}(wh),Nh=function t(n){function e(t,e){var r,i;return t=null==t?0:+t,e=null==e?1:+e,function(){var o;if(null!=r)o=r,r=null;else do{r=2*n()-1,o=2*n()-1,i=r*r+o*o}while(!i||i>1);return t+e*o*Math.sqrt(-2*Math.log(i)/i)}}return e.source=t,e}(wh),Th=function t(n){function e(){var t=Nh.source(n).apply(this,arguments);return function(){return Math.exp(t())}}return e.source=t,e}(wh),Ah=function t(n){function e(t){return function(){for(var e=0,r=0;rr&&(n=e,e=r,r=n),function(t){return Math.max(e,Math.min(r,t))}}function Ih(t,n,e){var r=t[0],i=t[1],o=n[0],a=n[1];return i2?Hh:Ih,i=o=null,l}function l(n){return isNaN(n=+n)?e:(i||(i=r(a.map(t),u,c)))(t(f(n)))}return l.invert=function(e){return f(n((o||(o=r(u,a.map(t),me)))(e)))},l.domain=function(t){return arguments.length?(a=zh.call(t,Uh),f===Bh||(f=Yh(a)),s()):a.slice()},l.range=function(t){return arguments.length?(u=Rh.call(t),s()):u.slice()},l.rangeRound=function(t){return u=Rh.call(t),c=Ae,s()},l.clamp=function(t){return arguments.length?(f=t?Yh(a):Bh,l):f!==Bh},l.interpolate=function(t){return arguments.length?(c=t,s()):c},l.unknown=function(t){return arguments.length?(e=t,l):e},function(e,r){return t=e,n=r,s()}}function Vh(t,n){return Xh()(t,n)}function Gh(n,e,r,i){var o,a=w(n,e,r);switch((i=Oa(null==i?",f":i)).type){case"s":var u=Math.max(Math.abs(n),Math.abs(e));return null!=i.precision||isNaN(o=Wa(a,u))||(i.precision=o),t.formatPrefix(i,u);case"":case"e":case"g":case"p":case"r":null!=i.precision||isNaN(o=Za(a,Math.max(Math.abs(n),Math.abs(e))))||(i.precision=o-("e"===i.type));break;case"f":case"%":null!=i.precision||isNaN(o=$a(a))||(i.precision=o-2*("%"===i.type))}return t.format(i)}function $h(t){var n=t.domain;return t.ticks=function(t){var e=n();return m(e[0],e[e.length-1],null==t?10:t)},t.tickFormat=function(t,e){var r=n();return Gh(r[0],r[r.length-1],null==t?10:t,e)},t.nice=function(e){null==e&&(e=10);var r,i=n(),o=0,a=i.length-1,u=i[o],c=i[a];return c0?r=x(u=Math.floor(u/r)*r,c=Math.ceil(c/r)*r,e):r<0&&(r=x(u=Math.ceil(u*r)/r,c=Math.floor(c*r)/r,e)),r>0?(i[o]=Math.floor(u/r)*r,i[a]=Math.ceil(c/r)*r,n(i)):r<0&&(i[o]=Math.ceil(u*r)/r,i[a]=Math.floor(c*r)/r,n(i)),t},t}function Wh(t,n){var e,r=0,i=(t=t.slice()).length-1,o=t[r],a=t[i];return a0){for(;hc)break;v.push(l)}}else for(;h=1;--s)if(!((l=f*s)c)break;v.push(l)}}else v=m(h,d,Math.min(d-h,p)).map(r);return n?v.reverse():v},i.tickFormat=function(n,o){if(null==o&&(o=10===a?".0e":","),"function"!=typeof o&&(o=t.format(o)),n===1/0)return o;null==n&&(n=10);var u=Math.max(1,a*n/i.ticks().length);return function(t){var n=t/r(Math.round(e(t)));return n*a0))return u;do{u.push(a=new Date(+e)),n(e,o),t(e)}while(a=n)for(;t(n),!e(n);)n.setTime(n-1)},function(t,r){if(t>=t)if(r<0)for(;++r<=0;)for(;n(t,-1),!e(t););else for(;--r>=0;)for(;n(t,1),!e(t););})},e&&(i.count=function(n,r){return ld.setTime(+n),hd.setTime(+r),t(ld),t(hd),Math.floor(e(ld,hd))},i.every=function(t){return t=Math.floor(t),isFinite(t)&&t>0?t>1?i.filter(r?function(n){return r(n)%t==0}:function(n){return i.count(0,n)%t==0}):i:null}),i}var pd=dd(function(){},function(t,n){t.setTime(+t+n)},function(t,n){return n-t});pd.every=function(t){return t=Math.floor(t),isFinite(t)&&t>0?t>1?dd(function(n){n.setTime(Math.floor(n/t)*t)},function(n,e){n.setTime(+n+e*t)},function(n,e){return(e-n)/t}):pd:null};var vd=pd.range,gd=6e4,yd=6048e5,_d=dd(function(t){t.setTime(t-t.getMilliseconds())},function(t,n){t.setTime(+t+1e3*n)},function(t,n){return(n-t)/1e3},function(t){return t.getUTCSeconds()}),bd=_d.range,md=dd(function(t){t.setTime(t-t.getMilliseconds()-1e3*t.getSeconds())},function(t,n){t.setTime(+t+n*gd)},function(t,n){return(n-t)/gd},function(t){return t.getMinutes()}),xd=md.range,wd=dd(function(t){t.setTime(t-t.getMilliseconds()-1e3*t.getSeconds()-t.getMinutes()*gd)},function(t,n){t.setTime(+t+36e5*n)},function(t,n){return(n-t)/36e5},function(t){return t.getHours()}),Md=wd.range,Nd=dd(function(t){t.setHours(0,0,0,0)},function(t,n){t.setDate(t.getDate()+n)},function(t,n){return(n-t-(n.getTimezoneOffset()-t.getTimezoneOffset())*gd)/864e5},function(t){return t.getDate()-1}),Td=Nd.range;function Ad(t){return dd(function(n){n.setDate(n.getDate()-(n.getDay()+7-t)%7),n.setHours(0,0,0,0)},function(t,n){t.setDate(t.getDate()+7*n)},function(t,n){return(n-t-(n.getTimezoneOffset()-t.getTimezoneOffset())*gd)/yd})}var Sd=Ad(0),kd=Ad(1),Ed=Ad(2),Cd=Ad(3),Pd=Ad(4),zd=Ad(5),Rd=Ad(6),Dd=Sd.range,qd=kd.range,Ld=Ed.range,Ud=Cd.range,Od=Pd.range,Bd=zd.range,Fd=Rd.range,Yd=dd(function(t){t.setDate(1),t.setHours(0,0,0,0)},function(t,n){t.setMonth(t.getMonth()+n)},function(t,n){return n.getMonth()-t.getMonth()+12*(n.getFullYear()-t.getFullYear())},function(t){return t.getMonth()}),Id=Yd.range,Hd=dd(function(t){t.setMonth(0,1),t.setHours(0,0,0,0)},function(t,n){t.setFullYear(t.getFullYear()+n)},function(t,n){return n.getFullYear()-t.getFullYear()},function(t){return t.getFullYear()});Hd.every=function(t){return isFinite(t=Math.floor(t))&&t>0?dd(function(n){n.setFullYear(Math.floor(n.getFullYear()/t)*t),n.setMonth(0,1),n.setHours(0,0,0,0)},function(n,e){n.setFullYear(n.getFullYear()+e*t)}):null};var jd=Hd.range,Xd=dd(function(t){t.setUTCSeconds(0,0)},function(t,n){t.setTime(+t+n*gd)},function(t,n){return(n-t)/gd},function(t){return t.getUTCMinutes()}),Vd=Xd.range,Gd=dd(function(t){t.setUTCMinutes(0,0,0)},function(t,n){t.setTime(+t+36e5*n)},function(t,n){return(n-t)/36e5},function(t){return t.getUTCHours()}),$d=Gd.range,Wd=dd(function(t){t.setUTCHours(0,0,0,0)},function(t,n){t.setUTCDate(t.getUTCDate()+n)},function(t,n){return(n-t)/864e5},function(t){return t.getUTCDate()-1}),Zd=Wd.range;function Qd(t){return dd(function(n){n.setUTCDate(n.getUTCDate()-(n.getUTCDay()+7-t)%7),n.setUTCHours(0,0,0,0)},function(t,n){t.setUTCDate(t.getUTCDate()+7*n)},function(t,n){return(n-t)/yd})}var Kd=Qd(0),Jd=Qd(1),tp=Qd(2),np=Qd(3),ep=Qd(4),rp=Qd(5),ip=Qd(6),op=Kd.range,ap=Jd.range,up=tp.range,cp=np.range,fp=ep.range,sp=rp.range,lp=ip.range,hp=dd(function(t){t.setUTCDate(1),t.setUTCHours(0,0,0,0)},function(t,n){t.setUTCMonth(t.getUTCMonth()+n)},function(t,n){return n.getUTCMonth()-t.getUTCMonth()+12*(n.getUTCFullYear()-t.getUTCFullYear())},function(t){return t.getUTCMonth()}),dp=hp.range,pp=dd(function(t){t.setUTCMonth(0,1),t.setUTCHours(0,0,0,0)},function(t,n){t.setUTCFullYear(t.getUTCFullYear()+n)},function(t,n){return n.getUTCFullYear()-t.getUTCFullYear()},function(t){return t.getUTCFullYear()});pp.every=function(t){return isFinite(t=Math.floor(t))&&t>0?dd(function(n){n.setUTCFullYear(Math.floor(n.getUTCFullYear()/t)*t),n.setUTCMonth(0,1),n.setUTCHours(0,0,0,0)},function(n,e){n.setUTCFullYear(n.getUTCFullYear()+e*t)}):null};var vp=pp.range;function gp(t){if(0<=t.y&&t.y<100){var n=new Date(-1,t.m,t.d,t.H,t.M,t.S,t.L);return n.setFullYear(t.y),n}return new Date(t.y,t.m,t.d,t.H,t.M,t.S,t.L)}function yp(t){if(0<=t.y&&t.y<100){var n=new Date(Date.UTC(-1,t.m,t.d,t.H,t.M,t.S,t.L));return n.setUTCFullYear(t.y),n}return new Date(Date.UTC(t.y,t.m,t.d,t.H,t.M,t.S,t.L))}function _p(t,n,e){return{y:t,m:n,d:e,H:0,M:0,S:0,L:0}}function bp(t){var n=t.dateTime,e=t.date,r=t.time,i=t.periods,o=t.days,a=t.shortDays,u=t.months,c=t.shortMonths,f=Sp(i),s=kp(i),l=Sp(o),h=kp(o),d=Sp(a),p=kp(a),v=Sp(u),g=kp(u),y=Sp(c),_=kp(c),b={a:function(t){return a[t.getDay()]},A:function(t){return o[t.getDay()]},b:function(t){return c[t.getMonth()]},B:function(t){return u[t.getMonth()]},c:null,d:Wp,e:Wp,f:tv,H:Zp,I:Qp,j:Kp,L:Jp,m:nv,M:ev,p:function(t){return i[+(t.getHours()>=12)]},q:function(t){return 1+~~(t.getMonth()/3)},Q:Cv,s:Pv,S:rv,u:iv,U:ov,V:av,w:uv,W:cv,x:null,X:null,y:fv,Y:sv,Z:lv,"%":Ev},m={a:function(t){return a[t.getUTCDay()]},A:function(t){return o[t.getUTCDay()]},b:function(t){return c[t.getUTCMonth()]},B:function(t){return u[t.getUTCMonth()]},c:null,d:hv,e:hv,f:yv,H:dv,I:pv,j:vv,L:gv,m:_v,M:bv,p:function(t){return i[+(t.getUTCHours()>=12)]},q:function(t){return 1+~~(t.getUTCMonth()/3)},Q:Cv,s:Pv,S:mv,u:xv,U:wv,V:Mv,w:Nv,W:Tv,x:null,X:null,y:Av,Y:Sv,Z:kv,"%":Ev},x={a:function(t,n,e){var r=d.exec(n.slice(e));return r?(t.w=p[r[0].toLowerCase()],e+r[0].length):-1},A:function(t,n,e){var r=l.exec(n.slice(e));return r?(t.w=h[r[0].toLowerCase()],e+r[0].length):-1},b:function(t,n,e){var r=y.exec(n.slice(e));return r?(t.m=_[r[0].toLowerCase()],e+r[0].length):-1},B:function(t,n,e){var r=v.exec(n.slice(e));return r?(t.m=g[r[0].toLowerCase()],e+r[0].length):-1},c:function(t,e,r){return N(t,n,e,r)},d:Bp,e:Bp,f:Xp,H:Yp,I:Yp,j:Fp,L:jp,m:Op,M:Ip,p:function(t,n,e){var r=f.exec(n.slice(e));return r?(t.p=s[r[0].toLowerCase()],e+r[0].length):-1},q:Up,Q:Gp,s:$p,S:Hp,u:Cp,U:Pp,V:zp,w:Ep,W:Rp,x:function(t,n,r){return N(t,e,n,r)},X:function(t,n,e){return N(t,r,n,e)},y:qp,Y:Dp,Z:Lp,"%":Vp};function w(t,n){return function(e){var r,i,o,a=[],u=-1,c=0,f=t.length;for(e instanceof Date||(e=new Date(+e));++u53)return null;"w"in o||(o.w=1),"Z"in o?(i=(r=yp(_p(o.y,0,1))).getUTCDay(),r=i>4||0===i?Jd.ceil(r):Jd(r),r=Wd.offset(r,7*(o.V-1)),o.y=r.getUTCFullYear(),o.m=r.getUTCMonth(),o.d=r.getUTCDate()+(o.w+6)%7):(i=(r=gp(_p(o.y,0,1))).getDay(),r=i>4||0===i?kd.ceil(r):kd(r),r=Nd.offset(r,7*(o.V-1)),o.y=r.getFullYear(),o.m=r.getMonth(),o.d=r.getDate()+(o.w+6)%7)}else("W"in o||"U"in o)&&("w"in o||(o.w="u"in o?o.u%7:"W"in o?1:0),i="Z"in o?yp(_p(o.y,0,1)).getUTCDay():gp(_p(o.y,0,1)).getDay(),o.m=0,o.d="W"in o?(o.w+6)%7+7*o.W-(i+5)%7:o.w+7*o.U-(i+6)%7);return"Z"in o?(o.H+=o.Z/100|0,o.M+=o.Z%100,yp(o)):gp(o)}}function N(t,n,e,r){for(var i,o,a=0,u=n.length,c=e.length;a=c)return-1;if(37===(i=n.charCodeAt(a++))){if(i=n.charAt(a++),!(o=x[i in xp?n.charAt(a++):i])||(r=o(t,e,r))<0)return-1}else if(i!=e.charCodeAt(r++))return-1}return r}return b.x=w(e,b),b.X=w(r,b),b.c=w(n,b),m.x=w(e,m),m.X=w(r,m),m.c=w(n,m),{format:function(t){var n=w(t+="",b);return n.toString=function(){return t},n},parse:function(t){var n=M(t+="",!1);return n.toString=function(){return t},n},utcFormat:function(t){var n=w(t+="",m);return n.toString=function(){return t},n},utcParse:function(t){var n=M(t+="",!0);return n.toString=function(){return t},n}}}var mp,xp={"-":"",_:" ",0:"0"},wp=/^\s*\d+/,Mp=/^%/,Np=/[\\^$*+?|[\]().{}]/g;function Tp(t,n,e){var r=t<0?"-":"",i=(r?-t:t)+"",o=i.length;return r+(o68?1900:2e3),e+r[0].length):-1}function Lp(t,n,e){var r=/^(Z)|([+-]\d\d)(?::?(\d\d))?/.exec(n.slice(e,e+6));return r?(t.Z=r[1]?0:-(r[2]+(r[3]||"00")),e+r[0].length):-1}function Up(t,n,e){var r=wp.exec(n.slice(e,e+1));return r?(t.q=3*r[0]-3,e+r[0].length):-1}function Op(t,n,e){var r=wp.exec(n.slice(e,e+2));return r?(t.m=r[0]-1,e+r[0].length):-1}function Bp(t,n,e){var r=wp.exec(n.slice(e,e+2));return r?(t.d=+r[0],e+r[0].length):-1}function Fp(t,n,e){var r=wp.exec(n.slice(e,e+3));return r?(t.m=0,t.d=+r[0],e+r[0].length):-1}function Yp(t,n,e){var r=wp.exec(n.slice(e,e+2));return r?(t.H=+r[0],e+r[0].length):-1}function Ip(t,n,e){var r=wp.exec(n.slice(e,e+2));return r?(t.M=+r[0],e+r[0].length):-1}function Hp(t,n,e){var r=wp.exec(n.slice(e,e+2));return r?(t.S=+r[0],e+r[0].length):-1}function jp(t,n,e){var r=wp.exec(n.slice(e,e+3));return r?(t.L=+r[0],e+r[0].length):-1}function Xp(t,n,e){var r=wp.exec(n.slice(e,e+6));return r?(t.L=Math.floor(r[0]/1e3),e+r[0].length):-1}function Vp(t,n,e){var r=Mp.exec(n.slice(e,e+1));return r?e+r[0].length:-1}function Gp(t,n,e){var r=wp.exec(n.slice(e));return r?(t.Q=+r[0],e+r[0].length):-1}function $p(t,n,e){var r=wp.exec(n.slice(e));return r?(t.s=+r[0],e+r[0].length):-1}function Wp(t,n){return Tp(t.getDate(),n,2)}function Zp(t,n){return Tp(t.getHours(),n,2)}function Qp(t,n){return Tp(t.getHours()%12||12,n,2)}function Kp(t,n){return Tp(1+Nd.count(Hd(t),t),n,3)}function Jp(t,n){return Tp(t.getMilliseconds(),n,3)}function tv(t,n){return Jp(t,n)+"000"}function nv(t,n){return Tp(t.getMonth()+1,n,2)}function ev(t,n){return Tp(t.getMinutes(),n,2)}function rv(t,n){return Tp(t.getSeconds(),n,2)}function iv(t){var n=t.getDay();return 0===n?7:n}function ov(t,n){return Tp(Sd.count(Hd(t)-1,t),n,2)}function av(t,n){var e=t.getDay();return t=e>=4||0===e?Pd(t):Pd.ceil(t),Tp(Pd.count(Hd(t),t)+(4===Hd(t).getDay()),n,2)}function uv(t){return t.getDay()}function cv(t,n){return Tp(kd.count(Hd(t)-1,t),n,2)}function fv(t,n){return Tp(t.getFullYear()%100,n,2)}function sv(t,n){return Tp(t.getFullYear()%1e4,n,4)}function lv(t){var n=t.getTimezoneOffset();return(n>0?"-":(n*=-1,"+"))+Tp(n/60|0,"0",2)+Tp(n%60,"0",2)}function hv(t,n){return Tp(t.getUTCDate(),n,2)}function dv(t,n){return Tp(t.getUTCHours(),n,2)}function pv(t,n){return Tp(t.getUTCHours()%12||12,n,2)}function vv(t,n){return Tp(1+Wd.count(pp(t),t),n,3)}function gv(t,n){return Tp(t.getUTCMilliseconds(),n,3)}function yv(t,n){return gv(t,n)+"000"}function _v(t,n){return Tp(t.getUTCMonth()+1,n,2)}function bv(t,n){return Tp(t.getUTCMinutes(),n,2)}function mv(t,n){return Tp(t.getUTCSeconds(),n,2)}function xv(t){var n=t.getUTCDay();return 0===n?7:n}function wv(t,n){return Tp(Kd.count(pp(t)-1,t),n,2)}function Mv(t,n){var e=t.getUTCDay();return t=e>=4||0===e?ep(t):ep.ceil(t),Tp(ep.count(pp(t),t)+(4===pp(t).getUTCDay()),n,2)}function Nv(t){return t.getUTCDay()}function Tv(t,n){return Tp(Jd.count(pp(t)-1,t),n,2)}function Av(t,n){return Tp(t.getUTCFullYear()%100,n,2)}function Sv(t,n){return Tp(t.getUTCFullYear()%1e4,n,4)}function kv(){return"+0000"}function Ev(){return"%"}function Cv(t){return+t}function Pv(t){return Math.floor(+t/1e3)}function zv(n){return mp=bp(n),t.timeFormat=mp.format,t.timeParse=mp.parse,t.utcFormat=mp.utcFormat,t.utcParse=mp.utcParse,mp}zv({dateTime:"%x, %X",date:"%-m/%-d/%Y",time:"%-I:%M:%S %p",periods:["AM","PM"],days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],shortDays:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],months:["January","February","March","April","May","June","July","August","September","October","November","December"],shortMonths:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]});var Rv=Date.prototype.toISOString?function(t){return t.toISOString()}:t.utcFormat("%Y-%m-%dT%H:%M:%S.%LZ");var Dv=+new Date("2000-01-01T00:00:00.000Z")?function(t){var n=new Date(t);return isNaN(n)?null:n}:t.utcParse("%Y-%m-%dT%H:%M:%S.%LZ"),qv=1e3,Lv=60*qv,Uv=60*Lv,Ov=24*Uv,Bv=7*Ov,Fv=30*Ov,Yv=365*Ov;function Iv(t){return new Date(t)}function Hv(t){return t instanceof Date?+t:+new Date(+t)}function jv(t,n,r,i,o,a,u,c,f){var s=Vh(Bh,Bh),l=s.invert,h=s.domain,d=f(".%L"),p=f(":%S"),v=f("%I:%M"),g=f("%I %p"),y=f("%a %d"),_=f("%b %d"),b=f("%B"),m=f("%Y"),x=[[u,1,qv],[u,5,5*qv],[u,15,15*qv],[u,30,30*qv],[a,1,Lv],[a,5,5*Lv],[a,15,15*Lv],[a,30,30*Lv],[o,1,Uv],[o,3,3*Uv],[o,6,6*Uv],[o,12,12*Uv],[i,1,Ov],[i,2,2*Ov],[r,1,Bv],[n,1,Fv],[n,3,3*Fv],[t,1,Yv]];function M(e){return(u(e)=1?Cy:t<=-1?-Cy:Math.asin(t)}function Ry(t){return t.innerRadius}function Dy(t){return t.outerRadius}function qy(t){return t.startAngle}function Ly(t){return t.endAngle}function Uy(t){return t&&t.padAngle}function Oy(t,n,e,r,i,o,a){var u=t-e,c=n-r,f=(a?o:-o)/Sy(u*u+c*c),s=f*c,l=-f*u,h=t+s,d=n+l,p=e+s,v=r+l,g=(h+p)/2,y=(d+v)/2,_=p-h,b=v-d,m=_*_+b*b,x=i-o,w=h*v-p*d,M=(b<0?-1:1)*Sy(Ny(0,x*x*m-w*w)),N=(w*b-_*M)/m,T=(-w*_-b*M)/m,A=(w*b+_*M)/m,S=(-w*_+b*M)/m,k=N-g,E=T-y,C=A-g,P=S-y;return k*k+E*E>C*C+P*P&&(N=A,T=S),{cx:N,cy:T,x01:-s,y01:-l,x11:N*(i/x-1),y11:T*(i/x-1)}}function By(t){this._context=t}function Fy(t){return new By(t)}function Yy(t){return t[0]}function Iy(t){return t[1]}function Hy(){var t=Yy,n=Iy,e=my(!0),r=null,i=Fy,o=null;function a(a){var u,c,f,s=a.length,l=!1;for(null==r&&(o=i(f=no())),u=0;u<=s;++u)!(u=s;--l)u.point(g[l],y[l]);u.lineEnd(),u.areaEnd()}v&&(g[f]=+t(h,f,c),y[f]=+e(h,f,c),u.point(n?+n(h,f,c):g[f],r?+r(h,f,c):y[f]))}if(d)return u=null,d+""||null}function f(){return Hy().defined(i).curve(a).context(o)}return c.x=function(e){return arguments.length?(t="function"==typeof e?e:my(+e),n=null,c):t},c.x0=function(n){return arguments.length?(t="function"==typeof n?n:my(+n),c):t},c.x1=function(t){return arguments.length?(n=null==t?null:"function"==typeof t?t:my(+t),c):n},c.y=function(t){return arguments.length?(e="function"==typeof t?t:my(+t),r=null,c):e},c.y0=function(t){return arguments.length?(e="function"==typeof t?t:my(+t),c):e},c.y1=function(t){return arguments.length?(r=null==t?null:"function"==typeof t?t:my(+t),c):r},c.lineX0=c.lineY0=function(){return f().x(t).y(e)},c.lineY1=function(){return f().x(t).y(r)},c.lineX1=function(){return f().x(n).y(e)},c.defined=function(t){return arguments.length?(i="function"==typeof t?t:my(!!t),c):i},c.curve=function(t){return arguments.length?(a=t,null!=o&&(u=a(o)),c):a},c.context=function(t){return arguments.length?(null==t?o=u=null:u=a(o=t),c):o},c}function Xy(t,n){return nt?1:n>=t?0:NaN}function Vy(t){return t}By.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._point=0},lineEnd:function(){(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;default:this._context.lineTo(t,n)}}};var Gy=Wy(Fy);function $y(t){this._curve=t}function Wy(t){function n(n){return new $y(t(n))}return n._curve=t,n}function Zy(t){var n=t.curve;return t.angle=t.x,delete t.x,t.radius=t.y,delete t.y,t.curve=function(t){return arguments.length?n(Wy(t)):n()._curve},t}function Qy(){return Zy(Hy().curve(Gy))}function Ky(){var t=jy().curve(Gy),n=t.curve,e=t.lineX0,r=t.lineX1,i=t.lineY0,o=t.lineY1;return t.angle=t.x,delete t.x,t.startAngle=t.x0,delete t.x0,t.endAngle=t.x1,delete t.x1,t.radius=t.y,delete t.y,t.innerRadius=t.y0,delete t.y0,t.outerRadius=t.y1,delete t.y1,t.lineStartAngle=function(){return Zy(e())},delete t.lineX0,t.lineEndAngle=function(){return Zy(r())},delete t.lineX1,t.lineInnerRadius=function(){return Zy(i())},delete t.lineY0,t.lineOuterRadius=function(){return Zy(o())},delete t.lineY1,t.curve=function(t){return arguments.length?n(Wy(t)):n()._curve},t}function Jy(t,n){return[(n=+n)*Math.cos(t-=Math.PI/2),n*Math.sin(t)]}$y.prototype={areaStart:function(){this._curve.areaStart()},areaEnd:function(){this._curve.areaEnd()},lineStart:function(){this._curve.lineStart()},lineEnd:function(){this._curve.lineEnd()},point:function(t,n){this._curve.point(n*Math.sin(t),n*-Math.cos(t))}};var t_=Array.prototype.slice;function n_(t){return t.source}function e_(t){return t.target}function r_(t){var n=n_,e=e_,r=Yy,i=Iy,o=null;function a(){var a,u=t_.call(arguments),c=n.apply(this,u),f=e.apply(this,u);if(o||(o=a=no()),t(o,+r.apply(this,(u[0]=c,u)),+i.apply(this,u),+r.apply(this,(u[0]=f,u)),+i.apply(this,u)),a)return o=null,a+""||null}return a.source=function(t){return arguments.length?(n=t,a):n},a.target=function(t){return arguments.length?(e=t,a):e},a.x=function(t){return arguments.length?(r="function"==typeof t?t:my(+t),a):r},a.y=function(t){return arguments.length?(i="function"==typeof t?t:my(+t),a):i},a.context=function(t){return arguments.length?(o=null==t?null:t,a):o},a}function i_(t,n,e,r,i){t.moveTo(n,e),t.bezierCurveTo(n=(n+r)/2,e,n,i,r,i)}function o_(t,n,e,r,i){t.moveTo(n,e),t.bezierCurveTo(n,e=(e+i)/2,r,e,r,i)}function a_(t,n,e,r,i){var o=Jy(n,e),a=Jy(n,e=(e+i)/2),u=Jy(r,e),c=Jy(r,i);t.moveTo(o[0],o[1]),t.bezierCurveTo(a[0],a[1],u[0],u[1],c[0],c[1])}var u_={draw:function(t,n){var e=Math.sqrt(n/Ey);t.moveTo(e,0),t.arc(0,0,e,0,Py)}},c_={draw:function(t,n){var e=Math.sqrt(n/5)/2;t.moveTo(-3*e,-e),t.lineTo(-e,-e),t.lineTo(-e,-3*e),t.lineTo(e,-3*e),t.lineTo(e,-e),t.lineTo(3*e,-e),t.lineTo(3*e,e),t.lineTo(e,e),t.lineTo(e,3*e),t.lineTo(-e,3*e),t.lineTo(-e,e),t.lineTo(-3*e,e),t.closePath()}},f_=Math.sqrt(1/3),s_=2*f_,l_={draw:function(t,n){var e=Math.sqrt(n/s_),r=e*f_;t.moveTo(0,-e),t.lineTo(r,0),t.lineTo(0,e),t.lineTo(-r,0),t.closePath()}},h_=Math.sin(Ey/10)/Math.sin(7*Ey/10),d_=Math.sin(Py/10)*h_,p_=-Math.cos(Py/10)*h_,v_={draw:function(t,n){var e=Math.sqrt(.8908130915292852*n),r=d_*e,i=p_*e;t.moveTo(0,-e),t.lineTo(r,i);for(var o=1;o<5;++o){var a=Py*o/5,u=Math.cos(a),c=Math.sin(a);t.lineTo(c*e,-u*e),t.lineTo(u*r-c*i,c*r+u*i)}t.closePath()}},g_={draw:function(t,n){var e=Math.sqrt(n),r=-e/2;t.rect(r,r,e,e)}},y_=Math.sqrt(3),__={draw:function(t,n){var e=-Math.sqrt(n/(3*y_));t.moveTo(0,2*e),t.lineTo(-y_*e,-e),t.lineTo(y_*e,-e),t.closePath()}},b_=Math.sqrt(3)/2,m_=1/Math.sqrt(12),x_=3*(m_/2+1),w_={draw:function(t,n){var e=Math.sqrt(n/x_),r=e/2,i=e*m_,o=r,a=e*m_+e,u=-o,c=a;t.moveTo(r,i),t.lineTo(o,a),t.lineTo(u,c),t.lineTo(-.5*r-b_*i,b_*r+-.5*i),t.lineTo(-.5*o-b_*a,b_*o+-.5*a),t.lineTo(-.5*u-b_*c,b_*u+-.5*c),t.lineTo(-.5*r+b_*i,-.5*i-b_*r),t.lineTo(-.5*o+b_*a,-.5*a-b_*o),t.lineTo(-.5*u+b_*c,-.5*c-b_*u),t.closePath()}},M_=[u_,c_,l_,g_,v_,__,w_];function N_(){}function T_(t,n,e){t._context.bezierCurveTo((2*t._x0+t._x1)/3,(2*t._y0+t._y1)/3,(t._x0+2*t._x1)/3,(t._y0+2*t._y1)/3,(t._x0+4*t._x1+n)/6,(t._y0+4*t._y1+e)/6)}function A_(t){this._context=t}function S_(t){this._context=t}function k_(t){this._context=t}function E_(t,n){this._basis=new A_(t),this._beta=n}A_.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},lineEnd:function(){switch(this._point){case 3:T_(this,this._x1,this._y1);case 2:this._context.lineTo(this._x1,this._y1)}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;break;case 2:this._point=3,this._context.lineTo((5*this._x0+this._x1)/6,(5*this._y0+this._y1)/6);default:T_(this,t,n)}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=n}},S_.prototype={areaStart:N_,areaEnd:N_,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._y0=this._y1=this._y2=this._y3=this._y4=NaN,this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x2,this._y2),this._context.closePath();break;case 2:this._context.moveTo((this._x2+2*this._x3)/3,(this._y2+2*this._y3)/3),this._context.lineTo((this._x3+2*this._x2)/3,(this._y3+2*this._y2)/3),this._context.closePath();break;case 3:this.point(this._x2,this._y2),this.point(this._x3,this._y3),this.point(this._x4,this._y4)}},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._x2=t,this._y2=n;break;case 1:this._point=2,this._x3=t,this._y3=n;break;case 2:this._point=3,this._x4=t,this._y4=n,this._context.moveTo((this._x0+4*this._x1+t)/6,(this._y0+4*this._y1+n)/6);break;default:T_(this,t,n)}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=n}},k_.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3;var e=(this._x0+4*this._x1+t)/6,r=(this._y0+4*this._y1+n)/6;this._line?this._context.lineTo(e,r):this._context.moveTo(e,r);break;case 3:this._point=4;default:T_(this,t,n)}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=n}},E_.prototype={lineStart:function(){this._x=[],this._y=[],this._basis.lineStart()},lineEnd:function(){var t=this._x,n=this._y,e=t.length-1;if(e>0)for(var r,i=t[0],o=n[0],a=t[e]-i,u=n[e]-o,c=-1;++c<=e;)r=c/e,this._basis.point(this._beta*t[c]+(1-this._beta)*(i+r*a),this._beta*n[c]+(1-this._beta)*(o+r*u));this._x=this._y=null,this._basis.lineEnd()},point:function(t,n){this._x.push(+t),this._y.push(+n)}};var C_=function t(n){function e(t){return 1===n?new A_(t):new E_(t,n)}return e.beta=function(n){return t(+n)},e}(.85);function P_(t,n,e){t._context.bezierCurveTo(t._x1+t._k*(t._x2-t._x0),t._y1+t._k*(t._y2-t._y0),t._x2+t._k*(t._x1-n),t._y2+t._k*(t._y1-e),t._x2,t._y2)}function z_(t,n){this._context=t,this._k=(1-n)/6}z_.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._point=0},lineEnd:function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:P_(this,this._x1,this._y1)}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2,this._x1=t,this._y1=n;break;case 2:this._point=3;default:P_(this,t,n)}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var R_=function t(n){function e(t){return new z_(t,n)}return e.tension=function(n){return t(+n)},e}(0);function D_(t,n){this._context=t,this._k=(1-n)/6}D_.prototype={areaStart:N_,areaEnd:N_,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN,this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x3,this._y3),this._context.closePath();break;case 2:this._context.lineTo(this._x3,this._y3),this._context.closePath();break;case 3:this.point(this._x3,this._y3),this.point(this._x4,this._y4),this.point(this._x5,this._y5)}},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._x3=t,this._y3=n;break;case 1:this._point=2,this._context.moveTo(this._x4=t,this._y4=n);break;case 2:this._point=3,this._x5=t,this._y5=n;break;default:P_(this,t,n)}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var q_=function t(n){function e(t){return new D_(t,n)}return e.tension=function(n){return t(+n)},e}(0);function L_(t,n){this._context=t,this._k=(1-n)/6}L_.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3,this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:P_(this,t,n)}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var U_=function t(n){function e(t){return new L_(t,n)}return e.tension=function(n){return t(+n)},e}(0);function O_(t,n,e){var r=t._x1,i=t._y1,o=t._x2,a=t._y2;if(t._l01_a>ky){var u=2*t._l01_2a+3*t._l01_a*t._l12_a+t._l12_2a,c=3*t._l01_a*(t._l01_a+t._l12_a);r=(r*u-t._x0*t._l12_2a+t._x2*t._l01_2a)/c,i=(i*u-t._y0*t._l12_2a+t._y2*t._l01_2a)/c}if(t._l23_a>ky){var f=2*t._l23_2a+3*t._l23_a*t._l12_a+t._l12_2a,s=3*t._l23_a*(t._l23_a+t._l12_a);o=(o*f+t._x1*t._l23_2a-n*t._l12_2a)/s,a=(a*f+t._y1*t._l23_2a-e*t._l12_2a)/s}t._context.bezierCurveTo(r,i,o,a,t._x2,t._y2)}function B_(t,n){this._context=t,this._alpha=n}B_.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:this.point(this._x2,this._y2)}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){if(t=+t,n=+n,this._point){var e=this._x2-t,r=this._y2-n;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(e*e+r*r,this._alpha))}switch(this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;break;case 2:this._point=3;default:O_(this,t,n)}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var F_=function t(n){function e(t){return n?new B_(t,n):new z_(t,0)}return e.alpha=function(n){return t(+n)},e}(.5);function Y_(t,n){this._context=t,this._alpha=n}Y_.prototype={areaStart:N_,areaEnd:N_,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x3,this._y3),this._context.closePath();break;case 2:this._context.lineTo(this._x3,this._y3),this._context.closePath();break;case 3:this.point(this._x3,this._y3),this.point(this._x4,this._y4),this.point(this._x5,this._y5)}},point:function(t,n){if(t=+t,n=+n,this._point){var e=this._x2-t,r=this._y2-n;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(e*e+r*r,this._alpha))}switch(this._point){case 0:this._point=1,this._x3=t,this._y3=n;break;case 1:this._point=2,this._context.moveTo(this._x4=t,this._y4=n);break;case 2:this._point=3,this._x5=t,this._y5=n;break;default:O_(this,t,n)}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var I_=function t(n){function e(t){return n?new Y_(t,n):new D_(t,0)}return e.alpha=function(n){return t(+n)},e}(.5);function H_(t,n){this._context=t,this._alpha=n}H_.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){if(t=+t,n=+n,this._point){var e=this._x2-t,r=this._y2-n;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(e*e+r*r,this._alpha))}switch(this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3,this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:O_(this,t,n)}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var j_=function t(n){function e(t){return n?new H_(t,n):new L_(t,0)}return e.alpha=function(n){return t(+n)},e}(.5);function X_(t){this._context=t}function V_(t){return t<0?-1:1}function G_(t,n,e){var r=t._x1-t._x0,i=n-t._x1,o=(t._y1-t._y0)/(r||i<0&&-0),a=(e-t._y1)/(i||r<0&&-0),u=(o*i+a*r)/(r+i);return(V_(o)+V_(a))*Math.min(Math.abs(o),Math.abs(a),.5*Math.abs(u))||0}function $_(t,n){var e=t._x1-t._x0;return e?(3*(t._y1-t._y0)/e-n)/2:n}function W_(t,n,e){var r=t._x0,i=t._y0,o=t._x1,a=t._y1,u=(o-r)/3;t._context.bezierCurveTo(r+u,i+u*n,o-u,a-u*e,o,a)}function Z_(t){this._context=t}function Q_(t){this._context=new K_(t)}function K_(t){this._context=t}function J_(t){this._context=t}function tb(t){var n,e,r=t.length-1,i=new Array(r),o=new Array(r),a=new Array(r);for(i[0]=0,o[0]=2,a[0]=t[0]+2*t[1],n=1;n=0;--n)i[n]=(a[n]-i[n+1])/o[n];for(o[r-1]=(t[r]+i[r-1])/2,n=0;n1)for(var e,r,i,o=1,a=t[n[0]],u=a.length;o=0;)e[n]=n;return e}function ib(t,n){return t[n]}function ob(t){var n=t.map(ab);return rb(t).sort(function(t,e){return n[t]-n[e]})}function ab(t){for(var n,e=-1,r=0,i=t.length,o=-1/0;++eo&&(o=n,r=e);return r}function ub(t){var n=t.map(cb);return rb(t).sort(function(t,e){return n[t]-n[e]})}function cb(t){for(var n,e=0,r=-1,i=t.length;++r0)){if(o/=h,h<0){if(o0){if(o>l)return;o>s&&(s=o)}if(o=r-c,h||!(o<0)){if(o/=h,h<0){if(o>l)return;o>s&&(s=o)}else if(h>0){if(o0)){if(o/=d,d<0){if(o0){if(o>l)return;o>s&&(s=o)}if(o=i-f,d||!(o<0)){if(o/=d,d<0){if(o>l)return;o>s&&(s=o)}else if(d>0){if(o0||l<1)||(s>0&&(t[0]=[c+s*h,f+s*d]),l<1&&(t[1]=[c+l*h,f+l*d]),!0)}}}}}function xb(t,n,e,r,i){var o=t[1];if(o)return!0;var a,u,c=t[0],f=t.left,s=t.right,l=f[0],h=f[1],d=s[0],p=s[1],v=(l+d)/2,g=(h+p)/2;if(p===h){if(v=r)return;if(l>d){if(c){if(c[1]>=i)return}else c=[v,e];o=[v,i]}else{if(c){if(c[1]1)if(l>d){if(c){if(c[1]>=i)return}else c=[(e-u)/a,e];o=[(i-u)/a,i]}else{if(c){if(c[1]=r)return}else c=[n,a*n+u];o=[r,a*r+u]}else{if(c){if(c[0]=0&&(this._t=1-this._t,this._line=1-this._line)},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;default:if(this._t<=0)this._context.lineTo(this._x,n),this._context.lineTo(t,n);else{var e=this._x*(1-this._t)+t*this._t;this._context.lineTo(e,this._y),this._context.lineTo(e,n)}}this._x=t,this._y=n}},hb.prototype={constructor:hb,insert:function(t,n){var e,r,i;if(t){if(n.P=t,n.N=t.N,t.N&&(t.N.P=n),t.N=n,t.R){for(t=t.R;t.L;)t=t.L;t.L=n}else t.R=n;e=t}else this._?(t=gb(this._),n.P=null,n.N=t,t.P=t.L=n,e=t):(n.P=n.N=null,this._=n,e=null);for(n.L=n.R=null,n.U=e,n.C=!0,t=n;e&&e.C;)e===(r=e.U).L?(i=r.R)&&i.C?(e.C=i.C=!1,r.C=!0,t=r):(t===e.R&&(pb(this,e),e=(t=e).U),e.C=!1,r.C=!0,vb(this,r)):(i=r.L)&&i.C?(e.C=i.C=!1,r.C=!0,t=r):(t===e.L&&(vb(this,e),e=(t=e).U),e.C=!1,r.C=!0,pb(this,r)),e=t.U;this._.C=!1},remove:function(t){t.N&&(t.N.P=t.P),t.P&&(t.P.N=t.N),t.N=t.P=null;var n,e,r,i=t.U,o=t.L,a=t.R;if(e=o?a?gb(a):o:a,i?i.L===t?i.L=e:i.R=e:this._=e,o&&a?(r=e.C,e.C=t.C,e.L=o,o.U=e,e!==a?(i=e.U,e.U=t.U,t=e.R,i.L=t,e.R=a,a.U=e):(e.U=i,i=e,t=e.R)):(r=t.C,t=e),t&&(t.U=i),!r)if(t&&t.C)t.C=!1;else{do{if(t===this._)break;if(t===i.L){if((n=i.R).C&&(n.C=!1,i.C=!0,pb(this,i),n=i.R),n.L&&n.L.C||n.R&&n.R.C){n.R&&n.R.C||(n.L.C=!1,n.C=!0,vb(this,n),n=i.R),n.C=i.C,i.C=n.R.C=!1,pb(this,i),t=this._;break}}else if((n=i.L).C&&(n.C=!1,i.C=!0,vb(this,i),n=i.L),n.L&&n.L.C||n.R&&n.R.C){n.L&&n.L.C||(n.R.C=!1,n.C=!0,pb(this,n),n=i.L),n.C=i.C,i.C=n.L.C=!1,vb(this,i),t=this._;break}n.C=!0,t=i,i=i.U}while(!t.C);t&&(t.C=!1)}}};var Tb,Ab=[];function Sb(){db(this),this.x=this.y=this.arc=this.site=this.cy=null}function kb(t){var n=t.P,e=t.N;if(n&&e){var r=n.site,i=t.site,o=e.site;if(r!==o){var a=i[0],u=i[1],c=r[0]-a,f=r[1]-u,s=o[0]-a,l=o[1]-u,h=2*(c*l-f*s);if(!(h>=-Hb)){var d=c*c+f*f,p=s*s+l*l,v=(l*d-f*p)/h,g=(c*p-s*d)/h,y=Ab.pop()||new Sb;y.arc=t,y.site=i,y.x=v+a,y.y=(y.cy=g+u)+Math.sqrt(v*v+g*g),t.circle=y;for(var _=null,b=Fb._;b;)if(y.yIb)u=u.L;else{if(!((i=o-Ub(u,a))>Ib)){r>-Ib?(n=u.P,e=u):i>-Ib?(n=u,e=u.N):n=e=u;break}if(!u.R){n=u;break}u=u.R}!function(t){Bb[t.index]={site:t,halfedges:[]}}(t);var c=zb(t);if(Ob.insert(n,c),n||e){if(n===e)return Eb(n),e=zb(n.site),Ob.insert(c,e),c.edge=e.edge=yb(n.site,c.site),kb(n),void kb(e);if(e){Eb(n),Eb(e);var f=n.site,s=f[0],l=f[1],h=t[0]-s,d=t[1]-l,p=e.site,v=p[0]-s,g=p[1]-l,y=2*(h*g-d*v),_=h*h+d*d,b=v*v+g*g,m=[(g*_-d*b)/y+s,(h*b-v*_)/y+l];bb(e.edge,f,p,m),c.edge=yb(f,t,null,m),e.edge=yb(t,p,null,m),kb(n),kb(e)}else c.edge=yb(n.site,c.site)}}function Lb(t,n){var e=t.site,r=e[0],i=e[1],o=i-n;if(!o)return r;var a=t.P;if(!a)return-1/0;var u=(e=a.site)[0],c=e[1],f=c-n;if(!f)return u;var s=u-r,l=1/o-1/f,h=s/f;return l?(-h+Math.sqrt(h*h-2*l*(s*s/(-2*f)-c+f/2+i-o/2)))/l+r:(r+u)/2}function Ub(t,n){var e=t.N;if(e)return Lb(e,n);var r=t.site;return r[1]===n?r[0]:1/0}var Ob,Bb,Fb,Yb,Ib=1e-6,Hb=1e-12;function jb(t,n,e){return(t[0]-e[0])*(n[1]-t[1])-(t[0]-n[0])*(e[1]-t[1])}function Xb(t,n){return n[1]-t[1]||n[0]-t[0]}function Vb(t,n){var e,r,i,o=t.sort(Xb).pop();for(Yb=[],Bb=new Array(t.length),Ob=new hb,Fb=new hb;;)if(i=Tb,o&&(!i||o[1]Ib||Math.abs(i[0][1]-i[1][1])>Ib)||delete Yb[o]}(a,u,c,f),function(t,n,e,r){var i,o,a,u,c,f,s,l,h,d,p,v,g=Bb.length,y=!0;for(i=0;iIb||Math.abs(v-h)>Ib)&&(c.splice(u,0,Yb.push(_b(a,d,Math.abs(p-t)Ib?[t,Math.abs(l-t)Ib?[Math.abs(h-r)Ib?[e,Math.abs(l-e)Ib?[Math.abs(h-n)=u)return null;var c=t-i.site[0],f=n-i.site[1],s=c*c+f*f;do{i=o.cells[r=a],a=null,i.halfedges.forEach(function(e){var r=o.edges[e],u=r.left;if(u!==i.site&&u||(u=r.right)){var c=t-u[0],f=n-u[1],l=c*c+f*f;lr?(r+i)/2:Math.min(0,r)||Math.max(0,i),a>o?(o+a)/2:Math.min(0,o)||Math.max(0,a))}Qb.prototype=Wb.prototype,t.FormatSpecifier=Ba,t.active=function(t,n){var e,r,i=t.__transition;if(i)for(r in n=null==n?null:n+"",i)if((e=i[r]).state>xr&&e.name===n)return new Ur([[t]],yi,n,+r);return null},t.arc=function(){var t=Ry,n=Dy,e=my(0),r=null,i=qy,o=Ly,a=Uy,u=null;function c(){var c,f,s=+t.apply(this,arguments),l=+n.apply(this,arguments),h=i.apply(this,arguments)-Cy,d=o.apply(this,arguments)-Cy,p=xy(d-h),v=d>h;if(u||(u=c=no()),lky)if(p>Py-ky)u.moveTo(l*My(h),l*Ay(h)),u.arc(0,0,l,h,d,!v),s>ky&&(u.moveTo(s*My(d),s*Ay(d)),u.arc(0,0,s,d,h,v));else{var g,y,_=h,b=d,m=h,x=d,w=p,M=p,N=a.apply(this,arguments)/2,T=N>ky&&(r?+r.apply(this,arguments):Sy(s*s+l*l)),A=Ty(xy(l-s)/2,+e.apply(this,arguments)),S=A,k=A;if(T>ky){var E=zy(T/s*Ay(N)),C=zy(T/l*Ay(N));(w-=2*E)>ky?(m+=E*=v?1:-1,x-=E):(w=0,m=x=(h+d)/2),(M-=2*C)>ky?(_+=C*=v?1:-1,b-=C):(M=0,_=b=(h+d)/2)}var P=l*My(_),z=l*Ay(_),R=s*My(x),D=s*Ay(x);if(A>ky){var q,L=l*My(b),U=l*Ay(b),O=s*My(m),B=s*Ay(m);if(p1?0:t<-1?Ey:Math.acos(t)}((F*I+Y*H)/(Sy(F*F+Y*Y)*Sy(I*I+H*H)))/2),X=Sy(q[0]*q[0]+q[1]*q[1]);S=Ty(A,(s-X)/(j-1)),k=Ty(A,(l-X)/(j+1))}}M>ky?k>ky?(g=Oy(O,B,P,z,l,k,v),y=Oy(L,U,R,D,l,k,v),u.moveTo(g.cx+g.x01,g.cy+g.y01),kky&&w>ky?S>ky?(g=Oy(R,D,L,U,s,-S,v),y=Oy(P,z,O,B,s,-S,v),u.lineTo(g.cx+g.x01,g.cy+g.y01),S>a,f=i+2*u>>a,s=bo(20);function l(r){var i=new Float32Array(c*f),l=new Float32Array(c*f);r.forEach(function(r,o,s){var l=+t(r,o,s)+u>>a,h=+n(r,o,s)+u>>a,d=+e(r,o,s);l>=0&&l=0&&h>a),So({width:c,height:f,data:l},{width:c,height:f,data:i},o>>a),Ao({width:c,height:f,data:i},{width:c,height:f,data:l},o>>a),So({width:c,height:f,data:l},{width:c,height:f,data:i},o>>a),Ao({width:c,height:f,data:i},{width:c,height:f,data:l},o>>a),So({width:c,height:f,data:l},{width:c,height:f,data:i},o>>a);var d=s(i);if(!Array.isArray(d)){var p=T(i);d=w(0,p,d),(d=g(0,Math.floor(p/d)*d,d)).shift()}return To().thresholds(d).size([c,f])(i).map(h)}function h(t){return t.value*=Math.pow(2,-2*a),t.coordinates.forEach(d),t}function d(t){t.forEach(p)}function p(t){t.forEach(v)}function v(t){t[0]=t[0]*Math.pow(2,a)-u,t[1]=t[1]*Math.pow(2,a)-u}function y(){return c=r+2*(u=3*o)>>a,f=i+2*u>>a,l}return l.x=function(n){return arguments.length?(t="function"==typeof n?n:bo(+n),l):t},l.y=function(t){return arguments.length?(n="function"==typeof t?t:bo(+t),l):n},l.weight=function(t){return arguments.length?(e="function"==typeof t?t:bo(+t),l):e},l.size=function(t){if(!arguments.length)return[r,i];var n=Math.ceil(t[0]),e=Math.ceil(t[1]);if(!(n>=0||n>=0))throw new Error("invalid size");return r=n,i=e,y()},l.cellSize=function(t){if(!arguments.length)return 1<=1))throw new Error("invalid cell size");return a=Math.floor(Math.log(t)/Math.LN2),y()},l.thresholds=function(t){return arguments.length?(s="function"==typeof t?t:Array.isArray(t)?bo(yo.call(t)):bo(t),l):s},l.bandwidth=function(t){if(!arguments.length)return Math.sqrt(o*(o+1));if(!((t=+t)>=0))throw new Error("invalid bandwidth");return o=Math.round((Math.sqrt(4*t*t+1)-1)/2),y()},l},t.contours=To,t.create=function(t){return Rt(Z(t).call(document.documentElement))},t.creator=Z,t.cross=function(t,n,e){var r,i,o,u,c=t.length,f=n.length,s=new Array(c*f);for(null==e&&(e=a),r=o=0;rt?1:n>=t?0:NaN},t.deviation=f,t.dispatch=I,t.drag=function(){var n,e,r,i,o=Gt,a=$t,u=Wt,c=Zt,f={},s=I("start","drag","end"),l=0,h=0;function d(t){t.on("mousedown.drag",p).filter(c).on("touchstart.drag",y).on("touchmove.drag",_).on("touchend.drag touchcancel.drag",b).style("touch-action","none").style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}function p(){if(!i&&o.apply(this,arguments)){var u=m("mouse",a.apply(this,arguments),Bt,this,arguments);u&&(Rt(t.event.view).on("mousemove.drag",v,!0).on("mouseup.drag",g,!0),Ht(t.event.view),Yt(),r=!1,n=t.event.clientX,e=t.event.clientY,u("start"))}}function v(){if(It(),!r){var i=t.event.clientX-n,o=t.event.clientY-e;r=i*i+o*o>h}f.mouse("drag")}function g(){Rt(t.event.view).on("mousemove.drag mouseup.drag",null),jt(t.event.view,r),It(),f.mouse("end")}function y(){if(o.apply(this,arguments)){var n,e,r=t.event.changedTouches,i=a.apply(this,arguments),u=r.length;for(n=0;nc+d||if+d||ou.index){var p=c-a.x-a.vx,v=f-a.y-a.vy,g=p*p+v*v;gt.r&&(t.r=t[n].r)}function u(){if(n){var r,i,o=n.length;for(e=new Array(o),r=0;r=a)){(t.data!==n||t.next)&&(0===s&&(d+=(s=ya())*s),0===l&&(d+=(l=ya())*l),d1?(null==e?u.remove(t):u.set(t,d(e)),n):u.get(t)},find:function(n,e,r){var i,o,a,u,c,f=0,s=t.length;for(null==r?r=1/0:r*=r,f=0;f1?(f.on(t,e),n):f.on(t)}}},t.forceX=function(t){var n,e,r,i=ga(.1);function o(t){for(var i,o=0,a=n.length;o=.12&&i<.234&&r>=-.425&&r<-.214?u:i>=.166&&i<.234&&r>=-.214&&r<-.115?c:a).invert(t)},s.stream=function(e){return t&&n===e?t:(r=[a.stream(n=e),u.stream(e),c.stream(e)],i=r.length,t={point:function(t,n){for(var e=-1;++ePc(r[0],r[1])&&(r[1]=i[1]),Pc(i[0],r[1])>Pc(r[0],r[1])&&(r[0]=i[0])):o.push(r=i);for(a=-1/0,n=0,r=o[e=o.length-1];n<=e;r=i,++n)i=o[n],(u=Pc(r[1],i[0]))>a&&(a=u,Zu=i[0],Ku=r[1])}return ic=oc=null,Zu===1/0||Qu===1/0?[[NaN,NaN],[NaN,NaN]]:[[Zu,Qu],[Ku,Ju]]},t.geoCentroid=function(t){ac=uc=cc=fc=sc=lc=hc=dc=pc=vc=gc=0,Cu(t,Dc);var n=pc,e=vc,r=gc,i=n*n+e*e+r*r;return i2?t[2]+90:90]):[(t=e())[0],t[1],t[2]-90]},e([0,0,90]).scale(159.155)},t.geoTransverseMercatorRaw=Ml,t.gray=function(t,n){return new Bn(t,0,0,null==n?1:n)},t.hcl=Xn,t.hierarchy=kl,t.histogram=function(){var t=v,n=s,e=M;function r(r){var o,a,u=r.length,c=new Array(u);for(o=0;ol;)h.pop(),--d;var p,v=new Array(d+1);for(o=0;o<=d;++o)(p=v[o]=[]).x0=o>0?h[o-1]:s,p.x1=o1)&&(t-=Math.floor(t));var n=Math.abs(t-.5);return ly.h=360*t-100,ly.s=1.5-1.5*n,ly.l=.8-.9*n,ly+""},t.interpolateRdBu=yg,t.interpolateRdGy=bg,t.interpolateRdPu=Yg,t.interpolateRdYlBu=xg,t.interpolateRdYlGn=Mg,t.interpolateReds=oy,t.interpolateRgb=he,t.interpolateRgbBasis=pe,t.interpolateRgbBasisClosed=ve,t.interpolateRound=Ae,t.interpolateSinebow=function(t){var n;return t=(.5-t)*Math.PI,hy.r=255*(n=Math.sin(t))*n,hy.g=255*(n=Math.sin(t+dy))*n,hy.b=255*(n=Math.sin(t+py))*n,hy+""},t.interpolateSpectral=Tg,t.interpolateString=Ne,t.interpolateTransformCss=qe,t.interpolateTransformSvg=Le,t.interpolateTurbo=function(t){return t=Math.max(0,Math.min(1,t)),"rgb("+Math.max(0,Math.min(255,Math.round(34.61+t*(1172.33-t*(10793.56-t*(33300.12-t*(38394.49-14825.05*t)))))))+", "+Math.max(0,Math.min(255,Math.round(23.31+t*(557.33+t*(1225.33-t*(3574.96-t*(1073.77+707.56*t)))))))+", "+Math.max(0,Math.min(255,Math.round(27.2+t*(3211.1-t*(15327.97-t*(27814-t*(22569.18-6838.66*t)))))))+")"},t.interpolateViridis=gy,t.interpolateWarm=fy,t.interpolateYlGn=Xg,t.interpolateYlGnBu=Hg,t.interpolateYlOrBr=Gg,t.interpolateYlOrRd=Wg,t.interpolateZoom=Ie,t.interrupt=Pr,t.interval=function(t,n,e){var r=new lr,i=n;return null==n?(r.restart(t,n,e),r):(n=+n,e=null==e?fr():+e,r.restart(function o(a){a+=i,r.restart(o,i+=n,e),t(a)},n,e),r)},t.isoFormat=Rv,t.isoParse=Dv,t.json=function(t,n){return fetch(t,n).then(la)},t.keys=function(t){var n=[];for(var e in t)n.push(e);return n},t.lab=On,t.lch=function(t,n,e,r){return 1===arguments.length?jn(t):new Vn(e,n,t,null==r?1:r)},t.line=Hy,t.lineRadial=Qy,t.linkHorizontal=function(){return r_(i_)},t.linkRadial=function(){var t=r_(a_);return t.angle=t.x,delete t.x,t.radius=t.y,delete t.y,t},t.linkVertical=function(){return r_(o_)},t.local=qt,t.map=co,t.matcher=nt,t.max=T,t.mean=function(t,n){var e,r=t.length,i=r,o=-1,a=0;if(null==n)for(;++o=r.length)return null!=t&&e.sort(t),null!=n?n(e):e;for(var c,f,s,l=-1,h=e.length,d=r[i++],p=co(),v=a();++lr.length)return e;var a,u=i[o-1];return null!=n&&o>=r.length?a=e.entries():(a=[],e.each(function(n,e){a.push({key:e,values:t(n,o)})})),null!=u?a.sort(function(t,n){return u(t.key,n.key)}):a}(o(t,0,lo,ho),0)},key:function(t){return r.push(t),e},sortKeys:function(t){return i[r.length-1]=t,e},sortValues:function(n){return t=n,e},rollup:function(t){return n=t,e}}},t.now=fr,t.pack=function(){var t=null,n=1,e=1,r=Wl;function i(i){return i.x=n/2,i.y=e/2,t?i.eachBefore(Kl(t)).eachAfter(Jl(r,.5)).eachBefore(th(1)):i.eachBefore(Kl(Ql)).eachAfter(Jl(Wl,1)).eachAfter(Jl(r,i.r/Math.min(n,e))).eachBefore(th(Math.min(n,e)/(2*i.r))),i}return i.radius=function(n){return arguments.length?(t=Gl(n),i):t},i.size=function(t){return arguments.length?(n=+t[0],e=+t[1],i):[n,e]},i.padding=function(t){return arguments.length?(r="function"==typeof t?t:Zl(+t),i):r},i},t.packEnclose=Dl,t.packSiblings=function(t){return Vl(t),t},t.pairs=function(t,n){null==n&&(n=a);for(var e=0,r=t.length-1,i=t[0],o=new Array(r<0?0:r);e0&&(d+=l);for(null!=n?p.sort(function(t,e){return n(v[t],v[e])}):null!=e&&p.sort(function(t,n){return e(a[t],a[n])}),u=0,f=d?(y-h*b)/d:0;u0?l*f:0)+b,v[c]={data:a[c],index:u,value:l,startAngle:g,endAngle:s,padAngle:_};return v}return a.value=function(n){return arguments.length?(t="function"==typeof n?n:my(+n),a):t},a.sortValues=function(t){return arguments.length?(n=t,e=null,a):n},a.sort=function(t){return arguments.length?(e=t,n=null,a):e},a.startAngle=function(t){return arguments.length?(r="function"==typeof t?t:my(+t),a):r},a.endAngle=function(t){return arguments.length?(i="function"==typeof t?t:my(+t),a):i},a.padAngle=function(t){return arguments.length?(o="function"==typeof t?t:my(+t),a):o},a},t.piecewise=function(t,n){for(var e=0,r=n.length-1,i=n[0],o=new Array(r<0?0:r);eu!=f>u&&a<(c-e)*(u-r)/(f-r)+e&&(s=!s),c=e,f=r;return s},t.polygonHull=function(t){if((e=t.length)<3)return null;var n,e,r=new Array(e),i=new Array(e);for(n=0;n=0;--n)f.push(t[r[o[n]][2]]);for(n=+u;n0?a[n-1]:r[0],n=o?[a[o-1],r]:[a[n-1],a[n]]},c.unknown=function(t){return arguments.length?(n=t,c):c},c.thresholds=function(){return a.slice()},c.copy=function(){return t().domain([e,r]).range(u).unknown(n)},Eh.apply($h(c),arguments)},t.scaleSequential=function t(){var n=$h(Xv()(Bh));return n.copy=function(){return Vv(n,t())},Ch.apply(n,arguments)},t.scaleSequentialLog=function t(){var n=ed(Xv()).domain([1,10]);return n.copy=function(){return Vv(n,t()).base(n.base())},Ch.apply(n,arguments)},t.scaleSequentialPow=Gv,t.scaleSequentialQuantile=function t(){var e=[],r=Bh;function o(t){if(!isNaN(t=+t))return r((i(e,t)-1)/(e.length-1))}return o.domain=function(t){if(!arguments.length)return e.slice();e=[];for(var r,i=0,a=t.length;i0)for(var e,r,i,o,a,u,c=0,f=t[n[0]].length;c0?(r[0]=o,r[1]=o+=i):i<0?(r[1]=a,r[0]=a+=i):(r[0]=0,r[1]=i)},t.stackOffsetExpand=function(t,n){if((r=t.length)>0){for(var e,r,i,o=0,a=t[0].length;o0){for(var e,r=0,i=t[n[0]],o=i.length;r0&&(r=(e=t[n[0]]).length)>0){for(var e,r,i,o=0,a=1;a0)throw new Error("cycle");return o}return e.id=function(n){return arguments.length?(t=$l(n),e):t},e.parentId=function(t){return arguments.length?(n=$l(t),e):n},e},t.style=ft,t.sum=function(t,n){var e,r=t.length,i=-1,o=0;if(null==n)for(;++i=0;--i)u.push(e=n.children[i]=new dh(r[i],i)),e.parent=n;return(a.parent=new dh(null,0)).children=[a],a}(i);if(c.eachAfter(o),c.parent.m=-c.z,c.eachBefore(a),r)i.eachBefore(u);else{var f=i,s=i,l=i;i.eachBefore(function(t){t.xs.x&&(s=t),t.depth>l.depth&&(l=t)});var h=f===s?1:t(f,s)/2,d=h-f.x,p=n/(s.x+h+d),v=e/(l.depth||1);i.eachBefore(function(t){t.x=(t.x+d)*p,t.y=t.depth*v})}return i}function o(n){var e=n.children,r=n.parent.children,i=n.i?r[n.i-1]:null;if(e){!function(t){for(var n,e=0,r=0,i=t.children,o=i.length;--o>=0;)(n=i[o]).z+=e,n.m+=e,e+=n.s+(r+=n.c)}(n);var o=(e[0].z+e[e.length-1].z)/2;i?(n.z=i.z+t(n._,i._),n.m=n.z-o):n.z=o}else i&&(n.z=i.z+t(n._,i._));n.parent.A=function(n,e,r){if(e){for(var i,o=n,a=n,u=e,c=o.parent.children[0],f=o.m,s=a.m,l=u.m,h=c.m;u=sh(u),o=fh(o),u&&o;)c=fh(c),(a=sh(a)).a=n,(i=u.z+l-o.z-f+t(u._,o._))>0&&(lh(hh(u,n,r),n,i),f+=i,s+=i),l+=u.m,f+=o.m,h+=c.m,s+=a.m;u&&!sh(a)&&(a.t=u,a.m+=l-s),o&&!fh(c)&&(c.t=o,c.m+=f-h,r=n)}return r}(n,i,n.parent.A||r[0])}function a(t){t._.x=t.z+t.parent.m,t.m+=t.parent.m}function u(t){t.x*=n,t.y=t.depth*e}return i.separation=function(n){return arguments.length?(t=n,i):t},i.size=function(t){return arguments.length?(r=!1,n=+t[0],e=+t[1],i):r?null:[n,e]},i.nodeSize=function(t){return arguments.length?(r=!0,n=+t[0],e=+t[1],i):r?[n,e]:null},i},t.treemap=function(){var t=yh,n=!1,e=1,r=1,i=[0],o=Wl,a=Wl,u=Wl,c=Wl,f=Wl;function s(t){return t.x0=t.y0=0,t.x1=e,t.y1=r,t.eachBefore(l),i=[0],n&&t.eachBefore(nh),t}function l(n){var e=i[n.depth],r=n.x0+e,s=n.y0+e,l=n.x1-e,h=n.y1-e;l=e-1){var s=u[n];return s.x0=i,s.y0=o,s.x1=a,void(s.y1=c)}for(var l=f[n],h=r/2+l,d=n+1,p=e-1;d>>1;f[v]c-o){var _=(i*y+a*g)/r;t(n,d,g,i,o,_,c),t(d,e,y,_,o,a,c)}else{var b=(o*y+c*g)/r;t(n,d,g,i,o,a,b),t(d,e,y,i,b,a,c)}}(0,c,t.value,n,e,r,i)},t.treemapDice=eh,t.treemapResquarify=_h,t.treemapSlice=ph,t.treemapSliceDice=function(t,n,e,r,i){(1&t.depth?ph:eh)(t,n,e,r,i)},t.treemapSquarify=yh,t.tsv=sa,t.tsvFormat=Ko,t.tsvFormatBody=Jo,t.tsvFormatRow=na,t.tsvFormatRows=ta,t.tsvFormatValue=ea,t.tsvParse=Zo,t.tsvParseRows=Qo,t.utcDay=Wd,t.utcDays=Zd,t.utcFriday=rp,t.utcFridays=sp,t.utcHour=Gd,t.utcHours=$d,t.utcMillisecond=pd,t.utcMilliseconds=vd,t.utcMinute=Xd,t.utcMinutes=Vd,t.utcMonday=Jd,t.utcMondays=ap,t.utcMonth=hp,t.utcMonths=dp,t.utcSaturday=ip,t.utcSaturdays=lp,t.utcSecond=_d,t.utcSeconds=bd,t.utcSunday=Kd,t.utcSundays=op,t.utcThursday=ep,t.utcThursdays=fp,t.utcTuesday=tp,t.utcTuesdays=up,t.utcWednesday=np,t.utcWednesdays=cp,t.utcWeek=Kd,t.utcWeeks=op,t.utcYear=pp,t.utcYears=vp,t.values=function(t){var n=[];for(var e in t)n.push(t[e]);return n},t.variance=c,t.version="5.16.0",t.voronoi=function(){var t=sb,n=lb,e=null;function r(r){return new Vb(r.map(function(e,i){var o=[Math.round(t(e,i,r)/Ib)*Ib,Math.round(n(e,i,r)/Ib)*Ib];return o.index=i,o.data=e,o}),e)}return r.polygons=function(t){return r(t).polygons()},r.links=function(t){return r(t).links()},r.triangles=function(t){return r(t).triangles()},r.x=function(n){return arguments.length?(t="function"==typeof n?n:fb(+n),r):t},r.y=function(t){return arguments.length?(n="function"==typeof t?t:fb(+t),r):n},r.extent=function(t){return arguments.length?(e=null==t?null:[[+t[0][0],+t[0][1]],[+t[1][0],+t[1][1]]],r):e&&[[e[0][0],e[0][1]],[e[1][0],e[1][1]]]},r.size=function(t){return arguments.length?(e=null==t?null:[[0,0],[+t[0],+t[1]]],r):e&&[e[1][0]-e[0][0],e[1][1]-e[0][1]]},r},t.window=ct,t.xml=da,t.zip=function(){return k(arguments)},t.zoom=function(){var n,e,r=tm,i=nm,o=om,a=rm,u=im,c=[0,1/0],f=[[-1/0,-1/0],[1/0,1/0]],s=250,l=Ie,h=I("start","zoom","end"),d=500,p=150,v=0;function g(t){t.property("__zoom",em).on("wheel.zoom",M).on("mousedown.zoom",N).on("dblclick.zoom",T).filter(u).on("touchstart.zoom",A).on("touchmove.zoom",S).on("touchend.zoom touchcancel.zoom",k).style("touch-action","none").style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}function y(t,n){return(n=Math.max(c[0],Math.min(c[1],n)))===t.k?t:new Wb(n,t.x,t.y)}function _(t,n,e){var r=n[0]-e[0]*t.k,i=n[1]-e[1]*t.k;return r===t.x&&i===t.y?t:new Wb(t.k,r,i)}function b(t){return[(+t[0][0]+ +t[1][0])/2,(+t[0][1]+ +t[1][1])/2]}function m(t,n,e){t.on("start.zoom",function(){x(this,arguments).start()}).on("interrupt.zoom end.zoom",function(){x(this,arguments).end()}).tween("zoom",function(){var t=this,r=arguments,o=x(t,r),a=i.apply(t,r),u=null==e?b(a):"function"==typeof e?e.apply(t,r):e,c=Math.max(a[1][0]-a[0][0],a[1][1]-a[0][1]),f=t.__zoom,s="function"==typeof n?n.apply(t,r):n,h=l(f.invert(u).concat(c/f.k),s.invert(u).concat(c/s.k));return function(t){if(1===t)t=s;else{var n=h(t),e=c/n[2];t=new Wb(e,u[0]-n[0]*e,u[1]-n[1]*e)}o.zoom(null,t)}})}function x(t,n,e){return!e&&t.__zooming||new w(t,n)}function w(t,n){this.that=t,this.args=n,this.active=0,this.extent=i.apply(t,n),this.taps=0}function M(){if(r.apply(this,arguments)){var t=x(this,arguments),n=this.__zoom,e=Math.max(c[0],Math.min(c[1],n.k*Math.pow(2,a.apply(this,arguments)))),i=Bt(this);if(t.wheel)t.mouse[0][0]===i[0]&&t.mouse[0][1]===i[1]||(t.mouse[1]=n.invert(t.mouse[0]=i)),clearTimeout(t.wheel);else{if(n.k===e)return;t.mouse=[i,n.invert(i)],Pr(this),t.start()}Jb(),t.wheel=setTimeout(function(){t.wheel=null,t.end()},p),t.zoom("mouse",o(_(y(n,e),t.mouse[0],t.mouse[1]),t.extent,f))}}function N(){if(!e&&r.apply(this,arguments)){var n=x(this,arguments,!0),i=Rt(t.event.view).on("mousemove.zoom",function(){if(Jb(),!n.moved){var e=t.event.clientX-u,r=t.event.clientY-c;n.moved=e*e+r*r>v}n.zoom("mouse",o(_(n.that.__zoom,n.mouse[0]=Bt(n.that),n.mouse[1]),n.extent,f))},!0).on("mouseup.zoom",function(){i.on("mousemove.zoom mouseup.zoom",null),jt(t.event.view,n.moved),Jb(),n.end()},!0),a=Bt(this),u=t.event.clientX,c=t.event.clientY;Ht(t.event.view),Kb(),n.mouse=[a,this.__zoom.invert(a)],Pr(this),n.start()}}function T(){if(r.apply(this,arguments)){var n=this.__zoom,e=Bt(this),a=n.invert(e),u=n.k*(t.event.shiftKey?.5:2),c=o(_(y(n,u),e,a),i.apply(this,arguments),f);Jb(),s>0?Rt(this).transition().duration(s).call(m,c,e):Rt(this).call(g.transform,c)}}function A(){if(r.apply(this,arguments)){var e,i,o,a,u=t.event.touches,c=u.length,f=x(this,arguments,t.event.changedTouches.length===c);for(Kb(),i=0;in?1:t>=n?0:NaN}function e(t,n){return null==t||null==n?NaN:nt?1:n>=t?0:NaN}function r(t){let r,o,a;function u(t,n,e=0,i=t.length){if(e>>1;o(t[r],n)<0?e=r+1:i=r}while(en(t(e),r),a=(n,e)=>t(n)-e):(r=t===n||t===e?t:i,o=t,a=t),{left:u,center:function(t,n,e=0,r=t.length){const i=u(t,n,e,r-1);return i>e&&a(t[i-1],n)>-a(t[i],n)?i-1:i},right:function(t,n,e=0,i=t.length){if(e>>1;o(t[r],n)<=0?e=r+1:i=r}while(e{n(t,e,(r<<=2)+0,(i<<=2)+0,o<<=2),n(t,e,r+1,i+1,o),n(t,e,r+2,i+2,o),n(t,e,r+3,i+3,o)}}));function d(t){return function(n,e,r=e){if(!((e=+e)>=0))throw new RangeError("invalid rx");if(!((r=+r)>=0))throw new RangeError("invalid ry");let{data:i,width:o,height:a}=n;if(!((o=Math.floor(o))>=0))throw new RangeError("invalid width");if(!((a=Math.floor(void 0!==a?a:i.length/o))>=0))throw new RangeError("invalid height");if(!o||!a||!e&&!r)return n;const u=e&&t(e),c=r&&t(r),f=i.slice();return u&&c?(p(u,f,i,o,a),p(u,i,f,o,a),p(u,f,i,o,a),g(c,i,f,o,a),g(c,f,i,o,a),g(c,i,f,o,a)):u?(p(u,i,f,o,a),p(u,f,i,o,a),p(u,i,f,o,a)):c&&(g(c,i,f,o,a),g(c,f,i,o,a),g(c,i,f,o,a)),n}}function p(t,n,e,r,i){for(let o=0,a=r*i;o{if(!((o-=a)>=i))return;let u=t*r[i];const c=a*t;for(let t=i,n=i+c;t{if(!((a-=u)>=o))return;let c=n*i[o];const f=u*n,s=f+u;for(let t=o,n=o+f;t=n&&++e;else{let r=-1;for(let i of t)null!=(i=n(i,++r,t))&&(i=+i)>=i&&++e}return e}function _(t){return 0|t.length}function b(t){return!(t>0)}function m(t){return"object"!=typeof t||"length"in t?t:Array.from(t)}function x(t,n){let e,r=0,i=0,o=0;if(void 0===n)for(let n of t)null!=n&&(n=+n)>=n&&(e=n-i,i+=e/++r,o+=e*(n-i));else{let a=-1;for(let u of t)null!=(u=n(u,++a,t))&&(u=+u)>=u&&(e=u-i,i+=e/++r,o+=e*(u-i))}if(r>1)return o/(r-1)}function w(t,n){const e=x(t,n);return e?Math.sqrt(e):e}function M(t,n){let e,r;if(void 0===n)for(const n of t)null!=n&&(void 0===e?n>=n&&(e=r=n):(e>n&&(e=n),r=o&&(e=r=o):(e>o&&(e=o),r0){for(o=t[--i];i>0&&(n=o,e=t[--i],o=n+e,r=e-(o-n),!r););i>0&&(r<0&&t[i-1]<0||r>0&&t[i-1]>0)&&(e=2*r,n=o+e,e==n-o&&(o=n))}return o}}class InternMap extends Map{constructor(t,n=N){if(super(),Object.defineProperties(this,{_intern:{value:new Map},_key:{value:n}}),null!=t)for(const[n,e]of t)this.set(n,e)}get(t){return super.get(A(this,t))}has(t){return super.has(A(this,t))}set(t,n){return super.set(S(this,t),n)}delete(t){return super.delete(E(this,t))}}class InternSet extends Set{constructor(t,n=N){if(super(),Object.defineProperties(this,{_intern:{value:new Map},_key:{value:n}}),null!=t)for(const n of t)this.add(n)}has(t){return super.has(A(this,t))}add(t){return super.add(S(this,t))}delete(t){return super.delete(E(this,t))}}function A({_intern:t,_key:n},e){const r=n(e);return t.has(r)?t.get(r):e}function S({_intern:t,_key:n},e){const r=n(e);return t.has(r)?t.get(r):(t.set(r,e),e)}function E({_intern:t,_key:n},e){const r=n(e);return t.has(r)&&(e=t.get(r),t.delete(r)),e}function N(t){return null!==t&&"object"==typeof t?t.valueOf():t}function k(t){return t}function C(t,...n){return F(t,k,k,n)}function P(t,...n){return F(t,Array.from,k,n)}function z(t,n){for(let e=1,r=n.length;et.pop().map((([n,e])=>[...t,n,e]))));return t}function $(t,n,...e){return F(t,k,n,e)}function D(t,n,...e){return F(t,Array.from,n,e)}function R(t){if(1!==t.length)throw new Error("duplicate key");return t[0]}function F(t,n,e,r){return function t(i,o){if(o>=r.length)return e(i);const a=new InternMap,u=r[o++];let c=-1;for(const t of i){const n=u(t,++c,i),e=a.get(n);e?e.push(t):a.set(n,[t])}for(const[n,e]of a)a.set(n,t(e,o));return n(a)}(t,0)}function q(t,n){return Array.from(n,(n=>t[n]))}function U(t,...n){if("function"!=typeof t[Symbol.iterator])throw new TypeError("values is not iterable");t=Array.from(t);let[e]=n;if(e&&2!==e.length||n.length>1){const r=Uint32Array.from(t,((t,n)=>n));return n.length>1?(n=n.map((n=>t.map(n))),r.sort(((t,e)=>{for(const r of n){const n=O(r[t],r[e]);if(n)return n}}))):(e=t.map(e),r.sort(((t,n)=>O(e[t],e[n])))),q(t,r)}return t.sort(I(e))}function I(t=n){if(t===n)return O;if("function"!=typeof t)throw new TypeError("compare is not a function");return(n,e)=>{const r=t(n,e);return r||0===r?r:(0===t(e,e))-(0===t(n,n))}}function O(t,n){return(null==t||!(t>=t))-(null==n||!(n>=n))||(tn?1:0)}var B=Array.prototype.slice;function Y(t){return()=>t}const L=Math.sqrt(50),j=Math.sqrt(10),H=Math.sqrt(2);function X(t,n,e){const r=(n-t)/Math.max(0,e),i=Math.floor(Math.log10(r)),o=r/Math.pow(10,i),a=o>=L?10:o>=j?5:o>=H?2:1;let u,c,f;return i<0?(f=Math.pow(10,-i)/a,u=Math.round(t*f),c=Math.round(n*f),u/fn&&--c,f=-f):(f=Math.pow(10,i)*a,u=Math.round(t/f),c=Math.round(n/f),u*fn&&--c),c0))return[];if((t=+t)===(n=+n))return[t];const r=n=i))return[];const u=o-i+1,c=new Array(u);if(r)if(a<0)for(let t=0;t0?(t=Math.floor(t/i)*i,n=Math.ceil(n/i)*i):i<0&&(t=Math.ceil(t*i)/i,n=Math.floor(n*i)/i),r=i}}function K(t){return Math.max(1,Math.ceil(Math.log(v(t))/Math.LN2)+1)}function Q(){var t=k,n=M,e=K;function r(r){Array.isArray(r)||(r=Array.from(r));var i,o,a,u=r.length,c=new Array(u);for(i=0;i=h)if(t>=h&&n===M){const t=V(l,h,e);isFinite(t)&&(t>0?h=(Math.floor(h/t)+1)*t:t<0&&(h=(Math.ceil(h*-t)+1)/-t))}else d.pop()}for(var p=d.length,g=0,y=p;d[g]<=l;)++g;for(;d[y-1]>h;)--y;(g||y0?d[i-1]:l,v.x1=i0)for(i=0;i=n)&&(e=n);else{let r=-1;for(let i of t)null!=(i=n(i,++r,t))&&(e=i)&&(e=i)}return e}function tt(t,n){let e,r=-1,i=-1;if(void 0===n)for(const n of t)++i,null!=n&&(e=n)&&(e=n,r=i);else for(let o of t)null!=(o=n(o,++i,t))&&(e=o)&&(e=o,r=i);return r}function nt(t,n){let e;if(void 0===n)for(const n of t)null!=n&&(e>n||void 0===e&&n>=n)&&(e=n);else{let r=-1;for(let i of t)null!=(i=n(i,++r,t))&&(e>i||void 0===e&&i>=i)&&(e=i)}return e}function et(t,n){let e,r=-1,i=-1;if(void 0===n)for(const n of t)++i,null!=n&&(e>n||void 0===e&&n>=n)&&(e=n,r=i);else for(let o of t)null!=(o=n(o,++i,t))&&(e>o||void 0===e&&o>=o)&&(e=o,r=i);return r}function rt(t,n,e=0,r=1/0,i){if(n=Math.floor(n),e=Math.floor(Math.max(0,e)),r=Math.floor(Math.min(t.length-1,r)),!(e<=n&&n<=r))return t;for(i=void 0===i?O:I(i);r>e;){if(r-e>600){const o=r-e+1,a=n-e+1,u=Math.log(o),c=.5*Math.exp(2*u/3),f=.5*Math.sqrt(u*c*(o-c)/o)*(a-o/2<0?-1:1);rt(t,n,Math.max(e,Math.floor(n-a*c/o+f)),Math.min(r,Math.floor(n+(o-a)*c/o+f)),i)}const o=t[n];let a=e,u=r;for(it(t,e,n),i(t[r],o)>0&&it(t,e,r);a0;)--u}0===i(t[e],o)?it(t,e,u):(++u,it(t,u,r)),u<=n&&(e=u+1),n<=u&&(r=u-1)}return t}function it(t,n,e){const r=t[n];t[n]=t[e],t[e]=r}function ot(t,e=n){let r,i=!1;if(1===e.length){let o;for(const a of t){const t=e(a);(i?n(t,o)>0:0===n(t,t))&&(r=a,o=t,i=!0)}}else for(const n of t)(i?e(n,r)>0:0===e(n,n))&&(r=n,i=!0);return r}function at(t,n,e){if(t=Float64Array.from(function*(t,n){if(void 0===n)for(let n of t)null!=n&&(n=+n)>=n&&(yield n);else{let e=-1;for(let r of t)null!=(r=n(r,++e,t))&&(r=+r)>=r&&(yield r)}}(t,e)),(r=t.length)&&!isNaN(n=+n)){if(n<=0||r<2)return nt(t);if(n>=1)return J(t);var r,i=(r-1)*n,o=Math.floor(i),a=J(rt(t,o).subarray(0,o+1));return a+(nt(t.subarray(o+1))-a)*(i-o)}}function ut(t,n,e=o){if((r=t.length)&&!isNaN(n=+n)){if(n<=0||r<2)return+e(t[0],0,t);if(n>=1)return+e(t[r-1],r-1,t);var r,i=(r-1)*n,a=Math.floor(i),u=+e(t[a],a,t);return u+(+e(t[a+1],a+1,t)-u)*(i-a)}}function ct(t,n,e=o){if(!isNaN(n=+n)){if(r=Float64Array.from(t,((n,r)=>o(e(t[r],r,t)))),n<=0)return et(r);if(n>=1)return tt(r);var r,i=Uint32Array.from(t,((t,n)=>n)),a=r.length-1,u=Math.floor(a*n);return rt(i,u,0,a,((t,n)=>O(r[t],r[n]))),(u=ot(i.subarray(0,u+1),(t=>r[t])))>=0?u:-1}}function ft(t){return Array.from(function*(t){for(const n of t)yield*n}(t))}function st(t,n){return[t,n]}function lt(t,n,e){t=+t,n=+n,e=(i=arguments.length)<2?(n=t,t=0,1):i<3?1:+e;for(var r=-1,i=0|Math.max(0,Math.ceil((n-t)/e)),o=new Array(i);++r+t(n)}function kt(t,n){return n=Math.max(0,t.bandwidth()-2*n)/2,t.round()&&(n=Math.round(n)),e=>+t(e)+n}function Ct(){return!this.__axis}function Pt(t,n){var e=[],r=null,i=null,o=6,a=6,u=3,c="undefined"!=typeof window&&window.devicePixelRatio>1?0:.5,f=t===xt||t===Tt?-1:1,s=t===Tt||t===wt?"x":"y",l=t===xt||t===Mt?St:Et;function h(h){var d=null==r?n.ticks?n.ticks.apply(n,e):n.domain():r,p=null==i?n.tickFormat?n.tickFormat.apply(n,e):mt:i,g=Math.max(o,0)+u,y=n.range(),v=+y[0]+c,_=+y[y.length-1]+c,b=(n.bandwidth?kt:Nt)(n.copy(),c),m=h.selection?h.selection():h,x=m.selectAll(".domain").data([null]),w=m.selectAll(".tick").data(d,n).order(),M=w.exit(),T=w.enter().append("g").attr("class","tick"),A=w.select("line"),S=w.select("text");x=x.merge(x.enter().insert("path",".tick").attr("class","domain").attr("stroke","currentColor")),w=w.merge(T),A=A.merge(T.append("line").attr("stroke","currentColor").attr(s+"2",f*o)),S=S.merge(T.append("text").attr("fill","currentColor").attr(s,f*g).attr("dy",t===xt?"0em":t===Mt?"0.71em":"0.32em")),h!==m&&(x=x.transition(h),w=w.transition(h),A=A.transition(h),S=S.transition(h),M=M.transition(h).attr("opacity",At).attr("transform",(function(t){return isFinite(t=b(t))?l(t+c):this.getAttribute("transform")})),T.attr("opacity",At).attr("transform",(function(t){var n=this.parentNode.__axis;return l((n&&isFinite(n=n(t))?n:b(t))+c)}))),M.remove(),x.attr("d",t===Tt||t===wt?a?"M"+f*a+","+v+"H"+c+"V"+_+"H"+f*a:"M"+c+","+v+"V"+_:a?"M"+v+","+f*a+"V"+c+"H"+_+"V"+f*a:"M"+v+","+c+"H"+_),w.attr("opacity",1).attr("transform",(function(t){return l(b(t)+c)})),A.attr(s+"2",f*o),S.attr(s,f*g).text(p),m.filter(Ct).attr("fill","none").attr("font-size",10).attr("font-family","sans-serif").attr("text-anchor",t===wt?"start":t===Tt?"end":"middle"),m.each((function(){this.__axis=b}))}return h.scale=function(t){return arguments.length?(n=t,h):n},h.ticks=function(){return e=Array.from(arguments),h},h.tickArguments=function(t){return arguments.length?(e=null==t?[]:Array.from(t),h):e.slice()},h.tickValues=function(t){return arguments.length?(r=null==t?null:Array.from(t),h):r&&r.slice()},h.tickFormat=function(t){return arguments.length?(i=t,h):i},h.tickSize=function(t){return arguments.length?(o=a=+t,h):o},h.tickSizeInner=function(t){return arguments.length?(o=+t,h):o},h.tickSizeOuter=function(t){return arguments.length?(a=+t,h):a},h.tickPadding=function(t){return arguments.length?(u=+t,h):u},h.offset=function(t){return arguments.length?(c=+t,h):c},h}var zt={value:()=>{}};function $t(){for(var t,n=0,e=arguments.length,r={};n=0&&(n=t.slice(e+1),t=t.slice(0,e)),t&&!r.hasOwnProperty(t))throw new Error("unknown type: "+t);return{type:t,name:n}}))),a=-1,u=o.length;if(!(arguments.length<2)){if(null!=n&&"function"!=typeof n)throw new Error("invalid callback: "+n);for(;++a0)for(var e,r,i=new Array(e),o=0;o=0&&"xmlns"!==(n=t.slice(0,e))&&(t=t.slice(e+1)),Ut.hasOwnProperty(n)?{space:Ut[n],local:t}:t}function Ot(t){return function(){var n=this.ownerDocument,e=this.namespaceURI;return e===qt&&n.documentElement.namespaceURI===qt?n.createElement(t):n.createElementNS(e,t)}}function Bt(t){return function(){return this.ownerDocument.createElementNS(t.space,t.local)}}function Yt(t){var n=It(t);return(n.local?Bt:Ot)(n)}function Lt(){}function jt(t){return null==t?Lt:function(){return this.querySelector(t)}}function Ht(t){return null==t?[]:Array.isArray(t)?t:Array.from(t)}function Xt(){return[]}function Gt(t){return null==t?Xt:function(){return this.querySelectorAll(t)}}function Vt(t){return function(){return this.matches(t)}}function Wt(t){return function(n){return n.matches(t)}}var Zt=Array.prototype.find;function Kt(){return this.firstElementChild}var Qt=Array.prototype.filter;function Jt(){return Array.from(this.children)}function tn(t){return new Array(t.length)}function nn(t,n){this.ownerDocument=t.ownerDocument,this.namespaceURI=t.namespaceURI,this._next=null,this._parent=t,this.__data__=n}function en(t,n,e,r,i,o){for(var a,u=0,c=n.length,f=o.length;un?1:t>=n?0:NaN}function cn(t){return function(){this.removeAttribute(t)}}function fn(t){return function(){this.removeAttributeNS(t.space,t.local)}}function sn(t,n){return function(){this.setAttribute(t,n)}}function ln(t,n){return function(){this.setAttributeNS(t.space,t.local,n)}}function hn(t,n){return function(){var e=n.apply(this,arguments);null==e?this.removeAttribute(t):this.setAttribute(t,e)}}function dn(t,n){return function(){var e=n.apply(this,arguments);null==e?this.removeAttributeNS(t.space,t.local):this.setAttributeNS(t.space,t.local,e)}}function pn(t){return t.ownerDocument&&t.ownerDocument.defaultView||t.document&&t||t.defaultView}function gn(t){return function(){this.style.removeProperty(t)}}function yn(t,n,e){return function(){this.style.setProperty(t,n,e)}}function vn(t,n,e){return function(){var r=n.apply(this,arguments);null==r?this.style.removeProperty(t):this.style.setProperty(t,r,e)}}function _n(t,n){return t.style.getPropertyValue(n)||pn(t).getComputedStyle(t,null).getPropertyValue(n)}function bn(t){return function(){delete this[t]}}function mn(t,n){return function(){this[t]=n}}function xn(t,n){return function(){var e=n.apply(this,arguments);null==e?delete this[t]:this[t]=e}}function wn(t){return t.trim().split(/^|\s+/)}function Mn(t){return t.classList||new Tn(t)}function Tn(t){this._node=t,this._names=wn(t.getAttribute("class")||"")}function An(t,n){for(var e=Mn(t),r=-1,i=n.length;++r=0&&(this._names.splice(n,1),this._node.setAttribute("class",this._names.join(" ")))},contains:function(t){return this._names.indexOf(t)>=0}};var Gn=[null];function Vn(t,n){this._groups=t,this._parents=n}function Wn(){return new Vn([[document.documentElement]],Gn)}function Zn(t){return"string"==typeof t?new Vn([[document.querySelector(t)]],[document.documentElement]):new Vn([[t]],Gn)}Vn.prototype=Wn.prototype={constructor:Vn,select:function(t){"function"!=typeof t&&(t=jt(t));for(var n=this._groups,e=n.length,r=new Array(e),i=0;i=m&&(m=b+1);!(_=y[m])&&++m=0;)(r=i[o])&&(a&&4^r.compareDocumentPosition(a)&&a.parentNode.insertBefore(r,a),a=r);return this},sort:function(t){function n(n,e){return n&&e?t(n.__data__,e.__data__):!n-!e}t||(t=un);for(var e=this._groups,r=e.length,i=new Array(r),o=0;o1?this.each((null==n?gn:"function"==typeof n?vn:yn)(t,n,null==e?"":e)):_n(this.node(),t)},property:function(t,n){return arguments.length>1?this.each((null==n?bn:"function"==typeof n?xn:mn)(t,n)):this.node()[t]},classed:function(t,n){var e=wn(t+"");if(arguments.length<2){for(var r=Mn(this.node()),i=-1,o=e.length;++i=0&&(n=t.slice(e+1),t=t.slice(0,e)),{type:t,name:n}}))}(t+""),a=o.length;if(!(arguments.length<2)){for(u=n?Ln:Yn,r=0;r()=>t;function fe(t,{sourceEvent:n,subject:e,target:r,identifier:i,active:o,x:a,y:u,dx:c,dy:f,dispatch:s}){Object.defineProperties(this,{type:{value:t,enumerable:!0,configurable:!0},sourceEvent:{value:n,enumerable:!0,configurable:!0},subject:{value:e,enumerable:!0,configurable:!0},target:{value:r,enumerable:!0,configurable:!0},identifier:{value:i,enumerable:!0,configurable:!0},active:{value:o,enumerable:!0,configurable:!0},x:{value:a,enumerable:!0,configurable:!0},y:{value:u,enumerable:!0,configurable:!0},dx:{value:c,enumerable:!0,configurable:!0},dy:{value:f,enumerable:!0,configurable:!0},_:{value:s}})}function se(t){return!t.ctrlKey&&!t.button}function le(){return this.parentNode}function he(t,n){return null==n?{x:t.x,y:t.y}:n}function de(){return navigator.maxTouchPoints||"ontouchstart"in this}function pe(t,n,e){t.prototype=n.prototype=e,e.constructor=t}function ge(t,n){var e=Object.create(t.prototype);for(var r in n)e[r]=n[r];return e}function ye(){}fe.prototype.on=function(){var t=this._.on.apply(this._,arguments);return t===this._?this:t};var ve=.7,_e=1/ve,be="\\s*([+-]?\\d+)\\s*",me="\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)\\s*",xe="\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)%\\s*",we=/^#([0-9a-f]{3,8})$/,Me=new RegExp(`^rgb\\(${be},${be},${be}\\)$`),Te=new RegExp(`^rgb\\(${xe},${xe},${xe}\\)$`),Ae=new RegExp(`^rgba\\(${be},${be},${be},${me}\\)$`),Se=new RegExp(`^rgba\\(${xe},${xe},${xe},${me}\\)$`),Ee=new RegExp(`^hsl\\(${me},${xe},${xe}\\)$`),Ne=new RegExp(`^hsla\\(${me},${xe},${xe},${me}\\)$`),ke={aliceblue:15792383,antiquewhite:16444375,aqua:65535,aquamarine:8388564,azure:15794175,beige:16119260,bisque:16770244,black:0,blanchedalmond:16772045,blue:255,blueviolet:9055202,brown:10824234,burlywood:14596231,cadetblue:6266528,chartreuse:8388352,chocolate:13789470,coral:16744272,cornflowerblue:6591981,cornsilk:16775388,crimson:14423100,cyan:65535,darkblue:139,darkcyan:35723,darkgoldenrod:12092939,darkgray:11119017,darkgreen:25600,darkgrey:11119017,darkkhaki:12433259,darkmagenta:9109643,darkolivegreen:5597999,darkorange:16747520,darkorchid:10040012,darkred:9109504,darksalmon:15308410,darkseagreen:9419919,darkslateblue:4734347,darkslategray:3100495,darkslategrey:3100495,darkturquoise:52945,darkviolet:9699539,deeppink:16716947,deepskyblue:49151,dimgray:6908265,dimgrey:6908265,dodgerblue:2003199,firebrick:11674146,floralwhite:16775920,forestgreen:2263842,fuchsia:16711935,gainsboro:14474460,ghostwhite:16316671,gold:16766720,goldenrod:14329120,gray:8421504,green:32768,greenyellow:11403055,grey:8421504,honeydew:15794160,hotpink:16738740,indianred:13458524,indigo:4915330,ivory:16777200,khaki:15787660,lavender:15132410,lavenderblush:16773365,lawngreen:8190976,lemonchiffon:16775885,lightblue:11393254,lightcoral:15761536,lightcyan:14745599,lightgoldenrodyellow:16448210,lightgray:13882323,lightgreen:9498256,lightgrey:13882323,lightpink:16758465,lightsalmon:16752762,lightseagreen:2142890,lightskyblue:8900346,lightslategray:7833753,lightslategrey:7833753,lightsteelblue:11584734,lightyellow:16777184,lime:65280,limegreen:3329330,linen:16445670,magenta:16711935,maroon:8388608,mediumaquamarine:6737322,mediumblue:205,mediumorchid:12211667,mediumpurple:9662683,mediumseagreen:3978097,mediumslateblue:8087790,mediumspringgreen:64154,mediumturquoise:4772300,mediumvioletred:13047173,midnightblue:1644912,mintcream:16121850,mistyrose:16770273,moccasin:16770229,navajowhite:16768685,navy:128,oldlace:16643558,olive:8421376,olivedrab:7048739,orange:16753920,orangered:16729344,orchid:14315734,palegoldenrod:15657130,palegreen:10025880,paleturquoise:11529966,palevioletred:14381203,papayawhip:16773077,peachpuff:16767673,peru:13468991,pink:16761035,plum:14524637,powderblue:11591910,purple:8388736,rebeccapurple:6697881,red:16711680,rosybrown:12357519,royalblue:4286945,saddlebrown:9127187,salmon:16416882,sandybrown:16032864,seagreen:3050327,seashell:16774638,sienna:10506797,silver:12632256,skyblue:8900331,slateblue:6970061,slategray:7372944,slategrey:7372944,snow:16775930,springgreen:65407,steelblue:4620980,tan:13808780,teal:32896,thistle:14204888,tomato:16737095,turquoise:4251856,violet:15631086,wheat:16113331,white:16777215,whitesmoke:16119285,yellow:16776960,yellowgreen:10145074};function Ce(){return this.rgb().formatHex()}function Pe(){return this.rgb().formatRgb()}function ze(t){var n,e;return t=(t+"").trim().toLowerCase(),(n=we.exec(t))?(e=n[1].length,n=parseInt(n[1],16),6===e?$e(n):3===e?new qe(n>>8&15|n>>4&240,n>>4&15|240&n,(15&n)<<4|15&n,1):8===e?De(n>>24&255,n>>16&255,n>>8&255,(255&n)/255):4===e?De(n>>12&15|n>>8&240,n>>8&15|n>>4&240,n>>4&15|240&n,((15&n)<<4|15&n)/255):null):(n=Me.exec(t))?new qe(n[1],n[2],n[3],1):(n=Te.exec(t))?new qe(255*n[1]/100,255*n[2]/100,255*n[3]/100,1):(n=Ae.exec(t))?De(n[1],n[2],n[3],n[4]):(n=Se.exec(t))?De(255*n[1]/100,255*n[2]/100,255*n[3]/100,n[4]):(n=Ee.exec(t))?Le(n[1],n[2]/100,n[3]/100,1):(n=Ne.exec(t))?Le(n[1],n[2]/100,n[3]/100,n[4]):ke.hasOwnProperty(t)?$e(ke[t]):"transparent"===t?new qe(NaN,NaN,NaN,0):null}function $e(t){return new qe(t>>16&255,t>>8&255,255&t,1)}function De(t,n,e,r){return r<=0&&(t=n=e=NaN),new qe(t,n,e,r)}function Re(t){return t instanceof ye||(t=ze(t)),t?new qe((t=t.rgb()).r,t.g,t.b,t.opacity):new qe}function Fe(t,n,e,r){return 1===arguments.length?Re(t):new qe(t,n,e,null==r?1:r)}function qe(t,n,e,r){this.r=+t,this.g=+n,this.b=+e,this.opacity=+r}function Ue(){return`#${Ye(this.r)}${Ye(this.g)}${Ye(this.b)}`}function Ie(){const t=Oe(this.opacity);return`${1===t?"rgb(":"rgba("}${Be(this.r)}, ${Be(this.g)}, ${Be(this.b)}${1===t?")":`, ${t})`}`}function Oe(t){return isNaN(t)?1:Math.max(0,Math.min(1,t))}function Be(t){return Math.max(0,Math.min(255,Math.round(t)||0))}function Ye(t){return((t=Be(t))<16?"0":"")+t.toString(16)}function Le(t,n,e,r){return r<=0?t=n=e=NaN:e<=0||e>=1?t=n=NaN:n<=0&&(t=NaN),new Xe(t,n,e,r)}function je(t){if(t instanceof Xe)return new Xe(t.h,t.s,t.l,t.opacity);if(t instanceof ye||(t=ze(t)),!t)return new Xe;if(t instanceof Xe)return t;var n=(t=t.rgb()).r/255,e=t.g/255,r=t.b/255,i=Math.min(n,e,r),o=Math.max(n,e,r),a=NaN,u=o-i,c=(o+i)/2;return u?(a=n===o?(e-r)/u+6*(e0&&c<1?0:a,new Xe(a,u,c,t.opacity)}function He(t,n,e,r){return 1===arguments.length?je(t):new Xe(t,n,e,null==r?1:r)}function Xe(t,n,e,r){this.h=+t,this.s=+n,this.l=+e,this.opacity=+r}function Ge(t){return(t=(t||0)%360)<0?t+360:t}function Ve(t){return Math.max(0,Math.min(1,t||0))}function We(t,n,e){return 255*(t<60?n+(e-n)*t/60:t<180?e:t<240?n+(e-n)*(240-t)/60:n)}pe(ye,ze,{copy(t){return Object.assign(new this.constructor,this,t)},displayable(){return this.rgb().displayable()},hex:Ce,formatHex:Ce,formatHex8:function(){return this.rgb().formatHex8()},formatHsl:function(){return je(this).formatHsl()},formatRgb:Pe,toString:Pe}),pe(qe,Fe,ge(ye,{brighter(t){return t=null==t?_e:Math.pow(_e,t),new qe(this.r*t,this.g*t,this.b*t,this.opacity)},darker(t){return t=null==t?ve:Math.pow(ve,t),new qe(this.r*t,this.g*t,this.b*t,this.opacity)},rgb(){return this},clamp(){return new qe(Be(this.r),Be(this.g),Be(this.b),Oe(this.opacity))},displayable(){return-.5<=this.r&&this.r<255.5&&-.5<=this.g&&this.g<255.5&&-.5<=this.b&&this.b<255.5&&0<=this.opacity&&this.opacity<=1},hex:Ue,formatHex:Ue,formatHex8:function(){return`#${Ye(this.r)}${Ye(this.g)}${Ye(this.b)}${Ye(255*(isNaN(this.opacity)?1:this.opacity))}`},formatRgb:Ie,toString:Ie})),pe(Xe,He,ge(ye,{brighter(t){return t=null==t?_e:Math.pow(_e,t),new Xe(this.h,this.s,this.l*t,this.opacity)},darker(t){return t=null==t?ve:Math.pow(ve,t),new Xe(this.h,this.s,this.l*t,this.opacity)},rgb(){var t=this.h%360+360*(this.h<0),n=isNaN(t)||isNaN(this.s)?0:this.s,e=this.l,r=e+(e<.5?e:1-e)*n,i=2*e-r;return new qe(We(t>=240?t-240:t+120,i,r),We(t,i,r),We(t<120?t+240:t-120,i,r),this.opacity)},clamp(){return new Xe(Ge(this.h),Ve(this.s),Ve(this.l),Oe(this.opacity))},displayable(){return(0<=this.s&&this.s<=1||isNaN(this.s))&&0<=this.l&&this.l<=1&&0<=this.opacity&&this.opacity<=1},formatHsl(){const t=Oe(this.opacity);return`${1===t?"hsl(":"hsla("}${Ge(this.h)}, ${100*Ve(this.s)}%, ${100*Ve(this.l)}%${1===t?")":`, ${t})`}`}}));const Ze=Math.PI/180,Ke=180/Math.PI,Qe=.96422,Je=1,tr=.82521,nr=4/29,er=6/29,rr=3*er*er,ir=er*er*er;function or(t){if(t instanceof ur)return new ur(t.l,t.a,t.b,t.opacity);if(t instanceof pr)return gr(t);t instanceof qe||(t=Re(t));var n,e,r=lr(t.r),i=lr(t.g),o=lr(t.b),a=cr((.2225045*r+.7168786*i+.0606169*o)/Je);return r===i&&i===o?n=e=a:(n=cr((.4360747*r+.3850649*i+.1430804*o)/Qe),e=cr((.0139322*r+.0971045*i+.7141733*o)/tr)),new ur(116*a-16,500*(n-a),200*(a-e),t.opacity)}function ar(t,n,e,r){return 1===arguments.length?or(t):new ur(t,n,e,null==r?1:r)}function ur(t,n,e,r){this.l=+t,this.a=+n,this.b=+e,this.opacity=+r}function cr(t){return t>ir?Math.pow(t,1/3):t/rr+nr}function fr(t){return t>er?t*t*t:rr*(t-nr)}function sr(t){return 255*(t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055)}function lr(t){return(t/=255)<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4)}function hr(t){if(t instanceof pr)return new pr(t.h,t.c,t.l,t.opacity);if(t instanceof ur||(t=or(t)),0===t.a&&0===t.b)return new pr(NaN,0=1?(e=1,n-1):Math.floor(e*n),i=t[r],o=t[r+1],a=r>0?t[r-1]:2*i-o,u=r()=>t;function Cr(t,n){return function(e){return t+e*n}}function Pr(t,n){var e=n-t;return e?Cr(t,e>180||e<-180?e-360*Math.round(e/360):e):kr(isNaN(t)?n:t)}function zr(t){return 1==(t=+t)?$r:function(n,e){return e-n?function(t,n,e){return t=Math.pow(t,e),n=Math.pow(n,e)-t,e=1/e,function(r){return Math.pow(t+r*n,e)}}(n,e,t):kr(isNaN(n)?e:n)}}function $r(t,n){var e=n-t;return e?Cr(t,e):kr(isNaN(t)?n:t)}var Dr=function t(n){var e=zr(n);function r(t,n){var r=e((t=Fe(t)).r,(n=Fe(n)).r),i=e(t.g,n.g),o=e(t.b,n.b),a=$r(t.opacity,n.opacity);return function(n){return t.r=r(n),t.g=i(n),t.b=o(n),t.opacity=a(n),t+""}}return r.gamma=t,r}(1);function Rr(t){return function(n){var e,r,i=n.length,o=new Array(i),a=new Array(i),u=new Array(i);for(e=0;eo&&(i=n.slice(o,i),u[a]?u[a]+=i:u[++a]=i),(e=e[0])===(r=r[0])?u[a]?u[a]+=r:u[++a]=r:(u[++a]=null,c.push({i:a,x:Yr(e,r)})),o=Hr.lastIndex;return o180?n+=360:n-t>180&&(t+=360),o.push({i:e.push(i(e)+"rotate(",null,r)-2,x:Yr(t,n)})):n&&e.push(i(e)+"rotate("+n+r)}(o.rotate,a.rotate,u,c),function(t,n,e,o){t!==n?o.push({i:e.push(i(e)+"skewX(",null,r)-2,x:Yr(t,n)}):n&&e.push(i(e)+"skewX("+n+r)}(o.skewX,a.skewX,u,c),function(t,n,e,r,o,a){if(t!==e||n!==r){var u=o.push(i(o)+"scale(",null,",",null,")");a.push({i:u-4,x:Yr(t,e)},{i:u-2,x:Yr(n,r)})}else 1===e&&1===r||o.push(i(o)+"scale("+e+","+r+")")}(o.scaleX,o.scaleY,a.scaleX,a.scaleY,u,c),o=a=null,function(t){for(var n,e=-1,r=c.length;++e=0&&n._call.call(void 0,t),n=n._next;--yi}function Ci(){xi=(mi=Mi.now())+wi,yi=vi=0;try{ki()}finally{yi=0,function(){var t,n,e=pi,r=1/0;for(;e;)e._call?(r>e._time&&(r=e._time),t=e,e=e._next):(n=e._next,e._next=null,e=t?t._next=n:pi=n);gi=t,zi(r)}(),xi=0}}function Pi(){var t=Mi.now(),n=t-mi;n>bi&&(wi-=n,mi=t)}function zi(t){yi||(vi&&(vi=clearTimeout(vi)),t-xi>24?(t<1/0&&(vi=setTimeout(Ci,t-Mi.now()-wi)),_i&&(_i=clearInterval(_i))):(_i||(mi=Mi.now(),_i=setInterval(Pi,bi)),yi=1,Ti(Ci)))}function $i(t,n,e){var r=new Ei;return n=null==n?0:+n,r.restart((e=>{r.stop(),t(e+n)}),n,e),r}Ei.prototype=Ni.prototype={constructor:Ei,restart:function(t,n,e){if("function"!=typeof t)throw new TypeError("callback is not a function");e=(null==e?Ai():+e)+(null==n?0:+n),this._next||gi===this||(gi?gi._next=this:pi=this,gi=this),this._call=t,this._time=e,zi()},stop:function(){this._call&&(this._call=null,this._time=1/0,zi())}};var Di=$t("start","end","cancel","interrupt"),Ri=[],Fi=0,qi=1,Ui=2,Ii=3,Oi=4,Bi=5,Yi=6;function Li(t,n,e,r,i,o){var a=t.__transition;if(a){if(e in a)return}else t.__transition={};!function(t,n,e){var r,i=t.__transition;function o(t){e.state=qi,e.timer.restart(a,e.delay,e.time),e.delay<=t&&a(t-e.delay)}function a(o){var f,s,l,h;if(e.state!==qi)return c();for(f in i)if((h=i[f]).name===e.name){if(h.state===Ii)return $i(a);h.state===Oi?(h.state=Yi,h.timer.stop(),h.on.call("interrupt",t,t.__data__,h.index,h.group),delete i[f]):+fFi)throw new Error("too late; already scheduled");return e}function Hi(t,n){var e=Xi(t,n);if(e.state>Ii)throw new Error("too late; already running");return e}function Xi(t,n){var e=t.__transition;if(!e||!(e=e[n]))throw new Error("transition not found");return e}function Gi(t,n){var e,r,i,o=t.__transition,a=!0;if(o){for(i in n=null==n?null:n+"",o)(e=o[i]).name===n?(r=e.state>Ui&&e.state=0&&(t=t.slice(0,n)),!t||"start"===t}))}(n)?ji:Hi;return function(){var a=o(this,t),u=a.on;u!==r&&(i=(r=u).copy()).on(n,e),a.on=i}}(e,t,n))},attr:function(t,n){var e=It(t),r="transform"===e?ni:Ki;return this.attrTween(t,"function"==typeof n?(e.local?ro:eo)(e,r,Zi(this,"attr."+t,n)):null==n?(e.local?Ji:Qi)(e):(e.local?no:to)(e,r,n))},attrTween:function(t,n){var e="attr."+t;if(arguments.length<2)return(e=this.tween(e))&&e._value;if(null==n)return this.tween(e,null);if("function"!=typeof n)throw new Error;var r=It(t);return this.tween(e,(r.local?io:oo)(r,n))},style:function(t,n,e){var r="transform"==(t+="")?ti:Ki;return null==n?this.styleTween(t,function(t,n){var e,r,i;return function(){var o=_n(this,t),a=(this.style.removeProperty(t),_n(this,t));return o===a?null:o===e&&a===r?i:i=n(e=o,r=a)}}(t,r)).on("end.style."+t,lo(t)):"function"==typeof n?this.styleTween(t,function(t,n,e){var r,i,o;return function(){var a=_n(this,t),u=e(this),c=u+"";return null==u&&(this.style.removeProperty(t),c=u=_n(this,t)),a===c?null:a===r&&c===i?o:(i=c,o=n(r=a,u))}}(t,r,Zi(this,"style."+t,n))).each(function(t,n){var e,r,i,o,a="style."+n,u="end."+a;return function(){var c=Hi(this,t),f=c.on,s=null==c.value[a]?o||(o=lo(n)):void 0;f===e&&i===s||(r=(e=f).copy()).on(u,i=s),c.on=r}}(this._id,t)):this.styleTween(t,function(t,n,e){var r,i,o=e+"";return function(){var a=_n(this,t);return a===o?null:a===r?i:i=n(r=a,e)}}(t,r,n),e).on("end.style."+t,null)},styleTween:function(t,n,e){var r="style."+(t+="");if(arguments.length<2)return(r=this.tween(r))&&r._value;if(null==n)return this.tween(r,null);if("function"!=typeof n)throw new Error;return this.tween(r,function(t,n,e){var r,i;function o(){var o=n.apply(this,arguments);return o!==i&&(r=(i=o)&&function(t,n,e){return function(r){this.style.setProperty(t,n.call(this,r),e)}}(t,o,e)),r}return o._value=n,o}(t,n,null==e?"":e))},text:function(t){return this.tween("text","function"==typeof t?function(t){return function(){var n=t(this);this.textContent=null==n?"":n}}(Zi(this,"text",t)):function(t){return function(){this.textContent=t}}(null==t?"":t+""))},textTween:function(t){var n="text";if(arguments.length<1)return(n=this.tween(n))&&n._value;if(null==t)return this.tween(n,null);if("function"!=typeof t)throw new Error;return this.tween(n,function(t){var n,e;function r(){var r=t.apply(this,arguments);return r!==e&&(n=(e=r)&&function(t){return function(n){this.textContent=t.call(this,n)}}(r)),n}return r._value=t,r}(t))},remove:function(){return this.on("end.remove",function(t){return function(){var n=this.parentNode;for(var e in this.__transition)if(+e!==t)return;n&&n.removeChild(this)}}(this._id))},tween:function(t,n){var e=this._id;if(t+="",arguments.length<2){for(var r,i=Xi(this.node(),e).tween,o=0,a=i.length;o()=>t;function Qo(t,{sourceEvent:n,target:e,selection:r,mode:i,dispatch:o}){Object.defineProperties(this,{type:{value:t,enumerable:!0,configurable:!0},sourceEvent:{value:n,enumerable:!0,configurable:!0},target:{value:e,enumerable:!0,configurable:!0},selection:{value:r,enumerable:!0,configurable:!0},mode:{value:i,enumerable:!0,configurable:!0},_:{value:o}})}function Jo(t){t.preventDefault(),t.stopImmediatePropagation()}var ta={name:"drag"},na={name:"space"},ea={name:"handle"},ra={name:"center"};const{abs:ia,max:oa,min:aa}=Math;function ua(t){return[+t[0],+t[1]]}function ca(t){return[ua(t[0]),ua(t[1])]}var fa={name:"x",handles:["w","e"].map(va),input:function(t,n){return null==t?null:[[+t[0],n[0][1]],[+t[1],n[1][1]]]},output:function(t){return t&&[t[0][0],t[1][0]]}},sa={name:"y",handles:["n","s"].map(va),input:function(t,n){return null==t?null:[[n[0][0],+t[0]],[n[1][0],+t[1]]]},output:function(t){return t&&[t[0][1],t[1][1]]}},la={name:"xy",handles:["n","w","e","s","nw","ne","sw","se"].map(va),input:function(t){return null==t?null:ca(t)},output:function(t){return t}},ha={overlay:"crosshair",selection:"move",n:"ns-resize",e:"ew-resize",s:"ns-resize",w:"ew-resize",nw:"nwse-resize",ne:"nesw-resize",se:"nwse-resize",sw:"nesw-resize"},da={e:"w",w:"e",nw:"ne",ne:"nw",se:"sw",sw:"se"},pa={n:"s",s:"n",nw:"sw",ne:"se",se:"ne",sw:"nw"},ga={overlay:1,selection:1,n:null,e:1,s:null,w:-1,nw:-1,ne:1,se:1,sw:-1},ya={overlay:1,selection:1,n:-1,e:null,s:1,w:null,nw:-1,ne:-1,se:1,sw:1};function va(t){return{type:t}}function _a(t){return!t.ctrlKey&&!t.button}function ba(){var t=this.ownerSVGElement||this;return t.hasAttribute("viewBox")?[[(t=t.viewBox.baseVal).x,t.y],[t.x+t.width,t.y+t.height]]:[[0,0],[t.width.baseVal.value,t.height.baseVal.value]]}function ma(){return navigator.maxTouchPoints||"ontouchstart"in this}function xa(t){for(;!t.__brush;)if(!(t=t.parentNode))return;return t.__brush}function wa(t){var n,e=ba,r=_a,i=ma,o=!0,a=$t("start","brush","end"),u=6;function c(n){var e=n.property("__brush",g).selectAll(".overlay").data([va("overlay")]);e.enter().append("rect").attr("class","overlay").attr("pointer-events","all").attr("cursor",ha.overlay).merge(e).each((function(){var t=xa(this).extent;Zn(this).attr("x",t[0][0]).attr("y",t[0][1]).attr("width",t[1][0]-t[0][0]).attr("height",t[1][1]-t[0][1])})),n.selectAll(".selection").data([va("selection")]).enter().append("rect").attr("class","selection").attr("cursor",ha.selection).attr("fill","#777").attr("fill-opacity",.3).attr("stroke","#fff").attr("shape-rendering","crispEdges");var r=n.selectAll(".handle").data(t.handles,(function(t){return t.type}));r.exit().remove(),r.enter().append("rect").attr("class",(function(t){return"handle handle--"+t.type})).attr("cursor",(function(t){return ha[t.type]})),n.each(f).attr("fill","none").attr("pointer-events","all").on("mousedown.brush",h).filter(i).on("touchstart.brush",h).on("touchmove.brush",d).on("touchend.brush touchcancel.brush",p).style("touch-action","none").style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}function f(){var t=Zn(this),n=xa(this).selection;n?(t.selectAll(".selection").style("display",null).attr("x",n[0][0]).attr("y",n[0][1]).attr("width",n[1][0]-n[0][0]).attr("height",n[1][1]-n[0][1]),t.selectAll(".handle").style("display",null).attr("x",(function(t){return"e"===t.type[t.type.length-1]?n[1][0]-u/2:n[0][0]-u/2})).attr("y",(function(t){return"s"===t.type[0]?n[1][1]-u/2:n[0][1]-u/2})).attr("width",(function(t){return"n"===t.type||"s"===t.type?n[1][0]-n[0][0]+u:u})).attr("height",(function(t){return"e"===t.type||"w"===t.type?n[1][1]-n[0][1]+u:u}))):t.selectAll(".selection,.handle").style("display","none").attr("x",null).attr("y",null).attr("width",null).attr("height",null)}function s(t,n,e){var r=t.__brush.emitter;return!r||e&&r.clean?new l(t,n,e):r}function l(t,n,e){this.that=t,this.args=n,this.state=t.__brush,this.active=0,this.clean=e}function h(e){if((!n||e.touches)&&r.apply(this,arguments)){var i,a,u,c,l,h,d,p,g,y,v,_=this,b=e.target.__data__.type,m="selection"===(o&&e.metaKey?b="overlay":b)?ta:o&&e.altKey?ra:ea,x=t===sa?null:ga[b],w=t===fa?null:ya[b],M=xa(_),T=M.extent,A=M.selection,S=T[0][0],E=T[0][1],N=T[1][0],k=T[1][1],C=0,P=0,z=x&&w&&o&&e.shiftKey,$=Array.from(e.touches||[e],(t=>{const n=t.identifier;return(t=ne(t,_)).point0=t.slice(),t.identifier=n,t}));Gi(_);var D=s(_,arguments,!0).beforestart();if("overlay"===b){A&&(g=!0);const n=[$[0],$[1]||$[0]];M.selection=A=[[i=t===sa?S:aa(n[0][0],n[1][0]),u=t===fa?E:aa(n[0][1],n[1][1])],[l=t===sa?N:oa(n[0][0],n[1][0]),d=t===fa?k:oa(n[0][1],n[1][1])]],$.length>1&&I(e)}else i=A[0][0],u=A[0][1],l=A[1][0],d=A[1][1];a=i,c=u,h=l,p=d;var R=Zn(_).attr("pointer-events","none"),F=R.selectAll(".overlay").attr("cursor",ha[b]);if(e.touches)D.moved=U,D.ended=O;else{var q=Zn(e.view).on("mousemove.brush",U,!0).on("mouseup.brush",O,!0);o&&q.on("keydown.brush",(function(t){switch(t.keyCode){case 16:z=x&&w;break;case 18:m===ea&&(x&&(l=h-C*x,i=a+C*x),w&&(d=p-P*w,u=c+P*w),m=ra,I(t));break;case 32:m!==ea&&m!==ra||(x<0?l=h-C:x>0&&(i=a-C),w<0?d=p-P:w>0&&(u=c-P),m=na,F.attr("cursor",ha.selection),I(t));break;default:return}Jo(t)}),!0).on("keyup.brush",(function(t){switch(t.keyCode){case 16:z&&(y=v=z=!1,I(t));break;case 18:m===ra&&(x<0?l=h:x>0&&(i=a),w<0?d=p:w>0&&(u=c),m=ea,I(t));break;case 32:m===na&&(t.altKey?(x&&(l=h-C*x,i=a+C*x),w&&(d=p-P*w,u=c+P*w),m=ra):(x<0?l=h:x>0&&(i=a),w<0?d=p:w>0&&(u=c),m=ea),F.attr("cursor",ha[b]),I(t));break;default:return}Jo(t)}),!0),ae(e.view)}f.call(_),D.start(e,m.name)}function U(t){for(const n of t.changedTouches||[t])for(const t of $)t.identifier===n.identifier&&(t.cur=ne(n,_));if(z&&!y&&!v&&1===$.length){const t=$[0];ia(t.cur[0]-t[0])>ia(t.cur[1]-t[1])?v=!0:y=!0}for(const t of $)t.cur&&(t[0]=t.cur[0],t[1]=t.cur[1]);g=!0,Jo(t),I(t)}function I(t){const n=$[0],e=n.point0;var r;switch(C=n[0]-e[0],P=n[1]-e[1],m){case na:case ta:x&&(C=oa(S-i,aa(N-l,C)),a=i+C,h=l+C),w&&(P=oa(E-u,aa(k-d,P)),c=u+P,p=d+P);break;case ea:$[1]?(x&&(a=oa(S,aa(N,$[0][0])),h=oa(S,aa(N,$[1][0])),x=1),w&&(c=oa(E,aa(k,$[0][1])),p=oa(E,aa(k,$[1][1])),w=1)):(x<0?(C=oa(S-i,aa(N-i,C)),a=i+C,h=l):x>0&&(C=oa(S-l,aa(N-l,C)),a=i,h=l+C),w<0?(P=oa(E-u,aa(k-u,P)),c=u+P,p=d):w>0&&(P=oa(E-d,aa(k-d,P)),c=u,p=d+P));break;case ra:x&&(a=oa(S,aa(N,i-C*x)),h=oa(S,aa(N,l+C*x))),w&&(c=oa(E,aa(k,u-P*w)),p=oa(E,aa(k,d+P*w)))}ht+e))}function za(t,n){var e=0,r=null,i=null,o=null;function a(a){var u,c=a.length,f=new Array(c),s=Pa(0,c),l=new Array(c*c),h=new Array(c),d=0;a=Float64Array.from({length:c*c},n?(t,n)=>a[n%c][n/c|0]:(t,n)=>a[n/c|0][n%c]);for(let n=0;nr(f[t],f[n])));for(const e of s){const r=n;if(t){const t=Pa(1+~c,c).filter((t=>t<0?a[~t*c+e]:a[e*c+t]));i&&t.sort(((t,n)=>i(t<0?-a[~t*c+e]:a[e*c+t],n<0?-a[~n*c+e]:a[e*c+n])));for(const r of t)if(r<0){(l[~r*c+e]||(l[~r*c+e]={source:null,target:null})).target={index:e,startAngle:n,endAngle:n+=a[~r*c+e]*d,value:a[~r*c+e]}}else{(l[e*c+r]||(l[e*c+r]={source:null,target:null})).source={index:e,startAngle:n,endAngle:n+=a[e*c+r]*d,value:a[e*c+r]}}h[e]={index:e,startAngle:r,endAngle:n,value:f[e]}}else{const t=Pa(0,c).filter((t=>a[e*c+t]||a[t*c+e]));i&&t.sort(((t,n)=>i(a[e*c+t],a[e*c+n])));for(const r of t){let t;if(e=0))throw new Error(`invalid digits: ${t}`);if(n>15)return qa;const e=10**n;return function(t){this._+=t[0];for(let n=1,r=t.length;nRa)if(Math.abs(s*u-c*f)>Ra&&i){let h=e-o,d=r-a,p=u*u+c*c,g=h*h+d*d,y=Math.sqrt(p),v=Math.sqrt(l),_=i*Math.tan(($a-Math.acos((p+l-g)/(2*y*v)))/2),b=_/v,m=_/y;Math.abs(b-1)>Ra&&this._append`L${t+b*f},${n+b*s}`,this._append`A${i},${i},0,0,${+(s*h>f*d)},${this._x1=t+m*u},${this._y1=n+m*c}`}else this._append`L${this._x1=t},${this._y1=n}`;else;}arc(t,n,e,r,i,o){if(t=+t,n=+n,o=!!o,(e=+e)<0)throw new Error(`negative radius: ${e}`);let a=e*Math.cos(r),u=e*Math.sin(r),c=t+a,f=n+u,s=1^o,l=o?r-i:i-r;null===this._x1?this._append`M${c},${f}`:(Math.abs(this._x1-c)>Ra||Math.abs(this._y1-f)>Ra)&&this._append`L${c},${f}`,e&&(l<0&&(l=l%Da+Da),l>Fa?this._append`A${e},${e},0,1,${s},${t-a},${n-u}A${e},${e},0,1,${s},${this._x1=c},${this._y1=f}`:l>Ra&&this._append`A${e},${e},0,${+(l>=$a)},${s},${this._x1=t+e*Math.cos(i)},${this._y1=n+e*Math.sin(i)}`)}rect(t,n,e,r){this._append`M${this._x0=this._x1=+t},${this._y0=this._y1=+n}h${e=+e}v${+r}h${-e}Z`}toString(){return this._}};function Ia(){return new Ua}Ia.prototype=Ua.prototype;var Oa=Array.prototype.slice;function Ba(t){return function(){return t}}function Ya(t){return t.source}function La(t){return t.target}function ja(t){return t.radius}function Ha(t){return t.startAngle}function Xa(t){return t.endAngle}function Ga(){return 0}function Va(){return 10}function Wa(t){var n=Ya,e=La,r=ja,i=ja,o=Ha,a=Xa,u=Ga,c=null;function f(){var f,s=n.apply(this,arguments),l=e.apply(this,arguments),h=u.apply(this,arguments)/2,d=Oa.call(arguments),p=+r.apply(this,(d[0]=s,d)),g=o.apply(this,d)-Ea,y=a.apply(this,d)-Ea,v=+i.apply(this,(d[0]=l,d)),_=o.apply(this,d)-Ea,b=a.apply(this,d)-Ea;if(c||(c=f=Ia()),h>Ca&&(Ma(y-g)>2*h+Ca?y>g?(g+=h,y-=h):(g-=h,y+=h):g=y=(g+y)/2,Ma(b-_)>2*h+Ca?b>_?(_+=h,b-=h):(_-=h,b+=h):_=b=(_+b)/2),c.moveTo(p*Ta(g),p*Aa(g)),c.arc(0,0,p,g,y),g!==_||y!==b)if(t){var m=v-+t.apply(this,arguments),x=(_+b)/2;c.quadraticCurveTo(0,0,m*Ta(_),m*Aa(_)),c.lineTo(v*Ta(x),v*Aa(x)),c.lineTo(m*Ta(b),m*Aa(b))}else c.quadraticCurveTo(0,0,v*Ta(_),v*Aa(_)),c.arc(0,0,v,_,b);if(c.quadraticCurveTo(0,0,p*Ta(g),p*Aa(g)),c.closePath(),f)return c=null,f+""||null}return t&&(f.headRadius=function(n){return arguments.length?(t="function"==typeof n?n:Ba(+n),f):t}),f.radius=function(t){return arguments.length?(r=i="function"==typeof t?t:Ba(+t),f):r},f.sourceRadius=function(t){return arguments.length?(r="function"==typeof t?t:Ba(+t),f):r},f.targetRadius=function(t){return arguments.length?(i="function"==typeof t?t:Ba(+t),f):i},f.startAngle=function(t){return arguments.length?(o="function"==typeof t?t:Ba(+t),f):o},f.endAngle=function(t){return arguments.length?(a="function"==typeof t?t:Ba(+t),f):a},f.padAngle=function(t){return arguments.length?(u="function"==typeof t?t:Ba(+t),f):u},f.source=function(t){return arguments.length?(n=t,f):n},f.target=function(t){return arguments.length?(e=t,f):e},f.context=function(t){return arguments.length?(c=null==t?null:t,f):c},f}var Za=Array.prototype.slice;function Ka(t,n){return t-n}var Qa=t=>()=>t;function Ja(t,n){for(var e,r=-1,i=n.length;++rr!=d>r&&e<(h-f)*(r-s)/(d-s)+f&&(i=-i)}return i}function nu(t,n,e){var r,i,o,a;return function(t,n,e){return(n[0]-t[0])*(e[1]-t[1])==(e[0]-t[0])*(n[1]-t[1])}(t,n,e)&&(i=t[r=+(t[0]===n[0])],o=e[r],a=n[r],i<=o&&o<=a||a<=o&&o<=i)}function eu(){}var ru=[[],[[[1,1.5],[.5,1]]],[[[1.5,1],[1,1.5]]],[[[1.5,1],[.5,1]]],[[[1,.5],[1.5,1]]],[[[1,1.5],[.5,1]],[[1,.5],[1.5,1]]],[[[1,.5],[1,1.5]]],[[[1,.5],[.5,1]]],[[[.5,1],[1,.5]]],[[[1,1.5],[1,.5]]],[[[.5,1],[1,.5]],[[1.5,1],[1,1.5]]],[[[1.5,1],[1,.5]]],[[[.5,1],[1.5,1]]],[[[1,1.5],[1.5,1]]],[[[.5,1],[1,1.5]]],[]];function iu(){var t=1,n=1,e=K,r=u;function i(t){var n=e(t);if(Array.isArray(n))n=n.slice().sort(Ka);else{const e=M(t,ou);for(n=G(...Z(e[0],e[1],n),n);n[n.length-1]>=e[1];)n.pop();for(;n[1]o(t,n)))}function o(e,i){const o=null==i?NaN:+i;if(isNaN(o))throw new Error(`invalid value: ${i}`);var u=[],c=[];return function(e,r,i){var o,u,c,f,s,l,h=new Array,d=new Array;o=u=-1,f=au(e[0],r),ru[f<<1].forEach(p);for(;++o=r,ru[s<<2].forEach(p);for(;++o0?u.push([t]):c.push(t)})),c.forEach((function(t){for(var n,e=0,r=u.length;e0&&o0&&a=0&&o>=0))throw new Error("invalid size");return t=r,n=o,i},i.thresholds=function(t){return arguments.length?(e="function"==typeof t?t:Array.isArray(t)?Qa(Za.call(t)):Qa(t),i):e},i.smooth=function(t){return arguments.length?(r=t?u:eu,i):r===u},i}function ou(t){return isFinite(t)?t:NaN}function au(t,n){return null!=t&&+t>=n}function uu(t){return null==t||isNaN(t=+t)?-1/0:t}function cu(t,n,e,r){const i=r-n,o=e-n,a=isFinite(i)||isFinite(o)?i/o:Math.sign(i)/Math.sign(o);return isNaN(a)?t:t+a-.5}function fu(t){return t[0]}function su(t){return t[1]}function lu(){return 1}const hu=134217729,du=33306690738754706e-32;function pu(t,n,e,r,i){let o,a,u,c,f=n[0],s=r[0],l=0,h=0;s>f==s>-f?(o=f,f=n[++l]):(o=s,s=r[++h]);let d=0;if(lf==s>-f?(a=f+o,u=o-(a-f),f=n[++l]):(a=s+o,u=o-(a-s),s=r[++h]),o=a,0!==u&&(i[d++]=u);lf==s>-f?(a=o+f,c=a-o,u=o-(a-c)+(f-c),f=n[++l]):(a=o+s,c=a-o,u=o-(a-c)+(s-c),s=r[++h]),o=a,0!==u&&(i[d++]=u);for(;l=33306690738754716e-32*f?c:-function(t,n,e,r,i,o,a){let u,c,f,s,l,h,d,p,g,y,v,_,b,m,x,w,M,T;const A=t-i,S=e-i,E=n-o,N=r-o;m=A*N,h=hu*A,d=h-(h-A),p=A-d,h=hu*N,g=h-(h-N),y=N-g,x=p*y-(m-d*g-p*g-d*y),w=E*S,h=hu*E,d=h-(h-E),p=E-d,h=hu*S,g=h-(h-S),y=S-g,M=p*y-(w-d*g-p*g-d*y),v=x-M,l=x-v,_u[0]=x-(v+l)+(l-M),_=m+v,l=_-m,b=m-(_-l)+(v-l),v=b-w,l=b-v,_u[1]=b-(v+l)+(l-w),T=_+v,l=T-_,_u[2]=_-(T-l)+(v-l),_u[3]=T;let k=function(t,n){let e=n[0];for(let r=1;r=C||-k>=C)return k;if(l=t-A,u=t-(A+l)+(l-i),l=e-S,f=e-(S+l)+(l-i),l=n-E,c=n-(E+l)+(l-o),l=r-N,s=r-(N+l)+(l-o),0===u&&0===c&&0===f&&0===s)return k;if(C=vu*a+du*Math.abs(k),k+=A*s+N*u-(E*f+S*c),k>=C||-k>=C)return k;m=u*N,h=hu*u,d=h-(h-u),p=u-d,h=hu*N,g=h-(h-N),y=N-g,x=p*y-(m-d*g-p*g-d*y),w=c*S,h=hu*c,d=h-(h-c),p=c-d,h=hu*S,g=h-(h-S),y=S-g,M=p*y-(w-d*g-p*g-d*y),v=x-M,l=x-v,wu[0]=x-(v+l)+(l-M),_=m+v,l=_-m,b=m-(_-l)+(v-l),v=b-w,l=b-v,wu[1]=b-(v+l)+(l-w),T=_+v,l=T-_,wu[2]=_-(T-l)+(v-l),wu[3]=T;const P=pu(4,_u,4,wu,bu);m=A*s,h=hu*A,d=h-(h-A),p=A-d,h=hu*s,g=h-(h-s),y=s-g,x=p*y-(m-d*g-p*g-d*y),w=E*f,h=hu*E,d=h-(h-E),p=E-d,h=hu*f,g=h-(h-f),y=f-g,M=p*y-(w-d*g-p*g-d*y),v=x-M,l=x-v,wu[0]=x-(v+l)+(l-M),_=m+v,l=_-m,b=m-(_-l)+(v-l),v=b-w,l=b-v,wu[1]=b-(v+l)+(l-w),T=_+v,l=T-_,wu[2]=_-(T-l)+(v-l),wu[3]=T;const z=pu(P,bu,4,wu,mu);m=u*s,h=hu*u,d=h-(h-u),p=u-d,h=hu*s,g=h-(h-s),y=s-g,x=p*y-(m-d*g-p*g-d*y),w=c*f,h=hu*c,d=h-(h-c),p=c-d,h=hu*f,g=h-(h-f),y=f-g,M=p*y-(w-d*g-p*g-d*y),v=x-M,l=x-v,wu[0]=x-(v+l)+(l-M),_=m+v,l=_-m,b=m-(_-l)+(v-l),v=b-w,l=b-v,wu[1]=b-(v+l)+(l-w),T=_+v,l=T-_,wu[2]=_-(T-l)+(v-l),wu[3]=T;const $=pu(z,mu,4,wu,xu);return xu[$-1]}(t,n,e,r,i,o,f)}const Tu=Math.pow(2,-52),Au=new Uint32Array(512);class Su{static from(t,n=zu,e=$u){const r=t.length,i=new Float64Array(2*r);for(let o=0;o>1;if(n>0&&"number"!=typeof t[0])throw new Error("Expected coords to contain numbers.");this.coords=t;const e=Math.max(2*n-5,0);this._triangles=new Uint32Array(3*e),this._halfedges=new Int32Array(3*e),this._hashSize=Math.ceil(Math.sqrt(n)),this._hullPrev=new Uint32Array(n),this._hullNext=new Uint32Array(n),this._hullTri=new Uint32Array(n),this._hullHash=new Int32Array(this._hashSize),this._ids=new Uint32Array(n),this._dists=new Float64Array(n),this.update()}update(){const{coords:t,_hullPrev:n,_hullNext:e,_hullTri:r,_hullHash:i}=this,o=t.length>>1;let a=1/0,u=1/0,c=-1/0,f=-1/0;for(let n=0;nc&&(c=e),r>f&&(f=r),this._ids[n]=n}const s=(a+c)/2,l=(u+f)/2;let h,d,p;for(let n=0,e=1/0;n0&&(d=n,e=r)}let v=t[2*d],_=t[2*d+1],b=1/0;for(let n=0;nr&&(n[e++]=i,r=o)}return this.hull=n.subarray(0,e),this.triangles=new Uint32Array(0),void(this.halfedges=new Uint32Array(0))}if(Mu(g,y,v,_,m,x)<0){const t=d,n=v,e=_;d=p,v=m,_=x,p=t,m=n,x=e}const w=function(t,n,e,r,i,o){const a=e-t,u=r-n,c=i-t,f=o-n,s=a*a+u*u,l=c*c+f*f,h=.5/(a*f-u*c),d=t+(f*s-u*l)*h,p=n+(a*l-c*s)*h;return{x:d,y:p}}(g,y,v,_,m,x);this._cx=w.x,this._cy=w.y;for(let n=0;n0&&Math.abs(f-o)<=Tu&&Math.abs(s-a)<=Tu)continue;if(o=f,a=s,c===h||c===d||c===p)continue;let l=0;for(let t=0,n=this._hashKey(f,s);t=0;)if(y=g,y===l){y=-1;break}if(-1===y)continue;let v=this._addTriangle(y,c,e[y],-1,-1,r[y]);r[c]=this._legalize(v+2),r[y]=v,M++;let _=e[y];for(;g=e[_],Mu(f,s,t[2*_],t[2*_+1],t[2*g],t[2*g+1])<0;)v=this._addTriangle(_,c,g,r[c],-1,r[_]),r[c]=this._legalize(v+2),e[_]=_,M--,_=g;if(y===l)for(;g=n[y],Mu(f,s,t[2*g],t[2*g+1],t[2*y],t[2*y+1])<0;)v=this._addTriangle(g,c,y,-1,r[y],r[g]),this._legalize(v+2),r[g]=v,e[y]=y,M--,y=g;this._hullStart=n[c]=y,e[y]=n[_]=c,e[c]=_,i[this._hashKey(f,s)]=c,i[this._hashKey(t[2*y],t[2*y+1])]=y}this.hull=new Uint32Array(M);for(let t=0,n=this._hullStart;t0?3-e:1+e)/4}(t-this._cx,n-this._cy)*this._hashSize)%this._hashSize}_legalize(t){const{_triangles:n,_halfedges:e,coords:r}=this;let i=0,o=0;for(;;){const a=e[t],u=t-t%3;if(o=u+(t+2)%3,-1===a){if(0===i)break;t=Au[--i];continue}const c=a-a%3,f=u+(t+1)%3,s=c+(a+2)%3,l=n[o],h=n[t],d=n[f],p=n[s];if(Nu(r[2*l],r[2*l+1],r[2*h],r[2*h+1],r[2*d],r[2*d+1],r[2*p],r[2*p+1])){n[t]=p,n[a]=l;const r=e[s];if(-1===r){let n=this._hullStart;do{if(this._hullTri[n]===s){this._hullTri[n]=t;break}n=this._hullPrev[n]}while(n!==this._hullStart)}this._link(t,r),this._link(a,e[o]),this._link(o,s);const u=c+(a+1)%3;i=e&&n[t[a]]>o;)t[a+1]=t[a--];t[a+1]=r}else{let i=e+1,o=r;Pu(t,e+r>>1,i),n[t[e]]>n[t[r]]&&Pu(t,e,r),n[t[i]]>n[t[r]]&&Pu(t,i,r),n[t[e]]>n[t[i]]&&Pu(t,e,i);const a=t[i],u=n[a];for(;;){do{i++}while(n[t[i]]u);if(o=o-e?(Cu(t,n,i,r),Cu(t,n,e,o-1)):(Cu(t,n,e,o-1),Cu(t,n,i,r))}}function Pu(t,n,e){const r=t[n];t[n]=t[e],t[e]=r}function zu(t){return t[0]}function $u(t){return t[1]}const Du=1e-6;class Ru{constructor(){this._x0=this._y0=this._x1=this._y1=null,this._=""}moveTo(t,n){this._+=`M${this._x0=this._x1=+t},${this._y0=this._y1=+n}`}closePath(){null!==this._x1&&(this._x1=this._x0,this._y1=this._y0,this._+="Z")}lineTo(t,n){this._+=`L${this._x1=+t},${this._y1=+n}`}arc(t,n,e){const r=(t=+t)+(e=+e),i=n=+n;if(e<0)throw new Error("negative radius");null===this._x1?this._+=`M${r},${i}`:(Math.abs(this._x1-r)>Du||Math.abs(this._y1-i)>Du)&&(this._+="L"+r+","+i),e&&(this._+=`A${e},${e},0,1,1,${t-e},${n}A${e},${e},0,1,1,${this._x1=r},${this._y1=i}`)}rect(t,n,e,r){this._+=`M${this._x0=this._x1=+t},${this._y0=this._y1=+n}h${+e}v${+r}h${-e}Z`}value(){return this._||null}}class Fu{constructor(){this._=[]}moveTo(t,n){this._.push([t,n])}closePath(){this._.push(this._[0].slice())}lineTo(t,n){this._.push([t,n])}value(){return this._.length?this._:null}}class qu{constructor(t,[n,e,r,i]=[0,0,960,500]){if(!((r=+r)>=(n=+n)&&(i=+i)>=(e=+e)))throw new Error("invalid bounds");this.delaunay=t,this._circumcenters=new Float64Array(2*t.points.length),this.vectors=new Float64Array(2*t.points.length),this.xmax=r,this.xmin=n,this.ymax=i,this.ymin=e,this._init()}update(){return this.delaunay.update(),this._init(),this}_init(){const{delaunay:{points:t,hull:n,triangles:e},vectors:r}=this;let i,o;const a=this.circumcenters=this._circumcenters.subarray(0,e.length/3*2);for(let r,u,c=0,f=0,s=e.length;c1;)i-=2;for(let t=2;t0){if(n>=this.ymax)return null;(i=(this.ymax-n)/r)0){if(t>=this.xmax)return null;(i=(this.xmax-t)/e)this.xmax?2:0)|(nthis.ymax?8:0)}_simplify(t){if(t&&t.length>4){for(let n=0;n2&&function(t){const{triangles:n,coords:e}=t;for(let t=0;t1e-10)return!1}return!0}(t)){this.collinear=Int32Array.from({length:n.length/2},((t,n)=>n)).sort(((t,e)=>n[2*t]-n[2*e]||n[2*t+1]-n[2*e+1]));const t=this.collinear[0],e=this.collinear[this.collinear.length-1],r=[n[2*t],n[2*t+1],n[2*e],n[2*e+1]],i=1e-8*Math.hypot(r[3]-r[1],r[2]-r[0]);for(let t=0,e=n.length/2;t0&&(this.triangles=new Int32Array(3).fill(-1),this.halfedges=new Int32Array(3).fill(-1),this.triangles[0]=r[0],o[r[0]]=1,2===r.length&&(o[r[1]]=0,this.triangles[1]=r[1],this.triangles[2]=r[1]))}voronoi(t){return new qu(this,t)}*neighbors(t){const{inedges:n,hull:e,_hullIndex:r,halfedges:i,triangles:o,collinear:a}=this;if(a){const n=a.indexOf(t);return n>0&&(yield a[n-1]),void(n=0&&i!==e&&i!==r;)e=i;return i}_step(t,n,e){const{inedges:r,hull:i,_hullIndex:o,halfedges:a,triangles:u,points:c}=this;if(-1===r[t]||!c.length)return(t+1)%(c.length>>1);let f=t,s=Iu(n-c[2*t],2)+Iu(e-c[2*t+1],2);const l=r[t];let h=l;do{let r=u[h];const l=Iu(n-c[2*r],2)+Iu(e-c[2*r+1],2);if(l9999?"+"+Ku(n,6):Ku(n,4))+"-"+Ku(t.getUTCMonth()+1,2)+"-"+Ku(t.getUTCDate(),2)+(o?"T"+Ku(e,2)+":"+Ku(r,2)+":"+Ku(i,2)+"."+Ku(o,3)+"Z":i?"T"+Ku(e,2)+":"+Ku(r,2)+":"+Ku(i,2)+"Z":r||e?"T"+Ku(e,2)+":"+Ku(r,2)+"Z":"")}function Ju(t){var n=new RegExp('["'+t+"\n\r]"),e=t.charCodeAt(0);function r(t,n){var r,i=[],o=t.length,a=0,u=0,c=o<=0,f=!1;function s(){if(c)return Hu;if(f)return f=!1,ju;var n,r,i=a;if(t.charCodeAt(i)===Xu){for(;a++=o?c=!0:(r=t.charCodeAt(a++))===Gu?f=!0:r===Vu&&(f=!0,t.charCodeAt(a)===Gu&&++a),t.slice(i+1,n-1).replace(/""/g,'"')}for(;amc(n,e).then((n=>(new DOMParser).parseFromString(n,t)))}var Sc=Ac("application/xml"),Ec=Ac("text/html"),Nc=Ac("image/svg+xml");function kc(t,n,e,r){if(isNaN(n)||isNaN(e))return t;var i,o,a,u,c,f,s,l,h,d=t._root,p={data:r},g=t._x0,y=t._y0,v=t._x1,_=t._y1;if(!d)return t._root=p,t;for(;d.length;)if((f=n>=(o=(g+v)/2))?g=o:v=o,(s=e>=(a=(y+_)/2))?y=a:_=a,i=d,!(d=d[l=s<<1|f]))return i[l]=p,t;if(u=+t._x.call(null,d.data),c=+t._y.call(null,d.data),n===u&&e===c)return p.next=d,i?i[l]=p:t._root=p,t;do{i=i?i[l]=new Array(4):t._root=new Array(4),(f=n>=(o=(g+v)/2))?g=o:v=o,(s=e>=(a=(y+_)/2))?y=a:_=a}while((l=s<<1|f)==(h=(c>=a)<<1|u>=o));return i[h]=d,i[l]=p,t}function Cc(t,n,e,r,i){this.node=t,this.x0=n,this.y0=e,this.x1=r,this.y1=i}function Pc(t){return t[0]}function zc(t){return t[1]}function $c(t,n,e){var r=new Dc(null==n?Pc:n,null==e?zc:e,NaN,NaN,NaN,NaN);return null==t?r:r.addAll(t)}function Dc(t,n,e,r,i,o){this._x=t,this._y=n,this._x0=e,this._y0=r,this._x1=i,this._y1=o,this._root=void 0}function Rc(t){for(var n={data:t.data},e=n;t=t.next;)e=e.next={data:t.data};return n}var Fc=$c.prototype=Dc.prototype;function qc(t){return function(){return t}}function Uc(t){return 1e-6*(t()-.5)}function Ic(t){return t.x+t.vx}function Oc(t){return t.y+t.vy}function Bc(t){return t.index}function Yc(t,n){var e=t.get(n);if(!e)throw new Error("node not found: "+n);return e}Fc.copy=function(){var t,n,e=new Dc(this._x,this._y,this._x0,this._y0,this._x1,this._y1),r=this._root;if(!r)return e;if(!r.length)return e._root=Rc(r),e;for(t=[{source:r,target:e._root=new Array(4)}];r=t.pop();)for(var i=0;i<4;++i)(n=r.source[i])&&(n.length?t.push({source:n,target:r.target[i]=new Array(4)}):r.target[i]=Rc(n));return e},Fc.add=function(t){const n=+this._x.call(null,t),e=+this._y.call(null,t);return kc(this.cover(n,e),n,e,t)},Fc.addAll=function(t){var n,e,r,i,o=t.length,a=new Array(o),u=new Array(o),c=1/0,f=1/0,s=-1/0,l=-1/0;for(e=0;es&&(s=r),il&&(l=i));if(c>s||f>l)return this;for(this.cover(c,f).cover(s,l),e=0;et||t>=i||r>n||n>=o;)switch(u=(nh||(o=c.y0)>d||(a=c.x1)=v)<<1|t>=y)&&(c=p[p.length-1],p[p.length-1]=p[p.length-1-f],p[p.length-1-f]=c)}else{var _=t-+this._x.call(null,g.data),b=n-+this._y.call(null,g.data),m=_*_+b*b;if(m=(u=(p+y)/2))?p=u:y=u,(s=a>=(c=(g+v)/2))?g=c:v=c,n=d,!(d=d[l=s<<1|f]))return this;if(!d.length)break;(n[l+1&3]||n[l+2&3]||n[l+3&3])&&(e=n,h=l)}for(;d.data!==t;)if(r=d,!(d=d.next))return this;return(i=d.next)&&delete d.next,r?(i?r.next=i:delete r.next,this):n?(i?n[l]=i:delete n[l],(d=n[0]||n[1]||n[2]||n[3])&&d===(n[3]||n[2]||n[1]||n[0])&&!d.length&&(e?e[h]=d:this._root=d),this):(this._root=i,this)},Fc.removeAll=function(t){for(var n=0,e=t.length;n1?r[0]+r.slice(2):r,+t.slice(e+1)]}function Zc(t){return(t=Wc(Math.abs(t)))?t[1]:NaN}var Kc,Qc=/^(?:(.)?([<>=^]))?([+\-( ])?([$#])?(0)?(\d+)?(,)?(\.\d+)?(~)?([a-z%])?$/i;function Jc(t){if(!(n=Qc.exec(t)))throw new Error("invalid format: "+t);var n;return new tf({fill:n[1],align:n[2],sign:n[3],symbol:n[4],zero:n[5],width:n[6],comma:n[7],precision:n[8]&&n[8].slice(1),trim:n[9],type:n[10]})}function tf(t){this.fill=void 0===t.fill?" ":t.fill+"",this.align=void 0===t.align?">":t.align+"",this.sign=void 0===t.sign?"-":t.sign+"",this.symbol=void 0===t.symbol?"":t.symbol+"",this.zero=!!t.zero,this.width=void 0===t.width?void 0:+t.width,this.comma=!!t.comma,this.precision=void 0===t.precision?void 0:+t.precision,this.trim=!!t.trim,this.type=void 0===t.type?"":t.type+""}function nf(t,n){var e=Wc(t,n);if(!e)return t+"";var r=e[0],i=e[1];return i<0?"0."+new Array(-i).join("0")+r:r.length>i+1?r.slice(0,i+1)+"."+r.slice(i+1):r+new Array(i-r.length+2).join("0")}Jc.prototype=tf.prototype,tf.prototype.toString=function(){return this.fill+this.align+this.sign+this.symbol+(this.zero?"0":"")+(void 0===this.width?"":Math.max(1,0|this.width))+(this.comma?",":"")+(void 0===this.precision?"":"."+Math.max(0,0|this.precision))+(this.trim?"~":"")+this.type};var ef={"%":(t,n)=>(100*t).toFixed(n),b:t=>Math.round(t).toString(2),c:t=>t+"",d:function(t){return Math.abs(t=Math.round(t))>=1e21?t.toLocaleString("en").replace(/,/g,""):t.toString(10)},e:(t,n)=>t.toExponential(n),f:(t,n)=>t.toFixed(n),g:(t,n)=>t.toPrecision(n),o:t=>Math.round(t).toString(8),p:(t,n)=>nf(100*t,n),r:nf,s:function(t,n){var e=Wc(t,n);if(!e)return t+"";var r=e[0],i=e[1],o=i-(Kc=3*Math.max(-8,Math.min(8,Math.floor(i/3))))+1,a=r.length;return o===a?r:o>a?r+new Array(o-a+1).join("0"):o>0?r.slice(0,o)+"."+r.slice(o):"0."+new Array(1-o).join("0")+Wc(t,Math.max(0,n+o-1))[0]},X:t=>Math.round(t).toString(16).toUpperCase(),x:t=>Math.round(t).toString(16)};function rf(t){return t}var of,af=Array.prototype.map,uf=["y","z","a","f","p","n","µ","m","","k","M","G","T","P","E","Z","Y"];function cf(t){var n,e,r=void 0===t.grouping||void 0===t.thousands?rf:(n=af.call(t.grouping,Number),e=t.thousands+"",function(t,r){for(var i=t.length,o=[],a=0,u=n[0],c=0;i>0&&u>0&&(c+u+1>r&&(u=Math.max(1,r-c)),o.push(t.substring(i-=u,i+u)),!((c+=u+1)>r));)u=n[a=(a+1)%n.length];return o.reverse().join(e)}),i=void 0===t.currency?"":t.currency[0]+"",o=void 0===t.currency?"":t.currency[1]+"",a=void 0===t.decimal?".":t.decimal+"",u=void 0===t.numerals?rf:function(t){return function(n){return n.replace(/[0-9]/g,(function(n){return t[+n]}))}}(af.call(t.numerals,String)),c=void 0===t.percent?"%":t.percent+"",f=void 0===t.minus?"−":t.minus+"",s=void 0===t.nan?"NaN":t.nan+"";function l(t){var n=(t=Jc(t)).fill,e=t.align,l=t.sign,h=t.symbol,d=t.zero,p=t.width,g=t.comma,y=t.precision,v=t.trim,_=t.type;"n"===_?(g=!0,_="g"):ef[_]||(void 0===y&&(y=12),v=!0,_="g"),(d||"0"===n&&"="===e)&&(d=!0,n="0",e="=");var b="$"===h?i:"#"===h&&/[boxX]/.test(_)?"0"+_.toLowerCase():"",m="$"===h?o:/[%p]/.test(_)?c:"",x=ef[_],w=/[defgprs%]/.test(_);function M(t){var i,o,c,h=b,M=m;if("c"===_)M=x(t)+M,t="";else{var T=(t=+t)<0||1/t<0;if(t=isNaN(t)?s:x(Math.abs(t),y),v&&(t=function(t){t:for(var n,e=t.length,r=1,i=-1;r0&&(i=0)}return i>0?t.slice(0,i)+t.slice(n+1):t}(t)),T&&0==+t&&"+"!==l&&(T=!1),h=(T?"("===l?l:f:"-"===l||"("===l?"":l)+h,M=("s"===_?uf[8+Kc/3]:"")+M+(T&&"("===l?")":""),w)for(i=-1,o=t.length;++i(c=t.charCodeAt(i))||c>57){M=(46===c?a+t.slice(i+1):t.slice(i))+M,t=t.slice(0,i);break}}g&&!d&&(t=r(t,1/0));var A=h.length+t.length+M.length,S=A>1)+h+t+M+S.slice(A);break;default:t=S+h+t+M}return u(t)}return y=void 0===y?6:/[gprs]/.test(_)?Math.max(1,Math.min(21,y)):Math.max(0,Math.min(20,y)),M.toString=function(){return t+""},M}return{format:l,formatPrefix:function(t,n){var e=l(((t=Jc(t)).type="f",t)),r=3*Math.max(-8,Math.min(8,Math.floor(Zc(n)/3))),i=Math.pow(10,-r),o=uf[8+r/3];return function(t){return e(i*t)+o}}}}function ff(n){return of=cf(n),t.format=of.format,t.formatPrefix=of.formatPrefix,of}function sf(t){return Math.max(0,-Zc(Math.abs(t)))}function lf(t,n){return Math.max(0,3*Math.max(-8,Math.min(8,Math.floor(Zc(n)/3)))-Zc(Math.abs(t)))}function hf(t,n){return t=Math.abs(t),n=Math.abs(n)-t,Math.max(0,Zc(n)-Zc(t))+1}t.format=void 0,t.formatPrefix=void 0,ff({thousands:",",grouping:[3],currency:["$",""]});var df=1e-6,pf=1e-12,gf=Math.PI,yf=gf/2,vf=gf/4,_f=2*gf,bf=180/gf,mf=gf/180,xf=Math.abs,wf=Math.atan,Mf=Math.atan2,Tf=Math.cos,Af=Math.ceil,Sf=Math.exp,Ef=Math.hypot,Nf=Math.log,kf=Math.pow,Cf=Math.sin,Pf=Math.sign||function(t){return t>0?1:t<0?-1:0},zf=Math.sqrt,$f=Math.tan;function Df(t){return t>1?0:t<-1?gf:Math.acos(t)}function Rf(t){return t>1?yf:t<-1?-yf:Math.asin(t)}function Ff(t){return(t=Cf(t/2))*t}function qf(){}function Uf(t,n){t&&Of.hasOwnProperty(t.type)&&Of[t.type](t,n)}var If={Feature:function(t,n){Uf(t.geometry,n)},FeatureCollection:function(t,n){for(var e=t.features,r=-1,i=e.length;++r=0?1:-1,i=r*e,o=Tf(n=(n*=mf)/2+vf),a=Cf(n),u=Vf*a,c=Gf*o+u*Tf(i),f=u*r*Cf(i);as.add(Mf(f,c)),Xf=t,Gf=o,Vf=a}function ds(t){return[Mf(t[1],t[0]),Rf(t[2])]}function ps(t){var n=t[0],e=t[1],r=Tf(e);return[r*Tf(n),r*Cf(n),Cf(e)]}function gs(t,n){return t[0]*n[0]+t[1]*n[1]+t[2]*n[2]}function ys(t,n){return[t[1]*n[2]-t[2]*n[1],t[2]*n[0]-t[0]*n[2],t[0]*n[1]-t[1]*n[0]]}function vs(t,n){t[0]+=n[0],t[1]+=n[1],t[2]+=n[2]}function _s(t,n){return[t[0]*n,t[1]*n,t[2]*n]}function bs(t){var n=zf(t[0]*t[0]+t[1]*t[1]+t[2]*t[2]);t[0]/=n,t[1]/=n,t[2]/=n}var ms,xs,ws,Ms,Ts,As,Ss,Es,Ns,ks,Cs,Ps,zs,$s,Ds,Rs,Fs={point:qs,lineStart:Is,lineEnd:Os,polygonStart:function(){Fs.point=Bs,Fs.lineStart=Ys,Fs.lineEnd=Ls,rs=new T,cs.polygonStart()},polygonEnd:function(){cs.polygonEnd(),Fs.point=qs,Fs.lineStart=Is,Fs.lineEnd=Os,as<0?(Wf=-(Kf=180),Zf=-(Qf=90)):rs>df?Qf=90:rs<-df&&(Zf=-90),os[0]=Wf,os[1]=Kf},sphere:function(){Wf=-(Kf=180),Zf=-(Qf=90)}};function qs(t,n){is.push(os=[Wf=t,Kf=t]),nQf&&(Qf=n)}function Us(t,n){var e=ps([t*mf,n*mf]);if(es){var r=ys(es,e),i=ys([r[1],-r[0],0],r);bs(i),i=ds(i);var o,a=t-Jf,u=a>0?1:-1,c=i[0]*bf*u,f=xf(a)>180;f^(u*JfQf&&(Qf=o):f^(u*Jf<(c=(c+360)%360-180)&&cQf&&(Qf=n)),f?tjs(Wf,Kf)&&(Kf=t):js(t,Kf)>js(Wf,Kf)&&(Wf=t):Kf>=Wf?(tKf&&(Kf=t)):t>Jf?js(Wf,t)>js(Wf,Kf)&&(Kf=t):js(t,Kf)>js(Wf,Kf)&&(Wf=t)}else is.push(os=[Wf=t,Kf=t]);nQf&&(Qf=n),es=e,Jf=t}function Is(){Fs.point=Us}function Os(){os[0]=Wf,os[1]=Kf,Fs.point=qs,es=null}function Bs(t,n){if(es){var e=t-Jf;rs.add(xf(e)>180?e+(e>0?360:-360):e)}else ts=t,ns=n;cs.point(t,n),Us(t,n)}function Ys(){cs.lineStart()}function Ls(){Bs(ts,ns),cs.lineEnd(),xf(rs)>df&&(Wf=-(Kf=180)),os[0]=Wf,os[1]=Kf,es=null}function js(t,n){return(n-=t)<0?n+360:n}function Hs(t,n){return t[0]-n[0]}function Xs(t,n){return t[0]<=t[1]?t[0]<=n&&n<=t[1]:ngf&&(t-=Math.round(t/_f)*_f),[t,n]}function ul(t,n,e){return(t%=_f)?n||e?ol(fl(t),sl(n,e)):fl(t):n||e?sl(n,e):al}function cl(t){return function(n,e){return xf(n+=t)>gf&&(n-=Math.round(n/_f)*_f),[n,e]}}function fl(t){var n=cl(t);return n.invert=cl(-t),n}function sl(t,n){var e=Tf(t),r=Cf(t),i=Tf(n),o=Cf(n);function a(t,n){var a=Tf(n),u=Tf(t)*a,c=Cf(t)*a,f=Cf(n),s=f*e+u*r;return[Mf(c*i-s*o,u*e-f*r),Rf(s*i+c*o)]}return a.invert=function(t,n){var a=Tf(n),u=Tf(t)*a,c=Cf(t)*a,f=Cf(n),s=f*i-c*o;return[Mf(c*i+f*o,u*e+s*r),Rf(s*e-u*r)]},a}function ll(t){function n(n){return(n=t(n[0]*mf,n[1]*mf))[0]*=bf,n[1]*=bf,n}return t=ul(t[0]*mf,t[1]*mf,t.length>2?t[2]*mf:0),n.invert=function(n){return(n=t.invert(n[0]*mf,n[1]*mf))[0]*=bf,n[1]*=bf,n},n}function hl(t,n,e,r,i,o){if(e){var a=Tf(n),u=Cf(n),c=r*e;null==i?(i=n+r*_f,o=n-c/2):(i=dl(a,i),o=dl(a,o),(r>0?io)&&(i+=r*_f));for(var f,s=i;r>0?s>o:s1&&n.push(n.pop().concat(n.shift()))},result:function(){var e=n;return n=[],t=null,e}}}function gl(t,n){return xf(t[0]-n[0])=0;--o)i.point((s=f[o])[0],s[1]);else r(h.x,h.p.x,-1,i);h=h.p}f=(h=h.o).z,d=!d}while(!h.v);i.lineEnd()}}}function _l(t){if(n=t.length){for(var n,e,r=0,i=t[0];++r=0?1:-1,E=S*A,N=E>gf,k=y*w;if(c.add(Mf(k*S*Cf(E),v*M+k*Tf(E))),a+=N?A+S*_f:A,N^p>=e^m>=e){var C=ys(ps(d),ps(b));bs(C);var P=ys(o,C);bs(P);var z=(N^A>=0?-1:1)*Rf(P[2]);(r>z||r===z&&(C[0]||C[1]))&&(u+=N^A>=0?1:-1)}}return(a<-df||a0){for(l||(i.polygonStart(),l=!0),i.lineStart(),t=0;t1&&2&c&&h.push(h.pop().concat(h.shift())),a.push(h.filter(wl))}return h}}function wl(t){return t.length>1}function Ml(t,n){return((t=t.x)[0]<0?t[1]-yf-df:yf-t[1])-((n=n.x)[0]<0?n[1]-yf-df:yf-n[1])}al.invert=al;var Tl=xl((function(){return!0}),(function(t){var n,e=NaN,r=NaN,i=NaN;return{lineStart:function(){t.lineStart(),n=1},point:function(o,a){var u=o>0?gf:-gf,c=xf(o-e);xf(c-gf)0?yf:-yf),t.point(i,r),t.lineEnd(),t.lineStart(),t.point(u,r),t.point(o,r),n=0):i!==u&&c>=gf&&(xf(e-i)df?wf((Cf(n)*(o=Tf(r))*Cf(e)-Cf(r)*(i=Tf(n))*Cf(t))/(i*o*a)):(n+r)/2}(e,r,o,a),t.point(i,r),t.lineEnd(),t.lineStart(),t.point(u,r),n=0),t.point(e=o,r=a),i=u},lineEnd:function(){t.lineEnd(),e=r=NaN},clean:function(){return 2-n}}}),(function(t,n,e,r){var i;if(null==t)i=e*yf,r.point(-gf,i),r.point(0,i),r.point(gf,i),r.point(gf,0),r.point(gf,-i),r.point(0,-i),r.point(-gf,-i),r.point(-gf,0),r.point(-gf,i);else if(xf(t[0]-n[0])>df){var o=t[0]0,i=xf(n)>df;function o(t,e){return Tf(t)*Tf(e)>n}function a(t,e,r){var i=[1,0,0],o=ys(ps(t),ps(e)),a=gs(o,o),u=o[0],c=a-u*u;if(!c)return!r&&t;var f=n*a/c,s=-n*u/c,l=ys(i,o),h=_s(i,f);vs(h,_s(o,s));var d=l,p=gs(h,d),g=gs(d,d),y=p*p-g*(gs(h,h)-1);if(!(y<0)){var v=zf(y),_=_s(d,(-p-v)/g);if(vs(_,h),_=ds(_),!r)return _;var b,m=t[0],x=e[0],w=t[1],M=e[1];x0^_[1]<(xf(_[0]-m)gf^(m<=_[0]&&_[0]<=x)){var S=_s(d,(-p+v)/g);return vs(S,h),[_,ds(S)]}}}function u(n,e){var i=r?t:gf-t,o=0;return n<-i?o|=1:n>i&&(o|=2),e<-i?o|=4:e>i&&(o|=8),o}return xl(o,(function(t){var n,e,c,f,s;return{lineStart:function(){f=c=!1,s=1},point:function(l,h){var d,p=[l,h],g=o(l,h),y=r?g?0:u(l,h):g?u(l+(l<0?gf:-gf),h):0;if(!n&&(f=c=g)&&t.lineStart(),g!==c&&(!(d=a(n,p))||gl(n,d)||gl(p,d))&&(p[2]=1),g!==c)s=0,g?(t.lineStart(),d=a(p,n),t.point(d[0],d[1])):(d=a(n,p),t.point(d[0],d[1],2),t.lineEnd()),n=d;else if(i&&n&&r^g){var v;y&e||!(v=a(p,n,!0))||(s=0,r?(t.lineStart(),t.point(v[0][0],v[0][1]),t.point(v[1][0],v[1][1]),t.lineEnd()):(t.point(v[1][0],v[1][1]),t.lineEnd(),t.lineStart(),t.point(v[0][0],v[0][1],3)))}!g||n&&gl(n,p)||t.point(p[0],p[1]),n=p,c=g,e=y},lineEnd:function(){c&&t.lineEnd(),n=null},clean:function(){return s|(f&&c)<<1}}}),(function(n,r,i,o){hl(o,t,e,i,n,r)}),r?[0,-t]:[-gf,t-gf])}var Sl,El,Nl,kl,Cl=1e9,Pl=-Cl;function zl(t,n,e,r){function i(i,o){return t<=i&&i<=e&&n<=o&&o<=r}function o(i,o,u,f){var s=0,l=0;if(null==i||(s=a(i,u))!==(l=a(o,u))||c(i,o)<0^u>0)do{f.point(0===s||3===s?t:e,s>1?r:n)}while((s=(s+u+4)%4)!==l);else f.point(o[0],o[1])}function a(r,i){return xf(r[0]-t)0?0:3:xf(r[0]-e)0?2:1:xf(r[1]-n)0?1:0:i>0?3:2}function u(t,n){return c(t.x,n.x)}function c(t,n){var e=a(t,1),r=a(n,1);return e!==r?e-r:0===e?n[1]-t[1]:1===e?t[0]-n[0]:2===e?t[1]-n[1]:n[0]-t[0]}return function(a){var c,f,s,l,h,d,p,g,y,v,_,b=a,m=pl(),x={point:w,lineStart:function(){x.point=M,f&&f.push(s=[]);v=!0,y=!1,p=g=NaN},lineEnd:function(){c&&(M(l,h),d&&y&&m.rejoin(),c.push(m.result()));x.point=w,y&&b.lineEnd()},polygonStart:function(){b=m,c=[],f=[],_=!0},polygonEnd:function(){var n=function(){for(var n=0,e=0,i=f.length;er&&(h-o)*(r-a)>(d-a)*(t-o)&&++n:d<=r&&(h-o)*(r-a)<(d-a)*(t-o)&&--n;return n}(),e=_&&n,i=(c=ft(c)).length;(e||i)&&(a.polygonStart(),e&&(a.lineStart(),o(null,null,1,a),a.lineEnd()),i&&vl(c,u,n,o,a),a.polygonEnd());b=a,c=f=s=null}};function w(t,n){i(t,n)&&b.point(t,n)}function M(o,a){var u=i(o,a);if(f&&s.push([o,a]),v)l=o,h=a,d=u,v=!1,u&&(b.lineStart(),b.point(o,a));else if(u&&y)b.point(o,a);else{var c=[p=Math.max(Pl,Math.min(Cl,p)),g=Math.max(Pl,Math.min(Cl,g))],m=[o=Math.max(Pl,Math.min(Cl,o)),a=Math.max(Pl,Math.min(Cl,a))];!function(t,n,e,r,i,o){var a,u=t[0],c=t[1],f=0,s=1,l=n[0]-u,h=n[1]-c;if(a=e-u,l||!(a>0)){if(a/=l,l<0){if(a0){if(a>s)return;a>f&&(f=a)}if(a=i-u,l||!(a<0)){if(a/=l,l<0){if(a>s)return;a>f&&(f=a)}else if(l>0){if(a0)){if(a/=h,h<0){if(a0){if(a>s)return;a>f&&(f=a)}if(a=o-c,h||!(a<0)){if(a/=h,h<0){if(a>s)return;a>f&&(f=a)}else if(h>0){if(a0&&(t[0]=u+f*l,t[1]=c+f*h),s<1&&(n[0]=u+s*l,n[1]=c+s*h),!0}}}}}(c,m,t,n,e,r)?u&&(b.lineStart(),b.point(o,a),_=!1):(y||(b.lineStart(),b.point(c[0],c[1])),b.point(m[0],m[1]),u||b.lineEnd(),_=!1)}p=o,g=a,y=u}return x}}var $l={sphere:qf,point:qf,lineStart:function(){$l.point=Rl,$l.lineEnd=Dl},lineEnd:qf,polygonStart:qf,polygonEnd:qf};function Dl(){$l.point=$l.lineEnd=qf}function Rl(t,n){El=t*=mf,Nl=Cf(n*=mf),kl=Tf(n),$l.point=Fl}function Fl(t,n){t*=mf;var e=Cf(n*=mf),r=Tf(n),i=xf(t-El),o=Tf(i),a=r*Cf(i),u=kl*e-Nl*r*o,c=Nl*e+kl*r*o;Sl.add(Mf(zf(a*a+u*u),c)),El=t,Nl=e,kl=r}function ql(t){return Sl=new T,Lf(t,$l),+Sl}var Ul=[null,null],Il={type:"LineString",coordinates:Ul};function Ol(t,n){return Ul[0]=t,Ul[1]=n,ql(Il)}var Bl={Feature:function(t,n){return Ll(t.geometry,n)},FeatureCollection:function(t,n){for(var e=t.features,r=-1,i=e.length;++r0&&(i=Ol(t[o],t[o-1]))>0&&e<=i&&r<=i&&(e+r-i)*(1-Math.pow((e-r)/i,2))df})).map(c)).concat(lt(Af(o/d)*d,i,d).filter((function(t){return xf(t%g)>df})).map(f))}return v.lines=function(){return _().map((function(t){return{type:"LineString",coordinates:t}}))},v.outline=function(){return{type:"Polygon",coordinates:[s(r).concat(l(a).slice(1),s(e).reverse().slice(1),l(u).reverse().slice(1))]}},v.extent=function(t){return arguments.length?v.extentMajor(t).extentMinor(t):v.extentMinor()},v.extentMajor=function(t){return arguments.length?(r=+t[0][0],e=+t[1][0],u=+t[0][1],a=+t[1][1],r>e&&(t=r,r=e,e=t),u>a&&(t=u,u=a,a=t),v.precision(y)):[[r,u],[e,a]]},v.extentMinor=function(e){return arguments.length?(n=+e[0][0],t=+e[1][0],o=+e[0][1],i=+e[1][1],n>t&&(e=n,n=t,t=e),o>i&&(e=o,o=i,i=e),v.precision(y)):[[n,o],[t,i]]},v.step=function(t){return arguments.length?v.stepMajor(t).stepMinor(t):v.stepMinor()},v.stepMajor=function(t){return arguments.length?(p=+t[0],g=+t[1],v):[p,g]},v.stepMinor=function(t){return arguments.length?(h=+t[0],d=+t[1],v):[h,d]},v.precision=function(h){return arguments.length?(y=+h,c=Wl(o,i,90),f=Zl(n,t,y),s=Wl(u,a,90),l=Zl(r,e,y),v):y},v.extentMajor([[-180,-90+df],[180,90-df]]).extentMinor([[-180,-80-df],[180,80+df]])}var Ql,Jl,th,nh,eh=t=>t,rh=new T,ih=new T,oh={point:qf,lineStart:qf,lineEnd:qf,polygonStart:function(){oh.lineStart=ah,oh.lineEnd=fh},polygonEnd:function(){oh.lineStart=oh.lineEnd=oh.point=qf,rh.add(xf(ih)),ih=new T},result:function(){var t=rh/2;return rh=new T,t}};function ah(){oh.point=uh}function uh(t,n){oh.point=ch,Ql=th=t,Jl=nh=n}function ch(t,n){ih.add(nh*t-th*n),th=t,nh=n}function fh(){ch(Ql,Jl)}var sh=oh,lh=1/0,hh=lh,dh=-lh,ph=dh,gh={point:function(t,n){tdh&&(dh=t);nph&&(ph=n)},lineStart:qf,lineEnd:qf,polygonStart:qf,polygonEnd:qf,result:function(){var t=[[lh,hh],[dh,ph]];return dh=ph=-(hh=lh=1/0),t}};var yh,vh,_h,bh,mh=gh,xh=0,wh=0,Mh=0,Th=0,Ah=0,Sh=0,Eh=0,Nh=0,kh=0,Ch={point:Ph,lineStart:zh,lineEnd:Rh,polygonStart:function(){Ch.lineStart=Fh,Ch.lineEnd=qh},polygonEnd:function(){Ch.point=Ph,Ch.lineStart=zh,Ch.lineEnd=Rh},result:function(){var t=kh?[Eh/kh,Nh/kh]:Sh?[Th/Sh,Ah/Sh]:Mh?[xh/Mh,wh/Mh]:[NaN,NaN];return xh=wh=Mh=Th=Ah=Sh=Eh=Nh=kh=0,t}};function Ph(t,n){xh+=t,wh+=n,++Mh}function zh(){Ch.point=$h}function $h(t,n){Ch.point=Dh,Ph(_h=t,bh=n)}function Dh(t,n){var e=t-_h,r=n-bh,i=zf(e*e+r*r);Th+=i*(_h+t)/2,Ah+=i*(bh+n)/2,Sh+=i,Ph(_h=t,bh=n)}function Rh(){Ch.point=Ph}function Fh(){Ch.point=Uh}function qh(){Ih(yh,vh)}function Uh(t,n){Ch.point=Ih,Ph(yh=_h=t,vh=bh=n)}function Ih(t,n){var e=t-_h,r=n-bh,i=zf(e*e+r*r);Th+=i*(_h+t)/2,Ah+=i*(bh+n)/2,Sh+=i,Eh+=(i=bh*t-_h*n)*(_h+t),Nh+=i*(bh+n),kh+=3*i,Ph(_h=t,bh=n)}var Oh=Ch;function Bh(t){this._context=t}Bh.prototype={_radius:4.5,pointRadius:function(t){return this._radius=t,this},polygonStart:function(){this._line=0},polygonEnd:function(){this._line=NaN},lineStart:function(){this._point=0},lineEnd:function(){0===this._line&&this._context.closePath(),this._point=NaN},point:function(t,n){switch(this._point){case 0:this._context.moveTo(t,n),this._point=1;break;case 1:this._context.lineTo(t,n);break;default:this._context.moveTo(t+this._radius,n),this._context.arc(t,n,this._radius,0,_f)}},result:qf};var Yh,Lh,jh,Hh,Xh,Gh=new T,Vh={point:qf,lineStart:function(){Vh.point=Wh},lineEnd:function(){Yh&&Zh(Lh,jh),Vh.point=qf},polygonStart:function(){Yh=!0},polygonEnd:function(){Yh=null},result:function(){var t=+Gh;return Gh=new T,t}};function Wh(t,n){Vh.point=Zh,Lh=Hh=t,jh=Xh=n}function Zh(t,n){Hh-=t,Xh-=n,Gh.add(zf(Hh*Hh+Xh*Xh)),Hh=t,Xh=n}var Kh=Vh;let Qh,Jh,td,nd;class ed{constructor(t){this._append=null==t?rd:function(t){const n=Math.floor(t);if(!(n>=0))throw new RangeError(`invalid digits: ${t}`);if(n>15)return rd;if(n!==Qh){const t=10**n;Qh=n,Jh=function(n){let e=1;this._+=n[0];for(const r=n.length;e4*n&&g--){var m=a+h,x=u+d,w=c+p,M=zf(m*m+x*x+w*w),T=Rf(w/=M),A=xf(xf(w)-1)n||xf((v*k+_*C)/b-.5)>.3||a*h+u*d+c*p2?t[2]%360*mf:0,k()):[y*bf,v*bf,_*bf]},E.angle=function(t){return arguments.length?(b=t%360*mf,k()):b*bf},E.reflectX=function(t){return arguments.length?(m=t?-1:1,k()):m<0},E.reflectY=function(t){return arguments.length?(x=t?-1:1,k()):x<0},E.precision=function(t){return arguments.length?(a=dd(u,S=t*t),C()):zf(S)},E.fitExtent=function(t,n){return ud(E,t,n)},E.fitSize=function(t,n){return cd(E,t,n)},E.fitWidth=function(t,n){return fd(E,t,n)},E.fitHeight=function(t,n){return sd(E,t,n)},function(){return n=t.apply(this,arguments),E.invert=n.invert&&N,k()}}function _d(t){var n=0,e=gf/3,r=vd(t),i=r(n,e);return i.parallels=function(t){return arguments.length?r(n=t[0]*mf,e=t[1]*mf):[n*bf,e*bf]},i}function bd(t,n){var e=Cf(t),r=(e+Cf(n))/2;if(xf(r)0?n<-yf+df&&(n=-yf+df):n>yf-df&&(n=yf-df);var e=i/kf(Nd(n),r);return[e*Cf(r*t),i-e*Tf(r*t)]}return o.invert=function(t,n){var e=i-n,o=Pf(r)*zf(t*t+e*e),a=Mf(t,xf(e))*Pf(e);return e*r<0&&(a-=gf*Pf(t)*Pf(e)),[a/r,2*wf(kf(i/o,1/r))-yf]},o}function Cd(t,n){return[t,n]}function Pd(t,n){var e=Tf(t),r=t===n?Cf(t):(e-Tf(n))/(n-t),i=e/r+t;if(xf(r)=0;)n+=e[r].value;else n=1;t.value=n}function Gd(t,n){t instanceof Map?(t=[void 0,t],void 0===n&&(n=Wd)):void 0===n&&(n=Vd);for(var e,r,i,o,a,u=new Qd(t),c=[u];e=c.pop();)if((i=n(e.data))&&(a=(i=Array.from(i)).length))for(e.children=i,o=a-1;o>=0;--o)c.push(r=i[o]=new Qd(i[o])),r.parent=e,r.depth=e.depth+1;return u.eachBefore(Kd)}function Vd(t){return t.children}function Wd(t){return Array.isArray(t)?t[1]:null}function Zd(t){void 0!==t.data.value&&(t.value=t.data.value),t.data=t.data.data}function Kd(t){var n=0;do{t.height=n}while((t=t.parent)&&t.height<++n)}function Qd(t){this.data=t,this.depth=this.height=0,this.parent=null}function Jd(t){return null==t?null:tp(t)}function tp(t){if("function"!=typeof t)throw new Error;return t}function np(){return 0}function ep(t){return function(){return t}}qd.invert=function(t,n){for(var e,r=n,i=r*r,o=i*i*i,a=0;a<12&&(o=(i=(r-=e=(r*(zd+$d*i+o*(Dd+Rd*i))-n)/(zd+3*$d*i+o*(7*Dd+9*Rd*i)))*r)*i*i,!(xf(e)df&&--i>0);return[t/(.8707+(o=r*r)*(o*(o*o*o*(.003971-.001529*o)-.013791)-.131979)),r]},Od.invert=Md(Rf),Bd.invert=Md((function(t){return 2*wf(t)})),Yd.invert=function(t,n){return[-n,2*wf(Sf(t))-yf]},Qd.prototype=Gd.prototype={constructor:Qd,count:function(){return this.eachAfter(Xd)},each:function(t,n){let e=-1;for(const r of this)t.call(n,r,++e,this);return this},eachAfter:function(t,n){for(var e,r,i,o=this,a=[o],u=[],c=-1;o=a.pop();)if(u.push(o),e=o.children)for(r=0,i=e.length;r=0;--r)o.push(e[r]);return this},find:function(t,n){let e=-1;for(const r of this)if(t.call(n,r,++e,this))return r},sum:function(t){return this.eachAfter((function(n){for(var e=+t(n.data)||0,r=n.children,i=r&&r.length;--i>=0;)e+=r[i].value;n.value=e}))},sort:function(t){return this.eachBefore((function(n){n.children&&n.children.sort(t)}))},path:function(t){for(var n=this,e=function(t,n){if(t===n)return t;var e=t.ancestors(),r=n.ancestors(),i=null;t=e.pop(),n=r.pop();for(;t===n;)i=t,t=e.pop(),n=r.pop();return i}(n,t),r=[n];n!==e;)n=n.parent,r.push(n);for(var i=r.length;t!==e;)r.splice(i,0,t),t=t.parent;return r},ancestors:function(){for(var t=this,n=[t];t=t.parent;)n.push(t);return n},descendants:function(){return Array.from(this)},leaves:function(){var t=[];return this.eachBefore((function(n){n.children||t.push(n)})),t},links:function(){var t=this,n=[];return t.each((function(e){e!==t&&n.push({source:e.parent,target:e})})),n},copy:function(){return Gd(this).eachBefore(Zd)},[Symbol.iterator]:function*(){var t,n,e,r,i=this,o=[i];do{for(t=o.reverse(),o=[];i=t.pop();)if(yield i,n=i.children)for(e=0,r=n.length;e(t=(rp*t+ip)%op)/op}function up(t,n){for(var e,r,i=0,o=(t=function(t,n){let e,r,i=t.length;for(;i;)r=n()*i--|0,e=t[i],t[i]=t[r],t[r]=e;return t}(Array.from(t),n)).length,a=[];i0&&e*e>r*r+i*i}function lp(t,n){for(var e=0;e1e-6?(E+Math.sqrt(E*E-4*S*N))/(2*S):N/E);return{x:r+w+M*k,y:i+T+A*k,r:k}}function gp(t,n,e){var r,i,o,a,u=t.x-n.x,c=t.y-n.y,f=u*u+c*c;f?(i=n.r+e.r,i*=i,a=t.r+e.r,i>(a*=a)?(r=(f+a-i)/(2*f),o=Math.sqrt(Math.max(0,a/f-r*r)),e.x=t.x-r*u-o*c,e.y=t.y-r*c+o*u):(r=(f+i-a)/(2*f),o=Math.sqrt(Math.max(0,i/f-r*r)),e.x=n.x+r*u-o*c,e.y=n.y+r*c+o*u)):(e.x=n.x+e.r,e.y=n.y)}function yp(t,n){var e=t.r+n.r-1e-6,r=n.x-t.x,i=n.y-t.y;return e>0&&e*e>r*r+i*i}function vp(t){var n=t._,e=t.next._,r=n.r+e.r,i=(n.x*e.r+e.x*n.r)/r,o=(n.y*e.r+e.y*n.r)/r;return i*i+o*o}function _p(t){this._=t,this.next=null,this.previous=null}function bp(t,n){if(!(o=(t=function(t){return"object"==typeof t&&"length"in t?t:Array.from(t)}(t)).length))return 0;var e,r,i,o,a,u,c,f,s,l,h;if((e=t[0]).x=0,e.y=0,!(o>1))return e.r;if(r=t[1],e.x=-r.r,r.x=e.r,r.y=0,!(o>2))return e.r+r.r;gp(r,e,i=t[2]),e=new _p(e),r=new _p(r),i=new _p(i),e.next=i.previous=r,r.next=e.previous=i,i.next=r.previous=e;t:for(c=3;c1&&!zp(t,n););return t.slice(0,n)}function zp(t,n){if("/"===t[n]){let e=0;for(;n>0&&"\\"===t[--n];)++e;if(!(1&e))return!0}return!1}function $p(t,n){return t.parent===n.parent?1:2}function Dp(t){var n=t.children;return n?n[0]:t.t}function Rp(t){var n=t.children;return n?n[n.length-1]:t.t}function Fp(t,n,e){var r=e/(n.i-t.i);n.c-=r,n.s+=e,t.c+=r,n.z+=e,n.m+=e}function qp(t,n,e){return t.a.parent===n.parent?t.a:e}function Up(t,n){this._=t,this.parent=null,this.children=null,this.A=null,this.a=this,this.z=0,this.m=0,this.c=0,this.s=0,this.t=null,this.i=n}function Ip(t,n,e,r,i){for(var o,a=t.children,u=-1,c=a.length,f=t.value&&(i-e)/t.value;++uh&&(h=u),y=s*s*g,(d=Math.max(h/y,y/l))>p){s-=u;break}p=d}v.push(a={value:s,dice:c1?n:1)},e}(Op);var Lp=function t(n){function e(t,e,r,i,o){if((a=t._squarify)&&a.ratio===n)for(var a,u,c,f,s,l=-1,h=a.length,d=t.value;++l1?n:1)},e}(Op);function jp(t,n,e){return(n[0]-t[0])*(e[1]-t[1])-(n[1]-t[1])*(e[0]-t[0])}function Hp(t,n){return t[0]-n[0]||t[1]-n[1]}function Xp(t){const n=t.length,e=[0,1];let r,i=2;for(r=2;r1&&jp(t[e[i-2]],t[e[i-1]],t[r])<=0;)--i;e[i++]=r}return e.slice(0,i)}var Gp=Math.random,Vp=function t(n){function e(t,e){return t=null==t?0:+t,e=null==e?1:+e,1===arguments.length?(e=t,t=0):e-=t,function(){return n()*e+t}}return e.source=t,e}(Gp),Wp=function t(n){function e(t,e){return arguments.length<2&&(e=t,t=0),t=Math.floor(t),e=Math.floor(e)-t,function(){return Math.floor(n()*e+t)}}return e.source=t,e}(Gp),Zp=function t(n){function e(t,e){var r,i;return t=null==t?0:+t,e=null==e?1:+e,function(){var o;if(null!=r)o=r,r=null;else do{r=2*n()-1,o=2*n()-1,i=r*r+o*o}while(!i||i>1);return t+e*o*Math.sqrt(-2*Math.log(i)/i)}}return e.source=t,e}(Gp),Kp=function t(n){var e=Zp.source(n);function r(){var t=e.apply(this,arguments);return function(){return Math.exp(t())}}return r.source=t,r}(Gp),Qp=function t(n){function e(t){return(t=+t)<=0?()=>0:function(){for(var e=0,r=t;r>1;--r)e+=n();return e+r*n()}}return e.source=t,e}(Gp),Jp=function t(n){var e=Qp.source(n);function r(t){if(0==(t=+t))return n;var r=e(t);return function(){return r()/t}}return r.source=t,r}(Gp),tg=function t(n){function e(t){return function(){return-Math.log1p(-n())/t}}return e.source=t,e}(Gp),ng=function t(n){function e(t){if((t=+t)<0)throw new RangeError("invalid alpha");return t=1/-t,function(){return Math.pow(1-n(),t)}}return e.source=t,e}(Gp),eg=function t(n){function e(t){if((t=+t)<0||t>1)throw new RangeError("invalid p");return function(){return Math.floor(n()+t)}}return e.source=t,e}(Gp),rg=function t(n){function e(t){if((t=+t)<0||t>1)throw new RangeError("invalid p");return 0===t?()=>1/0:1===t?()=>1:(t=Math.log1p(-t),function(){return 1+Math.floor(Math.log1p(-n())/t)})}return e.source=t,e}(Gp),ig=function t(n){var e=Zp.source(n)();function r(t,r){if((t=+t)<0)throw new RangeError("invalid k");if(0===t)return()=>0;if(r=null==r?1:+r,1===t)return()=>-Math.log1p(-n())*r;var i=(t<1?t+1:t)-1/3,o=1/(3*Math.sqrt(i)),a=t<1?()=>Math.pow(n(),1/t):()=>1;return function(){do{do{var t=e(),u=1+o*t}while(u<=0);u*=u*u;var c=1-n()}while(c>=1-.0331*t*t*t*t&&Math.log(c)>=.5*t*t+i*(1-u+Math.log(u)));return i*u*a()*r}}return r.source=t,r}(Gp),og=function t(n){var e=ig.source(n);function r(t,n){var r=e(t),i=e(n);return function(){var t=r();return 0===t?0:t/(t+i())}}return r.source=t,r}(Gp),ag=function t(n){var e=rg.source(n),r=og.source(n);function i(t,n){return t=+t,(n=+n)>=1?()=>t:n<=0?()=>0:function(){for(var i=0,o=t,a=n;o*a>16&&o*(1-a)>16;){var u=Math.floor((o+1)*a),c=r(u,o-u+1)();c<=a?(i+=u,o-=u,a=(a-c)/(1-c)):(o=u-1,a/=c)}for(var f=a<.5,s=e(f?a:1-a),l=s(),h=0;l<=o;++h)l+=s();return i+(f?h:o-h)}}return i.source=t,i}(Gp),ug=function t(n){function e(t,e,r){var i;return 0==(t=+t)?i=t=>-Math.log(t):(t=1/t,i=n=>Math.pow(n,t)),e=null==e?0:+e,r=null==r?1:+r,function(){return e+r*i(-Math.log1p(-n()))}}return e.source=t,e}(Gp),cg=function t(n){function e(t,e){return t=null==t?0:+t,e=null==e?1:+e,function(){return t+e*Math.tan(Math.PI*n())}}return e.source=t,e}(Gp),fg=function t(n){function e(t,e){return t=null==t?0:+t,e=null==e?1:+e,function(){var r=n();return t+e*Math.log(r/(1-r))}}return e.source=t,e}(Gp),sg=function t(n){var e=ig.source(n),r=ag.source(n);function i(t){return function(){for(var i=0,o=t;o>16;){var a=Math.floor(.875*o),u=e(a)();if(u>o)return i+r(a-1,o/u)();i+=a,o-=u}for(var c=-Math.log1p(-n()),f=0;c<=o;++f)c-=Math.log1p(-n());return i+f}}return i.source=t,i}(Gp);const lg=1/4294967296;function hg(t,n){switch(arguments.length){case 0:break;case 1:this.range(t);break;default:this.range(n).domain(t)}return this}function dg(t,n){switch(arguments.length){case 0:break;case 1:"function"==typeof t?this.interpolator(t):this.range(t);break;default:this.domain(t),"function"==typeof n?this.interpolator(n):this.range(n)}return this}const pg=Symbol("implicit");function gg(){var t=new InternMap,n=[],e=[],r=pg;function i(i){let o=t.get(i);if(void 0===o){if(r!==pg)return r;t.set(i,o=n.push(i)-1)}return e[o%e.length]}return i.domain=function(e){if(!arguments.length)return n.slice();n=[],t=new InternMap;for(const r of e)t.has(r)||t.set(r,n.push(r)-1);return i},i.range=function(t){return arguments.length?(e=Array.from(t),i):e.slice()},i.unknown=function(t){return arguments.length?(r=t,i):r},i.copy=function(){return gg(n,e).unknown(r)},hg.apply(i,arguments),i}function yg(){var t,n,e=gg().unknown(void 0),r=e.domain,i=e.range,o=0,a=1,u=!1,c=0,f=0,s=.5;function l(){var e=r().length,l=an&&(e=t,t=n,n=e),function(e){return Math.max(t,Math.min(n,e))}}(a[0],a[t-1])),r=t>2?Mg:wg,i=o=null,l}function l(n){return null==n||isNaN(n=+n)?e:(i||(i=r(a.map(t),u,c)))(t(f(n)))}return l.invert=function(e){return f(n((o||(o=r(u,a.map(t),Yr)))(e)))},l.domain=function(t){return arguments.length?(a=Array.from(t,_g),s()):a.slice()},l.range=function(t){return arguments.length?(u=Array.from(t),s()):u.slice()},l.rangeRound=function(t){return u=Array.from(t),c=Vr,s()},l.clamp=function(t){return arguments.length?(f=!!t||mg,s()):f!==mg},l.interpolate=function(t){return arguments.length?(c=t,s()):c},l.unknown=function(t){return arguments.length?(e=t,l):e},function(e,r){return t=e,n=r,s()}}function Sg(){return Ag()(mg,mg)}function Eg(n,e,r,i){var o,a=W(n,e,r);switch((i=Jc(null==i?",f":i)).type){case"s":var u=Math.max(Math.abs(n),Math.abs(e));return null!=i.precision||isNaN(o=lf(a,u))||(i.precision=o),t.formatPrefix(i,u);case"":case"e":case"g":case"p":case"r":null!=i.precision||isNaN(o=hf(a,Math.max(Math.abs(n),Math.abs(e))))||(i.precision=o-("e"===i.type));break;case"f":case"%":null!=i.precision||isNaN(o=sf(a))||(i.precision=o-2*("%"===i.type))}return t.format(i)}function Ng(t){var n=t.domain;return t.ticks=function(t){var e=n();return G(e[0],e[e.length-1],null==t?10:t)},t.tickFormat=function(t,e){var r=n();return Eg(r[0],r[r.length-1],null==t?10:t,e)},t.nice=function(e){null==e&&(e=10);var r,i,o=n(),a=0,u=o.length-1,c=o[a],f=o[u],s=10;for(f0;){if((i=V(c,f,e))===r)return o[a]=c,o[u]=f,n(o);if(i>0)c=Math.floor(c/i)*i,f=Math.ceil(f/i)*i;else{if(!(i<0))break;c=Math.ceil(c*i)/i,f=Math.floor(f*i)/i}r=i}return t},t}function kg(t,n){var e,r=0,i=(t=t.slice()).length-1,o=t[r],a=t[i];return a-t(-n,e)}function Fg(n){const e=n(Cg,Pg),r=e.domain;let i,o,a=10;function u(){return i=function(t){return t===Math.E?Math.log:10===t&&Math.log10||2===t&&Math.log2||(t=Math.log(t),n=>Math.log(n)/t)}(a),o=function(t){return 10===t?Dg:t===Math.E?Math.exp:n=>Math.pow(t,n)}(a),r()[0]<0?(i=Rg(i),o=Rg(o),n(zg,$g)):n(Cg,Pg),e}return e.base=function(t){return arguments.length?(a=+t,u()):a},e.domain=function(t){return arguments.length?(r(t),u()):r()},e.ticks=t=>{const n=r();let e=n[0],u=n[n.length-1];const c=u0){for(;l<=h;++l)for(f=1;fu)break;p.push(s)}}else for(;l<=h;++l)for(f=a-1;f>=1;--f)if(s=l>0?f/o(-l):f*o(l),!(su)break;p.push(s)}2*p.length{if(null==n&&(n=10),null==r&&(r=10===a?"s":","),"function"!=typeof r&&(a%1||null!=(r=Jc(r)).precision||(r.trim=!0),r=t.format(r)),n===1/0)return r;const u=Math.max(1,a*n/e.ticks().length);return t=>{let n=t/o(Math.round(i(t)));return n*ar(kg(r(),{floor:t=>o(Math.floor(i(t))),ceil:t=>o(Math.ceil(i(t)))})),e}function qg(t){return function(n){return Math.sign(n)*Math.log1p(Math.abs(n/t))}}function Ug(t){return function(n){return Math.sign(n)*Math.expm1(Math.abs(n))*t}}function Ig(t){var n=1,e=t(qg(n),Ug(n));return e.constant=function(e){return arguments.length?t(qg(n=+e),Ug(n)):n},Ng(e)}function Og(t){return function(n){return n<0?-Math.pow(-n,t):Math.pow(n,t)}}function Bg(t){return t<0?-Math.sqrt(-t):Math.sqrt(t)}function Yg(t){return t<0?-t*t:t*t}function Lg(t){var n=t(mg,mg),e=1;return n.exponent=function(n){return arguments.length?1===(e=+n)?t(mg,mg):.5===e?t(Bg,Yg):t(Og(e),Og(1/e)):e},Ng(n)}function jg(){var t=Lg(Ag());return t.copy=function(){return Tg(t,jg()).exponent(t.exponent())},hg.apply(t,arguments),t}function Hg(t){return Math.sign(t)*t*t}const Xg=new Date,Gg=new Date;function Vg(t,n,e,r){function i(n){return t(n=0===arguments.length?new Date:new Date(+n)),n}return i.floor=n=>(t(n=new Date(+n)),n),i.ceil=e=>(t(e=new Date(e-1)),n(e,1),t(e),e),i.round=t=>{const n=i(t),e=i.ceil(t);return t-n(n(t=new Date(+t),null==e?1:Math.floor(e)),t),i.range=(e,r,o)=>{const a=[];if(e=i.ceil(e),o=null==o?1:Math.floor(o),!(e0))return a;let u;do{a.push(u=new Date(+e)),n(e,o),t(e)}while(uVg((n=>{if(n>=n)for(;t(n),!e(n);)n.setTime(n-1)}),((t,r)=>{if(t>=t)if(r<0)for(;++r<=0;)for(;n(t,-1),!e(t););else for(;--r>=0;)for(;n(t,1),!e(t););})),e&&(i.count=(n,r)=>(Xg.setTime(+n),Gg.setTime(+r),t(Xg),t(Gg),Math.floor(e(Xg,Gg))),i.every=t=>(t=Math.floor(t),isFinite(t)&&t>0?t>1?i.filter(r?n=>r(n)%t==0:n=>i.count(0,n)%t==0):i:null)),i}const Wg=Vg((()=>{}),((t,n)=>{t.setTime(+t+n)}),((t,n)=>n-t));Wg.every=t=>(t=Math.floor(t),isFinite(t)&&t>0?t>1?Vg((n=>{n.setTime(Math.floor(n/t)*t)}),((n,e)=>{n.setTime(+n+e*t)}),((n,e)=>(e-n)/t)):Wg:null);const Zg=Wg.range,Kg=1e3,Qg=6e4,Jg=36e5,ty=864e5,ny=6048e5,ey=2592e6,ry=31536e6,iy=Vg((t=>{t.setTime(t-t.getMilliseconds())}),((t,n)=>{t.setTime(+t+n*Kg)}),((t,n)=>(n-t)/Kg),(t=>t.getUTCSeconds())),oy=iy.range,ay=Vg((t=>{t.setTime(t-t.getMilliseconds()-t.getSeconds()*Kg)}),((t,n)=>{t.setTime(+t+n*Qg)}),((t,n)=>(n-t)/Qg),(t=>t.getMinutes())),uy=ay.range,cy=Vg((t=>{t.setUTCSeconds(0,0)}),((t,n)=>{t.setTime(+t+n*Qg)}),((t,n)=>(n-t)/Qg),(t=>t.getUTCMinutes())),fy=cy.range,sy=Vg((t=>{t.setTime(t-t.getMilliseconds()-t.getSeconds()*Kg-t.getMinutes()*Qg)}),((t,n)=>{t.setTime(+t+n*Jg)}),((t,n)=>(n-t)/Jg),(t=>t.getHours())),ly=sy.range,hy=Vg((t=>{t.setUTCMinutes(0,0,0)}),((t,n)=>{t.setTime(+t+n*Jg)}),((t,n)=>(n-t)/Jg),(t=>t.getUTCHours())),dy=hy.range,py=Vg((t=>t.setHours(0,0,0,0)),((t,n)=>t.setDate(t.getDate()+n)),((t,n)=>(n-t-(n.getTimezoneOffset()-t.getTimezoneOffset())*Qg)/ty),(t=>t.getDate()-1)),gy=py.range,yy=Vg((t=>{t.setUTCHours(0,0,0,0)}),((t,n)=>{t.setUTCDate(t.getUTCDate()+n)}),((t,n)=>(n-t)/ty),(t=>t.getUTCDate()-1)),vy=yy.range,_y=Vg((t=>{t.setUTCHours(0,0,0,0)}),((t,n)=>{t.setUTCDate(t.getUTCDate()+n)}),((t,n)=>(n-t)/ty),(t=>Math.floor(t/ty))),by=_y.range;function my(t){return Vg((n=>{n.setDate(n.getDate()-(n.getDay()+7-t)%7),n.setHours(0,0,0,0)}),((t,n)=>{t.setDate(t.getDate()+7*n)}),((t,n)=>(n-t-(n.getTimezoneOffset()-t.getTimezoneOffset())*Qg)/ny))}const xy=my(0),wy=my(1),My=my(2),Ty=my(3),Ay=my(4),Sy=my(5),Ey=my(6),Ny=xy.range,ky=wy.range,Cy=My.range,Py=Ty.range,zy=Ay.range,$y=Sy.range,Dy=Ey.range;function Ry(t){return Vg((n=>{n.setUTCDate(n.getUTCDate()-(n.getUTCDay()+7-t)%7),n.setUTCHours(0,0,0,0)}),((t,n)=>{t.setUTCDate(t.getUTCDate()+7*n)}),((t,n)=>(n-t)/ny))}const Fy=Ry(0),qy=Ry(1),Uy=Ry(2),Iy=Ry(3),Oy=Ry(4),By=Ry(5),Yy=Ry(6),Ly=Fy.range,jy=qy.range,Hy=Uy.range,Xy=Iy.range,Gy=Oy.range,Vy=By.range,Wy=Yy.range,Zy=Vg((t=>{t.setDate(1),t.setHours(0,0,0,0)}),((t,n)=>{t.setMonth(t.getMonth()+n)}),((t,n)=>n.getMonth()-t.getMonth()+12*(n.getFullYear()-t.getFullYear())),(t=>t.getMonth())),Ky=Zy.range,Qy=Vg((t=>{t.setUTCDate(1),t.setUTCHours(0,0,0,0)}),((t,n)=>{t.setUTCMonth(t.getUTCMonth()+n)}),((t,n)=>n.getUTCMonth()-t.getUTCMonth()+12*(n.getUTCFullYear()-t.getUTCFullYear())),(t=>t.getUTCMonth())),Jy=Qy.range,tv=Vg((t=>{t.setMonth(0,1),t.setHours(0,0,0,0)}),((t,n)=>{t.setFullYear(t.getFullYear()+n)}),((t,n)=>n.getFullYear()-t.getFullYear()),(t=>t.getFullYear()));tv.every=t=>isFinite(t=Math.floor(t))&&t>0?Vg((n=>{n.setFullYear(Math.floor(n.getFullYear()/t)*t),n.setMonth(0,1),n.setHours(0,0,0,0)}),((n,e)=>{n.setFullYear(n.getFullYear()+e*t)})):null;const nv=tv.range,ev=Vg((t=>{t.setUTCMonth(0,1),t.setUTCHours(0,0,0,0)}),((t,n)=>{t.setUTCFullYear(t.getUTCFullYear()+n)}),((t,n)=>n.getUTCFullYear()-t.getUTCFullYear()),(t=>t.getUTCFullYear()));ev.every=t=>isFinite(t=Math.floor(t))&&t>0?Vg((n=>{n.setUTCFullYear(Math.floor(n.getUTCFullYear()/t)*t),n.setUTCMonth(0,1),n.setUTCHours(0,0,0,0)}),((n,e)=>{n.setUTCFullYear(n.getUTCFullYear()+e*t)})):null;const rv=ev.range;function iv(t,n,e,i,o,a){const u=[[iy,1,Kg],[iy,5,5e3],[iy,15,15e3],[iy,30,3e4],[a,1,Qg],[a,5,3e5],[a,15,9e5],[a,30,18e5],[o,1,Jg],[o,3,108e5],[o,6,216e5],[o,12,432e5],[i,1,ty],[i,2,1728e5],[e,1,ny],[n,1,ey],[n,3,7776e6],[t,1,ry]];function c(n,e,i){const o=Math.abs(e-n)/i,a=r((([,,t])=>t)).right(u,o);if(a===u.length)return t.every(W(n/ry,e/ry,i));if(0===a)return Wg.every(Math.max(W(n,e,i),1));const[c,f]=u[o/u[a-1][2]=12)]},q:function(t){return 1+~~(t.getMonth()/3)},Q:k_,s:C_,S:Zv,u:Kv,U:Qv,V:t_,w:n_,W:e_,x:null,X:null,y:r_,Y:o_,Z:u_,"%":N_},m={a:function(t){return a[t.getUTCDay()]},A:function(t){return o[t.getUTCDay()]},b:function(t){return c[t.getUTCMonth()]},B:function(t){return u[t.getUTCMonth()]},c:null,d:c_,e:c_,f:d_,g:T_,G:S_,H:f_,I:s_,j:l_,L:h_,m:p_,M:g_,p:function(t){return i[+(t.getUTCHours()>=12)]},q:function(t){return 1+~~(t.getUTCMonth()/3)},Q:k_,s:C_,S:y_,u:v_,U:__,V:m_,w:x_,W:w_,x:null,X:null,y:M_,Y:A_,Z:E_,"%":N_},x={a:function(t,n,e){var r=d.exec(n.slice(e));return r?(t.w=p.get(r[0].toLowerCase()),e+r[0].length):-1},A:function(t,n,e){var r=l.exec(n.slice(e));return r?(t.w=h.get(r[0].toLowerCase()),e+r[0].length):-1},b:function(t,n,e){var r=v.exec(n.slice(e));return r?(t.m=_.get(r[0].toLowerCase()),e+r[0].length):-1},B:function(t,n,e){var r=g.exec(n.slice(e));return r?(t.m=y.get(r[0].toLowerCase()),e+r[0].length):-1},c:function(t,e,r){return T(t,n,e,r)},d:zv,e:zv,f:Uv,g:Nv,G:Ev,H:Dv,I:Dv,j:$v,L:qv,m:Pv,M:Rv,p:function(t,n,e){var r=f.exec(n.slice(e));return r?(t.p=s.get(r[0].toLowerCase()),e+r[0].length):-1},q:Cv,Q:Ov,s:Bv,S:Fv,u:Mv,U:Tv,V:Av,w:wv,W:Sv,x:function(t,n,r){return T(t,e,n,r)},X:function(t,n,e){return T(t,r,n,e)},y:Nv,Y:Ev,Z:kv,"%":Iv};function w(t,n){return function(e){var r,i,o,a=[],u=-1,c=0,f=t.length;for(e instanceof Date||(e=new Date(+e));++u53)return null;"w"in o||(o.w=1),"Z"in o?(i=(r=sv(lv(o.y,0,1))).getUTCDay(),r=i>4||0===i?qy.ceil(r):qy(r),r=yy.offset(r,7*(o.V-1)),o.y=r.getUTCFullYear(),o.m=r.getUTCMonth(),o.d=r.getUTCDate()+(o.w+6)%7):(i=(r=fv(lv(o.y,0,1))).getDay(),r=i>4||0===i?wy.ceil(r):wy(r),r=py.offset(r,7*(o.V-1)),o.y=r.getFullYear(),o.m=r.getMonth(),o.d=r.getDate()+(o.w+6)%7)}else("W"in o||"U"in o)&&("w"in o||(o.w="u"in o?o.u%7:"W"in o?1:0),i="Z"in o?sv(lv(o.y,0,1)).getUTCDay():fv(lv(o.y,0,1)).getDay(),o.m=0,o.d="W"in o?(o.w+6)%7+7*o.W-(i+5)%7:o.w+7*o.U-(i+6)%7);return"Z"in o?(o.H+=o.Z/100|0,o.M+=o.Z%100,sv(o)):fv(o)}}function T(t,n,e,r){for(var i,o,a=0,u=n.length,c=e.length;a=c)return-1;if(37===(i=n.charCodeAt(a++))){if(i=n.charAt(a++),!(o=x[i in pv?n.charAt(a++):i])||(r=o(t,e,r))<0)return-1}else if(i!=e.charCodeAt(r++))return-1}return r}return b.x=w(e,b),b.X=w(r,b),b.c=w(n,b),m.x=w(e,m),m.X=w(r,m),m.c=w(n,m),{format:function(t){var n=w(t+="",b);return n.toString=function(){return t},n},parse:function(t){var n=M(t+="",!1);return n.toString=function(){return t},n},utcFormat:function(t){var n=w(t+="",m);return n.toString=function(){return t},n},utcParse:function(t){var n=M(t+="",!0);return n.toString=function(){return t},n}}}var dv,pv={"-":"",_:" ",0:"0"},gv=/^\s*\d+/,yv=/^%/,vv=/[\\^$*+?|[\]().{}]/g;function _v(t,n,e){var r=t<0?"-":"",i=(r?-t:t)+"",o=i.length;return r+(o[t.toLowerCase(),n])))}function wv(t,n,e){var r=gv.exec(n.slice(e,e+1));return r?(t.w=+r[0],e+r[0].length):-1}function Mv(t,n,e){var r=gv.exec(n.slice(e,e+1));return r?(t.u=+r[0],e+r[0].length):-1}function Tv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.U=+r[0],e+r[0].length):-1}function Av(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.V=+r[0],e+r[0].length):-1}function Sv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.W=+r[0],e+r[0].length):-1}function Ev(t,n,e){var r=gv.exec(n.slice(e,e+4));return r?(t.y=+r[0],e+r[0].length):-1}function Nv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.y=+r[0]+(+r[0]>68?1900:2e3),e+r[0].length):-1}function kv(t,n,e){var r=/^(Z)|([+-]\d\d)(?::?(\d\d))?/.exec(n.slice(e,e+6));return r?(t.Z=r[1]?0:-(r[2]+(r[3]||"00")),e+r[0].length):-1}function Cv(t,n,e){var r=gv.exec(n.slice(e,e+1));return r?(t.q=3*r[0]-3,e+r[0].length):-1}function Pv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.m=r[0]-1,e+r[0].length):-1}function zv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.d=+r[0],e+r[0].length):-1}function $v(t,n,e){var r=gv.exec(n.slice(e,e+3));return r?(t.m=0,t.d=+r[0],e+r[0].length):-1}function Dv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.H=+r[0],e+r[0].length):-1}function Rv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.M=+r[0],e+r[0].length):-1}function Fv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.S=+r[0],e+r[0].length):-1}function qv(t,n,e){var r=gv.exec(n.slice(e,e+3));return r?(t.L=+r[0],e+r[0].length):-1}function Uv(t,n,e){var r=gv.exec(n.slice(e,e+6));return r?(t.L=Math.floor(r[0]/1e3),e+r[0].length):-1}function Iv(t,n,e){var r=yv.exec(n.slice(e,e+1));return r?e+r[0].length:-1}function Ov(t,n,e){var r=gv.exec(n.slice(e));return r?(t.Q=+r[0],e+r[0].length):-1}function Bv(t,n,e){var r=gv.exec(n.slice(e));return r?(t.s=+r[0],e+r[0].length):-1}function Yv(t,n){return _v(t.getDate(),n,2)}function Lv(t,n){return _v(t.getHours(),n,2)}function jv(t,n){return _v(t.getHours()%12||12,n,2)}function Hv(t,n){return _v(1+py.count(tv(t),t),n,3)}function Xv(t,n){return _v(t.getMilliseconds(),n,3)}function Gv(t,n){return Xv(t,n)+"000"}function Vv(t,n){return _v(t.getMonth()+1,n,2)}function Wv(t,n){return _v(t.getMinutes(),n,2)}function Zv(t,n){return _v(t.getSeconds(),n,2)}function Kv(t){var n=t.getDay();return 0===n?7:n}function Qv(t,n){return _v(xy.count(tv(t)-1,t),n,2)}function Jv(t){var n=t.getDay();return n>=4||0===n?Ay(t):Ay.ceil(t)}function t_(t,n){return t=Jv(t),_v(Ay.count(tv(t),t)+(4===tv(t).getDay()),n,2)}function n_(t){return t.getDay()}function e_(t,n){return _v(wy.count(tv(t)-1,t),n,2)}function r_(t,n){return _v(t.getFullYear()%100,n,2)}function i_(t,n){return _v((t=Jv(t)).getFullYear()%100,n,2)}function o_(t,n){return _v(t.getFullYear()%1e4,n,4)}function a_(t,n){var e=t.getDay();return _v((t=e>=4||0===e?Ay(t):Ay.ceil(t)).getFullYear()%1e4,n,4)}function u_(t){var n=t.getTimezoneOffset();return(n>0?"-":(n*=-1,"+"))+_v(n/60|0,"0",2)+_v(n%60,"0",2)}function c_(t,n){return _v(t.getUTCDate(),n,2)}function f_(t,n){return _v(t.getUTCHours(),n,2)}function s_(t,n){return _v(t.getUTCHours()%12||12,n,2)}function l_(t,n){return _v(1+yy.count(ev(t),t),n,3)}function h_(t,n){return _v(t.getUTCMilliseconds(),n,3)}function d_(t,n){return h_(t,n)+"000"}function p_(t,n){return _v(t.getUTCMonth()+1,n,2)}function g_(t,n){return _v(t.getUTCMinutes(),n,2)}function y_(t,n){return _v(t.getUTCSeconds(),n,2)}function v_(t){var n=t.getUTCDay();return 0===n?7:n}function __(t,n){return _v(Fy.count(ev(t)-1,t),n,2)}function b_(t){var n=t.getUTCDay();return n>=4||0===n?Oy(t):Oy.ceil(t)}function m_(t,n){return t=b_(t),_v(Oy.count(ev(t),t)+(4===ev(t).getUTCDay()),n,2)}function x_(t){return t.getUTCDay()}function w_(t,n){return _v(qy.count(ev(t)-1,t),n,2)}function M_(t,n){return _v(t.getUTCFullYear()%100,n,2)}function T_(t,n){return _v((t=b_(t)).getUTCFullYear()%100,n,2)}function A_(t,n){return _v(t.getUTCFullYear()%1e4,n,4)}function S_(t,n){var e=t.getUTCDay();return _v((t=e>=4||0===e?Oy(t):Oy.ceil(t)).getUTCFullYear()%1e4,n,4)}function E_(){return"+0000"}function N_(){return"%"}function k_(t){return+t}function C_(t){return Math.floor(+t/1e3)}function P_(n){return dv=hv(n),t.timeFormat=dv.format,t.timeParse=dv.parse,t.utcFormat=dv.utcFormat,t.utcParse=dv.utcParse,dv}t.timeFormat=void 0,t.timeParse=void 0,t.utcFormat=void 0,t.utcParse=void 0,P_({dateTime:"%x, %X",date:"%-m/%-d/%Y",time:"%-I:%M:%S %p",periods:["AM","PM"],days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],shortDays:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],months:["January","February","March","April","May","June","July","August","September","October","November","December"],shortMonths:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]});var z_="%Y-%m-%dT%H:%M:%S.%LZ";var $_=Date.prototype.toISOString?function(t){return t.toISOString()}:t.utcFormat(z_),D_=$_;var R_=+new Date("2000-01-01T00:00:00.000Z")?function(t){var n=new Date(t);return isNaN(n)?null:n}:t.utcParse(z_),F_=R_;function q_(t){return new Date(t)}function U_(t){return t instanceof Date?+t:+new Date(+t)}function I_(t,n,e,r,i,o,a,u,c,f){var s=Sg(),l=s.invert,h=s.domain,d=f(".%L"),p=f(":%S"),g=f("%I:%M"),y=f("%I %p"),v=f("%a %d"),_=f("%b %d"),b=f("%B"),m=f("%Y");function x(t){return(c(t)Fr(t[t.length-1]),ib=new Array(3).concat("d8b365f5f5f55ab4ac","a6611adfc27d80cdc1018571","a6611adfc27df5f5f580cdc1018571","8c510ad8b365f6e8c3c7eae55ab4ac01665e","8c510ad8b365f6e8c3f5f5f5c7eae55ab4ac01665e","8c510abf812ddfc27df6e8c3c7eae580cdc135978f01665e","8c510abf812ddfc27df6e8c3f5f5f5c7eae580cdc135978f01665e","5430058c510abf812ddfc27df6e8c3c7eae580cdc135978f01665e003c30","5430058c510abf812ddfc27df6e8c3f5f5f5c7eae580cdc135978f01665e003c30").map(H_),ob=rb(ib),ab=new Array(3).concat("af8dc3f7f7f77fbf7b","7b3294c2a5cfa6dba0008837","7b3294c2a5cff7f7f7a6dba0008837","762a83af8dc3e7d4e8d9f0d37fbf7b1b7837","762a83af8dc3e7d4e8f7f7f7d9f0d37fbf7b1b7837","762a839970abc2a5cfe7d4e8d9f0d3a6dba05aae611b7837","762a839970abc2a5cfe7d4e8f7f7f7d9f0d3a6dba05aae611b7837","40004b762a839970abc2a5cfe7d4e8d9f0d3a6dba05aae611b783700441b","40004b762a839970abc2a5cfe7d4e8f7f7f7d9f0d3a6dba05aae611b783700441b").map(H_),ub=rb(ab),cb=new Array(3).concat("e9a3c9f7f7f7a1d76a","d01c8bf1b6dab8e1864dac26","d01c8bf1b6daf7f7f7b8e1864dac26","c51b7de9a3c9fde0efe6f5d0a1d76a4d9221","c51b7de9a3c9fde0eff7f7f7e6f5d0a1d76a4d9221","c51b7dde77aef1b6dafde0efe6f5d0b8e1867fbc414d9221","c51b7dde77aef1b6dafde0eff7f7f7e6f5d0b8e1867fbc414d9221","8e0152c51b7dde77aef1b6dafde0efe6f5d0b8e1867fbc414d9221276419","8e0152c51b7dde77aef1b6dafde0eff7f7f7e6f5d0b8e1867fbc414d9221276419").map(H_),fb=rb(cb),sb=new Array(3).concat("998ec3f7f7f7f1a340","5e3c99b2abd2fdb863e66101","5e3c99b2abd2f7f7f7fdb863e66101","542788998ec3d8daebfee0b6f1a340b35806","542788998ec3d8daebf7f7f7fee0b6f1a340b35806","5427888073acb2abd2d8daebfee0b6fdb863e08214b35806","5427888073acb2abd2d8daebf7f7f7fee0b6fdb863e08214b35806","2d004b5427888073acb2abd2d8daebfee0b6fdb863e08214b358067f3b08","2d004b5427888073acb2abd2d8daebf7f7f7fee0b6fdb863e08214b358067f3b08").map(H_),lb=rb(sb),hb=new Array(3).concat("ef8a62f7f7f767a9cf","ca0020f4a58292c5de0571b0","ca0020f4a582f7f7f792c5de0571b0","b2182bef8a62fddbc7d1e5f067a9cf2166ac","b2182bef8a62fddbc7f7f7f7d1e5f067a9cf2166ac","b2182bd6604df4a582fddbc7d1e5f092c5de4393c32166ac","b2182bd6604df4a582fddbc7f7f7f7d1e5f092c5de4393c32166ac","67001fb2182bd6604df4a582fddbc7d1e5f092c5de4393c32166ac053061","67001fb2182bd6604df4a582fddbc7f7f7f7d1e5f092c5de4393c32166ac053061").map(H_),db=rb(hb),pb=new Array(3).concat("ef8a62ffffff999999","ca0020f4a582bababa404040","ca0020f4a582ffffffbababa404040","b2182bef8a62fddbc7e0e0e09999994d4d4d","b2182bef8a62fddbc7ffffffe0e0e09999994d4d4d","b2182bd6604df4a582fddbc7e0e0e0bababa8787874d4d4d","b2182bd6604df4a582fddbc7ffffffe0e0e0bababa8787874d4d4d","67001fb2182bd6604df4a582fddbc7e0e0e0bababa8787874d4d4d1a1a1a","67001fb2182bd6604df4a582fddbc7ffffffe0e0e0bababa8787874d4d4d1a1a1a").map(H_),gb=rb(pb),yb=new Array(3).concat("fc8d59ffffbf91bfdb","d7191cfdae61abd9e92c7bb6","d7191cfdae61ffffbfabd9e92c7bb6","d73027fc8d59fee090e0f3f891bfdb4575b4","d73027fc8d59fee090ffffbfe0f3f891bfdb4575b4","d73027f46d43fdae61fee090e0f3f8abd9e974add14575b4","d73027f46d43fdae61fee090ffffbfe0f3f8abd9e974add14575b4","a50026d73027f46d43fdae61fee090e0f3f8abd9e974add14575b4313695","a50026d73027f46d43fdae61fee090ffffbfe0f3f8abd9e974add14575b4313695").map(H_),vb=rb(yb),_b=new Array(3).concat("fc8d59ffffbf91cf60","d7191cfdae61a6d96a1a9641","d7191cfdae61ffffbfa6d96a1a9641","d73027fc8d59fee08bd9ef8b91cf601a9850","d73027fc8d59fee08bffffbfd9ef8b91cf601a9850","d73027f46d43fdae61fee08bd9ef8ba6d96a66bd631a9850","d73027f46d43fdae61fee08bffffbfd9ef8ba6d96a66bd631a9850","a50026d73027f46d43fdae61fee08bd9ef8ba6d96a66bd631a9850006837","a50026d73027f46d43fdae61fee08bffffbfd9ef8ba6d96a66bd631a9850006837").map(H_),bb=rb(_b),mb=new Array(3).concat("fc8d59ffffbf99d594","d7191cfdae61abdda42b83ba","d7191cfdae61ffffbfabdda42b83ba","d53e4ffc8d59fee08be6f59899d5943288bd","d53e4ffc8d59fee08bffffbfe6f59899d5943288bd","d53e4ff46d43fdae61fee08be6f598abdda466c2a53288bd","d53e4ff46d43fdae61fee08bffffbfe6f598abdda466c2a53288bd","9e0142d53e4ff46d43fdae61fee08be6f598abdda466c2a53288bd5e4fa2","9e0142d53e4ff46d43fdae61fee08bffffbfe6f598abdda466c2a53288bd5e4fa2").map(H_),xb=rb(mb),wb=new Array(3).concat("e5f5f999d8c92ca25f","edf8fbb2e2e266c2a4238b45","edf8fbb2e2e266c2a42ca25f006d2c","edf8fbccece699d8c966c2a42ca25f006d2c","edf8fbccece699d8c966c2a441ae76238b45005824","f7fcfde5f5f9ccece699d8c966c2a441ae76238b45005824","f7fcfde5f5f9ccece699d8c966c2a441ae76238b45006d2c00441b").map(H_),Mb=rb(wb),Tb=new Array(3).concat("e0ecf49ebcda8856a7","edf8fbb3cde38c96c688419d","edf8fbb3cde38c96c68856a7810f7c","edf8fbbfd3e69ebcda8c96c68856a7810f7c","edf8fbbfd3e69ebcda8c96c68c6bb188419d6e016b","f7fcfde0ecf4bfd3e69ebcda8c96c68c6bb188419d6e016b","f7fcfde0ecf4bfd3e69ebcda8c96c68c6bb188419d810f7c4d004b").map(H_),Ab=rb(Tb),Sb=new Array(3).concat("e0f3dba8ddb543a2ca","f0f9e8bae4bc7bccc42b8cbe","f0f9e8bae4bc7bccc443a2ca0868ac","f0f9e8ccebc5a8ddb57bccc443a2ca0868ac","f0f9e8ccebc5a8ddb57bccc44eb3d32b8cbe08589e","f7fcf0e0f3dbccebc5a8ddb57bccc44eb3d32b8cbe08589e","f7fcf0e0f3dbccebc5a8ddb57bccc44eb3d32b8cbe0868ac084081").map(H_),Eb=rb(Sb),Nb=new Array(3).concat("fee8c8fdbb84e34a33","fef0d9fdcc8afc8d59d7301f","fef0d9fdcc8afc8d59e34a33b30000","fef0d9fdd49efdbb84fc8d59e34a33b30000","fef0d9fdd49efdbb84fc8d59ef6548d7301f990000","fff7ecfee8c8fdd49efdbb84fc8d59ef6548d7301f990000","fff7ecfee8c8fdd49efdbb84fc8d59ef6548d7301fb300007f0000").map(H_),kb=rb(Nb),Cb=new Array(3).concat("ece2f0a6bddb1c9099","f6eff7bdc9e167a9cf02818a","f6eff7bdc9e167a9cf1c9099016c59","f6eff7d0d1e6a6bddb67a9cf1c9099016c59","f6eff7d0d1e6a6bddb67a9cf3690c002818a016450","fff7fbece2f0d0d1e6a6bddb67a9cf3690c002818a016450","fff7fbece2f0d0d1e6a6bddb67a9cf3690c002818a016c59014636").map(H_),Pb=rb(Cb),zb=new Array(3).concat("ece7f2a6bddb2b8cbe","f1eef6bdc9e174a9cf0570b0","f1eef6bdc9e174a9cf2b8cbe045a8d","f1eef6d0d1e6a6bddb74a9cf2b8cbe045a8d","f1eef6d0d1e6a6bddb74a9cf3690c00570b0034e7b","fff7fbece7f2d0d1e6a6bddb74a9cf3690c00570b0034e7b","fff7fbece7f2d0d1e6a6bddb74a9cf3690c00570b0045a8d023858").map(H_),$b=rb(zb),Db=new Array(3).concat("e7e1efc994c7dd1c77","f1eef6d7b5d8df65b0ce1256","f1eef6d7b5d8df65b0dd1c77980043","f1eef6d4b9dac994c7df65b0dd1c77980043","f1eef6d4b9dac994c7df65b0e7298ace125691003f","f7f4f9e7e1efd4b9dac994c7df65b0e7298ace125691003f","f7f4f9e7e1efd4b9dac994c7df65b0e7298ace125698004367001f").map(H_),Rb=rb(Db),Fb=new Array(3).concat("fde0ddfa9fb5c51b8a","feebe2fbb4b9f768a1ae017e","feebe2fbb4b9f768a1c51b8a7a0177","feebe2fcc5c0fa9fb5f768a1c51b8a7a0177","feebe2fcc5c0fa9fb5f768a1dd3497ae017e7a0177","fff7f3fde0ddfcc5c0fa9fb5f768a1dd3497ae017e7a0177","fff7f3fde0ddfcc5c0fa9fb5f768a1dd3497ae017e7a017749006a").map(H_),qb=rb(Fb),Ub=new Array(3).concat("edf8b17fcdbb2c7fb8","ffffcca1dab441b6c4225ea8","ffffcca1dab441b6c42c7fb8253494","ffffccc7e9b47fcdbb41b6c42c7fb8253494","ffffccc7e9b47fcdbb41b6c41d91c0225ea80c2c84","ffffd9edf8b1c7e9b47fcdbb41b6c41d91c0225ea80c2c84","ffffd9edf8b1c7e9b47fcdbb41b6c41d91c0225ea8253494081d58").map(H_),Ib=rb(Ub),Ob=new Array(3).concat("f7fcb9addd8e31a354","ffffccc2e69978c679238443","ffffccc2e69978c67931a354006837","ffffccd9f0a3addd8e78c67931a354006837","ffffccd9f0a3addd8e78c67941ab5d238443005a32","ffffe5f7fcb9d9f0a3addd8e78c67941ab5d238443005a32","ffffe5f7fcb9d9f0a3addd8e78c67941ab5d238443006837004529").map(H_),Bb=rb(Ob),Yb=new Array(3).concat("fff7bcfec44fd95f0e","ffffd4fed98efe9929cc4c02","ffffd4fed98efe9929d95f0e993404","ffffd4fee391fec44ffe9929d95f0e993404","ffffd4fee391fec44ffe9929ec7014cc4c028c2d04","ffffe5fff7bcfee391fec44ffe9929ec7014cc4c028c2d04","ffffe5fff7bcfee391fec44ffe9929ec7014cc4c02993404662506").map(H_),Lb=rb(Yb),jb=new Array(3).concat("ffeda0feb24cf03b20","ffffb2fecc5cfd8d3ce31a1c","ffffb2fecc5cfd8d3cf03b20bd0026","ffffb2fed976feb24cfd8d3cf03b20bd0026","ffffb2fed976feb24cfd8d3cfc4e2ae31a1cb10026","ffffccffeda0fed976feb24cfd8d3cfc4e2ae31a1cb10026","ffffccffeda0fed976feb24cfd8d3cfc4e2ae31a1cbd0026800026").map(H_),Hb=rb(jb),Xb=new Array(3).concat("deebf79ecae13182bd","eff3ffbdd7e76baed62171b5","eff3ffbdd7e76baed63182bd08519c","eff3ffc6dbef9ecae16baed63182bd08519c","eff3ffc6dbef9ecae16baed64292c62171b5084594","f7fbffdeebf7c6dbef9ecae16baed64292c62171b5084594","f7fbffdeebf7c6dbef9ecae16baed64292c62171b508519c08306b").map(H_),Gb=rb(Xb),Vb=new Array(3).concat("e5f5e0a1d99b31a354","edf8e9bae4b374c476238b45","edf8e9bae4b374c47631a354006d2c","edf8e9c7e9c0a1d99b74c47631a354006d2c","edf8e9c7e9c0a1d99b74c47641ab5d238b45005a32","f7fcf5e5f5e0c7e9c0a1d99b74c47641ab5d238b45005a32","f7fcf5e5f5e0c7e9c0a1d99b74c47641ab5d238b45006d2c00441b").map(H_),Wb=rb(Vb),Zb=new Array(3).concat("f0f0f0bdbdbd636363","f7f7f7cccccc969696525252","f7f7f7cccccc969696636363252525","f7f7f7d9d9d9bdbdbd969696636363252525","f7f7f7d9d9d9bdbdbd969696737373525252252525","fffffff0f0f0d9d9d9bdbdbd969696737373525252252525","fffffff0f0f0d9d9d9bdbdbd969696737373525252252525000000").map(H_),Kb=rb(Zb),Qb=new Array(3).concat("efedf5bcbddc756bb1","f2f0f7cbc9e29e9ac86a51a3","f2f0f7cbc9e29e9ac8756bb154278f","f2f0f7dadaebbcbddc9e9ac8756bb154278f","f2f0f7dadaebbcbddc9e9ac8807dba6a51a34a1486","fcfbfdefedf5dadaebbcbddc9e9ac8807dba6a51a34a1486","fcfbfdefedf5dadaebbcbddc9e9ac8807dba6a51a354278f3f007d").map(H_),Jb=rb(Qb),tm=new Array(3).concat("fee0d2fc9272de2d26","fee5d9fcae91fb6a4acb181d","fee5d9fcae91fb6a4ade2d26a50f15","fee5d9fcbba1fc9272fb6a4ade2d26a50f15","fee5d9fcbba1fc9272fb6a4aef3b2ccb181d99000d","fff5f0fee0d2fcbba1fc9272fb6a4aef3b2ccb181d99000d","fff5f0fee0d2fcbba1fc9272fb6a4aef3b2ccb181da50f1567000d").map(H_),nm=rb(tm),em=new Array(3).concat("fee6cefdae6be6550d","feeddefdbe85fd8d3cd94701","feeddefdbe85fd8d3ce6550da63603","feeddefdd0a2fdae6bfd8d3ce6550da63603","feeddefdd0a2fdae6bfd8d3cf16913d948018c2d04","fff5ebfee6cefdd0a2fdae6bfd8d3cf16913d948018c2d04","fff5ebfee6cefdd0a2fdae6bfd8d3cf16913d94801a636037f2704").map(H_),rm=rb(em);var im=hi(Tr(300,.5,0),Tr(-240,.5,1)),om=hi(Tr(-100,.75,.35),Tr(80,1.5,.8)),am=hi(Tr(260,.75,.35),Tr(80,1.5,.8)),um=Tr();var cm=Fe(),fm=Math.PI/3,sm=2*Math.PI/3;function lm(t){var n=t.length;return function(e){return t[Math.max(0,Math.min(n-1,Math.floor(e*n)))]}}var hm=lm(H_("44015444025645045745055946075a46085c460a5d460b5e470d60470e6147106347116447136548146748166848176948186a481a6c481b6d481c6e481d6f481f70482071482173482374482475482576482677482878482979472a7a472c7a472d7b472e7c472f7d46307e46327e46337f463480453581453781453882443983443a83443b84433d84433e85423f854240864241864142874144874045884046883f47883f48893e49893e4a893e4c8a3d4d8a3d4e8a3c4f8a3c508b3b518b3b528b3a538b3a548c39558c39568c38588c38598c375a8c375b8d365c8d365d8d355e8d355f8d34608d34618d33628d33638d32648e32658e31668e31678e31688e30698e306a8e2f6b8e2f6c8e2e6d8e2e6e8e2e6f8e2d708e2d718e2c718e2c728e2c738e2b748e2b758e2a768e2a778e2a788e29798e297a8e297b8e287c8e287d8e277e8e277f8e27808e26818e26828e26828e25838e25848e25858e24868e24878e23888e23898e238a8d228b8d228c8d228d8d218e8d218f8d21908d21918c20928c20928c20938c1f948c1f958b1f968b1f978b1f988b1f998a1f9a8a1e9b8a1e9c891e9d891f9e891f9f881fa0881fa1881fa1871fa28720a38620a48621a58521a68522a78522a88423a98324aa8325ab8225ac8226ad8127ad8128ae8029af7f2ab07f2cb17e2db27d2eb37c2fb47c31b57b32b67a34b67935b77937b87838b9773aba763bbb753dbc743fbc7340bd7242be7144bf7046c06f48c16e4ac16d4cc26c4ec36b50c46a52c56954c56856c66758c7655ac8645cc8635ec96260ca6063cb5f65cb5e67cc5c69cd5b6ccd5a6ece5870cf5773d05675d05477d1537ad1517cd2507fd34e81d34d84d44b86d54989d5488bd6468ed64590d74393d74195d84098d83e9bd93c9dd93ba0da39a2da37a5db36a8db34aadc32addc30b0dd2fb2dd2db5de2bb8de29bade28bddf26c0df25c2df23c5e021c8e020cae11fcde11dd0e11cd2e21bd5e21ad8e219dae319dde318dfe318e2e418e5e419e7e419eae51aece51befe51cf1e51df4e61ef6e620f8e621fbe723fde725")),dm=lm(H_("00000401000501010601010802010902020b02020d03030f03031204041405041606051806051a07061c08071e0907200a08220b09240c09260d0a290e0b2b100b2d110c2f120d31130d34140e36150e38160f3b180f3d19103f1a10421c10441d11471e114920114b21114e22115024125325125527125829115a2a115c2c115f2d11612f116331116533106734106936106b38106c390f6e3b0f703d0f713f0f72400f74420f75440f764510774710784910784a10794c117a4e117b4f127b51127c52137c54137d56147d57157e59157e5a167e5c167f5d177f5f187f601880621980641a80651a80671b80681c816a1c816b1d816d1d816e1e81701f81721f817320817521817621817822817922827b23827c23827e24828025828125818326818426818627818827818928818b29818c29818e2a81902a81912b81932b80942c80962c80982d80992d809b2e7f9c2e7f9e2f7fa02f7fa1307ea3307ea5317ea6317da8327daa337dab337cad347cae347bb0357bb2357bb3367ab5367ab73779b83779ba3878bc3978bd3977bf3a77c03a76c23b75c43c75c53c74c73d73c83e73ca3e72cc3f71cd4071cf4070d0416fd2426fd3436ed5446dd6456cd8456cd9466bdb476adc4869de4968df4a68e04c67e24d66e34e65e44f64e55064e75263e85362e95462ea5661eb5760ec5860ed5a5fee5b5eef5d5ef05f5ef1605df2625df2645cf3655cf4675cf4695cf56b5cf66c5cf66e5cf7705cf7725cf8745cf8765cf9785df9795df97b5dfa7d5efa7f5efa815ffb835ffb8560fb8761fc8961fc8a62fc8c63fc8e64fc9065fd9266fd9467fd9668fd9869fd9a6afd9b6bfe9d6cfe9f6dfea16efea36ffea571fea772fea973feaa74feac76feae77feb078feb27afeb47bfeb67cfeb77efeb97ffebb81febd82febf84fec185fec287fec488fec68afec88cfeca8dfecc8ffecd90fecf92fed194fed395fed597fed799fed89afdda9cfddc9efddea0fde0a1fde2a3fde3a5fde5a7fde7a9fde9aafdebacfcecaefceeb0fcf0b2fcf2b4fcf4b6fcf6b8fcf7b9fcf9bbfcfbbdfcfdbf")),pm=lm(H_("00000401000501010601010802010a02020c02020e03021004031204031405041706041907051b08051d09061f0a07220b07240c08260d08290e092b10092d110a30120a32140b34150b37160b39180c3c190c3e1b0c411c0c431e0c451f0c48210c4a230c4c240c4f260c51280b53290b552b0b572d0b592f0a5b310a5c320a5e340a5f3609613809623909633b09643d09653e0966400a67420a68440a68450a69470b6a490b6a4a0c6b4c0c6b4d0d6c4f0d6c510e6c520e6d540f6d550f6d57106e59106e5a116e5c126e5d126e5f136e61136e62146e64156e65156e67166e69166e6a176e6c186e6d186e6f196e71196e721a6e741a6e751b6e771c6d781c6d7a1d6d7c1d6d7d1e6d7f1e6c801f6c82206c84206b85216b87216b88226a8a226a8c23698d23698f24699025689225689326679526679727669827669a28659b29649d29649f2a63a02a63a22b62a32c61a52c60a62d60a82e5fa92e5eab2f5ead305dae305cb0315bb1325ab3325ab43359b63458b73557b93556ba3655bc3754bd3853bf3952c03a51c13a50c33b4fc43c4ec63d4dc73e4cc83f4bca404acb4149cc4248ce4347cf4446d04545d24644d34743d44842d54a41d74b3fd84c3ed94d3dda4e3cdb503bdd513ade5238df5337e05536e15635e25734e35933e45a31e55c30e65d2fe75e2ee8602de9612bea632aeb6429eb6628ec6726ed6925ee6a24ef6c23ef6e21f06f20f1711ff1731df2741cf3761bf37819f47918f57b17f57d15f67e14f68013f78212f78410f8850ff8870ef8890cf98b0bf98c0af98e09fa9008fa9207fa9407fb9606fb9706fb9906fb9b06fb9d07fc9f07fca108fca309fca50afca60cfca80dfcaa0ffcac11fcae12fcb014fcb216fcb418fbb61afbb81dfbba1ffbbc21fbbe23fac026fac228fac42afac62df9c72ff9c932f9cb35f8cd37f8cf3af7d13df7d340f6d543f6d746f5d949f5db4cf4dd4ff4df53f4e156f3e35af3e55df2e661f2e865f2ea69f1ec6df1ed71f1ef75f1f179f2f27df2f482f3f586f3f68af4f88ef5f992f6fa96f8fb9af9fc9dfafda1fcffa4")),gm=lm(H_("0d088710078813078916078a19068c1b068d1d068e20068f2206902406912605912805922a05932c05942e05952f059631059733059735049837049938049a3a049a3c049b3e049c3f049c41049d43039e44039e46039f48039f4903a04b03a14c02a14e02a25002a25102a35302a35502a45601a45801a45901a55b01a55c01a65e01a66001a66100a76300a76400a76600a76700a86900a86a00a86c00a86e00a86f00a87100a87201a87401a87501a87701a87801a87a02a87b02a87d03a87e03a88004a88104a78305a78405a78606a68707a68808a68a09a58b0aa58d0ba58e0ca48f0da4910ea3920fa39410a29511a19613a19814a099159f9a169f9c179e9d189d9e199da01a9ca11b9ba21d9aa31e9aa51f99a62098a72197a82296aa2395ab2494ac2694ad2793ae2892b02991b12a90b22b8fb32c8eb42e8db52f8cb6308bb7318ab83289ba3388bb3488bc3587bd3786be3885bf3984c03a83c13b82c23c81c33d80c43e7fc5407ec6417dc7427cc8437bc9447aca457acb4679cc4778cc4977cd4a76ce4b75cf4c74d04d73d14e72d24f71d35171d45270d5536fd5546ed6556dd7566cd8576bd9586ada5a6ada5b69db5c68dc5d67dd5e66de5f65de6164df6263e06363e16462e26561e26660e3685fe4695ee56a5de56b5de66c5ce76e5be76f5ae87059e97158e97257ea7457eb7556eb7655ec7754ed7953ed7a52ee7b51ef7c51ef7e50f07f4ff0804ef1814df1834cf2844bf3854bf3874af48849f48948f58b47f58c46f68d45f68f44f79044f79143f79342f89441f89540f9973ff9983ef99a3efa9b3dfa9c3cfa9e3bfb9f3afba139fba238fca338fca537fca636fca835fca934fdab33fdac33fdae32fdaf31fdb130fdb22ffdb42ffdb52efeb72dfeb82cfeba2cfebb2bfebd2afebe2afec029fdc229fdc328fdc527fdc627fdc827fdca26fdcb26fccd25fcce25fcd025fcd225fbd324fbd524fbd724fad824fada24f9dc24f9dd25f8df25f8e125f7e225f7e425f6e626f6e826f5e926f5eb27f4ed27f3ee27f3f027f2f227f1f426f1f525f0f724f0f921"));function ym(t){return function(){return t}}const vm=Math.abs,_m=Math.atan2,bm=Math.cos,mm=Math.max,xm=Math.min,wm=Math.sin,Mm=Math.sqrt,Tm=1e-12,Am=Math.PI,Sm=Am/2,Em=2*Am;function Nm(t){return t>=1?Sm:t<=-1?-Sm:Math.asin(t)}function km(t){let n=3;return t.digits=function(e){if(!arguments.length)return n;if(null==e)n=null;else{const t=Math.floor(e);if(!(t>=0))throw new RangeError(`invalid digits: ${e}`);n=t}return t},()=>new Ua(n)}function Cm(t){return t.innerRadius}function Pm(t){return t.outerRadius}function zm(t){return t.startAngle}function $m(t){return t.endAngle}function Dm(t){return t&&t.padAngle}function Rm(t,n,e,r,i,o,a){var u=t-e,c=n-r,f=(a?o:-o)/Mm(u*u+c*c),s=f*c,l=-f*u,h=t+s,d=n+l,p=e+s,g=r+l,y=(h+p)/2,v=(d+g)/2,_=p-h,b=g-d,m=_*_+b*b,x=i-o,w=h*g-p*d,M=(b<0?-1:1)*Mm(mm(0,x*x*m-w*w)),T=(w*b-_*M)/m,A=(-w*_-b*M)/m,S=(w*b+_*M)/m,E=(-w*_+b*M)/m,N=T-y,k=A-v,C=S-y,P=E-v;return N*N+k*k>C*C+P*P&&(T=S,A=E),{cx:T,cy:A,x01:-s,y01:-l,x11:T*(i/x-1),y11:A*(i/x-1)}}var Fm=Array.prototype.slice;function qm(t){return"object"==typeof t&&"length"in t?t:Array.from(t)}function Um(t){this._context=t}function Im(t){return new Um(t)}function Om(t){return t[0]}function Bm(t){return t[1]}function Ym(t,n){var e=ym(!0),r=null,i=Im,o=null,a=km(u);function u(u){var c,f,s,l=(u=qm(u)).length,h=!1;for(null==r&&(o=i(s=a())),c=0;c<=l;++c)!(c=l;--h)u.point(v[h],_[h]);u.lineEnd(),u.areaEnd()}y&&(v[s]=+t(d,s,f),_[s]=+n(d,s,f),u.point(r?+r(d,s,f):v[s],e?+e(d,s,f):_[s]))}if(p)return u=null,p+""||null}function s(){return Ym().defined(i).curve(a).context(o)}return t="function"==typeof t?t:void 0===t?Om:ym(+t),n="function"==typeof n?n:ym(void 0===n?0:+n),e="function"==typeof e?e:void 0===e?Bm:ym(+e),f.x=function(n){return arguments.length?(t="function"==typeof n?n:ym(+n),r=null,f):t},f.x0=function(n){return arguments.length?(t="function"==typeof n?n:ym(+n),f):t},f.x1=function(t){return arguments.length?(r=null==t?null:"function"==typeof t?t:ym(+t),f):r},f.y=function(t){return arguments.length?(n="function"==typeof t?t:ym(+t),e=null,f):n},f.y0=function(t){return arguments.length?(n="function"==typeof t?t:ym(+t),f):n},f.y1=function(t){return arguments.length?(e=null==t?null:"function"==typeof t?t:ym(+t),f):e},f.lineX0=f.lineY0=function(){return s().x(t).y(n)},f.lineY1=function(){return s().x(t).y(e)},f.lineX1=function(){return s().x(r).y(n)},f.defined=function(t){return arguments.length?(i="function"==typeof t?t:ym(!!t),f):i},f.curve=function(t){return arguments.length?(a=t,null!=o&&(u=a(o)),f):a},f.context=function(t){return arguments.length?(null==t?o=u=null:u=a(o=t),f):o},f}function jm(t,n){return nt?1:n>=t?0:NaN}function Hm(t){return t}Um.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._point=0},lineEnd:function(){(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;default:this._context.lineTo(t,n)}}};var Xm=Vm(Im);function Gm(t){this._curve=t}function Vm(t){function n(n){return new Gm(t(n))}return n._curve=t,n}function Wm(t){var n=t.curve;return t.angle=t.x,delete t.x,t.radius=t.y,delete t.y,t.curve=function(t){return arguments.length?n(Vm(t)):n()._curve},t}function Zm(){return Wm(Ym().curve(Xm))}function Km(){var t=Lm().curve(Xm),n=t.curve,e=t.lineX0,r=t.lineX1,i=t.lineY0,o=t.lineY1;return t.angle=t.x,delete t.x,t.startAngle=t.x0,delete t.x0,t.endAngle=t.x1,delete t.x1,t.radius=t.y,delete t.y,t.innerRadius=t.y0,delete t.y0,t.outerRadius=t.y1,delete t.y1,t.lineStartAngle=function(){return Wm(e())},delete t.lineX0,t.lineEndAngle=function(){return Wm(r())},delete t.lineX1,t.lineInnerRadius=function(){return Wm(i())},delete t.lineY0,t.lineOuterRadius=function(){return Wm(o())},delete t.lineY1,t.curve=function(t){return arguments.length?n(Vm(t)):n()._curve},t}function Qm(t,n){return[(n=+n)*Math.cos(t-=Math.PI/2),n*Math.sin(t)]}Gm.prototype={areaStart:function(){this._curve.areaStart()},areaEnd:function(){this._curve.areaEnd()},lineStart:function(){this._curve.lineStart()},lineEnd:function(){this._curve.lineEnd()},point:function(t,n){this._curve.point(n*Math.sin(t),n*-Math.cos(t))}};class Jm{constructor(t,n){this._context=t,this._x=n}areaStart(){this._line=0}areaEnd(){this._line=NaN}lineStart(){this._point=0}lineEnd(){(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line}point(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;default:this._x?this._context.bezierCurveTo(this._x0=(this._x0+t)/2,this._y0,this._x0,n,t,n):this._context.bezierCurveTo(this._x0,this._y0=(this._y0+n)/2,t,this._y0,t,n)}this._x0=t,this._y0=n}}class tx{constructor(t){this._context=t}lineStart(){this._point=0}lineEnd(){}point(t,n){if(t=+t,n=+n,0===this._point)this._point=1;else{const e=Qm(this._x0,this._y0),r=Qm(this._x0,this._y0=(this._y0+n)/2),i=Qm(t,this._y0),o=Qm(t,n);this._context.moveTo(...e),this._context.bezierCurveTo(...r,...i,...o)}this._x0=t,this._y0=n}}function nx(t){return new Jm(t,!0)}function ex(t){return new Jm(t,!1)}function rx(t){return new tx(t)}function ix(t){return t.source}function ox(t){return t.target}function ax(t){let n=ix,e=ox,r=Om,i=Bm,o=null,a=null,u=km(c);function c(){let c;const f=Fm.call(arguments),s=n.apply(this,f),l=e.apply(this,f);if(null==o&&(a=t(c=u())),a.lineStart(),f[0]=s,a.point(+r.apply(this,f),+i.apply(this,f)),f[0]=l,a.point(+r.apply(this,f),+i.apply(this,f)),a.lineEnd(),c)return a=null,c+""||null}return c.source=function(t){return arguments.length?(n=t,c):n},c.target=function(t){return arguments.length?(e=t,c):e},c.x=function(t){return arguments.length?(r="function"==typeof t?t:ym(+t),c):r},c.y=function(t){return arguments.length?(i="function"==typeof t?t:ym(+t),c):i},c.context=function(n){return arguments.length?(null==n?o=a=null:a=t(o=n),c):o},c}const ux=Mm(3);var cx={draw(t,n){const e=.59436*Mm(n+xm(n/28,.75)),r=e/2,i=r*ux;t.moveTo(0,e),t.lineTo(0,-e),t.moveTo(-i,-r),t.lineTo(i,r),t.moveTo(-i,r),t.lineTo(i,-r)}},fx={draw(t,n){const e=Mm(n/Am);t.moveTo(e,0),t.arc(0,0,e,0,Em)}},sx={draw(t,n){const e=Mm(n/5)/2;t.moveTo(-3*e,-e),t.lineTo(-e,-e),t.lineTo(-e,-3*e),t.lineTo(e,-3*e),t.lineTo(e,-e),t.lineTo(3*e,-e),t.lineTo(3*e,e),t.lineTo(e,e),t.lineTo(e,3*e),t.lineTo(-e,3*e),t.lineTo(-e,e),t.lineTo(-3*e,e),t.closePath()}};const lx=Mm(1/3),hx=2*lx;var dx={draw(t,n){const e=Mm(n/hx),r=e*lx;t.moveTo(0,-e),t.lineTo(r,0),t.lineTo(0,e),t.lineTo(-r,0),t.closePath()}},px={draw(t,n){const e=.62625*Mm(n);t.moveTo(0,-e),t.lineTo(e,0),t.lineTo(0,e),t.lineTo(-e,0),t.closePath()}},gx={draw(t,n){const e=.87559*Mm(n-xm(n/7,2));t.moveTo(-e,0),t.lineTo(e,0),t.moveTo(0,e),t.lineTo(0,-e)}},yx={draw(t,n){const e=Mm(n),r=-e/2;t.rect(r,r,e,e)}},vx={draw(t,n){const e=.4431*Mm(n);t.moveTo(e,e),t.lineTo(e,-e),t.lineTo(-e,-e),t.lineTo(-e,e),t.closePath()}};const _x=wm(Am/10)/wm(7*Am/10),bx=wm(Em/10)*_x,mx=-bm(Em/10)*_x;var xx={draw(t,n){const e=Mm(.8908130915292852*n),r=bx*e,i=mx*e;t.moveTo(0,-e),t.lineTo(r,i);for(let n=1;n<5;++n){const o=Em*n/5,a=bm(o),u=wm(o);t.lineTo(u*e,-a*e),t.lineTo(a*r-u*i,u*r+a*i)}t.closePath()}};const wx=Mm(3);var Mx={draw(t,n){const e=-Mm(n/(3*wx));t.moveTo(0,2*e),t.lineTo(-wx*e,-e),t.lineTo(wx*e,-e),t.closePath()}};const Tx=Mm(3);var Ax={draw(t,n){const e=.6824*Mm(n),r=e/2,i=e*Tx/2;t.moveTo(0,-e),t.lineTo(i,r),t.lineTo(-i,r),t.closePath()}};const Sx=-.5,Ex=Mm(3)/2,Nx=1/Mm(12),kx=3*(Nx/2+1);var Cx={draw(t,n){const e=Mm(n/kx),r=e/2,i=e*Nx,o=r,a=e*Nx+e,u=-o,c=a;t.moveTo(r,i),t.lineTo(o,a),t.lineTo(u,c),t.lineTo(Sx*r-Ex*i,Ex*r+Sx*i),t.lineTo(Sx*o-Ex*a,Ex*o+Sx*a),t.lineTo(Sx*u-Ex*c,Ex*u+Sx*c),t.lineTo(Sx*r+Ex*i,Sx*i-Ex*r),t.lineTo(Sx*o+Ex*a,Sx*a-Ex*o),t.lineTo(Sx*u+Ex*c,Sx*c-Ex*u),t.closePath()}},Px={draw(t,n){const e=.6189*Mm(n-xm(n/6,1.7));t.moveTo(-e,-e),t.lineTo(e,e),t.moveTo(-e,e),t.lineTo(e,-e)}};const zx=[fx,sx,dx,yx,xx,Mx,Cx],$x=[fx,gx,Px,Ax,cx,vx,px];function Dx(){}function Rx(t,n,e){t._context.bezierCurveTo((2*t._x0+t._x1)/3,(2*t._y0+t._y1)/3,(t._x0+2*t._x1)/3,(t._y0+2*t._y1)/3,(t._x0+4*t._x1+n)/6,(t._y0+4*t._y1+e)/6)}function Fx(t){this._context=t}function qx(t){this._context=t}function Ux(t){this._context=t}function Ix(t,n){this._basis=new Fx(t),this._beta=n}Fx.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},lineEnd:function(){switch(this._point){case 3:Rx(this,this._x1,this._y1);case 2:this._context.lineTo(this._x1,this._y1)}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;break;case 2:this._point=3,this._context.lineTo((5*this._x0+this._x1)/6,(5*this._y0+this._y1)/6);default:Rx(this,t,n)}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=n}},qx.prototype={areaStart:Dx,areaEnd:Dx,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._y0=this._y1=this._y2=this._y3=this._y4=NaN,this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x2,this._y2),this._context.closePath();break;case 2:this._context.moveTo((this._x2+2*this._x3)/3,(this._y2+2*this._y3)/3),this._context.lineTo((this._x3+2*this._x2)/3,(this._y3+2*this._y2)/3),this._context.closePath();break;case 3:this.point(this._x2,this._y2),this.point(this._x3,this._y3),this.point(this._x4,this._y4)}},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._x2=t,this._y2=n;break;case 1:this._point=2,this._x3=t,this._y3=n;break;case 2:this._point=3,this._x4=t,this._y4=n,this._context.moveTo((this._x0+4*this._x1+t)/6,(this._y0+4*this._y1+n)/6);break;default:Rx(this,t,n)}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=n}},Ux.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3;var e=(this._x0+4*this._x1+t)/6,r=(this._y0+4*this._y1+n)/6;this._line?this._context.lineTo(e,r):this._context.moveTo(e,r);break;case 3:this._point=4;default:Rx(this,t,n)}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=n}},Ix.prototype={lineStart:function(){this._x=[],this._y=[],this._basis.lineStart()},lineEnd:function(){var t=this._x,n=this._y,e=t.length-1;if(e>0)for(var r,i=t[0],o=n[0],a=t[e]-i,u=n[e]-o,c=-1;++c<=e;)r=c/e,this._basis.point(this._beta*t[c]+(1-this._beta)*(i+r*a),this._beta*n[c]+(1-this._beta)*(o+r*u));this._x=this._y=null,this._basis.lineEnd()},point:function(t,n){this._x.push(+t),this._y.push(+n)}};var Ox=function t(n){function e(t){return 1===n?new Fx(t):new Ix(t,n)}return e.beta=function(n){return t(+n)},e}(.85);function Bx(t,n,e){t._context.bezierCurveTo(t._x1+t._k*(t._x2-t._x0),t._y1+t._k*(t._y2-t._y0),t._x2+t._k*(t._x1-n),t._y2+t._k*(t._y1-e),t._x2,t._y2)}function Yx(t,n){this._context=t,this._k=(1-n)/6}Yx.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._point=0},lineEnd:function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:Bx(this,this._x1,this._y1)}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2,this._x1=t,this._y1=n;break;case 2:this._point=3;default:Bx(this,t,n)}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Lx=function t(n){function e(t){return new Yx(t,n)}return e.tension=function(n){return t(+n)},e}(0);function jx(t,n){this._context=t,this._k=(1-n)/6}jx.prototype={areaStart:Dx,areaEnd:Dx,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN,this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x3,this._y3),this._context.closePath();break;case 2:this._context.lineTo(this._x3,this._y3),this._context.closePath();break;case 3:this.point(this._x3,this._y3),this.point(this._x4,this._y4),this.point(this._x5,this._y5)}},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._x3=t,this._y3=n;break;case 1:this._point=2,this._context.moveTo(this._x4=t,this._y4=n);break;case 2:this._point=3,this._x5=t,this._y5=n;break;default:Bx(this,t,n)}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Hx=function t(n){function e(t){return new jx(t,n)}return e.tension=function(n){return t(+n)},e}(0);function Xx(t,n){this._context=t,this._k=(1-n)/6}Xx.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3,this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:Bx(this,t,n)}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Gx=function t(n){function e(t){return new Xx(t,n)}return e.tension=function(n){return t(+n)},e}(0);function Vx(t,n,e){var r=t._x1,i=t._y1,o=t._x2,a=t._y2;if(t._l01_a>Tm){var u=2*t._l01_2a+3*t._l01_a*t._l12_a+t._l12_2a,c=3*t._l01_a*(t._l01_a+t._l12_a);r=(r*u-t._x0*t._l12_2a+t._x2*t._l01_2a)/c,i=(i*u-t._y0*t._l12_2a+t._y2*t._l01_2a)/c}if(t._l23_a>Tm){var f=2*t._l23_2a+3*t._l23_a*t._l12_a+t._l12_2a,s=3*t._l23_a*(t._l23_a+t._l12_a);o=(o*f+t._x1*t._l23_2a-n*t._l12_2a)/s,a=(a*f+t._y1*t._l23_2a-e*t._l12_2a)/s}t._context.bezierCurveTo(r,i,o,a,t._x2,t._y2)}function Wx(t,n){this._context=t,this._alpha=n}Wx.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:this.point(this._x2,this._y2)}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){if(t=+t,n=+n,this._point){var e=this._x2-t,r=this._y2-n;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(e*e+r*r,this._alpha))}switch(this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;break;case 2:this._point=3;default:Vx(this,t,n)}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Zx=function t(n){function e(t){return n?new Wx(t,n):new Yx(t,0)}return e.alpha=function(n){return t(+n)},e}(.5);function Kx(t,n){this._context=t,this._alpha=n}Kx.prototype={areaStart:Dx,areaEnd:Dx,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x3,this._y3),this._context.closePath();break;case 2:this._context.lineTo(this._x3,this._y3),this._context.closePath();break;case 3:this.point(this._x3,this._y3),this.point(this._x4,this._y4),this.point(this._x5,this._y5)}},point:function(t,n){if(t=+t,n=+n,this._point){var e=this._x2-t,r=this._y2-n;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(e*e+r*r,this._alpha))}switch(this._point){case 0:this._point=1,this._x3=t,this._y3=n;break;case 1:this._point=2,this._context.moveTo(this._x4=t,this._y4=n);break;case 2:this._point=3,this._x5=t,this._y5=n;break;default:Vx(this,t,n)}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Qx=function t(n){function e(t){return n?new Kx(t,n):new jx(t,0)}return e.alpha=function(n){return t(+n)},e}(.5);function Jx(t,n){this._context=t,this._alpha=n}Jx.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){if(t=+t,n=+n,this._point){var e=this._x2-t,r=this._y2-n;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(e*e+r*r,this._alpha))}switch(this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3,this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:Vx(this,t,n)}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var tw=function t(n){function e(t){return n?new Jx(t,n):new Xx(t,0)}return e.alpha=function(n){return t(+n)},e}(.5);function nw(t){this._context=t}function ew(t){return t<0?-1:1}function rw(t,n,e){var r=t._x1-t._x0,i=n-t._x1,o=(t._y1-t._y0)/(r||i<0&&-0),a=(e-t._y1)/(i||r<0&&-0),u=(o*i+a*r)/(r+i);return(ew(o)+ew(a))*Math.min(Math.abs(o),Math.abs(a),.5*Math.abs(u))||0}function iw(t,n){var e=t._x1-t._x0;return e?(3*(t._y1-t._y0)/e-n)/2:n}function ow(t,n,e){var r=t._x0,i=t._y0,o=t._x1,a=t._y1,u=(o-r)/3;t._context.bezierCurveTo(r+u,i+u*n,o-u,a-u*e,o,a)}function aw(t){this._context=t}function uw(t){this._context=new cw(t)}function cw(t){this._context=t}function fw(t){this._context=t}function sw(t){var n,e,r=t.length-1,i=new Array(r),o=new Array(r),a=new Array(r);for(i[0]=0,o[0]=2,a[0]=t[0]+2*t[1],n=1;n=0;--n)i[n]=(a[n]-i[n+1])/o[n];for(o[r-1]=(t[r]+i[r-1])/2,n=0;n1)for(var e,r,i,o=1,a=t[n[0]],u=a.length;o=0;)e[n]=n;return e}function pw(t,n){return t[n]}function gw(t){const n=[];return n.key=t,n}function yw(t){var n=t.map(vw);return dw(t).sort((function(t,e){return n[t]-n[e]}))}function vw(t){for(var n,e=-1,r=0,i=t.length,o=-1/0;++eo&&(o=n,r=e);return r}function _w(t){var n=t.map(bw);return dw(t).sort((function(t,e){return n[t]-n[e]}))}function bw(t){for(var n,e=0,r=-1,i=t.length;++r=0&&(this._t=1-this._t,this._line=1-this._line)},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;default:if(this._t<=0)this._context.lineTo(this._x,n),this._context.lineTo(t,n);else{var e=this._x*(1-this._t)+t*this._t;this._context.lineTo(e,this._y),this._context.lineTo(e,n)}}this._x=t,this._y=n}};var mw=t=>()=>t;function xw(t,{sourceEvent:n,target:e,transform:r,dispatch:i}){Object.defineProperties(this,{type:{value:t,enumerable:!0,configurable:!0},sourceEvent:{value:n,enumerable:!0,configurable:!0},target:{value:e,enumerable:!0,configurable:!0},transform:{value:r,enumerable:!0,configurable:!0},_:{value:i}})}function ww(t,n,e){this.k=t,this.x=n,this.y=e}ww.prototype={constructor:ww,scale:function(t){return 1===t?this:new ww(this.k*t,this.x,this.y)},translate:function(t,n){return 0===t&0===n?this:new ww(this.k,this.x+this.k*t,this.y+this.k*n)},apply:function(t){return[t[0]*this.k+this.x,t[1]*this.k+this.y]},applyX:function(t){return t*this.k+this.x},applyY:function(t){return t*this.k+this.y},invert:function(t){return[(t[0]-this.x)/this.k,(t[1]-this.y)/this.k]},invertX:function(t){return(t-this.x)/this.k},invertY:function(t){return(t-this.y)/this.k},rescaleX:function(t){return t.copy().domain(t.range().map(this.invertX,this).map(t.invert,t))},rescaleY:function(t){return t.copy().domain(t.range().map(this.invertY,this).map(t.invert,t))},toString:function(){return"translate("+this.x+","+this.y+") scale("+this.k+")"}};var Mw=new ww(1,0,0);function Tw(t){for(;!t.__zoom;)if(!(t=t.parentNode))return Mw;return t.__zoom}function Aw(t){t.stopImmediatePropagation()}function Sw(t){t.preventDefault(),t.stopImmediatePropagation()}function Ew(t){return!(t.ctrlKey&&"wheel"!==t.type||t.button)}function Nw(){var t=this;return t instanceof SVGElement?(t=t.ownerSVGElement||t).hasAttribute("viewBox")?[[(t=t.viewBox.baseVal).x,t.y],[t.x+t.width,t.y+t.height]]:[[0,0],[t.width.baseVal.value,t.height.baseVal.value]]:[[0,0],[t.clientWidth,t.clientHeight]]}function kw(){return this.__zoom||Mw}function Cw(t){return-t.deltaY*(1===t.deltaMode?.05:t.deltaMode?1:.002)*(t.ctrlKey?10:1)}function Pw(){return navigator.maxTouchPoints||"ontouchstart"in this}function zw(t,n,e){var r=t.invertX(n[0][0])-e[0][0],i=t.invertX(n[1][0])-e[1][0],o=t.invertY(n[0][1])-e[0][1],a=t.invertY(n[1][1])-e[1][1];return t.translate(i>r?(r+i)/2:Math.min(0,r)||Math.max(0,i),a>o?(o+a)/2:Math.min(0,o)||Math.max(0,a))}Tw.prototype=ww.prototype,t.Adder=T,t.Delaunay=Lu,t.FormatSpecifier=tf,t.InternMap=InternMap,t.InternSet=InternSet,t.Node=Qd,t.Path=Ua,t.Voronoi=qu,t.ZoomTransform=ww,t.active=function(t,n){var e,r,i=t.__transition;if(i)for(r in n=null==n?null:n+"",i)if((e=i[r]).state>qi&&e.name===n)return new po([[t]],Zo,n,+r);return null},t.arc=function(){var t=Cm,n=Pm,e=ym(0),r=null,i=zm,o=$m,a=Dm,u=null,c=km(f);function f(){var f,s,l=+t.apply(this,arguments),h=+n.apply(this,arguments),d=i.apply(this,arguments)-Sm,p=o.apply(this,arguments)-Sm,g=vm(p-d),y=p>d;if(u||(u=f=c()),hTm)if(g>Em-Tm)u.moveTo(h*bm(d),h*wm(d)),u.arc(0,0,h,d,p,!y),l>Tm&&(u.moveTo(l*bm(p),l*wm(p)),u.arc(0,0,l,p,d,y));else{var v,_,b=d,m=p,x=d,w=p,M=g,T=g,A=a.apply(this,arguments)/2,S=A>Tm&&(r?+r.apply(this,arguments):Mm(l*l+h*h)),E=xm(vm(h-l)/2,+e.apply(this,arguments)),N=E,k=E;if(S>Tm){var C=Nm(S/l*wm(A)),P=Nm(S/h*wm(A));(M-=2*C)>Tm?(x+=C*=y?1:-1,w-=C):(M=0,x=w=(d+p)/2),(T-=2*P)>Tm?(b+=P*=y?1:-1,m-=P):(T=0,b=m=(d+p)/2)}var z=h*bm(b),$=h*wm(b),D=l*bm(w),R=l*wm(w);if(E>Tm){var F,q=h*bm(m),U=h*wm(m),I=l*bm(x),O=l*wm(x);if(g1?0:t<-1?Am:Math.acos(t)}((B*L+Y*j)/(Mm(B*B+Y*Y)*Mm(L*L+j*j)))/2),X=Mm(F[0]*F[0]+F[1]*F[1]);N=xm(E,(l-X)/(H-1)),k=xm(E,(h-X)/(H+1))}else N=k=0}T>Tm?k>Tm?(v=Rm(I,O,z,$,h,k,y),_=Rm(q,U,D,R,h,k,y),u.moveTo(v.cx+v.x01,v.cy+v.y01),kTm&&M>Tm?N>Tm?(v=Rm(D,R,q,U,l,-N,y),_=Rm(z,$,I,O,l,-N,y),u.lineTo(v.cx+v.x01,v.cy+v.y01),N=0))throw new RangeError("invalid r");let e=t.length;if(!((e=Math.floor(e))>=0))throw new RangeError("invalid length");if(!e||!n)return t;const r=y(n),i=t.slice();return r(t,i,0,e,1),r(i,t,0,e,1),r(t,i,0,e,1),t},t.blur2=l,t.blurImage=h,t.brush=function(){return wa(la)},t.brushSelection=function(t){var n=t.__brush;return n?n.dim.output(n.selection):null},t.brushX=function(){return wa(fa)},t.brushY=function(){return wa(sa)},t.buffer=function(t,n){return fetch(t,n).then(_c)},t.chord=function(){return za(!1,!1)},t.chordDirected=function(){return za(!0,!1)},t.chordTranspose=function(){return za(!1,!0)},t.cluster=function(){var t=Ld,n=1,e=1,r=!1;function i(i){var o,a=0;i.eachAfter((function(n){var e=n.children;e?(n.x=function(t){return t.reduce(jd,0)/t.length}(e),n.y=function(t){return 1+t.reduce(Hd,0)}(e)):(n.x=o?a+=t(n,o):0,n.y=0,o=n)}));var u=function(t){for(var n;n=t.children;)t=n[0];return t}(i),c=function(t){for(var n;n=t.children;)t=n[n.length-1];return t}(i),f=u.x-t(u,c)/2,s=c.x+t(c,u)/2;return i.eachAfter(r?function(t){t.x=(t.x-i.x)*n,t.y=(i.y-t.y)*e}:function(t){t.x=(t.x-f)/(s-f)*n,t.y=(1-(i.y?t.y/i.y:1))*e})}return i.separation=function(n){return arguments.length?(t=n,i):t},i.size=function(t){return arguments.length?(r=!1,n=+t[0],e=+t[1],i):r?null:[n,e]},i.nodeSize=function(t){return arguments.length?(r=!0,n=+t[0],e=+t[1],i):r?[n,e]:null},i},t.color=ze,t.contourDensity=function(){var t=fu,n=su,e=lu,r=960,i=500,o=20,a=2,u=3*o,c=r+2*u>>a,f=i+2*u>>a,s=Qa(20);function h(r){var i=new Float32Array(c*f),s=Math.pow(2,-a),h=-1;for(const o of r){var d=(t(o,++h,r)+u)*s,p=(n(o,h,r)+u)*s,g=+e(o,h,r);if(g&&d>=0&&d=0&&pt*r)))(n).map(((t,n)=>(t.value=+e[n],p(t))))}function p(t){return t.coordinates.forEach(g),t}function g(t){t.forEach(y)}function y(t){t.forEach(v)}function v(t){t[0]=t[0]*Math.pow(2,a)-u,t[1]=t[1]*Math.pow(2,a)-u}function _(){return c=r+2*(u=3*o)>>a,f=i+2*u>>a,d}return d.contours=function(t){var n=h(t),e=iu().size([c,f]),r=Math.pow(2,2*a),i=t=>{t=+t;var i=p(e.contour(n,t*r));return i.value=t,i};return Object.defineProperty(i,"max",{get:()=>J(n)/r}),i},d.x=function(n){return arguments.length?(t="function"==typeof n?n:Qa(+n),d):t},d.y=function(t){return arguments.length?(n="function"==typeof t?t:Qa(+t),d):n},d.weight=function(t){return arguments.length?(e="function"==typeof t?t:Qa(+t),d):e},d.size=function(t){if(!arguments.length)return[r,i];var n=+t[0],e=+t[1];if(!(n>=0&&e>=0))throw new Error("invalid size");return r=n,i=e,_()},d.cellSize=function(t){if(!arguments.length)return 1<=1))throw new Error("invalid cell size");return a=Math.floor(Math.log(t)/Math.LN2),_()},d.thresholds=function(t){return arguments.length?(s="function"==typeof t?t:Array.isArray(t)?Qa(Za.call(t)):Qa(t),d):s},d.bandwidth=function(t){if(!arguments.length)return Math.sqrt(o*(o+1));if(!((t=+t)>=0))throw new Error("invalid bandwidth");return o=(Math.sqrt(4*t*t+1)-1)/2,_()},d},t.contours=iu,t.count=v,t.create=function(t){return Zn(Yt(t).call(document.documentElement))},t.creator=Yt,t.cross=function(...t){const n="function"==typeof t[t.length-1]&&function(t){return n=>t(...n)}(t.pop()),e=(t=t.map(m)).map(_),r=t.length-1,i=new Array(r+1).fill(0),o=[];if(r<0||e.some(b))return o;for(;;){o.push(i.map(((n,e)=>t[e][n])));let a=r;for(;++i[a]===e[a];){if(0===a)return n?o.map(n):o;i[a--]=0}}},t.csv=wc,t.csvFormat=rc,t.csvFormatBody=ic,t.csvFormatRow=ac,t.csvFormatRows=oc,t.csvFormatValue=uc,t.csvParse=nc,t.csvParseRows=ec,t.cubehelix=Tr,t.cumsum=function(t,n){var e=0,r=0;return Float64Array.from(t,void 0===n?t=>e+=+t||0:i=>e+=+n(i,r++,t)||0)},t.curveBasis=function(t){return new Fx(t)},t.curveBasisClosed=function(t){return new qx(t)},t.curveBasisOpen=function(t){return new Ux(t)},t.curveBumpX=nx,t.curveBumpY=ex,t.curveBundle=Ox,t.curveCardinal=Lx,t.curveCardinalClosed=Hx,t.curveCardinalOpen=Gx,t.curveCatmullRom=Zx,t.curveCatmullRomClosed=Qx,t.curveCatmullRomOpen=tw,t.curveLinear=Im,t.curveLinearClosed=function(t){return new nw(t)},t.curveMonotoneX=function(t){return new aw(t)},t.curveMonotoneY=function(t){return new uw(t)},t.curveNatural=function(t){return new fw(t)},t.curveStep=function(t){return new lw(t,.5)},t.curveStepAfter=function(t){return new lw(t,1)},t.curveStepBefore=function(t){return new lw(t,0)},t.descending=e,t.deviation=w,t.difference=function(t,...n){t=new InternSet(t);for(const e of n)for(const n of e)t.delete(n);return t},t.disjoint=function(t,n){const e=n[Symbol.iterator](),r=new InternSet;for(const n of t){if(r.has(n))return!1;let t,i;for(;({value:t,done:i}=e.next())&&!i;){if(Object.is(n,t))return!1;r.add(t)}}return!0},t.dispatch=$t,t.drag=function(){var t,n,e,r,i=se,o=le,a=he,u=de,c={},f=$t("start","drag","end"),s=0,l=0;function h(t){t.on("mousedown.drag",d).filter(u).on("touchstart.drag",y).on("touchmove.drag",v,ee).on("touchend.drag touchcancel.drag",_).style("touch-action","none").style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}function d(a,u){if(!r&&i.call(this,a,u)){var c=b(this,o.call(this,a,u),a,u,"mouse");c&&(Zn(a.view).on("mousemove.drag",p,re).on("mouseup.drag",g,re),ae(a.view),ie(a),e=!1,t=a.clientX,n=a.clientY,c("start",a))}}function p(r){if(oe(r),!e){var i=r.clientX-t,o=r.clientY-n;e=i*i+o*o>l}c.mouse("drag",r)}function g(t){Zn(t.view).on("mousemove.drag mouseup.drag",null),ue(t.view,e),oe(t),c.mouse("end",t)}function y(t,n){if(i.call(this,t,n)){var e,r,a=t.changedTouches,u=o.call(this,t,n),c=a.length;for(e=0;e+t,t.easePoly=wo,t.easePolyIn=mo,t.easePolyInOut=wo,t.easePolyOut=xo,t.easeQuad=_o,t.easeQuadIn=function(t){return t*t},t.easeQuadInOut=_o,t.easeQuadOut=function(t){return t*(2-t)},t.easeSin=Ao,t.easeSinIn=function(t){return 1==+t?1:1-Math.cos(t*To)},t.easeSinInOut=Ao,t.easeSinOut=function(t){return Math.sin(t*To)},t.every=function(t,n){if("function"!=typeof n)throw new TypeError("test is not a function");let e=-1;for(const r of t)if(!n(r,++e,t))return!1;return!0},t.extent=M,t.fcumsum=function(t,n){const e=new T;let r=-1;return Float64Array.from(t,void 0===n?t=>e.add(+t||0):i=>e.add(+n(i,++r,t)||0))},t.filter=function(t,n){if("function"!=typeof n)throw new TypeError("test is not a function");const e=[];let r=-1;for(const i of t)n(i,++r,t)&&e.push(i);return e},t.flatGroup=function(t,...n){return z(P(t,...n),n)},t.flatRollup=function(t,n,...e){return z(D(t,n,...e),e)},t.forceCenter=function(t,n){var e,r=1;function i(){var i,o,a=e.length,u=0,c=0;for(i=0;if+p||os+p||ac.index){var g=f-u.x-u.vx,y=s-u.y-u.vy,v=g*g+y*y;vt.r&&(t.r=t[n].r)}function c(){if(n){var r,i,o=n.length;for(e=new Array(o),r=0;r[u(t,n,r),t])));for(a=0,i=new Array(f);a=u)){(t.data!==n||t.next)&&(0===l&&(p+=(l=Uc(e))*l),0===h&&(p+=(h=Uc(e))*h),p(t=(Lc*t+jc)%Hc)/Hc}();function l(){h(),f.call("tick",n),e1?(null==e?u.delete(t):u.set(t,p(e)),n):u.get(t)},find:function(n,e,r){var i,o,a,u,c,f=0,s=t.length;for(null==r?r=1/0:r*=r,f=0;f1?(f.on(t,e),n):f.on(t)}}},t.forceX=function(t){var n,e,r,i=qc(.1);function o(t){for(var i,o=0,a=n.length;o=.12&&i<.234&&r>=-.425&&r<-.214?u:i>=.166&&i<.234&&r>=-.214&&r<-.115?c:a).invert(t)},s.stream=function(e){return t&&n===e?t:(r=[a.stream(n=e),u.stream(e),c.stream(e)],i=r.length,t={point:function(t,n){for(var e=-1;++ejs(r[0],r[1])&&(r[1]=i[1]),js(i[0],r[1])>js(r[0],r[1])&&(r[0]=i[0])):o.push(r=i);for(a=-1/0,n=0,r=o[e=o.length-1];n<=e;r=i,++n)i=o[n],(u=js(r[1],i[0]))>a&&(a=u,Wf=i[0],Kf=r[1])}return is=os=null,Wf===1/0||Zf===1/0?[[NaN,NaN],[NaN,NaN]]:[[Wf,Zf],[Kf,Qf]]},t.geoCentroid=function(t){ms=xs=ws=Ms=Ts=As=Ss=Es=0,Ns=new T,ks=new T,Cs=new T,Lf(t,Gs);var n=+Ns,e=+ks,r=+Cs,i=Ef(n,e,r);return i=0))throw new RangeError(`invalid digits: ${t}`);i=n}return null===n&&(r=new ed(i)),a},a.projection(t).digits(i).context(n)},t.geoProjection=yd,t.geoProjectionMutator=vd,t.geoRotation=ll,t.geoStereographic=function(){return yd(Bd).scale(250).clipAngle(142)},t.geoStereographicRaw=Bd,t.geoStream=Lf,t.geoTransform=function(t){return{stream:id(t)}},t.geoTransverseMercator=function(){var t=Ed(Yd),n=t.center,e=t.rotate;return t.center=function(t){return arguments.length?n([-t[1],t[0]]):[(t=n())[1],-t[0]]},t.rotate=function(t){return arguments.length?e([t[0],t[1],t.length>2?t[2]+90:90]):[(t=e())[0],t[1],t[2]-90]},e([0,0,90]).scale(159.155)},t.geoTransverseMercatorRaw=Yd,t.gray=function(t,n){return new ur(t,0,0,null==n?1:n)},t.greatest=ot,t.greatestIndex=function(t,e=n){if(1===e.length)return tt(t,e);let r,i=-1,o=-1;for(const n of t)++o,(i<0?0===e(n,n):e(n,r)>0)&&(r=n,i=o);return i},t.group=C,t.groupSort=function(t,e,r){return(2!==e.length?U($(t,e,r),(([t,e],[r,i])=>n(e,i)||n(t,r))):U(C(t,r),(([t,r],[i,o])=>e(r,o)||n(t,i)))).map((([t])=>t))},t.groups=P,t.hcl=dr,t.hierarchy=Gd,t.histogram=Q,t.hsl=He,t.html=Ec,t.image=function(t,n){return new Promise((function(e,r){var i=new Image;for(var o in n)i[o]=n[o];i.onerror=r,i.onload=function(){e(i)},i.src=t}))},t.index=function(t,...n){return F(t,k,R,n)},t.indexes=function(t,...n){return F(t,Array.from,R,n)},t.interpolate=Gr,t.interpolateArray=function(t,n){return(Ir(n)?Ur:Or)(t,n)},t.interpolateBasis=Er,t.interpolateBasisClosed=Nr,t.interpolateBlues=Gb,t.interpolateBrBG=ob,t.interpolateBuGn=Mb,t.interpolateBuPu=Ab,t.interpolateCividis=function(t){return t=Math.max(0,Math.min(1,t)),"rgb("+Math.max(0,Math.min(255,Math.round(-4.54-t*(35.34-t*(2381.73-t*(6402.7-t*(7024.72-2710.57*t)))))))+", "+Math.max(0,Math.min(255,Math.round(32.49+t*(170.73+t*(52.82-t*(131.46-t*(176.58-67.37*t)))))))+", "+Math.max(0,Math.min(255,Math.round(81.24+t*(442.36-t*(2482.43-t*(6167.24-t*(6614.94-2475.67*t)))))))+")"},t.interpolateCool=am,t.interpolateCubehelix=li,t.interpolateCubehelixDefault=im,t.interpolateCubehelixLong=hi,t.interpolateDate=Br,t.interpolateDiscrete=function(t){var n=t.length;return function(e){return t[Math.max(0,Math.min(n-1,Math.floor(e*n)))]}},t.interpolateGnBu=Eb,t.interpolateGreens=Wb,t.interpolateGreys=Kb,t.interpolateHcl=ci,t.interpolateHclLong=fi,t.interpolateHsl=oi,t.interpolateHslLong=ai,t.interpolateHue=function(t,n){var e=Pr(+t,+n);return function(t){var n=e(t);return n-360*Math.floor(n/360)}},t.interpolateInferno=pm,t.interpolateLab=function(t,n){var e=$r((t=ar(t)).l,(n=ar(n)).l),r=$r(t.a,n.a),i=$r(t.b,n.b),o=$r(t.opacity,n.opacity);return function(n){return t.l=e(n),t.a=r(n),t.b=i(n),t.opacity=o(n),t+""}},t.interpolateMagma=dm,t.interpolateNumber=Yr,t.interpolateNumberArray=Ur,t.interpolateObject=Lr,t.interpolateOrRd=kb,t.interpolateOranges=rm,t.interpolatePRGn=ub,t.interpolatePiYG=fb,t.interpolatePlasma=gm,t.interpolatePuBu=$b,t.interpolatePuBuGn=Pb,t.interpolatePuOr=lb,t.interpolatePuRd=Rb,t.interpolatePurples=Jb,t.interpolateRainbow=function(t){(t<0||t>1)&&(t-=Math.floor(t));var n=Math.abs(t-.5);return um.h=360*t-100,um.s=1.5-1.5*n,um.l=.8-.9*n,um+""},t.interpolateRdBu=db,t.interpolateRdGy=gb,t.interpolateRdPu=qb,t.interpolateRdYlBu=vb,t.interpolateRdYlGn=bb,t.interpolateReds=nm,t.interpolateRgb=Dr,t.interpolateRgbBasis=Fr,t.interpolateRgbBasisClosed=qr,t.interpolateRound=Vr,t.interpolateSinebow=function(t){var n;return t=(.5-t)*Math.PI,cm.r=255*(n=Math.sin(t))*n,cm.g=255*(n=Math.sin(t+fm))*n,cm.b=255*(n=Math.sin(t+sm))*n,cm+""},t.interpolateSpectral=xb,t.interpolateString=Xr,t.interpolateTransformCss=ti,t.interpolateTransformSvg=ni,t.interpolateTurbo=function(t){return t=Math.max(0,Math.min(1,t)),"rgb("+Math.max(0,Math.min(255,Math.round(34.61+t*(1172.33-t*(10793.56-t*(33300.12-t*(38394.49-14825.05*t)))))))+", "+Math.max(0,Math.min(255,Math.round(23.31+t*(557.33+t*(1225.33-t*(3574.96-t*(1073.77+707.56*t)))))))+", "+Math.max(0,Math.min(255,Math.round(27.2+t*(3211.1-t*(15327.97-t*(27814-t*(22569.18-6838.66*t)))))))+")"},t.interpolateViridis=hm,t.interpolateWarm=om,t.interpolateYlGn=Bb,t.interpolateYlGnBu=Ib,t.interpolateYlOrBr=Lb,t.interpolateYlOrRd=Hb,t.interpolateZoom=ri,t.interrupt=Gi,t.intersection=function(t,...n){t=new InternSet(t),n=n.map(vt);t:for(const e of t)for(const r of n)if(!r.has(e)){t.delete(e);continue t}return t},t.interval=function(t,n,e){var r=new Ei,i=n;return null==n?(r.restart(t,n,e),r):(r._restart=r.restart,r.restart=function(t,n,e){n=+n,e=null==e?Ai():+e,r._restart((function o(a){a+=i,r._restart(o,i+=n,e),t(a)}),n,e)},r.restart(t,n,e),r)},t.isoFormat=D_,t.isoParse=F_,t.json=function(t,n){return fetch(t,n).then(Tc)},t.lab=ar,t.lch=function(t,n,e,r){return 1===arguments.length?hr(t):new pr(e,n,t,null==r?1:r)},t.least=function(t,e=n){let r,i=!1;if(1===e.length){let o;for(const a of t){const t=e(a);(i?n(t,o)<0:0===n(t,t))&&(r=a,o=t,i=!0)}}else for(const n of t)(i?e(n,r)<0:0===e(n,n))&&(r=n,i=!0);return r},t.leastIndex=ht,t.line=Ym,t.lineRadial=Zm,t.link=ax,t.linkHorizontal=function(){return ax(nx)},t.linkRadial=function(){const t=ax(rx);return t.angle=t.x,delete t.x,t.radius=t.y,delete t.y,t},t.linkVertical=function(){return ax(ex)},t.local=Qn,t.map=function(t,n){if("function"!=typeof t[Symbol.iterator])throw new TypeError("values is not iterable");if("function"!=typeof n)throw new TypeError("mapper is not a function");return Array.from(t,((e,r)=>n(e,r,t)))},t.matcher=Vt,t.max=J,t.maxIndex=tt,t.mean=function(t,n){let e=0,r=0;if(void 0===n)for(let n of t)null!=n&&(n=+n)>=n&&(++e,r+=n);else{let i=-1;for(let o of t)null!=(o=n(o,++i,t))&&(o=+o)>=o&&(++e,r+=o)}if(e)return r/e},t.median=function(t,n){return at(t,.5,n)},t.medianIndex=function(t,n){return ct(t,.5,n)},t.merge=ft,t.min=nt,t.minIndex=et,t.mode=function(t,n){const e=new InternMap;if(void 0===n)for(let n of t)null!=n&&n>=n&&e.set(n,(e.get(n)||0)+1);else{let r=-1;for(let i of t)null!=(i=n(i,++r,t))&&i>=i&&e.set(i,(e.get(i)||0)+1)}let r,i=0;for(const[t,n]of e)n>i&&(i=n,r=t);return r},t.namespace=It,t.namespaces=Ut,t.nice=Z,t.now=Ai,t.pack=function(){var t=null,n=1,e=1,r=np;function i(i){const o=ap();return i.x=n/2,i.y=e/2,t?i.eachBefore(xp(t)).eachAfter(wp(r,.5,o)).eachBefore(Mp(1)):i.eachBefore(xp(mp)).eachAfter(wp(np,1,o)).eachAfter(wp(r,i.r/Math.min(n,e),o)).eachBefore(Mp(Math.min(n,e)/(2*i.r))),i}return i.radius=function(n){return arguments.length?(t=Jd(n),i):t},i.size=function(t){return arguments.length?(n=+t[0],e=+t[1],i):[n,e]},i.padding=function(t){return arguments.length?(r="function"==typeof t?t:ep(+t),i):r},i},t.packEnclose=function(t){return up(t,ap())},t.packSiblings=function(t){return bp(t,ap()),t},t.pairs=function(t,n=st){const e=[];let r,i=!1;for(const o of t)i&&e.push(n(r,o)),r=o,i=!0;return e},t.partition=function(){var t=1,n=1,e=0,r=!1;function i(i){var o=i.height+1;return i.x0=i.y0=e,i.x1=t,i.y1=n/o,i.eachBefore(function(t,n){return function(r){r.children&&Ap(r,r.x0,t*(r.depth+1)/n,r.x1,t*(r.depth+2)/n);var i=r.x0,o=r.y0,a=r.x1-e,u=r.y1-e;a0&&(d+=l);for(null!=n?p.sort((function(t,e){return n(g[t],g[e])})):null!=e&&p.sort((function(t,n){return e(a[t],a[n])})),u=0,f=d?(v-h*b)/d:0;u0?l*f:0)+b,g[c]={data:a[c],index:u,value:l,startAngle:y,endAngle:s,padAngle:_};return g}return a.value=function(n){return arguments.length?(t="function"==typeof n?n:ym(+n),a):t},a.sortValues=function(t){return arguments.length?(n=t,e=null,a):n},a.sort=function(t){return arguments.length?(e=t,n=null,a):e},a.startAngle=function(t){return arguments.length?(r="function"==typeof t?t:ym(+t),a):r},a.endAngle=function(t){return arguments.length?(i="function"==typeof t?t:ym(+t),a):i},a.padAngle=function(t){return arguments.length?(o="function"==typeof t?t:ym(+t),a):o},a},t.piecewise=di,t.pointRadial=Qm,t.pointer=ne,t.pointers=function(t,n){return t.target&&(t=te(t),void 0===n&&(n=t.currentTarget),t=t.touches||[t]),Array.from(t,(t=>ne(t,n)))},t.polygonArea=function(t){for(var n,e=-1,r=t.length,i=t[r-1],o=0;++eu!=f>u&&a<(c-e)*(u-r)/(f-r)+e&&(s=!s),c=e,f=r;return s},t.polygonHull=function(t){if((e=t.length)<3)return null;var n,e,r=new Array(e),i=new Array(e);for(n=0;n=0;--n)f.push(t[r[o[n]][2]]);for(n=+u;n(n=1664525*n+1013904223|0,lg*(n>>>0))},t.randomLogNormal=Kp,t.randomLogistic=fg,t.randomNormal=Zp,t.randomPareto=ng,t.randomPoisson=sg,t.randomUniform=Vp,t.randomWeibull=ug,t.range=lt,t.rank=function(t,e=n){if("function"!=typeof t[Symbol.iterator])throw new TypeError("values is not iterable");let r=Array.from(t);const i=new Float64Array(r.length);2!==e.length&&(r=r.map(e),e=n);const o=(t,n)=>e(r[t],r[n]);let a,u;return(t=Uint32Array.from(r,((t,n)=>n))).sort(e===n?(t,n)=>O(r[t],r[n]):I(o)),t.forEach(((t,n)=>{const e=o(t,void 0===a?t:a);e>=0?((void 0===a||e>0)&&(a=t,u=n),i[t]=u):i[t]=NaN})),i},t.reduce=function(t,n,e){if("function"!=typeof n)throw new TypeError("reducer is not a function");const r=t[Symbol.iterator]();let i,o,a=-1;if(arguments.length<3){if(({done:i,value:e}=r.next()),i)return;++a}for(;({done:i,value:o}=r.next()),!i;)e=n(e,o,++a,t);return e},t.reverse=function(t){if("function"!=typeof t[Symbol.iterator])throw new TypeError("values is not iterable");return Array.from(t).reverse()},t.rgb=Fe,t.ribbon=function(){return Wa()},t.ribbonArrow=function(){return Wa(Va)},t.rollup=$,t.rollups=D,t.scaleBand=yg,t.scaleDiverging=function t(){var n=Ng(L_()(mg));return n.copy=function(){return B_(n,t())},dg.apply(n,arguments)},t.scaleDivergingLog=function t(){var n=Fg(L_()).domain([.1,1,10]);return n.copy=function(){return B_(n,t()).base(n.base())},dg.apply(n,arguments)},t.scaleDivergingPow=j_,t.scaleDivergingSqrt=function(){return j_.apply(null,arguments).exponent(.5)},t.scaleDivergingSymlog=function t(){var n=Ig(L_());return n.copy=function(){return B_(n,t()).constant(n.constant())},dg.apply(n,arguments)},t.scaleIdentity=function t(n){var e;function r(t){return null==t||isNaN(t=+t)?e:t}return r.invert=r,r.domain=r.range=function(t){return arguments.length?(n=Array.from(t,_g),r):n.slice()},r.unknown=function(t){return arguments.length?(e=t,r):e},r.copy=function(){return t(n).unknown(e)},n=arguments.length?Array.from(n,_g):[0,1],Ng(r)},t.scaleImplicit=pg,t.scaleLinear=function t(){var n=Sg();return n.copy=function(){return Tg(n,t())},hg.apply(n,arguments),Ng(n)},t.scaleLog=function t(){const n=Fg(Ag()).domain([1,10]);return n.copy=()=>Tg(n,t()).base(n.base()),hg.apply(n,arguments),n},t.scaleOrdinal=gg,t.scalePoint=function(){return vg(yg.apply(null,arguments).paddingInner(1))},t.scalePow=jg,t.scaleQuantile=function t(){var e,r=[],i=[],o=[];function a(){var t=0,n=Math.max(1,i.length);for(o=new Array(n-1);++t0?o[n-1]:r[0],n=i?[o[i-1],r]:[o[n-1],o[n]]},u.unknown=function(t){return arguments.length?(n=t,u):u},u.thresholds=function(){return o.slice()},u.copy=function(){return t().domain([e,r]).range(a).unknown(n)},hg.apply(Ng(u),arguments)},t.scaleRadial=function t(){var n,e=Sg(),r=[0,1],i=!1;function o(t){var r=function(t){return Math.sign(t)*Math.sqrt(Math.abs(t))}(e(t));return isNaN(r)?n:i?Math.round(r):r}return o.invert=function(t){return e.invert(Hg(t))},o.domain=function(t){return arguments.length?(e.domain(t),o):e.domain()},o.range=function(t){return arguments.length?(e.range((r=Array.from(t,_g)).map(Hg)),o):r.slice()},o.rangeRound=function(t){return o.range(t).round(!0)},o.round=function(t){return arguments.length?(i=!!t,o):i},o.clamp=function(t){return arguments.length?(e.clamp(t),o):e.clamp()},o.unknown=function(t){return arguments.length?(n=t,o):n},o.copy=function(){return t(e.domain(),r).round(i).clamp(e.clamp()).unknown(n)},hg.apply(o,arguments),Ng(o)},t.scaleSequential=function t(){var n=Ng(O_()(mg));return n.copy=function(){return B_(n,t())},dg.apply(n,arguments)},t.scaleSequentialLog=function t(){var n=Fg(O_()).domain([1,10]);return n.copy=function(){return B_(n,t()).base(n.base())},dg.apply(n,arguments)},t.scaleSequentialPow=Y_,t.scaleSequentialQuantile=function t(){var e=[],r=mg;function i(t){if(null!=t&&!isNaN(t=+t))return r((s(e,t,1)-1)/(e.length-1))}return i.domain=function(t){if(!arguments.length)return e.slice();e=[];for(let n of t)null==n||isNaN(n=+n)||e.push(n);return e.sort(n),i},i.interpolator=function(t){return arguments.length?(r=t,i):r},i.range=function(){return e.map(((t,n)=>r(n/(e.length-1))))},i.quantiles=function(t){return Array.from({length:t+1},((n,r)=>at(e,r/t)))},i.copy=function(){return t(r).domain(e)},dg.apply(i,arguments)},t.scaleSequentialSqrt=function(){return Y_.apply(null,arguments).exponent(.5)},t.scaleSequentialSymlog=function t(){var n=Ig(O_());return n.copy=function(){return B_(n,t()).constant(n.constant())},dg.apply(n,arguments)},t.scaleSqrt=function(){return jg.apply(null,arguments).exponent(.5)},t.scaleSymlog=function t(){var n=Ig(Ag());return n.copy=function(){return Tg(n,t()).constant(n.constant())},hg.apply(n,arguments)},t.scaleThreshold=function t(){var n,e=[.5],r=[0,1],i=1;function o(t){return null!=t&&t<=t?r[s(e,t,0,i)]:n}return o.domain=function(t){return arguments.length?(e=Array.from(t),i=Math.min(e.length,r.length-1),o):e.slice()},o.range=function(t){return arguments.length?(r=Array.from(t),i=Math.min(e.length,r.length-1),o):r.slice()},o.invertExtent=function(t){var n=r.indexOf(t);return[e[n-1],e[n]]},o.unknown=function(t){return arguments.length?(n=t,o):n},o.copy=function(){return t().domain(e).range(r).unknown(n)},hg.apply(o,arguments)},t.scaleTime=function(){return hg.apply(I_(uv,cv,tv,Zy,xy,py,sy,ay,iy,t.timeFormat).domain([new Date(2e3,0,1),new Date(2e3,0,2)]),arguments)},t.scaleUtc=function(){return hg.apply(I_(ov,av,ev,Qy,Fy,yy,hy,cy,iy,t.utcFormat).domain([Date.UTC(2e3,0,1),Date.UTC(2e3,0,2)]),arguments)},t.scan=function(t,n){const e=ht(t,n);return e<0?void 0:e},t.schemeAccent=G_,t.schemeBlues=Xb,t.schemeBrBG=ib,t.schemeBuGn=wb,t.schemeBuPu=Tb,t.schemeCategory10=X_,t.schemeDark2=V_,t.schemeGnBu=Sb,t.schemeGreens=Vb,t.schemeGreys=Zb,t.schemeObservable10=W_,t.schemeOrRd=Nb,t.schemeOranges=em,t.schemePRGn=ab,t.schemePaired=Z_,t.schemePastel1=K_,t.schemePastel2=Q_,t.schemePiYG=cb,t.schemePuBu=zb,t.schemePuBuGn=Cb,t.schemePuOr=sb,t.schemePuRd=Db,t.schemePurples=Qb,t.schemeRdBu=hb,t.schemeRdGy=pb,t.schemeRdPu=Fb,t.schemeRdYlBu=yb,t.schemeRdYlGn=_b,t.schemeReds=tm,t.schemeSet1=J_,t.schemeSet2=tb,t.schemeSet3=nb,t.schemeSpectral=mb,t.schemeTableau10=eb,t.schemeYlGn=Ob,t.schemeYlGnBu=Ub,t.schemeYlOrBr=Yb,t.schemeYlOrRd=jb,t.select=Zn,t.selectAll=function(t){return"string"==typeof t?new Vn([document.querySelectorAll(t)],[document.documentElement]):new Vn([Ht(t)],Gn)},t.selection=Wn,t.selector=jt,t.selectorAll=Gt,t.shuffle=dt,t.shuffler=pt,t.some=function(t,n){if("function"!=typeof n)throw new TypeError("test is not a function");let e=-1;for(const r of t)if(n(r,++e,t))return!0;return!1},t.sort=U,t.stack=function(){var t=ym([]),n=dw,e=hw,r=pw;function i(i){var o,a,u=Array.from(t.apply(this,arguments),gw),c=u.length,f=-1;for(const t of i)for(o=0,++f;o0)for(var e,r,i,o,a,u,c=0,f=t[n[0]].length;c0?(r[0]=o,r[1]=o+=i):i<0?(r[1]=a,r[0]=a+=i):(r[0]=0,r[1]=i)},t.stackOffsetExpand=function(t,n){if((r=t.length)>0){for(var e,r,i,o=0,a=t[0].length;o0){for(var e,r=0,i=t[n[0]],o=i.length;r0&&(r=(e=t[n[0]]).length)>0){for(var e,r,i,o=0,a=1;afunction(t){t=`${t}`;let n=t.length;zp(t,n-1)&&!zp(t,n-2)&&(t=t.slice(0,-1));return"/"===t[0]?t:`/${t}`}(t(n,e,r)))),e=n.map(Pp),i=new Set(n).add("");for(const t of e)i.has(t)||(i.add(t),n.push(t),e.push(Pp(t)),h.push(Np));d=(t,e)=>n[e],p=(t,n)=>e[n]}for(a=0,i=h.length;a=0&&(f=h[t]).data===Np;--t)f.data=null}if(u.parent=Sp,u.eachBefore((function(t){t.depth=t.parent.depth+1,--i})).eachBefore(Kd),u.parent=null,i>0)throw new Error("cycle");return u}return r.id=function(t){return arguments.length?(n=Jd(t),r):n},r.parentId=function(t){return arguments.length?(e=Jd(t),r):e},r.path=function(n){return arguments.length?(t=Jd(n),r):t},r},t.style=_n,t.subset=function(t,n){return _t(n,t)},t.sum=function(t,n){let e=0;if(void 0===n)for(let n of t)(n=+n)&&(e+=n);else{let r=-1;for(let i of t)(i=+n(i,++r,t))&&(e+=i)}return e},t.superset=_t,t.svg=Nc,t.symbol=function(t,n){let e=null,r=km(i);function i(){let i;if(e||(e=i=r()),t.apply(this,arguments).draw(e,+n.apply(this,arguments)),i)return e=null,i+""||null}return t="function"==typeof t?t:ym(t||fx),n="function"==typeof n?n:ym(void 0===n?64:+n),i.type=function(n){return arguments.length?(t="function"==typeof n?n:ym(n),i):t},i.size=function(t){return arguments.length?(n="function"==typeof t?t:ym(+t),i):n},i.context=function(t){return arguments.length?(e=null==t?null:t,i):e},i},t.symbolAsterisk=cx,t.symbolCircle=fx,t.symbolCross=sx,t.symbolDiamond=dx,t.symbolDiamond2=px,t.symbolPlus=gx,t.symbolSquare=yx,t.symbolSquare2=vx,t.symbolStar=xx,t.symbolTimes=Px,t.symbolTriangle=Mx,t.symbolTriangle2=Ax,t.symbolWye=Cx,t.symbolX=Px,t.symbols=zx,t.symbolsFill=zx,t.symbolsStroke=$x,t.text=mc,t.thresholdFreedmanDiaconis=function(t,n,e){const r=v(t),i=at(t,.75)-at(t,.25);return r&&i?Math.ceil((e-n)/(2*i*Math.pow(r,-1/3))):1},t.thresholdScott=function(t,n,e){const r=v(t),i=w(t);return r&&i?Math.ceil((e-n)*Math.cbrt(r)/(3.49*i)):1},t.thresholdSturges=K,t.tickFormat=Eg,t.tickIncrement=V,t.tickStep=W,t.ticks=G,t.timeDay=py,t.timeDays=gy,t.timeFormatDefaultLocale=P_,t.timeFormatLocale=hv,t.timeFriday=Sy,t.timeFridays=$y,t.timeHour=sy,t.timeHours=ly,t.timeInterval=Vg,t.timeMillisecond=Wg,t.timeMilliseconds=Zg,t.timeMinute=ay,t.timeMinutes=uy,t.timeMonday=wy,t.timeMondays=ky,t.timeMonth=Zy,t.timeMonths=Ky,t.timeSaturday=Ey,t.timeSaturdays=Dy,t.timeSecond=iy,t.timeSeconds=oy,t.timeSunday=xy,t.timeSundays=Ny,t.timeThursday=Ay,t.timeThursdays=zy,t.timeTickInterval=cv,t.timeTicks=uv,t.timeTuesday=My,t.timeTuesdays=Cy,t.timeWednesday=Ty,t.timeWednesdays=Py,t.timeWeek=xy,t.timeWeeks=Ny,t.timeYear=tv,t.timeYears=nv,t.timeout=$i,t.timer=Ni,t.timerFlush=ki,t.transition=go,t.transpose=gt,t.tree=function(){var t=$p,n=1,e=1,r=null;function i(i){var c=function(t){for(var n,e,r,i,o,a=new Up(t,0),u=[a];n=u.pop();)if(r=n._.children)for(n.children=new Array(o=r.length),i=o-1;i>=0;--i)u.push(e=n.children[i]=new Up(r[i],i)),e.parent=n;return(a.parent=new Up(null,0)).children=[a],a}(i);if(c.eachAfter(o),c.parent.m=-c.z,c.eachBefore(a),r)i.eachBefore(u);else{var f=i,s=i,l=i;i.eachBefore((function(t){t.xs.x&&(s=t),t.depth>l.depth&&(l=t)}));var h=f===s?1:t(f,s)/2,d=h-f.x,p=n/(s.x+h+d),g=e/(l.depth||1);i.eachBefore((function(t){t.x=(t.x+d)*p,t.y=t.depth*g}))}return i}function o(n){var e=n.children,r=n.parent.children,i=n.i?r[n.i-1]:null;if(e){!function(t){for(var n,e=0,r=0,i=t.children,o=i.length;--o>=0;)(n=i[o]).z+=e,n.m+=e,e+=n.s+(r+=n.c)}(n);var o=(e[0].z+e[e.length-1].z)/2;i?(n.z=i.z+t(n._,i._),n.m=n.z-o):n.z=o}else i&&(n.z=i.z+t(n._,i._));n.parent.A=function(n,e,r){if(e){for(var i,o=n,a=n,u=e,c=o.parent.children[0],f=o.m,s=a.m,l=u.m,h=c.m;u=Rp(u),o=Dp(o),u&&o;)c=Dp(c),(a=Rp(a)).a=n,(i=u.z+l-o.z-f+t(u._,o._))>0&&(Fp(qp(u,n,r),n,i),f+=i,s+=i),l+=u.m,f+=o.m,h+=c.m,s+=a.m;u&&!Rp(a)&&(a.t=u,a.m+=l-s),o&&!Dp(c)&&(c.t=o,c.m+=f-h,r=n)}return r}(n,i,n.parent.A||r[0])}function a(t){t._.x=t.z+t.parent.m,t.m+=t.parent.m}function u(t){t.x*=n,t.y=t.depth*e}return i.separation=function(n){return arguments.length?(t=n,i):t},i.size=function(t){return arguments.length?(r=!1,n=+t[0],e=+t[1],i):r?null:[n,e]},i.nodeSize=function(t){return arguments.length?(r=!0,n=+t[0],e=+t[1],i):r?[n,e]:null},i},t.treemap=function(){var t=Yp,n=!1,e=1,r=1,i=[0],o=np,a=np,u=np,c=np,f=np;function s(t){return t.x0=t.y0=0,t.x1=e,t.y1=r,t.eachBefore(l),i=[0],n&&t.eachBefore(Tp),t}function l(n){var e=i[n.depth],r=n.x0+e,s=n.y0+e,l=n.x1-e,h=n.y1-e;l=e-1){var s=u[n];return s.x0=i,s.y0=o,s.x1=a,void(s.y1=c)}var l=f[n],h=r/2+l,d=n+1,p=e-1;for(;d>>1;f[g]c-o){var _=r?(i*v+a*y)/r:a;t(n,d,y,i,o,_,c),t(d,e,v,_,o,a,c)}else{var b=r?(o*v+c*y)/r:c;t(n,d,y,i,o,a,b),t(d,e,v,i,b,a,c)}}(0,c,t.value,n,e,r,i)},t.treemapDice=Ap,t.treemapResquarify=Lp,t.treemapSlice=Ip,t.treemapSliceDice=function(t,n,e,r,i){(1&t.depth?Ip:Ap)(t,n,e,r,i)},t.treemapSquarify=Yp,t.tsv=Mc,t.tsvFormat=lc,t.tsvFormatBody=hc,t.tsvFormatRow=pc,t.tsvFormatRows=dc,t.tsvFormatValue=gc,t.tsvParse=fc,t.tsvParseRows=sc,t.union=function(...t){const n=new InternSet;for(const e of t)for(const t of e)n.add(t);return n},t.unixDay=_y,t.unixDays=by,t.utcDay=yy,t.utcDays=vy,t.utcFriday=By,t.utcFridays=Vy,t.utcHour=hy,t.utcHours=dy,t.utcMillisecond=Wg,t.utcMilliseconds=Zg,t.utcMinute=cy,t.utcMinutes=fy,t.utcMonday=qy,t.utcMondays=jy,t.utcMonth=Qy,t.utcMonths=Jy,t.utcSaturday=Yy,t.utcSaturdays=Wy,t.utcSecond=iy,t.utcSeconds=oy,t.utcSunday=Fy,t.utcSundays=Ly,t.utcThursday=Oy,t.utcThursdays=Gy,t.utcTickInterval=av,t.utcTicks=ov,t.utcTuesday=Uy,t.utcTuesdays=Hy,t.utcWednesday=Iy,t.utcWednesdays=Xy,t.utcWeek=Fy,t.utcWeeks=Ly,t.utcYear=ev,t.utcYears=rv,t.variance=x,t.version="7.9.0",t.window=pn,t.xml=Sc,t.zip=function(){return gt(arguments)},t.zoom=function(){var t,n,e,r=Ew,i=Nw,o=zw,a=Cw,u=Pw,c=[0,1/0],f=[[-1/0,-1/0],[1/0,1/0]],s=250,l=ri,h=$t("start","zoom","end"),d=500,p=150,g=0,y=10;function v(t){t.property("__zoom",kw).on("wheel.zoom",T,{passive:!1}).on("mousedown.zoom",A).on("dblclick.zoom",S).filter(u).on("touchstart.zoom",E).on("touchmove.zoom",N).on("touchend.zoom touchcancel.zoom",k).style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}function _(t,n){return(n=Math.max(c[0],Math.min(c[1],n)))===t.k?t:new ww(n,t.x,t.y)}function b(t,n,e){var r=n[0]-e[0]*t.k,i=n[1]-e[1]*t.k;return r===t.x&&i===t.y?t:new ww(t.k,r,i)}function m(t){return[(+t[0][0]+ +t[1][0])/2,(+t[0][1]+ +t[1][1])/2]}function x(t,n,e,r){t.on("start.zoom",(function(){w(this,arguments).event(r).start()})).on("interrupt.zoom end.zoom",(function(){w(this,arguments).event(r).end()})).tween("zoom",(function(){var t=this,o=arguments,a=w(t,o).event(r),u=i.apply(t,o),c=null==e?m(u):"function"==typeof e?e.apply(t,o):e,f=Math.max(u[1][0]-u[0][0],u[1][1]-u[0][1]),s=t.__zoom,h="function"==typeof n?n.apply(t,o):n,d=l(s.invert(c).concat(f/s.k),h.invert(c).concat(f/h.k));return function(t){if(1===t)t=h;else{var n=d(t),e=f/n[2];t=new ww(e,c[0]-n[0]*e,c[1]-n[1]*e)}a.zoom(null,t)}}))}function w(t,n,e){return!e&&t.__zooming||new M(t,n)}function M(t,n){this.that=t,this.args=n,this.active=0,this.sourceEvent=null,this.extent=i.apply(t,n),this.taps=0}function T(t,...n){if(r.apply(this,arguments)){var e=w(this,n).event(t),i=this.__zoom,u=Math.max(c[0],Math.min(c[1],i.k*Math.pow(2,a.apply(this,arguments)))),s=ne(t);if(e.wheel)e.mouse[0][0]===s[0]&&e.mouse[0][1]===s[1]||(e.mouse[1]=i.invert(e.mouse[0]=s)),clearTimeout(e.wheel);else{if(i.k===u)return;e.mouse=[s,i.invert(s)],Gi(this),e.start()}Sw(t),e.wheel=setTimeout((function(){e.wheel=null,e.end()}),p),e.zoom("mouse",o(b(_(i,u),e.mouse[0],e.mouse[1]),e.extent,f))}}function A(t,...n){if(!e&&r.apply(this,arguments)){var i=t.currentTarget,a=w(this,n,!0).event(t),u=Zn(t.view).on("mousemove.zoom",(function(t){if(Sw(t),!a.moved){var n=t.clientX-s,e=t.clientY-l;a.moved=n*n+e*e>g}a.event(t).zoom("mouse",o(b(a.that.__zoom,a.mouse[0]=ne(t,i),a.mouse[1]),a.extent,f))}),!0).on("mouseup.zoom",(function(t){u.on("mousemove.zoom mouseup.zoom",null),ue(t.view,a.moved),Sw(t),a.event(t).end()}),!0),c=ne(t,i),s=t.clientX,l=t.clientY;ae(t.view),Aw(t),a.mouse=[c,this.__zoom.invert(c)],Gi(this),a.start()}}function S(t,...n){if(r.apply(this,arguments)){var e=this.__zoom,a=ne(t.changedTouches?t.changedTouches[0]:t,this),u=e.invert(a),c=e.k*(t.shiftKey?.5:2),l=o(b(_(e,c),a,u),i.apply(this,n),f);Sw(t),s>0?Zn(this).transition().duration(s).call(x,l,a,t):Zn(this).call(v.transform,l,a,t)}}function E(e,...i){if(r.apply(this,arguments)){var o,a,u,c,f=e.touches,s=f.length,l=w(this,i,e.changedTouches.length===s).event(e);for(Aw(e),a=0;a { + console.log("Initialize Network Function"); + + // Get variables from config + const selector = config.selector; // DOM uuid + const width = config.width || 800; // window width + const height = config.height || 600; // window height + const delta = config.delta || 300; // time between frames + const padding = (config.node && config.node.image_padding) || 5; // distance between node and image + const xlim = [-0.1,1.1]; // limits of the x-coordinates + const ylim = [-0.1,1.1]; // limits of the y-coordinates + const arrowheadMultiplier = 4; // Multiplier for arrowhead size based on edge stroke width + const nodeStrokeWidth = 2.5; // Stroke width around nodes + + // Initialize svg canvas + const svg = d3.select(selector) + .append('svg') + .attr('width', width) + .attr('height', height) + .attr('viewBox', [0, 0, width, height]); + + // add container to store network + let container = svg.append("g"); + + // initialize link + let link = container.append("g") + .attr("class", "edges") + .selectAll(".link"); + + // initialize node + let node = container.append("g") + .attr("class", "nodes") + .selectAll("circle.node"); + + // initialize label + let label = container.append("g") + .attr("class", "labels") + .selectAll(".label"); + + // initialize image + let image = container.append("g") + .attr("class", "images") + .selectAll(".image"); + + // Helper function to calculate and store link endpoints on the data object + const calculateAndStoreLinkPath = (d) => { + const source_x = d.source.x; + const source_y = d.source.y; + const target_x = d.target.x; + const target_y = d.target.y; + + const sourceRadius = d.source.size || (config.node && config.node.size) || 15; + const targetRadius = d.target.size || (config.node && config.node.size) || 15; + + let effectiveSourceRadius = sourceRadius + nodeStrokeWidth / 2; + let effectiveTargetRadius = targetRadius + nodeStrokeWidth / 2; + + let finalPath; + + if (config.directed) { + // --- For CURVED Directed Links (Complex Path) --- + const dx = target_x - source_x; + const dy = target_y - source_y; + const distance = Math.sqrt(dx * dx + dy * dy); + + // Create a virtual path from center to center + const virtualPathData = `M${source_x},${source_y} A${distance},${distance} 0 0,1 ${target_x},${target_y}`; + + // Use a temporary path element to measure + const tempPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); + tempPath.setAttribute("d", virtualPathData); + + const pathLength = tempPath.getTotalLength(); + + // Adjust effective radii to account for arrowhead size + const edgeStrokeWidth = d.size || (config.edge && config.edge.size) || 2; + const arrowheadLength = edgeStrokeWidth * arrowheadMultiplier; + effectiveTargetRadius += arrowheadLength; + + // Find the precise intersection points by moving along the path + const startPoint = tempPath.getPointAtLength(effectiveSourceRadius); + const endPoint = tempPath.getPointAtLength(pathLength - effectiveTargetRadius); + + // Rebuild the arc with the new, correct endpoints + const newDx = endPoint.x - startPoint.x; + const newDy = endPoint.y - startPoint.y; + const newDistance = Math.sqrt(newDx * newDx + newDy * newDy); + + finalPath = `M${startPoint.x},${startPoint.y} A${newDistance},${newDistance} 0 0,1 ${endPoint.x},${endPoint.y}`; + + } else { + // --- For STRAIGHT Undirected Links (Simple Path) --- + const dx = target_x - source_x; + const dy = target_y - source_y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance === 0) { + d._path = `M${source_x},${source_y} L${target_x},${target_y}`; + return; + } + + const x1 = source_x + (dx / distance) * effectiveSourceRadius; + const y1 = source_y + (dy / distance) * effectiveSourceRadius; + const x2 = target_x - (dx / distance) * effectiveTargetRadius; + const y2 = target_y - (dy / distance) * effectiveTargetRadius; + finalPath = `M${x1},${y1} L${x2},${y2}`; + } + + d._path = finalPath; + }; + + const ticked = () => { + // First, iterate through the link data to calculate all endpoints (and curves if directed) + // so that the link only touches the edge of the node when opacity is < 1 + link.data().forEach(calculateAndStoreLinkPath); + + node.call(updateNodePosition); + link.call(updateLinkPosition); + if (config.show_labels) { + label.call(updateLabelPosition); + } + image.call(updateImagePosition); + } + + // update node position + const updateNodePosition = (node) => { + node.attr("transform", function(d) { + return "translate(" + d.x + "," + d.y + ")"; + }); + }; + + // update label position + const updateLabelPosition = (label) => { + label.attr("transform", function(d) { + return "translate(" + d.x + "," + d.y + ")"; + }); + }; + + // update image position + const updateImagePosition = (image) => { + image.attr("transform", function(d) { + return "translate(" + d.x + "," + d.y + ")"; + }); + }; + + // update link position + const updateLinkPosition = (link) => { + link.attr('d', d => d._path); + }; + + const simulation = d3.forceSimulation() + .velocityDecay(0.2) + .alphaMin(0.1) + .force('link', d3.forceLink().id(d => d.uid)) + .on('tick', ticked); + + let currentlyDragged = null; // Remember currently dragged node during update + + // Add drag functionality to the node objects + const drag = d3.drag() + .on("start", dragstarted) + .on("drag", dragged) + .on("end", dragended); + + function dragstarted(event, d) { + currentlyDragged = d; + event.sourceEvent.stopPropagation(); + if (!event.active) simulation.alphaTarget(0.3).restart(); + d.fx = d.x; + d.fy = d.y; + }; + + function dragged(event, d) { + d.fx = event.x; + d.fy = event.y; + }; + + function dragended(event, d) { + currentlyDragged = null; + if (!event.active) simulation.alphaTarget(0); + d.fx = null; + d.fy = null; + }; + + /** + * Creates a custom D3 force that repels nodes from a rectangular boundary. + * @param {number} x0 - The left boundary. + * @param {number} y0 - The top boundary. + * @param {number} x1 - The right boundary. + * @param {number} y1 - The bottom boundary. + * @param {number} strength - The strength of the repulsion. + */ + function forceBoundary(x0, y0, x1, y1, strength = 0.5) { + let nodes; + + function force(alpha) { + for (let i = 0, n = nodes.length; i < n; ++i) { + const node = nodes[i]; + const r = node.size || (config.node && config.node.size) || 15; + + // Push node away from the left boundary + if (node.x - r < x0) { + node.vx += (x0 - (node.x - r)) * strength * alpha; + } + // Push node away from the right boundary + if (node.x + r > x1) { + node.vx += (x1 - (node.x + r)) * strength * alpha; + } + // Push node away from the top boundary + if (node.y - r < y0) { + node.vy += (y0 - (node.y - r)) * strength * alpha; + } + // Push node away from the bottom boundary + if (node.y + r > y1) { + node.vy += (y1 - (node.y + r)) * strength * alpha; + } + } + } + + force.initialize = function(_) { + nodes = _; + }; + + return force; + } + + return Object.assign(svg.node(), { + update({nodes, links}) { + + // --- DATA PREPARATION --- + // Preserve node positions across updates + const oldNodesMap = new Map(node.data().map(d => [d.uid, d])); + nodes = nodes.map(newNode => { + const oldNode = oldNodesMap.get(newNode.uid); + + // Check if this is the node currently being dragged + if (currentlyDragged && currentlyDragged.uid === newNode.uid) { + // If so, update the original object in place... + Object.assign(currentlyDragged, newNode); + // ...and return the original object to preserve its identity. + return currentlyDragged; + } + + // For all other nodes, create a new merged object as before. + return { ...oldNode, ...newNode }; + }); + links = links.map(d => ({...d})); + + // --- NODES (CIRCLES) --- + // 1. Data Join + node = container.select('.nodes').selectAll("circle.node") + .data(nodes, d => d.uid); + + // 2. Exit Selection: Fade out and remove old nodes + node.exit() + .transition() + .duration(delta / 2) + .style("opacity", 0) + .remove(); + + // 3. Enter & Merge: Create new circles and merge with updating ones + node = node.enter().append('circle') + .attr("class", "node") + .call(drag) + .merge(node); + + // 4. Update Selection: Apply transitions to all nodes (new and existing) + node.transition() + .duration(delta) + .style("r", d => (d.size || (config.node && config.node.size)) + "px") // Use fallback for size + .style("fill", d => (d.color || (config.node && config.node.color))) // Use fallback for color + .style("opacity", d => (d.opacity || (config.node && config.node.opacity))) // Use fallback for opacity + .style("stroke-width", nodeStrokeWidth + "px") + .style("stroke", "#000000"); + + // --- IMAGES --- + // 1. Data Join + image = container.select('.images').selectAll('.image') + .data(nodes.filter(d => d.image), d => d.uid); + + // 2. Exit Selection: Fade out and remove old images + image.exit() + .transition() + .duration(delta / 2) + .style("opacity", 0) + .remove(); + + // 3. Enter & Merge: Create new images and merge with updating ones + image = image.enter().append('image') + .attr("class", "image") + .attr("xlink:href", d => d.image) + .style("width", "0px") + .style("height", "0px") + .call(drag) + .merge(image); + + // 4. Update Selection: Apply transitions to all images + image.transition() + .duration(delta) + .attr("x", d => -(d.size || (config.node && config.node.size)) + padding) + .attr("y", d => -(d.size || (config.node && config.node.size)) + padding) + .style("width", d => 2 * (d.size || (config.node && config.node.size)) - 2 * padding + "px") + .style("height", d => 2 * (d.size || (config.node && config.node.size)) - 2 * padding + "px"); + + + // --- LABELS --- + if (config.show_labels) { + // 1. Data Join + label = container.select('.labels').selectAll('.label-text') + .data(nodes, d => d.uid); + + // 2. Exit Selection: Fade out and remove old labels + label.exit() + .transition() + .duration(delta / 2) + .style("opacity", 0) + .remove(); + + // 3. Enter & Merge: Create new text elements and merge with updating ones + label = label.enter().append('text') + .attr("class", "label-text") + .attr("dy", ".32em") + .style("opacity", 0) // Start transparent to fade in + .merge(label); + + // 4. Update Selection: Apply transitions to all labels + label.transition() + .duration(delta) + .attr("x", d => (d.size || 15) + 5) // Use fallback for size + .text(d => d.uid) + .style("opacity", 1); // Fade in new/updating labels + } + + // --- DYNAMIC ARROWHEAD MARKERS --- + if (config.directed) { + // Ensure a element exists + const defs = container.selectAll('defs').data([1]).join('defs'); + + // 1. Get a list of all unique edge colors currently in the data + const uniqueColors = Array.from( + new Set(links.map(d => d.color || (config.edge && config.edge.color))) + ); + + // 2. Perform a data join to create one marker per unique color + const markers = defs.selectAll('marker') + .data(uniqueColors, color => color); // Key the data by the color string itself + + // 3. Remove any markers for colors that are no longer in the data + markers.exit().remove(); + + // 4. For any new colors, create a new marker + markers.enter().append('marker') + .attr('id', color => `arrowhead-${color.replace('#', '')}`) // e.g., "arrowhead-ff0000" + .attr('viewBox', '0 -5 10 10') + .attr('refX', 0) + .attr('refY', 0) + .attr('markerUnits', 'strokeWidth') + .attr('markerWidth', arrowheadMultiplier) + .attr('markerHeight', arrowheadMultiplier) + .attr('orient', 'auto') + .append('path') + .attr('d', 'M0,-5L10,0L0,5') + .style('fill', color => color); // Set the fill using the color data + } + + + // --- LINKS (EDGES) --- + // 1. Data Join + link = container.select(".edges").selectAll(".link") + .data(links, d => d.uid); + + // 2. Exit Selection + link.exit().remove(); + + // 3. Enter & Merge + link = link.enter().append("path") + .attr("class", "link") + .style("fill", "none") + .merge(link); + + // 4. Update Selection + link.transition() + .duration(delta) + .style("stroke", d => (d.color || (config.edge && config.edge.color))) + .style("color", d => (d.color || (config.edge && config.edge.color))) // For arrowhead color + .style("stroke-width", d => (d.size || (config.edge && config.edge.size)) + 'px') + .style("opacity", d => (d.opacity || (config.edge && config.edge.opacity))); + + // Conditionally add the correct arrowhead marker + if (config.directed) { + link.attr('marker-end', d => { + // Find the color for this specific link + const color = d.color || (config.edge && config.edge.color); + // Create the safe ID that matches the marker definition + const safeColorId = color.replace('#', ''); + // Return the URL pointing to the specific marker + return `url(#arrowhead-${safeColorId})`; + }); + } else { + link.attr('marker-end', null); + } + + simulation.nodes(nodes); + simulation.force("link").links(links); + + // Based on the config.simulation parameter, choose the layout type. + if (config.simulation) { + // TRUE: Use a dynamic spring layout (force-directed) + simulation + .force('charge', d3.forceManyBody().strength(-50)) // Nodes repel each other + .force('center', d3.forceCenter(0, 0)) // Center the graph + .force('x', null) // Remove the static x-force + .force('y', null) // Remove the static y-force + .force('boundary', forceBoundary(-width / 2, -height / 2, width / 2, height / 2)); + + // Adjust link force + simulation.force("link").strength(0.1).distance(70); + + } else { + // FALSE: Use the original fixed layout based on xpos and ypos + simulation.force('charge', d3.forceManyBody().strength(-20)); // Weak charge to prevent some overlap + simulation.force('center', null); // No need for centering force + simulation.force('boundary', null); + + // Use x/y forces to position nodes based on data + const xScale = d3.scaleLinear().domain(xlim).range([0, width]); + const yScale = d3.scaleLinear().domain(ylim).range([0, height]); + simulation.force('x', d3.forceX().strength(0.1).x(d => xScale(d.xpos))); + simulation.force('y', d3.forceY().strength(0.1).y(d => yScale(d.ypos))); + + // Weaken link force so it doesn't fight the x/y positioning + simulation.force("link").strength(0); + } + + // Restart simulation and render immediately + simulation.alpha(1).restart().tick(); + ticked(); + } }); -}; - -/*Add drag functionality to the node objects*/ -node.call( - d3.drag() - .on("start", dragstarted) - .on("drag", dragged) - .on("end", dragended) -); - -function dragstarted(d) { - d3.event.sourceEvent.stopPropagation(); - if (!d3.event.active) simulation.alphaTarget(0.3).restart(); - d.fx = d.x; - d.fy = d.y; -}; - -function dragged(d) { - d.fx = d3.event.x; - d.fy = d3.event.y; -}; - -function dragended(d) { - if (!d3.event.active) simulation.alphaTarget(0); - d.fx = null; - d.fy = null; -}; +}; // End Network diff --git a/src/pathpyG/visualisations/_d3js/templates/setup.html b/src/pathpyG/visualisations/_d3js/templates/setup.html deleted file mode 100644 index 5082439a9..000000000 --- a/src/pathpyG/visualisations/_d3js/templates/setup.html +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/src/pathpyG/visualisations/_d3js/templates/static.js b/src/pathpyG/visualisations/_d3js/templates/static.js index dd67ff3e2..314dd5a75 100644 --- a/src/pathpyG/visualisations/_d3js/templates/static.js +++ b/src/pathpyG/visualisations/_d3js/templates/static.js @@ -1,205 +1,14 @@ -console.log("Static Network Template"); -/* Resources - https://bl.ocks.org/mapio/53fed7d84cd1812d6a6639ed7aa83868 - https://codepen.io/smlo/pen/JdMOej -*/ +// Initialize Network +const network = Network(config); -// variables from the config file -const selector = config.selector; -const width = config.width || 800; -const height = config.height || 600; -const charge_distance = config.charge_distance || 400; -const charge_force = config.charge_force || -3000; -const curved = config.curved || false; -const directed = config.directed || false; -// const weight = false; -/* Create a svg element to display the network */ -var svg = d3.select(selector) - .append('svg') - .attr('width', width) - .attr('height', height) - -// add container to store the elements -var container = svg.append("g"); - -/*Add zoom function to the container */ -svg.call( - d3.zoom() - .scaleExtent([.1, 4]) - .on("zoom", function() { container.attr("transform", d3.event.transform); }) -); - - -/*Load nodes and links from the data */ -var nodes = data.nodes -var links = data.edges - -/*Create arrow head with same color as the edge */ -function marker (color) { - var reference; - svg.append("svg:defs").selectAll("marker") - .data([reference]) - .enter().append("svg:marker") - .attr("id", "arrow"+color) - .attr("viewBox", "0 -5 10 10") - .attr("refX", 10) - .attr("refY", -0) - .attr("markerWidth", 6) - .attr("markerHeight", 6) - .attr("orient", "auto") - .append("svg:path") - .attr('class','.arrow') - .attr("d", "M0,-5L10,0L0,5") - .style('opacity',1) - .style("fill", color); - return "url(#" + "arrow"+color + ")"; - }; - -/*Link creation template */ -var link = container.append("g").attr("class", "links") - .selectAll(".link") - .data(links) - .enter() - .append("path") - .attr("class", "link") - .style("stroke", function(d) { return d.color; }) - .style("stroke-opacity", function(d) { return d.opacity; }) - .style("stroke-width", function(d){ return d.size }) - .style("fill","none") - .attr("marker-end", function (d) {if(directed){return marker(d.color)}else{return null}; }); - - //.attr("marker-end", function (d) { return marker(d.color); }); - //.attr("marker-end", "url(#arrow)"); - -/*Node creation template */ -var node = container.append("g").attr("class", "nodes") - .selectAll("circle.node") - .data(nodes) - .enter().append("circle") - .attr("class", "node") - .attr("x", function(d) { return d.x; }) - .attr("y", function(d) { return d.y; }) - .style("r", function(d){ return d.size+"px"; }) - .style("fill", function(d) { return d.color; }) - .style("opacity", function(d) { return d.opacity; }); - -/*Label creation template */ -var text = container.append("g").attr("class","labels") - .selectAll("g") - .data(nodes) - .enter().append("g") - -text.append("text") - .attr("class", "label-text") - .attr("x", function(d) { - var r = (d.size === undefined) ? 15 : d.size; - return 5 + r; }) - .attr("dy", ".31em") - .text(function(d) { return d.label; }); - -/*Scale weight for d3js */ -var weightScale = d3.scaleLinear() - .domain(d3.extent(links, function (d) { return d.weight })) - .range([.1, 1]); - -/*Simulation of the forces*/ -var simulation = d3.forceSimulation(nodes) - .force("links", d3.forceLink(links) - .id(function(d) {return d.uid; }) - .distance(50) - .strength(function(d){return weightScale(d.weight);}) - ) - .force("charge", d3.forceManyBody() - .strength(charge_force) - .distanceMax(charge_distance) - ) - .force("center", d3.forceCenter(width / 2, height / 2)) - .on("tick", ticked); - -/*Update of the node and edge objects*/ -function ticked() { - node.call(updateNode); - link.call(updateLink); - text.call(updateText); +const drawStaticNetwork = () => { + // Get all nodes and links from the data object + const nodes = data.nodes; + const links = data.edges; + // Call the network's update function once with the complete dataset + network.update({nodes, links}); }; -/*Update link positions */ -function updateLink(link) { - // link - // .attr("x1", function(d) { return d.source.x; }) - // .attr("y1", function(d) { return d.source.y; }) - // .attr("x2", function(d) { return d.target.x; }) - // .attr("y2", function(d) { return d.target.y; }); - - - link.attr("d", function(d) { - var dx = d.target.x - d.source.x, - dy = d.target.y - d.source.y, - dr = Math.sqrt(dx * dx + dy * dy); - if(!curved)dr=0; - return "M" + - d.source.x + "," + - d.source.y + "A" + - dr + "," + dr + " 0 0,1 " + - d.target.x + "," + - d.target.y; - }); - - // recalculate and back off the distance - link.attr("d", function (d, i) { - var pl = this.getTotalLength(); - var r = (d.target.size === undefined) ? 15 : d.target.size; - var m = this.getPointAtLength(pl - r); - var dx = d.target.x - d.source.x, - dy = d.target.y - d.source.y, - dr = Math.sqrt(dx * dx + dy * dy); - if(!curved)dr=0; - var result = "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + m.x + "," + m.y; - return result; - }); -}; - - -/*Update node positions */ -function updateNode(node) { - node.attr("transform", function(d) { - return "translate(" + d.x + "," + d.y + ")"; - }); - // node - // .attr("cx", function(d) { return d.x; }) - // .attr("cy", function(d) { return d.y; }); -}; - -/*Update text positions */ -function updateText(text) { - text.attr("transform", function(d) { - return "translate(" + d.x + "," + d.y + ")"; - }); -}; - -/*Add drag functionality to the node objects*/ -node.call( - d3.drag() - .on("start", dragstarted) - .on("drag", dragged) - .on("end", dragended) -); - -function dragstarted(d) { - d3.event.sourceEvent.stopPropagation(); - if (!d3.event.active) simulation.alphaTarget(0.3).restart(); - d.fx = d.x; - d.fy = d.y; -}; - -function dragged(d) { - d.fx = d3.event.x; - d.fy = d3.event.y; -}; - -function dragended(d) { - if (!d3.event.active) simulation.alphaTarget(0); - d.fx = null; - d.fy = null; -}; +// Draw the network. +drawStaticNetwork(); diff --git a/src/pathpyG/visualisations/_d3js/templates/styles.css b/src/pathpyG/visualisations/_d3js/templates/styles.css index 269889a11..c6e315aaa 100644 --- a/src/pathpyG/visualisations/_d3js/templates/styles.css +++ b/src/pathpyG/visualisations/_d3js/templates/styles.css @@ -1,24 +1,5 @@ -svg circle.node { - fill: #3b5998; - stroke: #1b3978; - stroke-width: 2.5px; - r: 15px; - opacity: 1; -} - -.link { - stroke: #969595; - stroke-opacity: .75; - stroke-width: 2.5px; -} - -.arrow { - fill: #969595; -} - - .label-text { fill: #969595; font-size: 16px; font-family: sans-serif; -} +} \ No newline at end of file diff --git a/src/pathpyG/visualisations/_d3js/templates/temporal.js b/src/pathpyG/visualisations/_d3js/templates/temporal.js index f6619307b..aef092524 100755 --- a/src/pathpyG/visualisations/_d3js/templates/temporal.js +++ b/src/pathpyG/visualisations/_d3js/templates/temporal.js @@ -1,339 +1,136 @@ -console.log("Temporal Network Template"); -/* Resources - https://bl.ocks.org/mapio/53fed7d84cd1812d6a6639ed7aa83868 - https://codepen.io/smlo/pen/JdMOej - https://observablehq.com/@d3/temporal-force-directed-graph -*/ - -// console.log(data); - -// variables from the config file -const selector = config.selector; -const width = config.width || 800; -const height = config.height || 600; -const delta = config.delta || 300; - -// variables for the temporal components -const startTime = config.start; -const endTime = config.end; -const targetValue = config.intervals || 300; -const duration = config.delta || 300; - -// variables for the edge components -const curved = config.curved || false; -const directed = config.directed || false; - -/* Create a svg element to display the network */ -let svg = d3.select(selector) - .append('svg') - .attr('width', width) - .attr('height', height); - -/*Container to store d3js objects */ -let container = svg.append("g"); - -/*Link creation template */ -let edges = container.append("g").attr("class", "edges") - .selectAll(".link"); - -/*Node creation template */ -let nodes = container.append("g").attr("class", "nodes") - .selectAll("circle.node"); - -/*Label creation template */ -let labels = container.append("g").attr("class", "labels") - .selectAll(".label"); - -/*Time counter */ -let text = svg.append("text") - .text("T="+startTime) - .attr("x", 20) - .attr("y", 20); - -let bttn = svg.append("text") - .attr("x",70) - .attr("y", 20) - .text("Play"); - -/*Assign data to variable*/ -let network = data - - -/*Create arrow head with same color as the edge */ -function marker (color) { - var reference; - svg.append("svg:defs").selectAll("marker") - .data([reference]) - .enter().append("svg:marker") - .attr("id", "arrow"+color) - .attr("viewBox", "0 -5 10 10") - .attr("refX", 10) - .attr("refY", -0) - .attr("markerWidth", 6) - .attr("markerHeight", 6) - .attr("orient", "auto") - .append("svg:path") - .attr('class','.arrow') - .attr("d", "M0,-5L10,0L0,5") - .style('opacity',1) - .style("fill", color); - return "url(#" + "arrow"+color + ")"; - }; - -/*Render function to show dynamic networks*/ -function render(){ - - // get network data - let nodeData = network.nodes; - let edgeData = network.edges; - // let labelData = network.nodes; - - // render network objects - renderNodes(nodeData); - renderEdges(edgeData); - renderLabels(nodeData); - - // run simulation - simulation.nodes(nodeData); - simulation.force("links").links(edgeData); - simulation.alpha(1).restart(); +const scrubber = (values, { + chartUpdate, + format = value => value, + initial = 0, + delay = null, + autoplay = true, + loop = true, + loopDelay = null, + alternate = false +} = {}) => { + values = Array.from(values); + const form = d3.create('form') + .style('font', '12px var(--sans-serif)') + .style('font-variant-numeric', 'tabular-nums') + .style('display', 'flex') + .style('height', 33) + .style('align-items', 'center') + .attr('value', values[initial]); + const formB = form.append('button') + .attr('name', 'b') + .attr('type', 'button') + .style('margin-right', '0.4em') + .style('width', '5em') + .text('Play'); + + const label = form.append('label') + .style('display', 'flex') + .style('align-items', 'center'); + const formI = label.append('input') + .attr('name', 'i') + .attr('type', 'range') + .attr('min', 0) + .attr('max', values.length - 1) + .attr('value', initial) + .attr('step', 1) + .style('width', 180); + const formO = label.append('output') + .attr('name', 'o') + .text(format(values[initial])); + + let frame = null; + let timer = null; + let interval = null; + let direction = 1; + + const stop = () => { + formB.text('Play'); + if (frame !== null) cancelAnimationFrame(frame), frame = null; + if (timer !== null) clearTimeout(timer), timer = null; + if (interval !== null) clearInterval(interval), interval = null; + } + + const running = () => { + return frame !== null || timer !== null || interval !== null; + } + + const formIPostUpdate = (event) => { + const index = parseInt(formI.property('value')); + if (event && event.isTrusted && running()) stop(); + formO.property('value', format(values[index], index, values)); + + chartUpdate(index); + } + + const step = () => { + formI.property('value', (parseInt(formI.property('value')) + direction + values.length) % values.length); + formIPostUpdate(); + } + + const tick = () => { + if (parseInt(formI.property('value')) === (direction > 0 ? values.length - 1 : direction < 0 ? 0 : NaN)) { + if (!loop) return stop(); + if (alternate) direction = -direction; + if (loopDelay !== null) { + if (frame !== null) cancelAnimationFrame(frame), frame = null; + if (interval !== null) clearInterval(interval), interval = null; + timer = setTimeout(() => (step(), start()), loopDelay); + return; + } + } + if (delay === null) frame = requestAnimationFrame(tick); + step(); + } + + const start = () => { + formB.text('Pause'); + if (delay === null) frame = requestAnimationFrame(tick); + else interval = setInterval(tick, delay); + } + + formI.on('input', (event) => { + formIPostUpdate(event); + }); + formB.on('click', () => { + if (running()) return stop(); + direction = alternate && parseInt(formI.property('value')) === values.length - 1 ? -1 : 1; + formI.property('value', (parseInt(formI.property('value')) + direction) % values.length); + formIPostUpdate(); + + start(); + }); + + if (autoplay) start(); + else stop(); + + return form; } -/*Render node objects*/ -function renderNodes(data){ - // console.log("render Nodes") - - nodes = container.select('.nodes').selectAll('circle.node').data(data,d=>d.uid); - - let new_nodes = nodes.enter().append("circle") - .attr("class", "node") - .style("r", function(d){ return d.size+"px"; }) - .style("fill", d => d.color) - .style("opacity", d => d.opacity) - .call(drag); - - nodes.exit() - .transition() // transition to shrink node - .duration(delta) - .style("r", "0px") - .remove(); - - nodes = nodes.merge(new_nodes); - nodes.transition() // transition to change size and color - .duration(delta) - .style("r", function(d){ return d.size+"px"; }) - .style("fill", d => d.color) - .style("opacity", d => d.opacity); -}; - -/*Render label objects*/ -function renderLabels(data){ - // console.log("render Nodes") - - labels = container.select('.nodes').selectAll('.label-text').data(data, d=> d.uid); +// Initialize Network +const network = Network(config); - let new_labels = labels.enter().append("text") - .attr("class", "label-text") - .attr("x", function(d) { - var r = (d.size === undefined) ? 15 : d.size; - return 5 + r; }) - .attr("dy", ".32em") - .text(d=>d.label); - - labels.exit().remove(); +// Function to filter elements +const contains = ({start,end}, time) => start <= time && time < end; - labels = labels.merge(new_labels); +// Function to update network over time +const update = (index) => { + const time = index;//times[index]; + const nodes = data.nodes.filter(d => contains(d, time)); + const links = data.edges.filter(d => contains(d, time)); + network.update({nodes,links}); }; -/*Render edge objects*/ -function renderEdges(data){ - // console.log("render Edges") - edges = container.select(".edges").selectAll(".link").data(data, d=> d.uid); - - let new_edges = edges.enter().append("path") - .attr("class", "link") - .style("stroke", d => d.color) - .style("stroke-opacity", d => d.opacity) - .style("stroke-width", d => d.size) - .style("fill","none") - .attr("marker-end", function (d) {if(directed){return marker(d.color)}else{return null}; }); - - edges.exit().remove(); - - edges = edges.merge(new_edges); - - edges.transition() // transition to change size and color - .duration(delta) - .style("stroke", d => d.color) - .style("stroke-opacity", d => d.opacity) - .style("stroke-width", d => d.size); -}; - -/*Add zoom function to the container */ -svg.call( - d3.zoom() - .scaleExtent([.1, 4]) - .on("zoom", function() { container.attr("transform", d3.event.transform); }) -).on("dblclick.zoom", null); - - -/*Simulation of the forces*/ -const simulation = d3.forceSimulation() - .force("charge", d3.forceManyBody().strength(-3000)) - .force("center", d3.forceCenter(width / 2, height / 2)) - .force("x", d3.forceX(width / 2).strength(1)) - .force("y", d3.forceY(height / 2).strength(1)) - .force("links", d3.forceLink() - .id( d => d.uid) - .distance(50).strength(1)) - .on("tick", ticked); - +// Range function +const range = (start, stop, step) => + Array.from({ length: (stop - start) / step + 1 }, (_, i) => start + i * step); -/*Update of the node and edge objects*/ -function ticked() { - nodes.call(updateNodePositions); - edges.call(updateEdgePositions); - labels.call(updateLabelPositions); -}; - -/*Update link positions */ -function updateEdgePositions(edges) { - // edges - // .attr("x1", d => d.source.x) - // .attr("y1", d => d.source.y) - // .attr("x2", d => d.target.x) - // .attr("y2", d => d.target.y); - - edges.attr("d", function(d) { - var dx = d.target.x - d.source.x, - dy = d.target.y - d.source.y, - dr = Math.sqrt(dx * dx + dy * dy); - if(!curved)dr=0; - return "M" + - d.source.x + "," + - d.source.y + "A" + - dr + "," + dr + " 0 0,1 " + - d.target.x + "," + - d.target.y; - }); - - // recalculate and back off the distance - edges.attr("d", function (d, i) { - var pl = this.getTotalLength(); - var r = (d.target.size === undefined) ? 15 : d.target.size; - var m = this.getPointAtLength(pl - r); - var dx = d.target.x - d.source.x, - dy = d.target.y - d.source.y, - dr = Math.sqrt(dx * dx + dy * dy); - if(!curved)dr=0; - var result = "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + m.x + "," + m.y; - return result; - }); -}; - -/*Update node positions */ -function updateNodePositions(nodes) { - nodes.attr("transform", function(d) { - return "translate(" + d.x + "," + d.y + ")"; - }); - // nodes - // .attr("cx", d => d.x) - // .attr("cy", d => d.y); -}; - - -/*Update node positions */ -function updateLabelPositions(labels) { - labels.attr("transform", function(d) { - return "translate(" + d.x + "," + d.y + ")"; - }); -}; - -/*Add drag functionality to the node objects*/ -const drag = d3.drag() - .on("start", dragstarted) - .on("drag", dragged) - .on("end", dragended); - -function dragstarted(d) { - d3.event.sourceEvent.stopPropagation(); - if (!d3.event.active) simulation.alphaTarget(0.3).restart(); - d.fx = d.x; - d.fy = d.y; -}; - -function dragged(d) { - d.fx = d3.event.x; - d.fy = d3.event.y; -}; - -function dragended(d) { - if (!d3.event.active) simulation.alphaTarget(0); - d.fx = null; - d.fy = null; -}; - - -/*Temporal components*/ -let currentValue = 0; -let time = startTime; -var timer = null; - -var x = d3.scaleLinear() - .domain([startTime,endTime]) - .range([0,targetValue]) - .clamp(true); - -let step = function () { - // increase time value - currentValue = currentValue + (targetValue/endTime); - // convert time value to time step - time = x.invert(currentValue); - // update the network - update(); - // stop the timer - if (currentValue >= targetValue) { - timer.stop(); - currentValue = 0; - bttn.text("Play") - text.text(d => "T="+startTime); - console.log("End of the timer"); - }; -}; - -contains = ({start, end}, time) => start <= time && time < end - -function update(){ - console.log("update Network"); - console.log(time); - - // Make copy to don't lose the data - let copy = {...data}; - // TODO Instead of copy make a map to keep object properties - network=copy; - network.nodes = copy.nodes.filter(d => contains(d,time)); - network.edges = copy.edges.filter(d => contains(d,time)); - text.text(d => "T="+Math.round(time)); - render(); -}; - -bttn.on('click', function() { - if (bttn.text() == "Pause") { - timer.stop(); - bttn.text("Play"); - }else{ - runTimer(); - }; - -}); - -function runTimer(){ - timer = d3.interval(step,duration); - bttn.text("Pause"); -} +// Considered time range +times = range(d3.min(data.nodes, d => d.start),d3.max(data.nodes, d => d.end),1); -runTimer(); +// Initiate Network +update(1); -// initialize timer -//let timer = d3.interval(step,duration); +// Add counter and start updating network +const scrubberForm = scrubber(times,{chartUpdate:update, delay: config.delta || 300}); +d3.select(config.selector).append(() => scrubberForm.node()); diff --git a/src/pathpyG/visualisations/network_plot.py b/src/pathpyG/visualisations/network_plot.py index 0f2cc4e56..06d6aa914 100644 --- a/src/pathpyG/visualisations/network_plot.py +++ b/src/pathpyG/visualisations/network_plot.py @@ -41,7 +41,7 @@ def __init__(self, network: Graph, **kwargs: Any) -> None: self.network = network self.node_args = {} self.edge_args = {} - self.attributes = ["color", "size", "opacity"] + self.attributes = ["color", "size", "opacity", "image"] # extract node and edge specific arguments from kwargs for key in kwargs.keys(): if key.startswith("node_"): @@ -70,10 +70,10 @@ def _compute_node_data(self) -> None: nodes: pd.DataFrame = pd.DataFrame(index=self.network.nodes) for attribute in self.attributes: # set default value for each attribute based on the pathpyG.toml config - if isinstance(self.config.get("node").get(attribute), list | tuple): # type: ignore[union-attr] - nodes[attribute] = [self.config.get("node").get(attribute)] * len(nodes) # type: ignore[union-attr] + if isinstance(self.config.get("node").get(attribute, None), list | tuple): # type: ignore[union-attr] + nodes[attribute] = [self.config.get("node").get(attribute, None)] * len(nodes) # type: ignore[union-attr] else: - nodes[attribute] = self.config.get("node").get(attribute) # type: ignore[union-attr] + nodes[attribute] = self.config.get("node").get(attribute, None) # type: ignore[union-attr] # check if attribute is given as argument if attribute in self.node_args: if isinstance(self.node_args[attribute], dict): @@ -81,7 +81,7 @@ def _compute_node_data(self) -> None: else: nodes[attribute] = self.node_args[attribute] # check if attribute is given as node attribute - elif attribute in self.network.node_attrs(): + elif f"node_{attribute}" in self.network.node_attrs(): nodes[attribute] = self.network.data[f"node_{attribute}"] # convert needed attributes to useful values @@ -97,10 +97,10 @@ def _compute_edge_data(self) -> None: edges: pd.DataFrame = pd.DataFrame(index=pd.MultiIndex.from_tuples(self.network.edges, names=["source", "target"])) for attribute in self.attributes: # set default value for each attribute based on the pathpyG.toml config - if isinstance(self.config.get("edge").get(attribute), list | tuple): # type: ignore[union-attr] - edges[attribute] = [self.config.get("edge").get(attribute)] * len(edges) # type: ignore[union-attr] + if isinstance(self.config.get("edge").get(attribute, None), list | tuple): # type: ignore[union-attr] + edges[attribute] = [self.config.get("edge").get(attribute, None)] * len(edges) # type: ignore[union-attr] else: - edges[attribute] = self.config.get("edge").get(attribute) # type: ignore[union-attr] + edges[attribute] = self.config.get("edge").get(attribute, None) # type: ignore[union-attr] # check if attribute is given as argument if attribute in self.edge_args: if isinstance(self.edge_args[attribute], dict): diff --git a/src/pathpyG/visualisations/utils.py b/src/pathpyG/visualisations/utils.py index e60e8af08..7ca427399 100644 --- a/src/pathpyG/visualisations/utils.py +++ b/src/pathpyG/visualisations/utils.py @@ -8,6 +8,7 @@ # Copyright (c) 2016-2023 Pathpy Developers # ============================================================================= +from typing import Callable def rgb_to_hex(rgb: tuple) -> str: """Convert rgb color tuple to hex string. @@ -37,14 +38,31 @@ def inch_to_cm(value: float) -> float: """Convert inch to cm.""" return value * 2.54 +def inch_to_px(value: float, dpi: int = 96) -> float: + """Convert inch to px.""" + return value * dpi + +def px_to_inch(value: float, dpi: int = 96) -> float: + """Convert px to inch.""" + return value / dpi + def unit_str_to_float(value: str, unit: str) -> float: """Convert string with unit to float in `unit`.""" - if value.endswith("cm"): - return float(value[:-2]) if unit == "cm" else cm_to_inch(float(value[:-2])) - elif value.endswith("in"): - return inch_to_cm(float(value[:-2])) if unit == "cm" else float(value[:-2]) + conversion_functions: dict[str, Callable[[float], float]] = { + "cm_to_in": cm_to_inch, + "in_to_cm": inch_to_cm, + "in_to_px": inch_to_px, + "px_to_in": px_to_inch, + "cm_to_px": lambda x: inch_to_px(cm_to_inch(x)), + "px_to_cm": lambda x: cm_to_inch(px_to_inch(x)), + } + conversion_key = f"{value[-2:]}_to_{unit}" + if conversion_key in conversion_functions: + return conversion_functions[conversion_key](float(value[:-2])) + elif value[-2:] == unit: + return float(value[:-2]) else: - raise ValueError("Value must end with 'cm' or 'in'.") + raise ValueError(f"The provided conversion '{conversion_key}' is not supported.") # ============================================================================= # eof From a0968c5c0ad8532c8bcca6d9fb40e945037894cf Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Thu, 9 Oct 2025 12:58:49 +0000 Subject: [PATCH 04/44] update configs --- src/pathpyG/pathpyG.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pathpyG/pathpyG.toml b/src/pathpyG/pathpyG.toml index 9e27e1df6..31089df83 100644 --- a/src/pathpyG/pathpyG.toml +++ b/src/pathpyG/pathpyG.toml @@ -31,11 +31,13 @@ layout = "spring_layout" width = "12cm" height = "12cm" latex_class_options = "" +delta = 300 [visualisation.node] color = [36, 74, 92] size = 15 opacity = 0.75 +image_padding = 5 [visualisation.edge] color = [76, 112, 123] From a560626c02951a5dbd472af7fea4b05aa1807eb7 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Thu, 9 Oct 2025 13:31:11 +0000 Subject: [PATCH 05/44] add automatic d3js layouting and margin config param --- src/pathpyG/pathpyG.toml | 1 + .../visualisations/_d3js/templates/network.js | 9 +++++---- src/pathpyG/visualisations/_matplotlib/backend.py | 4 ++-- src/pathpyG/visualisations/_tikz/backend.py | 5 +++-- .../visualisations/_tikz/templates/static.tex | 15 ++++++++------- src/pathpyG/visualisations/network_plot.py | 1 + src/pathpyG/visualisations/plot_function.py | 9 ++++++++- 7 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/pathpyG/pathpyG.toml b/src/pathpyG/pathpyG.toml index 31089df83..77f298fee 100644 --- a/src/pathpyG/pathpyG.toml +++ b/src/pathpyG/pathpyG.toml @@ -32,6 +32,7 @@ width = "12cm" height = "12cm" latex_class_options = "" delta = 300 +margin = 0.1 [visualisation.node] color = [36, 74, 92] diff --git a/src/pathpyG/visualisations/_d3js/templates/network.js b/src/pathpyG/visualisations/_d3js/templates/network.js index 4aad6c39f..5c45a3b1d 100644 --- a/src/pathpyG/visualisations/_d3js/templates/network.js +++ b/src/pathpyG/visualisations/_d3js/templates/network.js @@ -17,8 +17,9 @@ const Network = (config) => { const height = config.height || 600; // window height const delta = config.delta || 300; // time between frames const padding = (config.node && config.node.image_padding) || 5; // distance between node and image - const xlim = [-0.1,1.1]; // limits of the x-coordinates - const ylim = [-0.1,1.1]; // limits of the y-coordinates + const margin = config.margin || 0.1; // margin around the plot area for fixed layout + const xlim = [-1*margin, 1+(1*margin)]; // limits of the x-coordinates + const ylim = [-1*margin, 1+(1*margin)]; // limits of the y-coordinates const arrowheadMultiplier = 4; // Multiplier for arrowhead size based on edge stroke width const nodeStrokeWidth = 2.5; // Stroke width around nodes @@ -418,10 +419,10 @@ const Network = (config) => { // TRUE: Use a dynamic spring layout (force-directed) simulation .force('charge', d3.forceManyBody().strength(-50)) // Nodes repel each other - .force('center', d3.forceCenter(0, 0)) // Center the graph + .force('center', d3.forceCenter(width/2, height/2)) // Center the graph .force('x', null) // Remove the static x-force .force('y', null) // Remove the static y-force - .force('boundary', forceBoundary(-width / 2, -height / 2, width / 2, height / 2)); + .force('boundary', forceBoundary(0, 0, width, height)); // Adjust link force simulation.force("link").strength(0.1).distance(70); diff --git a/src/pathpyG/visualisations/_matplotlib/backend.py b/src/pathpyG/visualisations/_matplotlib/backend.py index e9ec51be0..234cb45ac 100644 --- a/src/pathpyG/visualisations/_matplotlib/backend.py +++ b/src/pathpyG/visualisations/_matplotlib/backend.py @@ -89,8 +89,8 @@ def to_fig(self) -> tuple[plt.Figure, plt.Axes]: ) # set limits - ax.set_xlim(-0.1, 1.1) - ax.set_ylim(-0.1, 1.1) + ax.set_xlim(-1 * self.config["margin"], 1 + (1*self.config["margin"])) + ax.set_ylim(-1 * self.config["margin"], 1 + (1*self.config["margin"])) return fig, ax def add_undirected_edges(self, source_coords, target_coords, ax, size_factor): diff --git a/src/pathpyG/visualisations/_tikz/backend.py b/src/pathpyG/visualisations/_tikz/backend.py index b278a77c1..c0ca3e169 100644 --- a/src/pathpyG/visualisations/_tikz/backend.py +++ b/src/pathpyG/visualisations/_tikz/backend.py @@ -175,8 +175,9 @@ def to_tex(self) -> str: # fill template with data tex = Template(tex_template).substitute( classoptions=self.config.get("latex_class_options"), - width=self.config.get("width"), - height=self.config.get("height"), + width=unit_str_to_float(self.config.get("width"), "cm"), + height=unit_str_to_float(self.config.get("height"), "cm"), + margin=self.config.get("margin"), tikz=data, ) diff --git a/src/pathpyG/visualisations/_tikz/templates/static.tex b/src/pathpyG/visualisations/_tikz/templates/static.tex index 07d9a88c7..833ac8405 100644 --- a/src/pathpyG/visualisations/_tikz/templates/static.tex +++ b/src/pathpyG/visualisations/_tikz/templates/static.tex @@ -1,13 +1,14 @@ \documentclass[$classoptions]{standalone} \usepackage[dvipsnames]{xcolor} \usepackage{tikz-network} -\newcommand{\width}{$width} -\newcommand{\height}{$height} \begin{document} \begin{tikzpicture} -\tikzset{every node}=[font=\sffamily\bfseries] -\clip (-0.6*\width,-0.6*\height) rectangle (0.6*\width,0.6*\height); -\draw[draw,opacity=0] (-0.6*\width,-0.6*\height) rectangle (0.6*\width,0.6*\height); -$tikz + \pgfmathsetmacro{\width}{$width} + \pgfmathsetmacro{\height}{$height} + \pgfmathsetmacro{\margin}{0.5 + $margin} + \tikzset{every node}=[font=\sffamily\bfseries] + \clip (-\margin*\width cm,-\margin*\height cm) rectangle (\margin*\width cm,\margin*\height cm); + \draw[draw,opacity=0] (-\margin*\width cm,-\margin*\height cm) rectangle (\margin*\width cm,\margin*\height cm); + $tikz \end{tikzpicture} -\end{document} \ No newline at end of file +\end{document} diff --git a/src/pathpyG/visualisations/network_plot.py b/src/pathpyG/visualisations/network_plot.py index 06d6aa914..6865a69c6 100644 --- a/src/pathpyG/visualisations/network_plot.py +++ b/src/pathpyG/visualisations/network_plot.py @@ -179,6 +179,7 @@ def _compute_layout(self) -> None: def _compute_config(self) -> None: """Add additional configs.""" self.config["directed"] = self.network.is_directed() + self.config["simulation"] = self.config["layout"] is None # ============================================================================= diff --git a/src/pathpyG/visualisations/plot_function.py b/src/pathpyG/visualisations/plot_function.py index a1991545a..4bf2dd9ce 100644 --- a/src/pathpyG/visualisations/plot_function.py +++ b/src/pathpyG/visualisations/plot_function.py @@ -21,6 +21,7 @@ from pathpyG.visualisations.network_plot import NetworkPlot from pathpyG.visualisations.pathpy_plot import PathPyPlot from pathpyG.visualisations.plot_backend import PlotBackend +from pathpyG.visualisations._d3js.backend import D3jsBackend # from pathpyG.visualisations.temporal_network_plot import TemporalNetworkPlot # create logger @@ -155,10 +156,16 @@ def plot(graph: Graph, kind: Optional[str] = None, show_labels=None, **kwargs: A filename = kwargs.pop("filename", None) _backend: str = kwargs.pop("backend", None) - plt = PLOT_CLASSES[kind](graph, **kwargs) plot_backend_class = _get_plot_backend( backend=_backend, filename=filename, default=config.get("visualisation").get("default_backend") # type: ignore[union-attr] ) + + # Check if backend is d3js and if layouting is required + if plot_backend_class == D3jsBackend: + if "layout" not in kwargs: + kwargs["layout"] = None + + plt = PLOT_CLASSES[kind](graph, **kwargs) plot_backend = plot_backend_class(plt, show_labels=show_labels) if filename: plot_backend.save(filename) From 8ccb7062f2310008bee6e7e4fe9e4679b156a5d3 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Thu, 9 Oct 2025 16:21:26 +0000 Subject: [PATCH 06/44] optimise temporal_edges --- src/pathpyG/core/temporal_graph.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pathpyG/core/temporal_graph.py b/src/pathpyG/core/temporal_graph.py index 59ce7f8d1..7513a6456 100644 --- a/src/pathpyG/core/temporal_graph.py +++ b/src/pathpyG/core/temporal_graph.py @@ -11,6 +11,7 @@ from pathpyG import Graph from pathpyG.core.index_map import IndexMap +from pathpyG.utils import to_numpy class TemporalGraph(Graph): @@ -109,7 +110,8 @@ def temporal_edges(self) -> list: ('b', 'c', 2) ('c', 'a', 3) """ - return [(*self.mapping.to_ids(e), t.item()) for e, t in zip(self.data.edge_index.t(), self.data.time)] + edge_and_time = np.concatenate((self.mapping.to_ids(self.data.edge_index), to_numpy(self.data.time.unsqueeze(0))), axis=0) + return edge_and_time.T.tolist() def to(self, device: torch.device) -> TemporalGraph: """Moves all graph data to the specified device (CPU or GPU). From 8cc156c5ebc0087777ce04363852caed02a70143 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Thu, 9 Oct 2025 16:22:26 +0000 Subject: [PATCH 07/44] update d3js temporal graphs --- src/pathpyG/pathpyG.toml | 2 +- src/pathpyG/visualisations/_d3js/backend.py | 6 +- .../visualisations/_d3js/templates/network.js | 14 +- src/pathpyG/visualisations/network_plot.py | 6 +- src/pathpyG/visualisations/plot_function.py | 15 +- .../visualisations/temporal_network_plot.py | 187 +++++++++++------- 6 files changed, 134 insertions(+), 96 deletions(-) diff --git a/src/pathpyG/pathpyG.toml b/src/pathpyG/pathpyG.toml index 77f298fee..0a3a33f89 100644 --- a/src/pathpyG/pathpyG.toml +++ b/src/pathpyG/pathpyG.toml @@ -31,7 +31,7 @@ layout = "spring_layout" width = "12cm" height = "12cm" latex_class_options = "" -delta = 300 +delta = 1000 margin = 0.1 [visualisation.node] diff --git a/src/pathpyG/visualisations/_d3js/backend.py b/src/pathpyG/visualisations/_d3js/backend.py index 5cb64d841..19c2b886b 100644 --- a/src/pathpyG/visualisations/_d3js/backend.py +++ b/src/pathpyG/visualisations/_d3js/backend.py @@ -6,21 +6,21 @@ import tempfile import uuid import webbrowser -from string import Template from copy import deepcopy +from string import Template from pathpyG.utils.config import config from pathpyG.visualisations.network_plot import NetworkPlot from pathpyG.visualisations.plot_backend import PlotBackend +from pathpyG.visualisations.temporal_network_plot import TemporalNetworkPlot from pathpyG.visualisations.utils import rgb_to_hex, unit_str_to_float -# from pathpyG.visualisations.temporal_network_plot import TemporalNetworkPlot # create logger logger = logging.getLogger("root") SUPPORTED_KINDS = { NetworkPlot: "static", - # TemporalNetworkPlot: "temporal", + TemporalNetworkPlot: "temporal", } diff --git a/src/pathpyG/visualisations/_d3js/templates/network.js b/src/pathpyG/visualisations/_d3js/templates/network.js index 5c45a3b1d..890f1affe 100644 --- a/src/pathpyG/visualisations/_d3js/templates/network.js +++ b/src/pathpyG/visualisations/_d3js/templates/network.js @@ -66,9 +66,16 @@ const Network = (config) => { let effectiveSourceRadius = sourceRadius + nodeStrokeWidth / 2; let effectiveTargetRadius = targetRadius + nodeStrokeWidth / 2; + if (config.directed) { + // Adjust effective radii to account for arrowhead size + const edgeStrokeWidth = d.size || (config.edge && config.edge.size) || 2; + const arrowheadLength = edgeStrokeWidth * arrowheadMultiplier; + effectiveTargetRadius += arrowheadLength; + } + let finalPath; - if (config.directed) { + if (config.curved) { // --- For CURVED Directed Links (Complex Path) --- const dx = target_x - source_x; const dy = target_y - source_y; @@ -83,11 +90,6 @@ const Network = (config) => { const pathLength = tempPath.getTotalLength(); - // Adjust effective radii to account for arrowhead size - const edgeStrokeWidth = d.size || (config.edge && config.edge.size) || 2; - const arrowheadLength = edgeStrokeWidth * arrowheadMultiplier; - effectiveTargetRadius += arrowheadLength; - // Find the precise intersection points by moving along the path const startPoint = tempPath.getPointAtLength(effectiveSourceRadius); const endPoint = tempPath.getPointAtLength(pathLength - effectiveTargetRadius); diff --git a/src/pathpyG/visualisations/network_plot.py b/src/pathpyG/visualisations/network_plot.py index 6865a69c6..7b77592ac 100644 --- a/src/pathpyG/visualisations/network_plot.py +++ b/src/pathpyG/visualisations/network_plot.py @@ -84,7 +84,7 @@ def _compute_node_data(self) -> None: elif f"node_{attribute}" in self.network.node_attrs(): nodes[attribute] = self.network.data[f"node_{attribute}"] - # convert needed attributes to useful values + # convert attributes to useful values nodes["color"] = self._convert_to_rgb_tuple(nodes["color"]) nodes["color"] = nodes["color"].map(self._convert_color) @@ -120,9 +120,10 @@ def _compute_edge_data(self) -> None: else: edges[attribute] = self.edge_args["weight"] - # convert needed attributes to useful values + # convert attributes to useful values edges["color"] = self._convert_to_rgb_tuple(edges["color"]) edges["color"] = edges["color"].map(self._convert_color) + # add source and target columns edges["source"] = edges.index.map(lambda x: x[0]) edges["target"] = edges.index.map(lambda x: x[1]) @@ -179,6 +180,7 @@ def _compute_layout(self) -> None: def _compute_config(self) -> None: """Add additional configs.""" self.config["directed"] = self.network.is_directed() + self.config["curved"] = self.network.is_directed() self.config["simulation"] = self.config["layout"] is None diff --git a/src/pathpyG/visualisations/plot_function.py b/src/pathpyG/visualisations/plot_function.py index 4bf2dd9ce..13fa89bed 100644 --- a/src/pathpyG/visualisations/plot_function.py +++ b/src/pathpyG/visualisations/plot_function.py @@ -18,11 +18,10 @@ from pathpyG import config from pathpyG.core.graph import Graph from pathpyG.core.temporal_graph import TemporalGraph +from pathpyG.visualisations._d3js.backend import D3jsBackend from pathpyG.visualisations.network_plot import NetworkPlot -from pathpyG.visualisations.pathpy_plot import PathPyPlot from pathpyG.visualisations.plot_backend import PlotBackend -from pathpyG.visualisations._d3js.backend import D3jsBackend -# from pathpyG.visualisations.temporal_network_plot import TemporalNetworkPlot +from pathpyG.visualisations.temporal_network_plot import TemporalNetworkPlot # create logger logger = logging.getLogger("root") @@ -54,10 +53,10 @@ def is_backend(backend: str) -> bool: # Supported Plot Classes PLOT_CLASSES: dict = { "static": NetworkPlot, - # "temporal": TemporalNetworkPlot, + "temporal": TemporalNetworkPlot, } -def _get_plot_backend(backend: Optional[str], filename: Optional[str], default: str) -> PlotBackend: +def _get_plot_backend(backend: Optional[str], filename: Optional[str], default: str) -> type[PlotBackend]: """Return the plotting backend to use.""" # check if backend is valid backend type based on enum if backend is not None and not Backends.is_backend(backend): @@ -85,10 +84,10 @@ def _get_plot_backend(backend: Optional[str], filename: Optional[str], default: logger.error(f"The <{_backend}> backend could not be imported.") raise ImportError from e - return getattr(module, f"{_backend.capitalize()}Backend") # type: ignore[return-value] + return getattr(module, f"{_backend.capitalize()}Backend") -def plot(graph: Graph, kind: Optional[str] = None, show_labels=None, **kwargs: Any) -> PathPyPlot: +def plot(graph: Graph, kind: Optional[str] = None, show_labels=None, **kwargs: Any) -> PlotBackend: """Make plot of pathpyG objects. Creates and displays a plot for a given `pathpyG` object. This function can @@ -160,7 +159,7 @@ def plot(graph: Graph, kind: Optional[str] = None, show_labels=None, **kwargs: A backend=_backend, filename=filename, default=config.get("visualisation").get("default_backend") # type: ignore[union-attr] ) - # Check if backend is d3js and if layouting is required + # Check if backend is d3js and set layout to None if not specifically given as argument if plot_backend_class == D3jsBackend: if "layout" not in kwargs: kwargs["layout"] = None diff --git a/src/pathpyG/visualisations/temporal_network_plot.py b/src/pathpyG/visualisations/temporal_network_plot.py index 32845827f..e65c765fe 100644 --- a/src/pathpyG/visualisations/temporal_network_plot.py +++ b/src/pathpyG/visualisations/temporal_network_plot.py @@ -1,10 +1,11 @@ from __future__ import annotations -from collections import defaultdict +import time from typing import TYPE_CHECKING, Any +import pandas as pd + from pathpyG.visualisations.network_plot import NetworkPlot -from pathpyG.visualisations.utils import Colormap, rgb_to_hex # pseudo load class for type checking if TYPE_CHECKING: @@ -21,80 +22,114 @@ def __init__(self, network: TemporalGraph, **kwargs: Any) -> None: """Initialize network plot class.""" super().__init__(network, **kwargs) - def _get_edge_data(self, edges: dict, attributes: set, attr: defaultdict, categories: set) -> None: - """Extract edge data from temporal network.""" - for u, v, t in self.network.temporal_edges: - uid = f"{u}-{v}-{t}" - edges[uid] = { - "uid": uid, - "source": str(u), - "target": str(v), - "start": int(t), - "end": int(t) + 1, - } - # add edge attributes if needed - for attribute in attributes: - attr[attribute][uid] = ( - self.network[f"edge_{attribute}", u, v].item() if attribute in categories else None - ) - - def _compute_node_data(self): - """_summary_""" - super()._compute_node_data() - - raw_color_attr = self.config.get("node_color", {}) - if not isinstance(raw_color_attr, dict): - return - - color_changes_by_node = defaultdict(list) - for key, color in raw_color_attr.items(): - if "-" not in key: - continue - - try: - node_id, time_str = key.rsplit("-", 1) - time = float(time_str) - except ValueError as exc: - raise ValueError(f"Invalid time-encoded node_color key: '{key}'") from exc - - if isinstance(color, (int, float)): - cmap = self.config.get("node_cmap", Colormap()) - rgb = cmap([color])[0] - color = rgb_to_hex(rgb[:3]) - - elif isinstance(color, tuple): - color = rgb_to_hex(color) - - color_changes_by_node[node_id].append({"time": time, "color": color}) - - for node_id, changes in color_changes_by_node.items(): - if node_id in self.data.get("nodes", {}): - self.data["nodes"][node_id]["color_change"] = sorted(changes, key=lambda x: x["time"]) - - def _get_node_data(self, nodes: dict, attributes: set, attr: defaultdict, categories: set) -> None: - """Extract node data from temporal network.""" - - time = {e[2] for e in self.network.temporal_edges} - - if self.config.get("end", None) is None: - self.config["end"] = int(max(time) + 1) - - if self.config.get("start", None) is None: - self.config["start"] = int(min(time) - 1) - - for uid in self.network.nodes: - nodes[uid] = { - "uid": str(uid), - "start": int(min(time) - 1), - "end": int(max(time) + 1), - } - - # add edge attributes if needed - for attribute in attributes: - attr[attribute][uid] = ( - self.network[f"node_{attribute}", uid].item() if attribute in categories else None - ) + def generate(self) -> None: + """Generate the plot.""" + self._compute_edge_data() + self._compute_node_data() + # self._compute_layout() + self._compute_config() + + def _compute_node_data(self) -> None: + """Generate the data structure for the nodes.""" + # initialize values with index `node-0` to indicate time step 0 + start_nodes: pd.DataFrame = pd.DataFrame(index=[f"{node}-0" for node in self.network.nodes]) + new_nodes: pd.DataFrame = pd.DataFrame() + # add attributes to start nodes and new nodes if given as dictionary + for attribute in self.attributes: + # set default value for each attribute based on the pathpyG.toml config + if isinstance(self.config.get("node").get(attribute, None), list | tuple): # type: ignore[union-attr] + start_nodes[attribute] = [self.config.get("node").get(attribute, None)] * len(start_nodes) # type: ignore[union-attr] + else: + start_nodes[attribute] = self.config.get("node").get(attribute, None) # type: ignore[union-attr] + # check if attribute is given as argument + if attribute in self.node_args: + if isinstance(self.node_args[attribute], dict): + # check if dict contains node or node-time keys + if "-" in next(iter(self.node_args[attribute].keys())): # type: ignore[union-attr] + # add node attribute according to node-time keys + new_nodes = new_nodes.join( + pd.DataFrame.from_dict(self.node_args[attribute], orient="index", columns=[attribute]), + how="outer", + ) + else: + # add node attributes to start nodes according to node keys + start_nodes[attribute] = start_nodes.index.map(lambda x: x[0]).map(self.node_args[attribute]) + else: + start_nodes[attribute] = self.node_args[attribute] + # check if attribute is given as node attribute + elif f"node_{attribute}" in self.network.node_attrs(): + start_nodes[attribute] = self.network.data[f"node_{attribute}"] + + # combine start nodes and new nodes + nodes = pd.concat([start_nodes, new_nodes]) + nodes["start"] = nodes.index.map(lambda x: int(x.split("-")[1])) + nodes["uid"] = nodes.index.map(lambda x: x.split("-")[0]) + # fill missing values with last known value + nodes = nodes.sort_values(by=["uid", "start"]).groupby("uid", sort=False).ffill() + nodes["uid"] = nodes.index.map(lambda x: x.split("-")[0]) + # add end time step with the start the node appears the next time or max time step + 1 + nodes["end"] = nodes.groupby("uid")["start"].shift(-1) + max_node_time = nodes["start"].max() + 1 + if max_node_time < self.network.data.time[-1].item(): + max_node_time = self.network.data.time[-1].item() + 1 + nodes["end"] = nodes["end"].fillna(max_node_time) + + # convert attributes to useful values + nodes["color"] = self._convert_to_rgb_tuple(nodes["color"]) + nodes["color"] = nodes["color"].map(self._convert_color) + + nodes = nodes.set_index(nodes["uid"]) + # save node data + self.data["nodes"] = nodes + + def _compute_edge_data(self) -> None: + """Generate the data structure for the edges.""" + start_time = time.time() + # initialize values + edges: pd.DataFrame = pd.DataFrame(index=[f"{source}-{target}-{time}" for source, target, time in self.network.temporal_edges]) + for attribute in self.attributes: + # set default value for each attribute based on the pathpyG.toml config + if isinstance(self.config.get("edge").get(attribute, None), list | tuple): # type: ignore[union-attr] + edges[attribute] = [self.config.get("edge").get(attribute, None)] * len(edges) # type: ignore[union-attr] + else: + edges[attribute] = self.config.get("edge").get(attribute, None) # type: ignore[union-attr] + # check if attribute is given as argument + if attribute in self.edge_args: + if isinstance(self.edge_args[attribute], dict): + new_colors = edges.index.map(self.edge_args[attribute]) + edges.loc[~new_colors.isna(), attribute] = new_colors[~new_colors.isna()] + else: + edges[attribute] = self.edge_args[attribute] + # check if attribute is given as edge attribute + elif f"edge_{attribute}" in self.network.edge_attrs(): + edges[attribute] = self.network.data[f"edge_{attribute}"] + # special case for size: If no edge_size is given use edge_weight if available + elif attribute == "size": + if "edge_weight" in self.network.edge_attrs(): + edges[attribute] = self.network.data["edge_weight"] + elif "weight" in self.edge_args: + if isinstance(self.edge_args["weight"], dict): + edges[attribute] = edges.index.map(lambda x: f"{x[0]}-{x[1]}-{x[2]}").map( + self.edge_args["weight"] + ) + else: + edges[attribute] = self.edge_args["weight"] + + + # convert needed attributes to useful values + edges["color"] = self._convert_to_rgb_tuple(edges["color"]) + edges["color"] = edges["color"].map(self._convert_color) + edges["source"] = edges.index.map(lambda x: x.split("-")[0]) + edges["target"] = edges.index.map(lambda x: x.split("-")[1]) + edges["start"] = edges.index.map(lambda x: int(x.split("-")[2])) + edges["end"] = edges["start"] + 1 # assume all edges last for one time step + edges.index = edges.index.map(lambda x: f"{x.split('-')[0]}-{x.split('-')[1]}") + + # save edge data + self.data["edges"] = edges def _compute_config(self) -> None: """Add additional configs.""" - pass \ No newline at end of file + self.config["directed"] = True + self.config["curved"] = False + self.config["simulation"] = self.config["layout"] is None From 8d672dbfd2b2841f6a3a55eff45b91cfd2d6650d Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Thu, 9 Oct 2025 18:24:12 +0000 Subject: [PATCH 08/44] update curved edges --- .../visualisations/_d3js/templates/network.js | 168 ++++++++---------- 1 file changed, 70 insertions(+), 98 deletions(-) diff --git a/src/pathpyG/visualisations/_d3js/templates/network.js b/src/pathpyG/visualisations/_d3js/templates/network.js index 890f1affe..9f2628672 100644 --- a/src/pathpyG/visualisations/_d3js/templates/network.js +++ b/src/pathpyG/visualisations/_d3js/templates/network.js @@ -22,6 +22,7 @@ const Network = (config) => { const ylim = [-1*margin, 1+(1*margin)]; // limits of the y-coordinates const arrowheadMultiplier = 4; // Multiplier for arrowhead size based on edge stroke width const nodeStrokeWidth = 2.5; // Stroke width around nodes + const curveFactor = 0.75; // Factor to control curvature of edges (higher = more curved) // Initialize svg canvas const svg = d3.select(selector) @@ -53,114 +54,85 @@ const Network = (config) => { .attr("class", "images") .selectAll(".image"); - // Helper function to calculate and store link endpoints on the data object - const calculateAndStoreLinkPath = (d) => { - const source_x = d.source.x; - const source_y = d.source.y; - const target_x = d.target.x; - const target_y = d.target.y; - - const sourceRadius = d.source.size || (config.node && config.node.size) || 15; - const targetRadius = d.target.size || (config.node && config.node.size) || 15; - - let effectiveSourceRadius = sourceRadius + nodeStrokeWidth / 2; - let effectiveTargetRadius = targetRadius + nodeStrokeWidth / 2; - - if (config.directed) { - // Adjust effective radii to account for arrowhead size + const ticked = () => { + // 1. Update node, label, and image positions + node.attr("transform", d => `translate(${d.x},${d.y})`); + label.attr("transform", d => `translate(${d.x},${d.y})`); + image.attr("transform", d => `translate(${d.x},${d.y})`); + + // 2. Update the link paths + link.attr('d', d => { + // --- A. Get necessary properties --- + const sourceRadius = d.source.size || (config.node && config.node.size) || 15; + const targetRadius = d.target.size || (config.node && config.node.size) || 15; const edgeStrokeWidth = d.size || (config.edge && config.edge.size) || 2; - const arrowheadLength = edgeStrokeWidth * arrowheadMultiplier; - effectiveTargetRadius += arrowheadLength; - } + const arrowheadLength = config.directed ? (edgeStrokeWidth * arrowheadMultiplier) : 0; - let finalPath; - - if (config.curved) { - // --- For CURVED Directed Links (Complex Path) --- - const dx = target_x - source_x; - const dy = target_y - source_y; - const distance = Math.sqrt(dx * dx + dy * dy); - - // Create a virtual path from center to center - const virtualPathData = `M${source_x},${source_y} A${distance},${distance} 0 0,1 ${target_x},${target_y}`; - - // Use a temporary path element to measure - const tempPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); - tempPath.setAttribute("d", virtualPathData); - - const pathLength = tempPath.getTotalLength(); - - // Find the precise intersection points by moving along the path - const startPoint = tempPath.getPointAtLength(effectiveSourceRadius); - const endPoint = tempPath.getPointAtLength(pathLength - effectiveTargetRadius); - - // Rebuild the arc with the new, correct endpoints - const newDx = endPoint.x - startPoint.x; - const newDy = endPoint.y - startPoint.y; - const newDistance = Math.sqrt(newDx * newDx + newDy * newDy); - - finalPath = `M${startPoint.x},${startPoint.y} A${newDistance},${newDistance} 0 0,1 ${endPoint.x},${endPoint.y}`; + const effectiveSourceRadius = sourceRadius + (nodeStrokeWidth / 2); + const effectiveTargetRadius = targetRadius + (nodeStrokeWidth / 2) + arrowheadLength; - } else { - // --- For STRAIGHT Undirected Links (Simple Path) --- - const dx = target_x - source_x; - const dy = target_y - source_y; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance === 0) { - d._path = `M${source_x},${source_y} L${target_x},${target_y}`; - return; - } - - const x1 = source_x + (dx / distance) * effectiveSourceRadius; - const y1 = source_y + (dy / distance) * effectiveSourceRadius; - const x2 = target_x - (dx / distance) * effectiveTargetRadius; - const y2 = target_y - (dy / distance) * effectiveTargetRadius; - finalPath = `M${x1},${y1} L${x2},${y2}`; - } - - d._path = finalPath; - }; + const x1 = d.source.x, y1 = d.source.y; + const x2 = d.target.x, y2 = d.target.y; - const ticked = () => { - // First, iterate through the link data to calculate all endpoints (and curves if directed) - // so that the link only touches the edge of the node when opacity is < 1 - link.data().forEach(calculateAndStoreLinkPath); - - node.call(updateNodePosition); - link.call(updateLinkPosition); - if (config.show_labels) { - label.call(updateLabelPosition); - } - image.call(updateImagePosition); - } + const dx = x2 - x1, dy = y2 - y1; + const distance = Math.hypot(dx, dy); - // update node position - const updateNodePosition = (node) => { - node.attr("transform", function(d) { - return "translate(" + d.x + "," + d.y + ")"; - }); - }; + // Don't draw if nodes are overlapping + if (distance < effectiveSourceRadius + effectiveTargetRadius) return ""; - // update label position - const updateLabelPosition = (label) => { - label.attr("transform", function(d) { - return "translate(" + d.x + "," + d.y + ")"; - }); - }; + // --- B. Straight Line Calculation (Fast Path) --- + if (!config.curved) { + const sourceX = d.source.x + (dx / distance) * effectiveSourceRadius; + const sourceY = d.source.y + (dy / distance) * effectiveSourceRadius; + const targetX = d.target.x - (dx / distance) * effectiveTargetRadius; + const targetY = d.target.y - (dy / distance) * effectiveTargetRadius; - // update image position - const updateImagePosition = (image) => { - image.attr("transform", function(d) { - return "translate(" + d.x + "," + d.y + ")"; + return `M${sourceX},${sourceY}L${targetX},${targetY}`; + } else { + // --- C. Curved Line Calculation --- + // The radius of the arc is the distance between the nodes times a curve factor + const arcRadius = distance * curveFactor; + + // Find the intersection of two circles: + // 1. The arc itself with unknown center. + // 2. The node's boundary circle. + + // Find the center of the arc's circle + // https://math.stackexchange.com/questions/1781438/finding-the-center-of-a-circle-given-two-points-and-a-radius-algebraically + xa = dx/2; ya = dy/2; + x0 = x1 + xa; y0 = y1 + ya; // center of the rhombus + a = distance/2; + b = Math.sqrt(arcRadius*arcRadius - a*a); + + // center of the arc's circle + const cx = x0 - (b * ya) / a; + const cy = y0 + (b * xa) / a; + + // source node intersection + const sourceDX = x1 - cx; const sourceDY = y1 - cy; + const sourceDistance = Math.hypot(sourceDX, sourceDY); + const sourceA = ((arcRadius*arcRadius) - (effectiveSourceRadius*effectiveSourceRadius) + (sourceDistance*sourceDistance)) / (2*sourceDistance); + const sourceH = Math.sqrt((arcRadius*arcRadius) - (sourceA*sourceA)); + const sourceMidX = cx + (sourceA * (sourceDX)) / sourceDistance; + const sourceMidY = cy + (sourceA * (sourceDY)) / sourceDistance; + const sourceNewX = sourceMidX - (sourceH * (sourceDY)) / sourceDistance; + const sourceNewY = sourceMidY + (sourceH * (sourceDX)) / sourceDistance; + + // target node intersection + const targetDX = x2 - cx; const targetDY = y2 - cy; + const targetDistance = Math.hypot(targetDX, targetDY); + const targetA = ((arcRadius*arcRadius) - (effectiveTargetRadius*effectiveTargetRadius) + (targetDistance*targetDistance)) / (2*targetDistance); + const targetH = Math.sqrt((arcRadius*arcRadius) - (targetA*targetA)); + const targetMidX = cx + (targetA * (targetDX)) / targetDistance; + const targetMidY = cy + (targetA * (targetDY)) / targetDistance; + const targetNewX = targetMidX + (targetH * (targetDY)) / targetDistance; + const targetNewY = targetMidY - (targetH * (targetDX)) / targetDistance; + + return `M${sourceNewX},${sourceNewY} A${arcRadius},${arcRadius} 0 0,1 ${targetNewX},${targetNewY}`; + } }); }; - // update link position - const updateLinkPosition = (link) => { - link.attr('d', d => d._path); - }; - const simulation = d3.forceSimulation() .velocityDecay(0.2) .alphaMin(0.1) From 21553d54144125472a827b28f914517011ac7ccf Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Thu, 9 Oct 2025 18:46:26 +0000 Subject: [PATCH 09/44] efficiency improvements --- .../visualisations/_d3js/templates/network.js | 25 +++++++++---------- src/pathpyG/visualisations/network_plot.py | 11 ++++++++ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/pathpyG/visualisations/_d3js/templates/network.js b/src/pathpyG/visualisations/_d3js/templates/network.js index 9f2628672..fcc6cd702 100644 --- a/src/pathpyG/visualisations/_d3js/templates/network.js +++ b/src/pathpyG/visualisations/_d3js/templates/network.js @@ -217,20 +217,20 @@ const Network = (config) => { const oldNodesMap = new Map(node.data().map(d => [d.uid, d])); nodes = nodes.map(newNode => { const oldNode = oldNodesMap.get(newNode.uid); - - // Check if this is the node currently being dragged - if (currentlyDragged && currentlyDragged.uid === newNode.uid) { - // If so, update the original object in place... - Object.assign(currentlyDragged, newNode); - // ...and return the original object to preserve its identity. - return currentlyDragged; + // If there's an old node, preserve its position and velocity. + // Also, preserve fixed position (fx, fy) if the user is dragging it. + if (oldNode) { + newNode.x = oldNode.x; + newNode.y = oldNode.y; + newNode.vx = oldNode.vx; + newNode.vy = oldNode.vy; + if (oldNode.fx) newNode.fx = oldNode.fx; + if (oldNode.fy) newNode.fy = oldNode.fy; } - - // For all other nodes, create a new merged object as before. - return { ...oldNode, ...newNode }; + return newNode; }); links = links.map(d => ({...d})); - + // --- NODES (CIRCLES) --- // 1. Data Join node = container.select('.nodes').selectAll("circle.node") @@ -418,8 +418,7 @@ const Network = (config) => { } // Restart simulation and render immediately - simulation.alpha(1).restart().tick(); - ticked(); + simulation.alpha(1).restart(); } }); }; // End Network diff --git a/src/pathpyG/visualisations/network_plot.py b/src/pathpyG/visualisations/network_plot.py index 7b77592ac..4d90a772f 100644 --- a/src/pathpyG/visualisations/network_plot.py +++ b/src/pathpyG/visualisations/network_plot.py @@ -127,6 +127,17 @@ def _compute_edge_data(self) -> None: edges["source"] = edges.index.map(lambda x: x[0]) edges["target"] = edges.index.map(lambda x: x[1]) + # remove duplicate edges for better efficiency + if not self.network.is_directed(): + # for undirected networks, sort source and target and drop duplicates + edges = edges.reset_index(drop=True) + edges["sorted"] = edges.apply(lambda row: tuple(sorted((row["source"], row["target"]))), axis=1) + edges = edges.drop_duplicates(subset=["sorted"]).drop(columns=["sorted"]) + edges = edges.set_index(["source", "target"]) + else: + # for directed networks, remove duplicates based on index + edges = edges[~edges.index.duplicated(keep="first")] + # save edge data self.data["edges"] = edges From 17baaa30c409036d7572c9b0600dce34bc9eadad Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Fri, 10 Oct 2025 11:45:08 +0000 Subject: [PATCH 10/44] fix issues with higher order and curved edges --- src/pathpyG/pathpyG.toml | 22 +--- .../visualisations/_d3js/templates/network.js | 109 +++++++++++------- .../_d3js/templates/temporal.js | 2 +- .../visualisations/_matplotlib/backend.py | 45 +++++--- src/pathpyG/visualisations/_tikz/backend.py | 28 ++++- src/pathpyG/visualisations/network_plot.py | 8 ++ .../visualisations/temporal_network_plot.py | 1 - 7 files changed, 134 insertions(+), 81 deletions(-) diff --git a/src/pathpyG/pathpyG.toml b/src/pathpyG/pathpyG.toml index 0a3a33f89..1856a2607 100644 --- a/src/pathpyG/pathpyG.toml +++ b/src/pathpyG/pathpyG.toml @@ -20,10 +20,6 @@ separator = "|" replace = "_" max_name_length = 5 -[hon] -separator = "=" -replace = "_" - [visualisation] default_backend = "d3js" cmap = "cividis" @@ -31,8 +27,8 @@ layout = "spring_layout" width = "12cm" height = "12cm" latex_class_options = "" -delta = 1000 margin = 0.1 +curvature = 0.25 [visualisation.node] color = [36, 74, 92] @@ -46,15 +42,7 @@ size = 2 opacity = 0.5 [visualisation.temporal] -start = "start" -end = "end" -timestamp = "timestamp" -duration = "duration" -duration_value = 1 -start_synonyms = ["beginning", "begin"] -end_synonyms = ["finished", "ending"] -timestamp_synonyms = ['time', "t"] -duration_synonyms = ["delta", "dt"] -active = "active" -is_active = true -unit = "s" +delta = 1000 + +[visualisation.higher_order] +separator = "->" diff --git a/src/pathpyG/visualisations/_d3js/templates/network.js b/src/pathpyG/visualisations/_d3js/templates/network.js index fcc6cd702..3f8bc9299 100644 --- a/src/pathpyG/visualisations/_d3js/templates/network.js +++ b/src/pathpyG/visualisations/_d3js/templates/network.js @@ -15,14 +15,14 @@ const Network = (config) => { const selector = config.selector; // DOM uuid const width = config.width || 800; // window width const height = config.height || 600; // window height - const delta = config.delta || 300; // time between frames + const delta = (config.temporal && config.temporal.delta) || 300; // time between frames const padding = (config.node && config.node.image_padding) || 5; // distance between node and image const margin = config.margin || 0.1; // margin around the plot area for fixed layout const xlim = [-1*margin, 1+(1*margin)]; // limits of the x-coordinates const ylim = [-1*margin, 1+(1*margin)]; // limits of the y-coordinates const arrowheadMultiplier = 4; // Multiplier for arrowhead size based on edge stroke width const nodeStrokeWidth = 2.5; // Stroke width around nodes - const curveFactor = 0.75; // Factor to control curvature of edges (higher = more curved) + const curveFactor = config.curvature || 0.25; // Factor to control curvature of edges (higher = more curved) // Initialize svg canvas const svg = d3.select(selector) @@ -68,8 +68,8 @@ const Network = (config) => { const edgeStrokeWidth = d.size || (config.edge && config.edge.size) || 2; const arrowheadLength = config.directed ? (edgeStrokeWidth * arrowheadMultiplier) : 0; - const effectiveSourceRadius = sourceRadius + (nodeStrokeWidth / 2); - const effectiveTargetRadius = targetRadius + (nodeStrokeWidth / 2) + arrowheadLength; + const effectiveSourceRadius = sourceRadius + (nodeStrokeWidth/2); + const effectiveTargetRadius = targetRadius + (nodeStrokeWidth/2) + arrowheadLength; const x1 = d.source.x, y1 = d.source.y; const x2 = d.target.x, y2 = d.target.y; @@ -89,46 +89,67 @@ const Network = (config) => { return `M${sourceX},${sourceY}L${targetX},${targetY}`; } else { - // --- C. Curved Line Calculation --- - // The radius of the arc is the distance between the nodes times a curve factor - const arcRadius = distance * curveFactor; - - // Find the intersection of two circles: - // 1. The arc itself with unknown center. - // 2. The node's boundary circle. - - // Find the center of the arc's circle - // https://math.stackexchange.com/questions/1781438/finding-the-center-of-a-circle-given-two-points-and-a-radius-algebraically - xa = dx/2; ya = dy/2; - x0 = x1 + xa; y0 = y1 + ya; // center of the rhombus - a = distance/2; - b = Math.sqrt(arcRadius*arcRadius - a*a); - - // center of the arc's circle - const cx = x0 - (b * ya) / a; - const cy = y0 + (b * xa) / a; - - // source node intersection - const sourceDX = x1 - cx; const sourceDY = y1 - cy; - const sourceDistance = Math.hypot(sourceDX, sourceDY); - const sourceA = ((arcRadius*arcRadius) - (effectiveSourceRadius*effectiveSourceRadius) + (sourceDistance*sourceDistance)) / (2*sourceDistance); - const sourceH = Math.sqrt((arcRadius*arcRadius) - (sourceA*sourceA)); - const sourceMidX = cx + (sourceA * (sourceDX)) / sourceDistance; - const sourceMidY = cy + (sourceA * (sourceDY)) / sourceDistance; - const sourceNewX = sourceMidX - (sourceH * (sourceDY)) / sourceDistance; - const sourceNewY = sourceMidY + (sourceH * (sourceDX)) / sourceDistance; - - // target node intersection - const targetDX = x2 - cx; const targetDY = y2 - cy; - const targetDistance = Math.hypot(targetDX, targetDY); - const targetA = ((arcRadius*arcRadius) - (effectiveTargetRadius*effectiveTargetRadius) + (targetDistance*targetDistance)) / (2*targetDistance); - const targetH = Math.sqrt((arcRadius*arcRadius) - (targetA*targetA)); - const targetMidX = cx + (targetA * (targetDX)) / targetDistance; - const targetMidY = cy + (targetA * (targetDY)) / targetDistance; - const targetNewX = targetMidX + (targetH * (targetDY)) / targetDistance; - const targetNewY = targetMidY - (targetH * (targetDX)) / targetDistance; - - return `M${sourceNewX},${sourceNewY} A${arcRadius},${arcRadius} 0 0,1 ${targetNewX},${targetNewY}`; + // --- C. Curved Line Calculation (Quadratic Bézier) --- + + // Find the midpoint, the vector between the nodes, and the distance. + const midX = (x1 + x2) / 2; + const midY = (y1 + y2) / 2; + + // Calculate the control point (P1) for the quadratic curve. + // This point is offset from the midpoint along a perpendicular vector. + if (distance === 0) { // Handle case where nodes are at the same position + return `M${x1},${y1}`; + } + // Get the normalized perpendicular vector. + const perpX = -dy / distance; + const perpY = dx / distance; + + // Define how much the curve should "bulge". This is your curvature factor. + const controlPointOffset = distance * curveFactor; + + // Calculate the final control point's coordinates. + const controlX = midX + perpX * controlPointOffset; + const controlY = midY + perpY * controlPointOffset; + + // Shorten the curve to start and end at the node's edge, not its center. + // To do this, we find the direction from the node's center towards the control point + // and move the start/end point along that direction by the node's radius. + + // a. For the source node (P0 -> P1 direction) + const dirSourceX = controlX - x1; + const dirSourceY = controlY - y1; + const lenSource = Math.hypot(dirSourceX, dirSourceY); + const normDirSourceX = dirSourceX / lenSource; + const normDirSourceY = dirSourceY / lenSource; + + // For the target node (P2 -> P1 direction) + const dirTargetX = controlX - x2; + const dirTargetY = controlY - y2; + const lenTarget = Math.hypot(dirTargetX, dirTargetY); + const normDirTargetX = dirTargetX / lenTarget; + const normDirTargetY = dirTargetY / lenTarget; + + if (lenTarget < effectiveTargetRadius) { + const message = "Arrowhead length is too long for some edges. Please reduce the edge size."; + console.error(message); + const sourceX = d.source.x + (dx / distance) * effectiveSourceRadius; + const sourceY = d.source.y + (dy / distance) * effectiveSourceRadius; + const targetX = d.target.x - (dx / distance) * effectiveTargetRadius; + const targetY = d.target.y - (dy / distance) * effectiveTargetRadius; + + return `M${sourceX},${sourceY}L${targetX},${targetY}`; + } + + // The new, shortened start point (x3, y3) + const x3 = x1 + normDirSourceX * effectiveSourceRadius; + const y3 = y1 + normDirSourceY * effectiveSourceRadius; + + // The new, shortened end point (x4, y4) + const x4 = x2 + normDirTargetX * effectiveTargetRadius; + const y4 = y2 + normDirTargetY * effectiveTargetRadius; + + // Return the SVG path string for the quadratic Bézier curve. + return `M${x3},${y3} Q${controlX},${controlY} ${x4},${y4}`; } }); }; diff --git a/src/pathpyG/visualisations/_d3js/templates/temporal.js b/src/pathpyG/visualisations/_d3js/templates/temporal.js index aef092524..5e9c30a17 100755 --- a/src/pathpyG/visualisations/_d3js/templates/temporal.js +++ b/src/pathpyG/visualisations/_d3js/templates/temporal.js @@ -132,5 +132,5 @@ times = range(d3.min(data.nodes, d => d.start),d3.max(data.nodes, d => d.end),1) update(1); // Add counter and start updating network -const scrubberForm = scrubber(times,{chartUpdate:update, delay: config.delta || 300}); +const scrubberForm = scrubber(times,{chartUpdate:update, delay: delta || 300}); d3.select(config.selector).append(() => scrubberForm.node()); diff --git a/src/pathpyG/visualisations/_matplotlib/backend.py b/src/pathpyG/visualisations/_matplotlib/backend.py index 234cb45ac..3c16b85bd 100644 --- a/src/pathpyG/visualisations/_matplotlib/backend.py +++ b/src/pathpyG/visualisations/_matplotlib/backend.py @@ -79,13 +79,13 @@ def to_fig(self) -> tuple[plt.Figure, plt.Axes]: if self.show_labels: for label in self.data["nodes"].index: x, y = self.data["nodes"].loc[label, ["x", "y"]] - # Annotate the node label with text above the node + # Annotate the node label with text in the center of the node ax.annotate( label, (x, y), - xytext=(0, 0.75 * self.data["nodes"].loc[label, "size"]), - textcoords="offset points", - fontsize=0.75 * self.data["nodes"]["size"].mean(), + fontsize=0.4 * self.data["nodes"]["size"].mean(), + ha="center", + va="center", ) # set limits @@ -163,7 +163,6 @@ def get_bezier_curve( source_node_size, target_node_size, head_length, - curvature=0.2, shorten=0.005, ): """Calculates the vertices and codes for a quadratic Bézier curve path. @@ -174,7 +173,6 @@ def get_bezier_curve( source_node_size (np.array): Size of the source nodes to adjust the curve shortening. target_node_size (np.array): Size of the target nodes to adjust the curve shortening. head_length (float): Length of the arrowhead to adjust the curve shortening. - curvature (float): A value controlling the curve's bend. shorten (float): Amount to shorten the curve at both ends to avoid overlap with nodes. Will shorten double at the target end to make space for the arrowhead. @@ -189,18 +187,33 @@ def get_bezier_curve( mid_point = (P0 + P2) / 2 vec = P2 - P0 dist = np.linalg.norm(vec, axis=1, keepdims=True) + # Avoid division by zero + dist[dist == 0] = 1e-6 # Perpendicular vector perp_vec = np.array([-vec[:, 1], vec[:, 0]]).T / dist # Calculate control points - P1 = mid_point + perp_vec * dist * curvature + P1 = mid_point + perp_vec * dist * self.config["curvature"] # Shorten the curve to avoid overlap with nodes - direction_P0_P1 = (P1 - P0) / np.linalg.norm(P1 - P0, axis=1, keepdims=True) - direction_P2_P1 = (P1 - P2) / np.linalg.norm(P1 - P2, axis=1, keepdims=True) - P0 += direction_P0_P1 * (shorten + source_node_size) - P2 += direction_P2_P1 * (shorten + target_node_size + head_length) + distance_P0_P1 = np.linalg.norm(P1 - P0, axis=1, keepdims=True) + distance_P0_P1[distance_P0_P1 == 0] = 1e-6 + distance_P2_P1 = np.linalg.norm(P1 - P2, axis=1, keepdims=True) + distance_P2_P1[distance_P2_P1 == 0] = 1e-6 + direction_P0_P1 = (P1 - P0) / distance_P0_P1 + direction_P2_P1 = (P1 - P2) / distance_P2_P1 + P0_offset_dist = shorten + source_node_size + P2_offset_dist = shorten + target_node_size + (head_length * self.data["edges"]["size"].values[:, np.newaxis]) + if np.any(distance_P2_P1/2 < P2_offset_dist): + logger.warning("Arrowhead length is too long for some edges. Please reduce the edge size. Using non-curved edges instead.") + direction_P0_P2 = vec / dist + P0 += direction_P0_P2 * P0_offset_dist + P2 -= direction_P0_P2 * P2_offset_dist + return [P0, P2], [Path.MOVETO, Path.LINETO] + + P0 += direction_P0_P1 * P0_offset_dist + P2 += direction_P2_P1 * P2_offset_dist vertices = [P0, P1, P2] codes = [ @@ -222,20 +235,22 @@ def get_arrowhead(self, vertices, head_length=0.01, head_width=0.02): tuple: A tuple containing (vertices, codes) for the Path object. """ # Extract the last segment of the Bézier curve - P1, P2 = vertices[1], vertices[2] + P1, P2 = vertices[-2], vertices[-1] # 1. Calculate the tangent vector (direction of the curve at the end) # For a quadratic curve, this is the vector from the control point to the end point. tangent = P2 - P1 tangent /= np.linalg.norm(tangent, axis=1, keepdims=True) + # Avoid division by zero + tangent[tangent == 0] = 1e-6 # 2. Calculate the perpendicular vector for the width perp = np.array([-tangent[:, 1], tangent[:, 0]]).T # 3. Define the three points of the arrowhead triangle base_center = P2 - tip = P2 + tangent * head_length - wing1 = base_center + perp * head_width / 2 - wing2 = base_center - perp * head_width / 2 + tip = P2 + tangent * head_length * self.data["edges"]["size"].values[:, np.newaxis] + wing1 = base_center + perp * head_width / 2 * self.data["edges"]["size"].values[:, np.newaxis] + wing2 = base_center - perp * head_width / 2 * self.data["edges"]["size"].values[:, np.newaxis] vertices = [wing1, tip, wing2, wing1] codes = [ diff --git a/src/pathpyG/visualisations/_tikz/backend.py b/src/pathpyG/visualisations/_tikz/backend.py index c0ca3e169..e52f4a838 100644 --- a/src/pathpyG/visualisations/_tikz/backend.py +++ b/src/pathpyG/visualisations/_tikz/backend.py @@ -9,6 +9,8 @@ import webbrowser from string import Template +import pandas as pd + from pathpyG import config from pathpyG.visualisations.network_plot import NetworkPlot from pathpyG.visualisations.pathpy_plot import PathPyPlot @@ -190,9 +192,11 @@ def to_tikz(self) -> str: node_strings = "\\Vertex[" # show labels if specified if self.show_labels: - node_strings += "label=" + self.data["nodes"].index.astype(str) + "," node_strings += ( - "fontsize=\\fontsize{" + str(int(0.75 * self.data["nodes"]["size"].mean())) + "}{10}\selectfont," + "label=$" + self.data["nodes"].index.astype(str).map(self._replace_with_LaTeX_math_symbol) + "$," + ) + node_strings += ( + "fontsize=\\fontsize{" + str(int(0.6 * self.data["nodes"]["size"].mean())) + "}{10}\selectfont," ) # Convert hex colors to rgb if necessary if self.data["nodes"]["color"].str.startswith("#").all(): @@ -201,7 +205,7 @@ def to_tikz(self) -> str: else: node_strings += "color=" + self.data["nodes"]["color"] + "," # add other options - node_strings += "size=" + (self.data["nodes"]["size"] * 0.05).astype(str) + "," + node_strings += "size=" + (self.data["nodes"]["size"] * 0.075).astype(str) + "," node_strings += "opacity=" + self.data["nodes"]["opacity"].astype(str) + "," # add position node_strings += ( @@ -231,3 +235,21 @@ def to_tikz(self) -> str: tikz += edge_strings.str.cat() return tikz + + def _replace_with_LaTeX_math_symbol(self, node_label: str) -> str: + """Replace certain symbols with LaTeX math symbols.""" + replacements = { + "->": r"\to ", + "<-": r"\gets ", + "<->": r"\leftrightarrow ", + "=>": r"\Rightarrow ", + "<=": r"\Leftarrow ", + "<=>": r"\Leftrightarrow ", + "!=": r"\neq ", + } + if self.config["higher_order"]["separator"].strip() in replacements: + node_label = node_label.replace( + self.config["higher_order"]["separator"], + replacements[self.config["higher_order"]["separator"].strip()], + ) + return node_label diff --git a/src/pathpyG/visualisations/network_plot.py b/src/pathpyG/visualisations/network_plot.py index 4d90a772f..55c2040cc 100644 --- a/src/pathpyG/visualisations/network_plot.py +++ b/src/pathpyG/visualisations/network_plot.py @@ -68,6 +68,9 @@ def _compute_node_data(self) -> None: """Generate the data structure for the nodes.""" # initialize values nodes: pd.DataFrame = pd.DataFrame(index=self.network.nodes) + # if higher-order network, convert node tuples to string representation + if self.network.order > 1: + nodes.index = nodes.index.map(lambda x: self.config["higher_order"]["separator"].join(map(str, x))) for attribute in self.attributes: # set default value for each attribute based on the pathpyG.toml config if isinstance(self.config.get("node").get(attribute, None), list | tuple): # type: ignore[union-attr] @@ -95,6 +98,9 @@ def _compute_edge_data(self) -> None: """Generate the data structure for the edges.""" # initialize values edges: pd.DataFrame = pd.DataFrame(index=pd.MultiIndex.from_tuples(self.network.edges, names=["source", "target"])) + # if higher-order network, convert node tuples to string representation + if self.network.order > 1: + edges.index = edges.index.map(lambda x: (self.config["higher_order"]["separator"].join(map(str, x[0])), self.config["higher_order"]["separator"].join(map(str, x[1])))) for attribute in self.attributes: # set default value for each attribute based on the pathpyG.toml config if isinstance(self.config.get("edge").get(attribute, None), list | tuple): # type: ignore[union-attr] @@ -182,6 +188,8 @@ def _compute_layout(self) -> None: # update x,y position of the nodes layout_df = pd.DataFrame.from_dict(layout, orient="index", columns=["x", "y"]) + if self.network.order > 1 and not isinstance(layout_df.index[0], str): + layout_df.index = layout_df.index.map(lambda x: self.config["higher_order"]["separator"].join(map(str, x))) # scale x and y to [0,1] layout_df["x"] = (layout_df["x"] - layout_df["x"].min()) / (layout_df["x"].max() - layout_df["x"].min()) layout_df["y"] = (layout_df["y"] - layout_df["y"].min()) / (layout_df["y"].max() - layout_df["y"].min()) diff --git a/src/pathpyG/visualisations/temporal_network_plot.py b/src/pathpyG/visualisations/temporal_network_plot.py index e65c765fe..fb321d219 100644 --- a/src/pathpyG/visualisations/temporal_network_plot.py +++ b/src/pathpyG/visualisations/temporal_network_plot.py @@ -84,7 +84,6 @@ def _compute_node_data(self) -> None: def _compute_edge_data(self) -> None: """Generate the data structure for the edges.""" - start_time = time.time() # initialize values edges: pd.DataFrame = pd.DataFrame(index=[f"{source}-{target}-{time}" for source, target, time in self.network.temporal_edges]) for attribute in self.attributes: From bfb507fd1e3902dbf8ef0ecaada675a39ab0fdca Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Fri, 10 Oct 2025 16:53:03 +0000 Subject: [PATCH 11/44] update manim --- .devcontainer/devcontainer.json | 2 +- src/pathpyG/pathpyG.toml | 34 +- src/pathpyG/visualisations/_d3js/backend.py | 13 +- .../_d3js/templates/temporal.js | 2 +- src/pathpyG/visualisations/_manim/__init__.py | 34 +- src/pathpyG/visualisations/_manim/backend.py | 137 +++++++ src/pathpyG/visualisations/_manim/core.py | 169 -------- .../visualisations/_manim/network_plots.py | 377 ------------------ .../_manim/temporal_graph_scene.py | 60 +++ .../visualisations/_matplotlib/backend.py | 3 +- src/pathpyG/visualisations/_tikz/backend.py | 42 +- src/pathpyG/visualisations/utils.py | 24 +- 12 files changed, 261 insertions(+), 636 deletions(-) create mode 100644 src/pathpyG/visualisations/_manim/backend.py delete mode 100644 src/pathpyG/visualisations/_manim/core.py delete mode 100644 src/pathpyG/visualisations/_manim/network_plots.py create mode 100644 src/pathpyG/visualisations/_manim/temporal_graph_scene.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 903f56c14..451f8d8f1 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -45,7 +45,7 @@ "ghcr.io/prulloac/devcontainer-features/latex:1": { "scheme": "minimal", "mirror": "https://mirror.ctan.org/systems/texlive/tlnet/", - "packages": "tikz-network,standalone,xcolor,xifthen,tools,ifmtarg,pgf,datatool,etoolbox,tracklang,amsmath,trimspaces,epstopdf-pkg,dvisvgm" + "packages": "tikz-network,standalone,xcolor,xifthen,tools,ifmtarg,pgf,datatool,etoolbox,tracklang,amsmath,trimspaces,epstopdf-pkg,dvisvgm,preview,babel-english" } } } diff --git a/src/pathpyG/pathpyG.toml b/src/pathpyG/pathpyG.toml index 1856a2607..8c8c8aa2b 100644 --- a/src/pathpyG/pathpyG.toml +++ b/src/pathpyG/pathpyG.toml @@ -21,28 +21,28 @@ replace = "_" max_name_length = 5 [visualisation] -default_backend = "d3js" -cmap = "cividis" -layout = "spring_layout" -width = "12cm" -height = "12cm" -latex_class_options = "" -margin = 0.1 -curvature = 0.25 +default_backend = "d3js" # Default backend for visualisations +cmap = "cividis" # Color map that is used if color is given integer values +layout = "spring_layout" # Default layout algorithm +width = "12cm" # Width of the plot in all backends +height = "12cm" # Height of the plot in all backends +latex_class_options = "" # LaTeX class options for TikZ visualisations +margin = 0.1 # Margin around the plot in all backends +curvature = 0.25 # Curvature for curved edges between nodes [visualisation.node] -color = [36, 74, 92] -size = 15 -opacity = 0.75 -image_padding = 5 +color = [36, 74, 92] # Node color in RGB from the pathpyG logo +size = 15 # Node size given as diameter +opacity = 0.75 # Node opacity between 0 and 1 +image_padding = 5 # Padding around images if image link is provided in d3js visualisations [visualisation.edge] -color = [76, 112, 123] -size = 2 -opacity = 0.5 +color = [76, 112, 123] # Edge color in RGB from the pathpyG logo +size = 2 # Edge width +opacity = 0.5 # Edge opacity between 0 and 1 [visualisation.temporal] -delta = 1000 +delta = 1000 # Time between frames in milliseconds [visualisation.higher_order] -separator = "->" +separator = "->" # Separator for higher-order node labels diff --git a/src/pathpyG/visualisations/_d3js/backend.py b/src/pathpyG/visualisations/_d3js/backend.py index 19c2b886b..8c4641a76 100644 --- a/src/pathpyG/visualisations/_d3js/backend.py +++ b/src/pathpyG/visualisations/_d3js/backend.py @@ -11,6 +11,7 @@ from pathpyG.utils.config import config from pathpyG.visualisations.network_plot import NetworkPlot +from pathpyG.visualisations.pathpy_plot import PathPyPlot from pathpyG.visualisations.plot_backend import PlotBackend from pathpyG.visualisations.temporal_network_plot import TemporalNetworkPlot from pathpyG.visualisations.utils import rgb_to_hex, unit_str_to_float @@ -18,7 +19,7 @@ # create logger logger = logging.getLogger("root") -SUPPORTED_KINDS = { +SUPPORTED_KINDS: dict[type, str] = { NetworkPlot: "static", TemporalNetworkPlot: "temporal", } @@ -27,13 +28,11 @@ class D3jsBackend(PlotBackend): """D3js plotting backend.""" - def __init__(self, plot, show_labels: bool): + def __init__(self, plot: PathPyPlot, show_labels: bool): super().__init__(plot, show_labels) self._kind = SUPPORTED_KINDS.get(type(plot), None) if self._kind is None: - logger.error( - f"Plot of type {type(plot)} not supported by D3js backend." - ) + logger.error(f"Plot of type {type(plot)} not supported by D3js backend.") raise ValueError(f"Plot of type {type(plot)} not supported.") def save(self, filename: str) -> None: @@ -78,7 +77,7 @@ def _prepare_config(self) -> dict: config["show_labels"] = self.show_labels return config - def to_json(self) -> tuple[str,str]: + def to_json(self) -> tuple[str, str]: """Convert data and config to json.""" data_dict = self._prepare_data() config_dict = self._prepare_config() @@ -159,4 +158,4 @@ def get_template(self, template_dir: str) -> str: with open(os.path.join(template_dir, f"{self._kind}.js")) as template: js_template += template.read() - return js_template \ No newline at end of file + return js_template diff --git a/src/pathpyG/visualisations/_d3js/templates/temporal.js b/src/pathpyG/visualisations/_d3js/templates/temporal.js index 5e9c30a17..facdc11ca 100755 --- a/src/pathpyG/visualisations/_d3js/templates/temporal.js +++ b/src/pathpyG/visualisations/_d3js/templates/temporal.js @@ -132,5 +132,5 @@ times = range(d3.min(data.nodes, d => d.start),d3.max(data.nodes, d => d.end),1) update(1); // Add counter and start updating network -const scrubberForm = scrubber(times,{chartUpdate:update, delay: delta || 300}); +const scrubberForm = scrubber(times,{chartUpdate:update, delay: (config.temporal && config.temporal.delta) || 300}); d3.select(config.selector).append(() => scrubberForm.node()); diff --git a/src/pathpyG/visualisations/_manim/__init__.py b/src/pathpyG/visualisations/_manim/__init__.py index ca6a9daa1..f47622abc 100644 --- a/src/pathpyG/visualisations/_manim/__init__.py +++ b/src/pathpyG/visualisations/_manim/__init__.py @@ -1,33 +1 @@ -""" -This is Manim Base Plot Class -""" - -from typing import Any -from pathpyG.visualisations._manim.network_plots import NetworkPlot -from pathpyG.visualisations._manim.network_plots import StaticNetworkPlot -from pathpyG.visualisations._manim.network_plots import TemporalNetworkPlot - -PLOT_CLASSES: dict = { - "network": NetworkPlot, - "static": StaticNetworkPlot, - "temporal": TemporalNetworkPlot, -} - - -def plot(data: dict, kind: str = "network", **kwargs: Any) -> Any: - """ - Function to create and return a Manim-based network plot. - - This function selects a plotting classs based on `kind` argument - and initializes it with the given data and optional keyword arguments. - - Args: - data (dict): The network data to be visualized. - kind (str, optional): The type of plot to create - **kwargs (Any): Additional keywork arguments passed to the onstructor. - These include options for styling and customizing the animation. - - Returns: - Any: An instance of selected plot class. - """ - return PLOT_CLASSES[kind](data, **kwargs) +"""Manim visualisations for pathpyG.""" diff --git a/src/pathpyG/visualisations/_manim/backend.py b/src/pathpyG/visualisations/_manim/backend.py new file mode 100644 index 000000000..c3dff7783 --- /dev/null +++ b/src/pathpyG/visualisations/_manim/backend.py @@ -0,0 +1,137 @@ +"""Generic manim plot class.""" + +from __future__ import annotations + +import base64 +import logging +import os +import shutil +import subprocess +import webbrowser +from pathlib import Path + +from manim import WHITE +from manim import config as manim_config + +from pathpyG import config +from pathpyG.visualisations._manim.temporal_graph_scene import TemporalGraphScene +from pathpyG.visualisations.pathpy_plot import PathPyPlot +from pathpyG.visualisations.plot_backend import PlotBackend +from pathpyG.visualisations.temporal_network_plot import TemporalNetworkPlot +from pathpyG.visualisations.utils import prepare_tempfile, unit_str_to_float + +# create logger +logger = logging.getLogger("root") + +SUPPORTED_KINDS: dict[type, str] = { + TemporalNetworkPlot: "temporal", +} + + +class ManimBackend(PlotBackend): + """Base class for Manim Plots integrated with Jupyter notebooks. + + This class defines the interface for Manim plots that are generated + from data and can be rendered for either saving or displaying inline. + """ + + def __init__(self, plot: PathPyPlot, show_labels: bool): + """Initializes the Manim backend with a given plot.""" + super().__init__(plot, show_labels=show_labels) + self._kind = SUPPORTED_KINDS.get(type(plot), None) + if self._kind is None: + logger.error(f"Plot of type {type(plot)} not supported by Matplotlib backend.") + raise ValueError(f"Plot of type {type(plot)} not supported.") + + # Optional config settings + manim_config.pixel_height = int(unit_str_to_float(self.config.get("height"), "px")) + manim_config.pixel_width = int(unit_str_to_float(self.config.get("width"), "px")) + manim_config.frame_rate = 15 + manim_config.quality = "high_quality" + manim_config.background_color = self.config.get("background_color", WHITE) + + def render_video( + self, + ): + """Renders the Manim animation. + + This method sets up the scene and prepares it for rendering. + """ + temp_dir, current_dir = prepare_tempfile() + manim_config.media_dir = temp_dir + manim_config.output_file = "default.mp4" + self.scene = TemporalGraphScene(data=self.data, config=self.config, show_labels=self.show_labels) + self.scene.render() + os.chdir(current_dir) + return Path(temp_dir) / "videos" / "1080p60" / "default.mp4", temp_dir + + def save(self, filename: str) -> None: + """Renders and saves a Manim animation to the working directory. + + This method creates a temporary scene using the instance's `raw data`, + renders it with Manim, and saves the resulting video. + + Args: + filename (str): Name for the File that will be saved. Is necessary for this function to work. + + Tip: + - use `**kwargs` to control aspects of the scene such as animation timing, layout, or styling + """ + # render temporary .mp4 + temp_file, temp_dir = self.render_video() + if filename.endswith(".gif"): + self.convert_to_gif(temp_file) + shutil.copy(temp_file, filename) + shutil.rmtree(temp_dir) + + def convert_to_gif(self, filename: str) -> None: + """Convert the rendered mp4 video to a gif file.""" + try: + subprocess.run( + [ + "ffmpeg", + "-i", + filename, + "-vf", + "fps=20,scale=720:-1:flags=lanczos", + "-y", + "-hide_banner", + "-loglevel", + "error", + filename.replace(".mp4", ".gif"), + ], + check=True, + ) + except Exception as e: + logger.error(f"GIF conversion failed: {e}") + + def show(self) -> None: + """Renders and displays a Manim animation. + + This method creates a temporary scene using the instance's `raw data`, + renders it with Manim, and embeds the resulting video in the notebook. + It is specifically for use in Juypter Environment + and will warn if used elsewhere. + + Notes: + - The scene is renderd into a temporary directory and not saved permanently + - Manim is expected to output the video under `videos/1080p60/TemporalNetworkPlot.mp4` which is the default + """ + temp_file, temp_dir = self.render_video() + + if config["environment"]["interactive"]: + from IPython.display import HTML, display + + video_bytes = temp_file.read_bytes() + video_b64 = base64.b64encode(video_bytes).decode() + video_html = f""" + + """ + display(HTML(video_html)) + else: + # open the file in the webbrowser + webbrowser.open(r"file:///" + temp_file) + shutil.rmtree(temp_dir) diff --git a/src/pathpyG/visualisations/_manim/core.py b/src/pathpyG/visualisations/_manim/core.py deleted file mode 100644 index 6b62f27a4..000000000 --- a/src/pathpyG/visualisations/_manim/core.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Generic manim plot class.""" - -from __future__ import annotations - -import base64 -import logging -import shutil -import tempfile -import subprocess -from pathlib import Path -from typing import Any - -from IPython.core.getipython import get_ipython -from IPython.display import HTML, display - -from pathpyG.visualisations.plot import PathPyPlot - -# create logger -logger = logging.getLogger("root") - - -def in_jupyter_notebook() -> bool: - """ - Detects whether the current Python session is running inside - a Jupyter Notebook. - - Returns: - bool: True if running inside a Jupyter notebook, False otherwise - """ - try: - return "IPKernelApp" in get_ipython().config - except NameError: - return False - except AttributeError: - return False - - -class ManimPlot(PathPyPlot): - """ - Base class for Manim Plots integrated with Jupyter notebooks - - This class defines the interface for Manim plots that are generated - from data and can be rendered for either saving or displaying inline. - """ - - def generate(self) -> None: - """ - Generate the plot. - """ - raise NotImplementedError - - def save(self, filename: str, **kwargs: Any) -> None: - """ - Renders and saves a Manim animation to a given or the working directory. - - This method creates a temporary scene using the instance's `raw data`, - renders it with Manim, and saves the resulting video. - - Args: - **kwargs (Any): Additional keyword arguments forwarded to the scene constructor. - These can be used to customize the rendering behaviour or pass scene-specific parameters - filename (str): Name for the File that will be saved. Is necessary for this function to work. - - Tip: - - use `**kwargs` to control aspects of the scene such as animation timing, layout, or styling - """ - save_dir = kwargs.get("save_dir", None) - - if save_dir is None: - save_dir = Path.cwd() - else: - save_dir = Path(save_dir) - - save_dir.mkdir(parents=True, exist_ok=True) - name, output_format = filename.rsplit(".", 1) - - with tempfile.TemporaryDirectory() as tmpdir: - tmp_path = Path(tmpdir) - temp_output_file = f"{self.__class__.__name__}" - - scene = self.__class__(data=self.raw_data, output_dir=tmp_path, output_file=temp_output_file, **kwargs) - scene.render() - - video_path = tmp_path / "videos" / "1080p60" / f"{temp_output_file}.mp4" - - if not video_path.exists(): - logger.warning("Rendered video not found at expected path: %s ", video_path) - return - - target_path = save_dir / f"{filename}" - - if output_format == "gif": - try: - subprocess.run( - [ - "ffmpeg", - "-i", - str(video_path.as_posix()), - "-vf", - "fps=20,scale=720:-1:flags=lanczos", - "-y", - "-hide_banner", - "-loglevel", - "error", - str(target_path.as_posix()), - ], - check=True, - ) - - # Optionally delete the intermediate MP4 - video_path.unlink() - except Exception as e: - logger.error(f"GIF conversion failed: {e}") - - else: - shutil.copy(video_path, target_path) - - def show(self, **kwargs: Any) -> None: - """ - Renders and displays a Manim animation within a Jupyter Notebook - - This method creates a temporary scene using the instance's `raw data`, - renders it with Manim, and embeds the resulting video in the notebook. - It is specifically for use in Juypter Environment - and will warn if used elsewhere. - - Args: - **kwargs (Any): Additional keyword arguments forwarded to the scene constructor. - These can be used to customize the rendering behaviour or pass scene-specific parameters - - Notes: - - The scene is renderd into a temporary directory and not saved permanently - - Manim is expected to output the video under `videos/720p30/TemporalNetworkPlot.mp4` which is the default - - Tip: - - use `**kwargs` to control aspects of the scene such as animation timing, layout, or styling - """ - if not in_jupyter_notebook(): - logger.warning("This function is designed for use within a Jupyter notebook.") - return - - with tempfile.TemporaryDirectory() as tmpdir: - tmp_path = Path(tmpdir) - output_file = f"{self.__class__.__name__}" - - scene = self.__class__(data=self.raw_data, output_dir=tmp_path, output_file=output_file, **kwargs) - scene.render() - - video_dir = tmp_path / "videos" / "1080p60" - video_path = video_dir / f"{output_file}.mp4" - - if video_path.exists(): - video_bytes = video_path.read_bytes() - video_b64 = base64.b64encode(video_bytes).decode() - - video_html = f""" - - """ - display(HTML(video_html)) - - for folder in ["media", "videos", "images"]: - folder_path = tmp_path / folder - if folder_path.exists(): - shutil.rmtree(folder_path) - else: - logger.warning("Expected video not found: %s", video_path) diff --git a/src/pathpyG/visualisations/_manim/network_plots.py b/src/pathpyG/visualisations/_manim/network_plots.py deleted file mode 100644 index f8d563efa..000000000 --- a/src/pathpyG/visualisations/_manim/network_plots.py +++ /dev/null @@ -1,377 +0,0 @@ -""" -Network plots using Manim. - -This module provides classes and utilites for visualizing networks using the Manim animation -engine. It includes base classes, custom rendering behaviour and configuration options for styling -and controlling the output. - -Classes: - - NetworkPlot: Base class for network visualizations. - - StaticNetworkPlot: Static layout and display of a network. - - TemporalNetworkPlot: Animated plot showing temporal evolution of a network -""" - -# ============================================================================= -# File : network_plots.py -- Network plots with manim -# ============================================================================= - -import logging -from pathlib import Path -from typing import Any - -import numpy as np -from manim import BLACK, BLUE, GRAY, UL, UP, WHITE, Graph, Line, Scene, Text -from tqdm import tqdm - -import pathpyG as pp -from pathpyG.visualisations._manim.core import ManimPlot - -logger = logging.getLogger("root") - - -class NetworkPlot(ManimPlot): - """Base class for static and dynamic network plots. - - This class stores the raw input data and configuration arguments, - serving as a parent for Manim-based visualisations. - """ - - _kind = "network" - - def __init__(self, data: dict, **kwargs: Any) -> None: - """ - Initializes a network plot. - - Args: - data (dict): Input network data dictionary - **kwargs (Any): Optional keyword arguments for configuration - """ - super().__init__() - self.data = {} - self.config = kwargs - self.raw_data = data - - -class TemporalNetworkPlot(NetworkPlot, Scene): - """ - Animated temporal network plot - - This class supports rendering of animations of temporal graphs over time, - using customizable layout strategies and time-based changes in color and layout. - """ - - _kind = "temporal" - - def __init__(self, data: dict, output_dir: str | Path = None, output_file: str = None, **kwargs) -> None: - """ - Initialize the temporal network plot. - - Args: - data (dict): Network data - output_dir (str | Path, optional): Directory to store output. - output_file (str, optional): Filename for output. - **kwargs: Additional keyword arguments to customize the plot. - - """ - from manim import config as manim_config - - NetworkPlot.__init__( - self, - data, - **kwargs, - ) - - if output_dir: - manim_config.media_dir = str(output_dir) - if output_file: - manim_config.output_file = output_file - - # Optional config settings - manim_config.pixel_height = 1080 - manim_config.pixel_width = 1920 - manim_config.frame_rate = 15 - manim_config.quality = "high_quality" - manim_config.background_color = self.config.get("background_color", WHITE) - - self.delta = self.config.get("delta", 1000) - self.start = self.config.get("start", 0) - self.end = self.config.get("end", None) - self.intervals = self.config.get("intervals", None) - self.dynamic_layout_interval = self.config.get("dynamic_layout_interval", None) - self.font_size = self.config.get("font_size", 8) - self.look_behind = self.config.get("look_behind", 5) - self.look_forward = self.config.get("look_forward", 3) - - # defaults - self.node_size = 0.4 - self.node_opacity = 1 - self.edge_size = 0.4 - self.edge_opacity = 1 - - self.node_label: dict[Any, Any] = {} - - Scene.__init__(self) - - def compute_edge_index(self) -> tuple: - """ - Convert input data into edge tuples and compute maximum time value. - - Returns: - tuple: - A tuple containing: - - - `tedges` (list of tuple): A list of temporal edges, where each edge is represented as - `(source, target, timestamp)`. - - `max_time` (int): The maximum timestamp found in the edge data. - """ - - tedges = [(d["source"], d["target"], d["start"]) for d in self.raw_data["edges"]] - max_time = max(d["start"] for d in self.raw_data["edges"]) - return tedges, max_time - - def get_layout(self, graph: pp.TemporalGraph, layout_type: str = "fr", time_window: tuple = None, old_layout: dict = None) -> dict: - """ - Compute spatial layout for network nodes using pathpy layout functions. - - Args: - graph (pp.TemporalGraph): Graph for which to compute layout. - type (str, optional): Layout algorithm to use (e.g., "fr", "random"). - time_window (tuple, optional): Optional (start, end) for subgraph - - Returns: - dict: Mapping from node IDs to 3D positions (x , y , z) - """ - layout_style = {} - layout_style["layout"] = layout_type - - #convert old_layout back to 2 dimensions because pathpyG's layout function only works in 2 dimensions - if old_layout != None: - old_layout = {k: v[:2] for k, v in old_layout.items()} - - try: - layout = pp.layout( - graph.get_window(*time_window).to_static_graph() if time_window != None else graph.to_static_graph(), - **layout_style, - seed=0, - positions = old_layout - ) - - for key in layout.keys(): - layout[key] = np.append( - layout[key], 0.0 - ) # manim works in 3 dimensions, not 2 --> add zeros as third dimension to every node coordinate - - layout_array = np.array(list(layout.values())) - mins = layout_array.min(axis=0) # compute the mins and maxs of the 3 dimensions - maxs = layout_array.max(axis=0) - center = (mins + maxs) / 2 # compute the center of the network - scale = ( - 4.0 / (maxs - mins).max() if (maxs - mins).max() != 0 else 1.0 - ) # compute scale, so that every node fits into a 2 x 2 box - - for k in layout: - layout[k] = (layout[k] - center) * scale # scale the position of each node - except IndexError as e: - layout = None - return layout - - def get_color_at_time(self, node_data: dict, time_step: int): - """Return Color from Dictionary that provides the color changes per node - - Args: - node_data (dict): holds all information for a specific node - time_step (int): timestep for which a color change might occur - - Returns: - The color the node changes to if any. - """ - if "color_change" not in node_data: - return node_data.get("color", BLUE) - - changes = [c for c in node_data["color_change"] if c["time"] <= time_step] - if not changes: - return node_data.get("color", BLUE) - - latest_change = max(changes, key=lambda c: c["time"]) - return latest_change["color"] - - def construct(self): - """ - Construct and animate the network scene using Manim. - - This method: - - Adds nodes using `Graph` - - Draws and removes temporal edges frame-by-frame - - Recomputes layout dynamically (if specified) based on on the temporal edges in the time window between the current step - look_behind and the current step + look_forward - - Displays timestamps - """ - - nodes_data = self.raw_data["nodes"] - edges_data = self.raw_data["edges"] - edge_list, end_time = self.compute_edge_index() - g = pp.TemporalGraph.from_edge_list(edge_list) # create ppG Graph - - start = self.start # start time of the simulation - end = end_time if self.end is None else self.end # end time of the simulation - delta = self.delta # time needed for progressing one time step - intervals = ( - self.intervals - ) # number of numeric intervals, if None --> intervals = num of timesteps (end - start) - dynamic_layout_interval = ( - self.dynamic_layout_interval - ) # specifies after how many time steps a new layout is computed - - # if intervals is not specified, every timestep is an interval - if intervals is None: - intervals = end - start - - look_behind = self.look_behind - look_forward = self.look_forward - - delta /= 1000 # convert milliseconds to seconds - layout = self.get_layout(g, "random" if dynamic_layout_interval is not None else "fr") - - time_stamps = g.data["time"] - time_stamps = [timestamp.item() for timestamp in time_stamps] - time_stamp_dict = dict((time, []) for time in time_stamps) - for v, w, t in g.temporal_edges: - time_stamp_dict[t].append((v, w)) - - graph = Graph( - [str(v["uid"]) for v in nodes_data], - [], - layout=layout, - labels=False, - vertex_config={ - str(v["uid"]): { - "radius": v.get("size", self.node_size), - "fill_color": v.get("color", BLUE), - "fill_opacity": (v.get("opacity", self.node_opacity)), - } - for v in nodes_data - }, - ) - self.add(graph) # create initial nodes - - # add labels - for node_data in nodes_data: - node_id = str(node_data["uid"]) - label_text = node_data.get("label", None) - if label_text is not None: - label = Text(label_text, font_size=self.font_size).set_color(BLACK) - label.next_to(graph[node_id], UP, buff=0.05) - self.node_label[node_id] = label - self.add(label) - - step_size = int((end - start + 1) / intervals) # step size based on the number of intervals - time_window = range(start, end + 1, step_size) - - for time_step in tqdm(time_window): - animation = False - range_stop = time_step + step_size - range_stop = range_stop if range_stop < end + 1 else end + 1 - - if step_size == 1 or time_step == end: - text = Text(f"T = {time_step}").set_color(BLACK) - else: - text = Text(f"T = {time_step} to T = {range_stop - 1}").set_color(BLACK) - text.to_corner(UL) - self.add(text) - - for step in range(time_step, range_stop, 1): - # dynamic layout change - if ( - dynamic_layout_interval is not None - and (step - start) % dynamic_layout_interval == 0 - and step - start != 0 - ): # change the layout based on the edges since the last change until the current timestep - # and only if there were edges in the last interval - new_layout = self.get_layout(g, time_window=(step - look_behind, step + look_forward), old_layout=layout) - if new_layout != None: - animations = [] - for node in g.nodes: - if node in new_layout: - new_pos = new_layout[node] - animations.append(graph[node].animate.move_to(new_pos)) - # also change the positions of the labels - if node in self.node_label: - label = self.node_label[node] - offset = graph[node].height / 2 + label.height / 2 + 0.05 - animations.append(label.animate.move_to(new_pos + offset * UP)) - - self.play(*animations, run_time=delta/2) - animation = True - - # color change - for node in g.nodes: - node_info = next(nd for nd in nodes_data if str(nd["uid"]) == node) - color = self.get_color_at_time(node_info, step) - graph[node].set_fill(color) - - lines = [] - for step in range(time_step, range_stop, 1): # generate Lines for all the timesteps in the current interval - if step in time_stamp_dict: - for edge in time_stamp_dict[step]: - u, v = edge - sender = graph[u].get_center() - receiver = graph[v].get_center() - - s_to_r_vec = receiver - sender # vector from receiver to sender - r_to_s_vec = sender - receiver # vector from sender to reiceiver - # normalize vectors - s_to_r_vec = 1 / np.linalg.norm(s_to_r_vec) * s_to_r_vec - r_to_s_vec = 1 / np.linalg.norm(r_to_s_vec) * r_to_s_vec - - node_u_data = next((node for node in nodes_data if str(node.get("uid")) == u), {}) - node_u_size = node_u_data.get("size", self.node_size) - node_v_data = next((node for node in nodes_data if str(node.get("uid")) == v), {}) - node_v_size = node_v_data.get("size", self.node_size) - - sender = graph[u].get_center() + (s_to_r_vec * node_u_size) - receiver = graph[v].get_center() + (r_to_s_vec * node_v_size) - - edge_info = next( - ( - e - for e in edges_data - if e["source"] == f'{u}' and e["target"] == f'{v}' and e["start"] <= step <= e["end"] - ), - None, - ) - if edge_info: - stroke_width = edge_info.get("size", self.edge_size) - stroke_opacity = edge_info.get("opacity", self.edge_opacity) - color = edge_info.get("color", GRAY) - else: - stroke_width = self.edge_size - stroke_opacity = self.edge_opacity - color = GRAY - - line = Line( - sender, - receiver, - stroke_width=stroke_width, - color=color, - stroke_opacity=stroke_opacity, - ) - lines.append(line) - if len(lines) > 0: - self.add(*lines) - if animation: - self.wait(delta/2) - else: - self.wait(delta) - self.remove(*lines) - else: - if animation: - self.wait(delta/2) - else: - self.wait(delta) - - self.remove(text) - - -class StaticNetworkPlot(NetworkPlot): - """Network plot class for a static network.""" - - _kind = "static" diff --git a/src/pathpyG/visualisations/_manim/temporal_graph_scene.py b/src/pathpyG/visualisations/_manim/temporal_graph_scene.py new file mode 100644 index 000000000..ba181cf39 --- /dev/null +++ b/src/pathpyG/visualisations/_manim/temporal_graph_scene.py @@ -0,0 +1,60 @@ +import logging + +import pandas as pd +from manim import Arrow, Create, Graph, Scene, Uncreate, GrowArrow + +# set manim log level to warning +logging.getLogger("manim").setLevel(logging.WARNING) + + +class TemporalGraphScene(Scene): + def __init__(self, data: dict, config: dict, show_labels: bool): + super().__init__() + self.data = data + self.data["edges"].index = pd.MultiIndex.from_tuples( + self.data["edges"][["source", "target"]].itertuples(index=False) + ) + self.config = config + self.show_labels = show_labels + + def construct(self): + """Constructs the Manim scene for the temporal graph.""" + self.data["nodes"]["size"] *= 0.025 # scale sizes down + vertex_config = ( + self.data["nodes"][["size", "color", "opacity"]] + .rename(columns={"size": "radius", "color": "fill_color", "opacity": "fill_opacity"}) + .to_dict(orient="index") + ) + g = Graph( + vertices=self.data["nodes"].index.tolist(), + edges=[], + vertex_config=vertex_config, + labels=self.show_labels, + ) + self.play(Create(g)) + self.wait() + for t in range(self.data["edges"]["end"].max() + 1): + # Gather all new edges to be added + animations = [] + new_edges = self.data["edges"][self.data["edges"]["start"] == t] + new_edge_config = ( + new_edges[["color", "opacity", "size"]] + .rename(columns={"color": "stroke_color", "opacity": "stroke_opacity", "size": "stroke_width"}) + .to_dict(orient="index") + ) + if not new_edges.empty: + edge_list = [(row[0], row[1]) for row in new_edges[["source", "target"]].itertuples(index=False)] + animations.append(g.animate(animation=GrowArrow).add_edges(*edge_list, edge_config=new_edge_config, edge_type=Arrow)) + + # Gather all old edges to be removed + old_edges = self.data["edges"][self.data["edges"]["end"] == t] + if not old_edges.empty: + edge_list = [(row[0], row[1]) for row in old_edges[["source", "target"]].itertuples(index=False)] + removed_edges = g.remove_edges(*edge_list) + animations.extend([removed_edge.animate.scale(0, scale_tips=True, about_point=removed_edge.get_end()) for removed_edge in removed_edges]) + + # play all animations + if animations: + self.play(*animations) + self.wait() + self.play(Uncreate(g)) diff --git a/src/pathpyG/visualisations/_matplotlib/backend.py b/src/pathpyG/visualisations/_matplotlib/backend.py index 3c16b85bd..c2bad6475 100644 --- a/src/pathpyG/visualisations/_matplotlib/backend.py +++ b/src/pathpyG/visualisations/_matplotlib/backend.py @@ -8,6 +8,7 @@ from matplotlib.path import Path from pathpyG.visualisations.network_plot import NetworkPlot +from pathpyG.visualisations.pathpy_plot import PathPyPlot from pathpyG.visualisations.plot_backend import PlotBackend from pathpyG.visualisations.utils import unit_str_to_float @@ -21,7 +22,7 @@ class MatplotlibBackend(PlotBackend): """Matplotlib plotting backend.""" - def __init__(self, plot, show_labels: bool): + def __init__(self, plot: PathPyPlot, show_labels: bool): super().__init__(plot, show_labels=show_labels) self._kind = SUPPORTED_KINDS.get(type(plot), None) if self._kind is None: diff --git a/src/pathpyG/visualisations/_tikz/backend.py b/src/pathpyG/visualisations/_tikz/backend.py index e52f4a838..d3c891b78 100644 --- a/src/pathpyG/visualisations/_tikz/backend.py +++ b/src/pathpyG/visualisations/_tikz/backend.py @@ -4,18 +4,15 @@ import os import shutil import subprocess -import tempfile import time import webbrowser from string import Template -import pandas as pd - from pathpyG import config from pathpyG.visualisations.network_plot import NetworkPlot from pathpyG.visualisations.pathpy_plot import PathPyPlot from pathpyG.visualisations.plot_backend import PlotBackend -from pathpyG.visualisations.utils import unit_str_to_float, hex_to_rgb +from pathpyG.visualisations.utils import hex_to_rgb, prepare_tempfile, unit_str_to_float # create logger logger = logging.getLogger("root") @@ -84,13 +81,15 @@ def show(self) -> None: def compile_svg(self) -> tuple: """Compile svg from tex.""" - temp_dir, current_dir, basename = self.prepare_compile() + temp_dir, current_dir = prepare_tempfile() + # save the tex file + self.save("default.tex") # latex compiler command = [ "latexmk", "--interaction=nonstopmode", - basename + ".tex", + "default.tex", ] try: subprocess.check_output(command, stderr=subprocess.STDOUT) @@ -101,9 +100,9 @@ def compile_svg(self) -> tuple: # dvisvgm command command = [ "dvisvgm", - basename + ".dvi", + "default.dvi", "-o", - basename + ".svg", + "default.svg", ] try: subprocess.check_output(command, stderr=subprocess.STDOUT) @@ -115,11 +114,13 @@ def compile_svg(self) -> tuple: os.chdir(current_dir) # return the name of the folder and temp svg file - return os.path.join(temp_dir, basename + ".svg"), temp_dir + return os.path.join(temp_dir, "default.svg"), temp_dir def compile_pdf(self) -> tuple: """Compile pdf from tex.""" - temp_dir, current_dir, basename = self.prepare_compile() + temp_dir, current_dir = prepare_tempfile() + # save the tex file + self.save("default.tex") # latex compiler command = [ @@ -127,7 +128,7 @@ def compile_pdf(self) -> tuple: "--pdf", "-shell-escape", "--interaction=nonstopmode", - basename + ".tex", + "default.tex", ] try: @@ -140,24 +141,7 @@ def compile_pdf(self) -> tuple: os.chdir(current_dir) # return the name of the folder and temp pdf file - return os.path.join(temp_dir, basename + ".pdf"), temp_dir - - def prepare_compile(self) -> tuple[str, str, str]: - """Prepare compilation of tex to pdf or svg by saving the tex file.""" - # basename - basename = "default" - # get current directory - current_dir = os.getcwd() - - # get temporal directory - temp_dir = tempfile.mkdtemp() - - # change to output dir - os.chdir(temp_dir) - - # save the tex file - self.save(basename + ".tex") - return temp_dir, current_dir, basename + return os.path.join(temp_dir, "default.pdf"), temp_dir def to_tex(self) -> str: """Convert data to tex.""" diff --git a/src/pathpyG/visualisations/utils.py b/src/pathpyG/visualisations/utils.py index 7ca427399..fe3ef3dcd 100644 --- a/src/pathpyG/visualisations/utils.py +++ b/src/pathpyG/visualisations/utils.py @@ -8,11 +8,28 @@ # Copyright (c) 2016-2023 Pathpy Developers # ============================================================================= +import os +import tempfile from typing import Callable + +def prepare_tempfile() -> tuple[str, str]: + """Prepare temporary directory and filename for compilation.""" + # get current directory + current_dir = os.getcwd() + + # get temporal directory + temp_dir = tempfile.mkdtemp() + + # change to output dir + os.chdir(temp_dir) + + return temp_dir, current_dir + + def rgb_to_hex(rgb: tuple) -> str: """Convert rgb color tuple to hex string. - + Args: rgb (tuple): RGB color tuple either in range 0-1 or 0-255. """ @@ -34,18 +51,22 @@ def cm_to_inch(value: float) -> float: """Convert cm to inch.""" return value / 2.54 + def inch_to_cm(value: float) -> float: """Convert inch to cm.""" return value * 2.54 + def inch_to_px(value: float, dpi: int = 96) -> float: """Convert inch to px.""" return value * dpi + def px_to_inch(value: float, dpi: int = 96) -> float: """Convert px to inch.""" return value / dpi + def unit_str_to_float(value: str, unit: str) -> float: """Convert string with unit to float in `unit`.""" conversion_functions: dict[str, Callable[[float], float]] = { @@ -64,6 +85,7 @@ def unit_str_to_float(value: str, unit: str) -> float: else: raise ValueError(f"The provided conversion '{conversion_key}' is not supported.") + # ============================================================================= # eof # From 3d3fae19d376cf33397251100a3d289d090dd0ae Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Mon, 13 Oct 2025 10:49:03 +0000 Subject: [PATCH 12/44] fix indexing --- src/pathpyG/visualisations/_d3js/backend.py | 2 ++ .../visualisations/_matplotlib/backend.py | 14 ++++----- src/pathpyG/visualisations/_tikz/backend.py | 2 +- src/pathpyG/visualisations/network_plot.py | 11 +++---- .../visualisations/temporal_network_plot.py | 31 +++++++++---------- 5 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/pathpyG/visualisations/_d3js/backend.py b/src/pathpyG/visualisations/_d3js/backend.py index 8c4641a76..ddf0289e8 100644 --- a/src/pathpyG/visualisations/_d3js/backend.py +++ b/src/pathpyG/visualisations/_d3js/backend.py @@ -61,6 +61,8 @@ def _prepare_data(self) -> dict: node_data = node_data.rename(columns={"x": "xpos", "y": "ypos"}) edge_data = self.data["edges"].copy() edge_data["uid"] = self.data["edges"].index.map(lambda x: f"{x[0]}-{x[1]}") + edge_data["source"] = edge_data.index.get_level_values("source") + edge_data["target"] = edge_data.index.get_level_values("target") data_dict = { "nodes": node_data.to_dict(orient="records"), "edges": edge_data.to_dict(orient="records"), diff --git a/src/pathpyG/visualisations/_matplotlib/backend.py b/src/pathpyG/visualisations/_matplotlib/backend.py index c2bad6475..4057e43ea 100644 --- a/src/pathpyG/visualisations/_matplotlib/backend.py +++ b/src/pathpyG/visualisations/_matplotlib/backend.py @@ -49,8 +49,8 @@ def to_fig(self) -> tuple[plt.Figure, plt.Axes]: ax.set_axis_off() # get source and target coordinates for edges - source_coords = self.data["nodes"].loc[self.data["edges"]["source"], ["x", "y"]].values - target_coords = self.data["nodes"].loc[self.data["edges"]["target"], ["x", "y"]].values + source_coords = self.data["nodes"].loc[self.data["edges"].index.get_level_values("source"), ["x", "y"]].values + target_coords = self.data["nodes"].loc[self.data["edges"].index.get_level_values("target"), ["x", "y"]].values if self.config["directed"]: self.add_directed_edges(source_coords, target_coords, ax, size_factor) @@ -100,9 +100,9 @@ def add_undirected_edges(self, source_coords, target_coords, ax, size_factor): vec = target_coords - source_coords dist = np.linalg.norm(vec, axis=1, keepdims=True) direction = vec / dist - source_coords += direction * (self.data["nodes"].loc[self.data["edges"]["source"], ["size"]].values * (size_factor / 2)) # /2 because we use radius instead of diameter - target_coords -= direction * (self.data["nodes"].loc[self.data["edges"]["target"], ["size"]].values * (size_factor / 2)) - + source_coords += direction * (self.data["nodes"].loc[self.data["edges"].index.get_level_values("source"), ["size"]].values * (size_factor / 2)) # /2 because we use radius instead of diameter + target_coords -= direction * (self.data["nodes"].loc[self.data["edges"].index.get_level_values("target"), ["size"]].values * (size_factor / 2)) + # create and add lines edge_lines = list(zip(source_coords, target_coords)) ax.add_collection( @@ -122,9 +122,9 @@ def add_directed_edges(self, source_coords, target_coords, ax, size_factor): vertices, codes = self.get_bezier_curve( source_coords, target_coords, - source_node_size=self.data["nodes"].loc[self.data["edges"]["source"], ["size"]].values + source_node_size=self.data["nodes"].loc[self.data["edges"].index.get_level_values("source"), ["size"]].values * (size_factor / 2), # /2 because we use radius instead of diameter - target_node_size=self.data["nodes"].loc[self.data["edges"]["target"], ["size"]].values + target_node_size=self.data["nodes"].loc[self.data["edges"].index.get_level_values("target"), ["size"]].values * (size_factor / 2), head_length=head_length, ) diff --git a/src/pathpyG/visualisations/_tikz/backend.py b/src/pathpyG/visualisations/_tikz/backend.py index d3c891b78..165c1d498 100644 --- a/src/pathpyG/visualisations/_tikz/backend.py +++ b/src/pathpyG/visualisations/_tikz/backend.py @@ -214,7 +214,7 @@ def to_tikz(self) -> str: edge_strings += "lw=" + self.data["edges"]["size"].astype(str) + "," edge_strings += "opacity=" + self.data["edges"]["opacity"].astype(str) + "]" edge_strings += ( - "(" + self.data["edges"]["source"].astype(str) + ")(" + self.data["edges"]["target"].astype(str) + ")\n" + "(" + self.data["edges"].index.get_level_values("source").astype(str) + ")(" + self.data["edges"].index.get_level_values("target").astype(str) + ")\n" ) tikz += edge_strings.str.cat() diff --git a/src/pathpyG/visualisations/network_plot.py b/src/pathpyG/visualisations/network_plot.py index 55c2040cc..16f5f4585 100644 --- a/src/pathpyG/visualisations/network_plot.py +++ b/src/pathpyG/visualisations/network_plot.py @@ -110,7 +110,8 @@ def _compute_edge_data(self) -> None: # check if attribute is given as argument if attribute in self.edge_args: if isinstance(self.edge_args[attribute], dict): - edges[attribute] = edges.index.map(lambda x: f"{x[0]}-{x[1]}").map(self.edge_args[attribute]) + new_attrs = edges.index.map(lambda x: f"{x[0]}-{x[1]}").map(self.edge_args[attribute]) + edges.loc[~new_attrs.isna(), attribute] = new_attrs[~new_attrs.isna()] else: edges[attribute] = self.edge_args[attribute] # check if attribute is given as edge attribute @@ -122,21 +123,19 @@ def _compute_edge_data(self) -> None: edges[attribute] = self.network.data["edge_weight"] elif "weight" in self.edge_args: if isinstance(self.edge_args["weight"], dict): - edges[attribute] = edges.index.map(lambda x: f"{x[0]}-{x[1]}").map(self.edge_args["weight"]) + new_attrs = edges.index.map(lambda x: f"{x[0]}-{x[1]}").map(self.edge_args["weight"]) + edges.loc[~new_attrs.isna(), attribute] = new_attrs[~new_attrs.isna()] else: edges[attribute] = self.edge_args["weight"] # convert attributes to useful values edges["color"] = self._convert_to_rgb_tuple(edges["color"]) edges["color"] = edges["color"].map(self._convert_color) - # add source and target columns - edges["source"] = edges.index.map(lambda x: x[0]) - edges["target"] = edges.index.map(lambda x: x[1]) # remove duplicate edges for better efficiency if not self.network.is_directed(): # for undirected networks, sort source and target and drop duplicates - edges = edges.reset_index(drop=True) + edges = edges.reset_index() edges["sorted"] = edges.apply(lambda row: tuple(sorted((row["source"], row["target"]))), axis=1) edges = edges.drop_duplicates(subset=["sorted"]).drop(columns=["sorted"]) edges = edges.set_index(["source", "target"]) diff --git a/src/pathpyG/visualisations/temporal_network_plot.py b/src/pathpyG/visualisations/temporal_network_plot.py index fb321d219..d089b40cd 100644 --- a/src/pathpyG/visualisations/temporal_network_plot.py +++ b/src/pathpyG/visualisations/temporal_network_plot.py @@ -1,6 +1,5 @@ from __future__ import annotations -import time from typing import TYPE_CHECKING, Any import pandas as pd @@ -32,7 +31,7 @@ def generate(self) -> None: def _compute_node_data(self) -> None: """Generate the data structure for the nodes.""" # initialize values with index `node-0` to indicate time step 0 - start_nodes: pd.DataFrame = pd.DataFrame(index=[f"{node}-0" for node in self.network.nodes]) + start_nodes: pd.DataFrame = pd.DataFrame(index=pd.MultiIndex.from_tuples([(node, 0) for node in self.network.nodes], names=["uid", "time"])) new_nodes: pd.DataFrame = pd.DataFrame() # add attributes to start nodes and new nodes if given as dictionary for attribute in self.attributes: @@ -53,7 +52,7 @@ def _compute_node_data(self) -> None: ) else: # add node attributes to start nodes according to node keys - start_nodes[attribute] = start_nodes.index.map(lambda x: x[0]).map(self.node_args[attribute]) + start_nodes[attribute] = start_nodes.index.get_level_values("uid").map(self.node_args[attribute]) else: start_nodes[attribute] = self.node_args[attribute] # check if attribute is given as node attribute @@ -61,12 +60,13 @@ def _compute_node_data(self) -> None: start_nodes[attribute] = self.network.data[f"node_{attribute}"] # combine start nodes and new nodes + new_nodes = new_nodes.set_index(new_nodes.index.map(lambda x: (x.split("-")[0], int(x.split("-")[1])))) + new_nodes.index.set_names(["uid", "time"], inplace=True) nodes = pd.concat([start_nodes, new_nodes]) - nodes["start"] = nodes.index.map(lambda x: int(x.split("-")[1])) - nodes["uid"] = nodes.index.map(lambda x: x.split("-")[0]) # fill missing values with last known value - nodes = nodes.sort_values(by=["uid", "start"]).groupby("uid", sort=False).ffill() - nodes["uid"] = nodes.index.map(lambda x: x.split("-")[0]) + nodes = nodes.sort_values(by=["uid", "time"]).groupby("uid", sort=False).ffill() + nodes["start"] = nodes.index.get_level_values("time") + nodes = nodes.droplevel("time") # add end time step with the start the node appears the next time or max time step + 1 nodes["end"] = nodes.groupby("uid")["start"].shift(-1) max_node_time = nodes["start"].max() + 1 @@ -78,14 +78,13 @@ def _compute_node_data(self) -> None: nodes["color"] = self._convert_to_rgb_tuple(nodes["color"]) nodes["color"] = nodes["color"].map(self._convert_color) - nodes = nodes.set_index(nodes["uid"]) # save node data self.data["nodes"] = nodes def _compute_edge_data(self) -> None: """Generate the data structure for the edges.""" # initialize values - edges: pd.DataFrame = pd.DataFrame(index=[f"{source}-{target}-{time}" for source, target, time in self.network.temporal_edges]) + edges: pd.DataFrame = pd.DataFrame(index=pd.MultiIndex.from_tuples(self.network.temporal_edges, names=["source", "target", "time"])) for attribute in self.attributes: # set default value for each attribute based on the pathpyG.toml config if isinstance(self.config.get("edge").get(attribute, None), list | tuple): # type: ignore[union-attr] @@ -95,8 +94,9 @@ def _compute_edge_data(self) -> None: # check if attribute is given as argument if attribute in self.edge_args: if isinstance(self.edge_args[attribute], dict): - new_colors = edges.index.map(self.edge_args[attribute]) - edges.loc[~new_colors.isna(), attribute] = new_colors[~new_colors.isna()] + # if dict does not contain values for all edges, only update those that are given + new_attrs = edges.index.map(lambda x: f"{x[0]}-{x[1]}-{x[2]}").map(self.edge_args[attribute]) + edges.loc[~new_attrs.isna(), attribute] = new_attrs[~new_attrs.isna()] else: edges[attribute] = self.edge_args[attribute] # check if attribute is given as edge attribute @@ -108,9 +108,10 @@ def _compute_edge_data(self) -> None: edges[attribute] = self.network.data["edge_weight"] elif "weight" in self.edge_args: if isinstance(self.edge_args["weight"], dict): - edges[attribute] = edges.index.map(lambda x: f"{x[0]}-{x[1]}-{x[2]}").map( + new_attrs = edges.index.map(lambda x: f"{x[0]}-{x[1]}-{x[2]}").map( self.edge_args["weight"] ) + edges.loc[~new_attrs.isna(), attribute] = new_attrs[~new_attrs.isna()] else: edges[attribute] = self.edge_args["weight"] @@ -118,11 +119,9 @@ def _compute_edge_data(self) -> None: # convert needed attributes to useful values edges["color"] = self._convert_to_rgb_tuple(edges["color"]) edges["color"] = edges["color"].map(self._convert_color) - edges["source"] = edges.index.map(lambda x: x.split("-")[0]) - edges["target"] = edges.index.map(lambda x: x.split("-")[1]) - edges["start"] = edges.index.map(lambda x: int(x.split("-")[2])) + edges["start"] = edges.index.get_level_values("time").astype(int) edges["end"] = edges["start"] + 1 # assume all edges last for one time step - edges.index = edges.index.map(lambda x: f"{x.split('-')[0]}-{x.split('-')[1]}") + edges.index = edges.index.droplevel("time") # save edge data self.data["edges"] = edges From 8ada504f1ab00a19e44a3af23baa3f123e9b0e7d Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Mon, 13 Oct 2025 12:24:35 +0000 Subject: [PATCH 13/44] update layout --- src/pathpyG/visualisations/layout.py | 621 ++++----------------------- 1 file changed, 86 insertions(+), 535 deletions(-) diff --git a/src/pathpyG/visualisations/layout.py b/src/pathpyG/visualisations/layout.py index 28f3e71db..9cd2f3246 100644 --- a/src/pathpyG/visualisations/layout.py +++ b/src/pathpyG/visualisations/layout.py @@ -21,11 +21,17 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # ============================================================================= +from typing import Iterable, Optional + import numpy as np -from pathpyG import tqdm +import torch +from torch import Tensor +from torch_geometric.utils import to_scipy_sparse_matrix + +from pathpyG.core.graph import Graph -def layout(network, **kwds): +def layout(network: Graph, layout: str = "random", weight: None | str | Iterable = None, **kwargs): """Function to generate a layout for the network. This function generates a layout configuration for the nodes in the @@ -33,195 +39,51 @@ def layout(network, **kwds): function is directly included in the plot function or can be separately called. - The layout function supports different network types and layout algorithm. - Currently supported networks are: - - - `cnet`, - - `networkx`, - - `igraph`, - - `pathpyG` - - node/edge list - Currently supported algorithms are: - - Fruchterman-Reingold force-directed algorithm - - Uniformly at random node positions + - All layouts that are implemented in `networkx` + - Random layout + - Circular layout + - Shell layout + - Spectral layout + - Kamada-Kawai layout + - Fruchterman-Reingold force-directed algorithm + - Grid layout The appearance of the layout can be modified by keyword arguments which will be explained in more detail below. Args: - network (network object): Network to be drawn. The network can be a `cnet`, `networkx`, `igraph`, `pathpy` object, or a tuple of a node list and edge list. - **kwds (Optional dict): Attributes used to modify the appearance of the layout. For details see below. - - # Layout: - - The layout can be modified by the following keyword arguments: - Note: - All layout arguments can be entered with or without `layout_` at the beginning, e.g. `layout_iterations` is equal to `iterations` - - Keyword Args: - layout (Optional dict or string): A dictionary with the node positions on a 2-dimensional plane. The - key value of the dict represents the node id while the value - represents a tuple of coordinates (e.g. $n = (x,y)$). The initial - layout can be placed anywhere on the 2-dimensional plane. - - Instead of a dictionary, the algorithm used for the layout can be defined - via a string value. Currently, supported are: - - - **Random layout**, where the nodes are uniformly at random placed in the - unit square. - - **Fruchterman-Reingold force-directed algorithm**. In this algorithm, the - nodes are represented by steel rings and the edges are springs between - them. The attractive force is analogous to the spring force and the - repulsive force is analogous to the electrical force. The basic idea is - to minimize the energy of the system by moving the nodes and changing - the forces between them. - - The algorithm can be enabled with the keywords: - | Algorithms | Keywords | - | ---------- | -------- | - | Random | `Random`, `random`, `rand`, `None` | - |Fruchterman-Reingold | `Fruchterman-Reingold`, `fruchterman_reingold`, `fr spring_layout`, `spring layout`, `FR` | - - force (float): Optimal distance between nodes. If None the distance is set to - 1/sqrt(n) where n is the number of nodes. Increase this value to move - nodes farther apart. - positions (dict): Initial positions for nodes as a dictionary with node as keys and values - as a coordinate list or tuple. If None, then use random initial - positions. - fixed (list): Nodes to keep fixed at initial position. - iterations (int): Maximum number of iterations taken. Defaults to 50. - threshold (float): Threshold for relative error in node position changes. The iteration - stops if the error is below this threshold. Defaults to 1e-4. - weight (string): or None, optional (default = None) - The edge attribute that holds the numerical value used for the edge - weight. If None, then all edge weights are 1. - dimension (int): Dimension of layout. Currently, only plots in 2 dimension are supported. Defaults to 2. - seed (int): Set the random state for deterministic node layouts. If int, `seed` is - the seed used by the random number generator, if None, the a random seed - by created by the numpy random number generator is used. - - In the layout style dictionary multiple keywords can be used to address - attributes. These keywords will be converted to an unique key word, - used in the remaining code. - - | keys | other valid keys | - | ---- | ---------------- | - | fixed | `fixed_nodes`, `fixed_vertices`, `fixed_n`, `fixed_v` | - | positions| `initial_positions`, `node_positions`, `vertex_positions`, `n_positions`, `v_positions` | - - Examples: - For illustration purpose a similar network as in the `python-igraph` tutorial - is used. Instead of `igraph`, the `cnet` module is used for creating the - network. - - Create an empty network object, and add some edges. - - >>> net = Network(name = 'my tikz test network',directed=True) - >>> net.add_edges_from([('ab','a','b'), ('ac','a','c'), ('cd','c','d'), - ... ('de','d','e'), ('ec','e','c'), ('cf','c','f'), - ... ('fa','f','a'), ('fg','f','g'),('gg','g','g'), - ... ('gd','g','d')]) - - Now a layout can be generated: - - >>> layout(net) - {'b': array([0.88878309, 0.15685131]), 'd': array([0.4659341 , 0.79839535]), - 'c': array([0.60386662, 0.40727962]), 'e': array([0.71073353, 0.65608203]), - 'g': array([0.42663927, 0.47412449]), 'f': array([0.48759769, 0.86787594]), - 'a': array([0.84154488, 0.1633732 ])} - - Per default, the node positions are assigned uniform random. In order to - create a layout, the layout methods of the packages can be used, or the - position of the nodes can be directly assigned, in form of a dictionary, - where the key is the `node_id` and the value is a tuple of the node position - in $x$ and $y$. - - Let us generate a force directed layout (e.g. Fruchterman-Reingold): - - >>> layout(net, layout='fr') - {'g': array([-0.77646408, 1.71291126]), 'c': array([-0.18639655,0.96232326]), - 'f': array([0.33394308, 0.93778681]), 'e': array([0.09740098, 1.28511973]), - 'a': array([1.37933158, 0.23171857]), 'b': array([ 2.93561876,-0.46183461]), - 'd': array([-0.29329793, 1.48971303])} - - Note, instead of the command `fr` also the command - `Fruchterman-Reingold` or any other command mentioned above can be - used. For more information see table above. - - In order to keep the properties of the layout for your network separate from - the network itself, you can simply set up a Python dictionary containing the - keyword arguments you would pass to [`layout`][pathpyG.visualisations.layout.layout] and then use the - double asterisk (**) operator to pass your specific layout attributes to - [`layout`][pathpyG.visualisations.layout.layout]: - - >>> layout_style = {} - >>> layout_style['layout'] = 'Fruchterman-Reingold' - >>> layout_style['seed'] = 1 - >>> layout_style['iterations'] = 100 - >>> layout(net,**layout_style) - {'d': array([-0.31778276, 1.78246882]), 'f': array([-0.8603259, 0.82328291]), - 'c': array([-0.4423771 , 1.21203895]), 'e': array([-0.79934355, 1.49000119]), - 'g': array([0.43694799, 1.51428788]), 'a': array([-2.15517293, 0.23948823]), - 'b': array([-3.84803812, -0.71628417])} + network (network object): Network to be drawn. + weight (str or Iterable): Edge attribute that should be used as weight. + If a string is provided, the attribute must be present in the edge + attributes of the network. If an iterable is provided, it must have + the same length as the number of edges in the network. + layout (str): Layout algorithm that should be used. + **kwargs (Optional dict): Attributes that will be passed to the layout function. """ # initialize variables - _weight = kwds.get("weight", None) - if _weight is None: - _weight = kwds.get("layout_weight", None) - - # check type of network - if "cnet" in str(type(network)): - # log.debug('The network is of type "cnet".') - nodes = list(network.nodes) - adjacency_matrix = network.adjacency_matrix(weight=_weight) - - elif "networkx" in str(type(network)): - # log.debug('The network is of type "networkx".') - nodes = list(network.nodes()) - import networkx as nx - - adjacency_matrix = nx.adjacency_matrix(network, weight=_weight) # type: ignore - elif "igraph" in str(type(network)): - # log.debug('The network is of type "igraph".') - nodes = list(range(len(network.vs))) - from scipy.sparse import coo_matrix - - A = np.array(network.get_adjacency(attribute=_weight).data) - adjacency_matrix = coo_matrix(A) - elif "pathpyG" in str(type(network)): - # log.debug('The network is of type "pathpy".') - nodes = list(network.nodes) - if _weight is not None: - _w = True + if isinstance(weight, str): + if weight in network.edge_attrs(): + weight = network.data[weight] else: - _w = False - adjacency_matrix = network.sparse_adj_matrix() - # elif isinstance(network, tuple): - # # log.debug('The network is of type "list".') - # nodes = network[0] - # from collections import OrderedDict - # edges = OrderedDict() - # for e in network[1]: - # edges[e] = e - - else: - print( - "Type of the network could not be determined." - ' Currently only "cnet", "networkx","igraph", "pathpy"' - ' and "node/edge list" is supported!' - ) - raise NotImplementedError + raise ValueError(f"Weight attribute '{weight}' not found in edge attributes.") + elif isinstance(weight, Iterable) and not isinstance(weight, torch.Tensor): + if len(weight) == network.m: + weight = torch.tensor(weight) + else: + raise ValueError("Length of weight iterable does not match number of edges in the network.") # create layout class - layout = Layout(nodes, adjacency_matrix, **kwds) + layout = Layout( + nodes=network.nodes, edge_index=network.data.edge_index, layout_type=layout, weight=weight, **kwargs + ) # return the layout return layout.generate_layout() class Layout(object): - """Default class to create layouts + """Default class to create layouts. The [`Layout`][pathpyG.visualisations.layout.Layout] class is used to generate node a layout drawer and return the calculated node positions as a dictionary, where the keywords @@ -231,400 +93,89 @@ class Layout(object): Args: nodes (list): list with node ids. The list contain a list of unique node ids. - **attr (dict): Attributes to add to node as key=value pairs. - See also [`layout`][pathpyG.visualisations.layout.layout] - - Note: See also - [`layout`][pathpyG.visualisations.layout.layout] + edge_index (Tensor): Edge index of the network. + The edge index is a tensor of shape [2, num_edges] and contains the + source and target nodes of each edge. + + weight (Tensor): Edge weights of the network. + The edge weights is a tensor of shape [num_edges] and contains the + weight of each edge. + **kwargs (dict): Keyword arguments to modify the layout. Will be passed + to the layout function. """ - def __init__(self, nodes, adjacency_matrix, **attr): - """Initialize the Layout class - - The [`Layout`][pathpyG.visualisations.layout.Layout] class is used to generate node a layout drawer and - return the calculated node positions as a dictionary, where the keywords - represents the node ids and the values represents a two dimensional tuple - with the x and y coordinates for the associated nodes. - - Args: - nodes (list): list with node ids. - The list contain a list of unique node ids. - **attr (dict): Attributes to add to node as key=value pairs. - See also [`layout`][pathpyG.visualisations.layout.layout] - """ - + def __init__(self, nodes: list, edge_index: Tensor, layout_type: str = "random", weight: Optional[Tensor] = None, **kwargs): + """Initialize the Layout class.""" # initialize variables self.nodes = nodes - self.adjacency_matrix = adjacency_matrix - - # rename the attributes - attr = self.rename_attributes(**attr) - - # options for the layouts - self.layout_type = attr.get("layout", None) - self.k = attr.get( - "force", - None, - ) - self.fixed = attr.get("fixed", None) - self.iterations = attr.get("iterations", 50) - self.threshold = attr.get("threshold", 1e-4) - self.weight = attr.get("weight", None) - self.dimension = attr.get("dimension", 2) - self.seed = attr.get("seed", None) - self.positions = attr.get("positions", None) - self.radius = attr.get("radius", 1.0) - self.direction = attr.get("direction", 1.0) - self.start_angle = attr.get("start_angle", 0.0) - - # TODO: allow also higher dimensional layouts - if self.dimension > 2: - print("Currently only plots with maximum dimension 2 are supported!") - self.dimension = 2 - - @staticmethod - def rename_attributes(**kwds): - """Rename layout attributes. - - In the style dictionary multiple keywords can be used to address - attributes. These keywords will be converted to an unique key word, - used in the remaining code. - - | keys | other valid keys | - | ---- | ---------------- | - | fixed | `fixed_nodes`, `fixed_vertices`, `fixed_n`, `fixed_v` | - | positions | `initial_positions`, `node_positions` `vertex_positions`, `n_positions`, `v_positions` | - """ - names = { - "fixed": ["fixed_nodes", "fixed_vertices", "fixed_v", "fixed_n"], - "positions": ["initial_positions", "node_positions", "vertex_positions", "n_positions", "v_positions"], - "layout_": ["layout_"], - } - - _kwds = {} - del_keys = [] - for key, value in kwds.items(): - for attr, name_list in names.items(): - for name in name_list: - if name in key and name[0] == key[0]: - _kwds[key.replace(name, attr).replace("layout_", "")] = value - del_keys.append(key) - break - # remove the replaced keys from the dict - for key in del_keys: - del kwds[key] - - return {**_kwds, **kwds} + self.edge_index = edge_index + self.weight = weight + self.layout_type = layout_type + self.kwargs = kwargs def generate_layout(self): """Function to pick and generate the right layout.""" # method names - names_rand = ["Random", "random", "rand", None] - names_fr = ["Fruchterman-Reingold", "fruchterman_reingold", "fr", "spring_layout", "spring layout", "FR"] - names_circular = ["circular", "circle", "ring", "1d-lattice", "lattice-1d"] names_grid = ["grid", "2d-lattice", "lattice-2d"] # check which layout should be plotted - if self.layout_type in names_rand: - self.layout = self.random() - elif self.layout_type in names_circular or (self.layout_type == "lattice" and self.dimension == 1): - self.layout = self.circular() - elif self.layout_type in names_grid or (self.layout_type == "lattice" and self.dimension == 2): + if self.layout_type in names_grid: self.layout = self.grid() - elif self.layout_type in names_fr: - self.layout = self.fruchterman_reingold() - - # print(self.layout) - return self.layout - - def random(self): - """Position nodes uniformly at random in the unit square. - - For every node, a position is generated by choosing each of dimension - coordinates uniformly at random on the interval $[0.0, 1.0)$. - - This algorithm can be enabled with the keywords: `Random`, - `random`, `rand`, or `None` - - Keyword Args: - dimension (int): Dimension of layout. Currently, only plots in 2 dimension are supported. Defaults to 2. - seed (int): Set the random state for deterministic node layouts. If int, `seed` is - the seed used by the random number generator, if None, the a random - seed by created by the numpy random number generator is used. - - Returns: - layout (dict): A dictionary of positions keyed by node - """ - np.random.seed(self.seed) - layout = np.random.rand(len(self.nodes), self.dimension) - return dict(zip(self.nodes, layout)) - - def fruchterman_reingold(self): - """Position nodes using Fruchterman-Reingold force-directed algorithm. - - In this algorithm, the nodes are represented by steel rings and the - edges are springs between them. The attractive force is analogous to the - spring force and the repulsive force is analogous to the electrical - force. The basic idea is to minimize the energy of the system by moving - the nodes and changing the forces between them. - - This algorithm can be enabled with the keywords: `Fruchterman-Reingold`, - `fruchterman_reingold`, `fr`, `spring_layout`, `spring layout`, `FR` - - Keyword Args: - force (float): Optimal distance between nodes. If None the distance is set to - 1/sqrt(n) where n is the number of nodes. Increase this value to move - nodes farther apart. - positions (dict): Initial positions for nodes as a dictionary with node as keys and values - as a coordinate list or tuple. If None, then use random initial - positions. - fixed (list): Nodes to keep fixed at initial position. - iterations (int): Maximum number of iterations taken. Defaults to 50. - threshold (float): Threshold for relative error in node position changes. The iteration - stops if the error is below this threshold. Defaults to 1e-4. - weight (string): The edge attribute that holds the numerical value used for the edge - weight. If None, then all edge weights are 1. - dimension (int): Dimension of layout. Currently, only plots in 2 dimension are supported. Defaults to 2. - seed (int): Set the random state for deterministic node layouts. If int, `seed` is - the seed used by the random number generator, if None, the a random seed - by created by the numpy random number generator is used. - - Returns: - layout (dict): A dictionary of positions keyed by node - """ - - # convert adjacency matrix - self.adjacency_matrix = self.adjacency_matrix.astype(float) - - if self.fixed is not None: - self.fixed = np.asarray([self.nodes.index(v) for v in self.fixed]) - - if self.positions is not None: - # Determine size of existing domain to adjust initial positions - _size = max(coord for t in self.positions.values() for coord in t) # type: ignore - if _size == 0: - _size = 1 - np.random.seed(self.seed) - self.layout = np.random.rand(len(self.nodes), self.dimension) * _size # type: ignore - - for i, n in enumerate(self.nodes): - if n in self.positions: - self.layout[i] = np.asarray(self.positions[n]) - else: - self.layout = None - _size = 0 - - if self.k is None and self.fixed is not None: - # We must adjust k by domain size for layouts not near 1x1 - self.k = _size / np.sqrt(len(self.nodes)) - - try: - # Sparse matrix - if len(self.nodes) < 500: # sparse solver for large graphs - raise ValueError - layout = self._sparse_fruchterman_reingold() - except: - layout = self._fruchterman_reingold() - - layout = dict(zip(self.nodes, layout)) - - return layout - - def _fruchterman_reingold(self): - """Fruchterman-Reingold algorithm for dense matrices. - - This algorithm is based on the Fruchterman-Reingold algorithm provided - by `networkx`. (Copyright (C) 2004-2018 by Aric Hagberg - Dan Schult Pieter Swart Richard - Penney All rights reserved. BSD - license.) - - """ - A = self.adjacency_matrix.todense() - k = self.k - try: - _n, _ = A.shape - except AttributeError: - print("Fruchterman-Reingold algorithm needs an adjacency matrix as input") - raise AttributeError - - # make sure we have an array instead of a matrix - A = np.asarray(A) - - if self.layout is None: - # random initial positions - np.random.seed(self.seed) - layout = np.asarray(np.random.rand(_n, self.dimension), dtype=A.dtype) else: - # make sure positions are of same type as matrix - layout = self.layout.astype(A.dtype) # type: ignore + self.layout = self.generate_nx_layout() - # optimal distance between nodes - if k is None: - k = np.sqrt(1.0 / _n) - # the initial "temperature" is about .1 of domain area (=1x1) - # this is the largest step allowed in the dynamics. - # We need to calculate this in case our fixed positions force our domain - # to be much bigger than 1x1 - t = max(max(layout.T[0]) - min(layout.T[0]), max(layout.T[1]) - min(layout.T[1])) * 0.1 - # simple cooling scheme. - # linearly step down by dt on each iteration so last iteration is size dt. - dt = t / float(self.iterations + 1) - delta = np.zeros((layout.shape[0], layout.shape[0], layout.shape[1]), dtype=A.dtype) - # the inscrutable (but fast) version - # this is still O(V^2) - # could use multilevel methods to speed this up significantly - for iteration in tqdm(range(self.iterations), desc="Calculating Fruchterman-Reingold layout"): - # matrix of difference between points - delta = layout[:, np.newaxis, :] - layout[np.newaxis, :, :] # type: ignore - # distance between points - distance = np.linalg.norm(delta, axis=-1) - # enforce minimum distance of 0.01 - np.clip(distance, 0.01, None, out=distance) - # displacement "force" - displacement = np.einsum("ijk,ij->ik", delta, (k * k / distance**2 - A * distance / k)) - # update layoutitions - length = np.linalg.norm(displacement, axis=-1) - length = np.where(length < 0.01, 0.1, length) - delta_layout = np.einsum("ij,i->ij", displacement, t / length) - if self.fixed is not None: - # don't change positions of fixed nodes - delta_layout[self.fixed] = 0.0 - layout += delta_layout - # cool temperature - t -= dt - error = np.linalg.norm(delta_layout) / _n - if error < self.threshold: - break - return layout + return self.layout - def _sparse_fruchterman_reingold(self): - """Fruchterman-Reingold algorithm for sparse matrices. + def generate_nx_layout(self): + """Function to generate a layout using networkx.""" + import networkx as nx - This algorithm is based on the Fruchterman-Reingold algorithm provided - by networkx. (Copyright (C) 2004-2018 by Aric Hagberg - Dan Schult Pieter Swart Richard - Penney All rights reserved. BSD - license.) + sp_matrix = to_scipy_sparse_matrix(self.edge_index.as_tensor(), edge_attr=self.weight, num_nodes=len(self.nodes)) + nx_network = nx.from_scipy_sparse_array(sp_matrix) + nx_network = nx.relabel_nodes(nx_network, {i: node for i, node in enumerate(self.nodes)}) - """ - A = self.adjacency_matrix - k = self.k - try: - _n, _ = A.shape - except AttributeError: - print("Fruchterman-Reingold algorithm needs an adjacency " "matrix as input") - raise AttributeError - try: - from scipy.sparse import spdiags, coo_matrix - except ImportError: - print("The sparse Fruchterman-Reingold algorithm needs the " "scipy package: http://scipy.org/") - raise ImportError - # make sure we have a LIst of Lists representation - try: - A = A.tolil() - except: - A = (coo_matrix(A)).tolil() + names_rand = ["random", "rand", None] + names_circular = ["circular", "circle", "ring", "1d-lattice", "lattice-1d"] + names_shell = ["shell", "concentric", "concentric-circles", "shell layout"] + names_spectral = ["spectral", "eigen", "spectral layout"] + names_kk = ["kamada-kawai", "kamada_kawai", "kk", "kamada", "kamada layout"] + names_fr = ["fruchterman-reingold", "fruchterman_reingold", "fr", "spring_layout", "spring layout", "spring"] - if self.layout is None: - # random initial positions - np.random.seed(self.seed) - layout = np.asarray(np.random.rand(_n, self.dimension), dtype=A.dtype) + if self.layout_type in names_rand: + layout = nx.random_layout(nx_network, **self.kwargs) + elif self.layout_type in names_circular: + layout = nx.circular_layout(nx_network, **self.kwargs) + elif self.layout_type in names_shell: + layout = nx.shell_layout(nx_network, **self.kwargs) + elif self.layout_type in names_spectral: + layout = nx.spectral_layout(nx_network, **self.kwargs) + elif self.layout_type in names_kk: + layout = nx.kamada_kawai_layout( + nx_network, weight="weight" if self.weight is not None else None, **self.kwargs + ) + elif self.layout_type in names_fr: + layout = nx.spring_layout(nx_network, weight="weight" if self.weight is not None else None, **self.kwargs) else: - # make sure positions are of same type as matrix - layout = layout.astype(A.dtype) # type: ignore - - # no fixed nodes - if self.fixed is None: - self.fixed = [] - - # optimal distance between nodes - if k is None: - k = np.sqrt(1.0 / _n) - # the initial "temperature" is about .1 of domain area (=1x1) - # this is the largest step allowed in the dynamics. - t = max(max(layout.T[0]) - min(layout.T[0]), max(layout.T[1]) - min(layout.T[1])) * 0.1 - # simple cooling scheme. - # linearly step down by dt on each iteration so last iteration is size dt. - dt = t / float(self.iterations + 1) - - displacement = np.zeros((self.dimension, _n)) - for iteration in range(self.iterations): - displacement *= 0 - # loop over rows - for i in range(A.shape[0]): - if i in self.fixed: - continue - # difference between this row's node position and all others - delta = (layout[i] - layout).T - # distance between points - distance = np.sqrt((delta**2).sum(axis=0)) - # enforce minimum distance of 0.01 - distance = np.where(distance < 0.01, 0.01, distance) - # the adjacency matrix row - Ai = np.asarray(A.getrowview(i).toarray()) - # displacement "force" - displacement[:, i] += (delta * (k * k / distance**2 - Ai * distance / k)).sum(axis=1) - # update positions - length = np.sqrt((displacement**2).sum(axis=0)) - length = np.where(length < 0.01, 0.1, length) - delta_layout = (displacement * t / length).T - layout += delta_layout - # cool temperature - t -= dt - err = np.linalg.norm(delta_layout) / _n - if err < self.threshold: - break - return layout - - def circular(self): - """Position nodes on a circle with given radius. - - This algorithm can be enabled with the keywords: `circular`, `circle`, `ring`, `lattice-1d`, `1d-lattice`, `lattice` - - Keyword Args: - radius (float): Sets the radius of the circle on which nodes - are positioned. Defaults to 1.0. - direction (float): Sets the direction in which nodes are placed on the circle. 1.0 for clockwise (default) - and -1.0 for counter-clockwise direction. Defaults to 1.0. - start_angle (float): Sets the angle of the first node relative to the 3pm position on a clock. - and -1.0 for counter-clockwise direction. Defaults to 90.0. - - Returns: - layout (dict): A dictionary of positions keyed by node - """ - - n = len(self.nodes) - rad = 2.0 * np.pi / n - rotation = (90.0 - self.start_angle * self.direction) * np.pi / 180.0 - layout = {} - - for i in range(n): - x = self.radius * np.cos(rotation - i * rad * self.direction) - y = self.radius * np.sin(rotation - i * rad * self.direction) - layout[self.nodes[i]] = (x, y) + raise ValueError(f"Layout '{self.layout_type}' not recognized.") return layout def grid(self): - """Position nodes on a two-dimensional grid + """Position nodes on a two-dimensional grid. This algorithm can be enabled with the keywords: `grid`, `lattice-2d`, `2d-lattice`, `lattice` Returns: layout (dict): A dictionary of positions keyed by node """ - n = len(self.nodes) width = 1.0 # number of nodes in horizontal/vertical direction k = np.floor(np.sqrt(n)) dist = width / k - layout = {} - i = 0 - for i in range(n): - layout[self.nodes[i]] = ((i % k) * dist, -(np.floor(i / k)) * dist) - i += 1 + x = (np.arange(0, n) % k) * dist + y = -(np.floor(np.arange(0, n) / k)) * dist + coords = np.vstack((x, y)).T - return layout + return {node: coords[i] for i, node in enumerate(self.nodes)} From a4a185c33523c7f731e03c09ea91a3aca721a71f Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Mon, 13 Oct 2025 13:48:34 +0000 Subject: [PATCH 14/44] add temporal network layouting --- src/pathpyG/pathpyG.toml | 1 + src/pathpyG/visualisations/layout.py | 10 ++- src/pathpyG/visualisations/network_plot.py | 2 +- .../visualisations/temporal_network_plot.py | 89 ++++++++++++++++--- 4 files changed, 87 insertions(+), 15 deletions(-) diff --git a/src/pathpyG/pathpyG.toml b/src/pathpyG/pathpyG.toml index 8c8c8aa2b..ca4748890 100644 --- a/src/pathpyG/pathpyG.toml +++ b/src/pathpyG/pathpyG.toml @@ -29,6 +29,7 @@ height = "12cm" # Height of the plot in all backends latex_class_options = "" # LaTeX class options for TikZ visualisations margin = 0.1 # Margin around the plot in all backends curvature = 0.25 # Curvature for curved edges between nodes +layout_window_size = [-1, -1] # Window size for layout algorithms that use temporal information. Default is [-1, -1] meaning that all timestamps in both directions is used. If an integer is given, this defines the number of time steps that are used to compute the layout at a specific time step. If tuple of two integers is given, this defines the number of time steps before and after the current time step that are used to compute the layout at a specific time step. [visualisation.node] color = [36, 74, 92] # Node color in RGB from the pathpyG logo diff --git a/src/pathpyG/visualisations/layout.py b/src/pathpyG/visualisations/layout.py index 9cd2f3246..ae2aabd7a 100644 --- a/src/pathpyG/visualisations/layout.py +++ b/src/pathpyG/visualisations/layout.py @@ -48,6 +48,7 @@ def layout(network: Graph, layout: str = "random", weight: None | str | Iterable - Spectral layout - Kamada-Kawai layout - Fruchterman-Reingold force-directed algorithm + - ForceAtlas2 layout algorithm - Grid layout The appearance of the layout can be modified by keyword arguments which will @@ -75,11 +76,11 @@ def layout(network: Graph, layout: str = "random", weight: None | str | Iterable raise ValueError("Length of weight iterable does not match number of edges in the network.") # create layout class - layout = Layout( + layout_cls = Layout( nodes=network.nodes, edge_index=network.data.edge_index, layout_type=layout, weight=weight, **kwargs ) # return the layout - return layout.generate_layout() + return layout_cls.generate_layout() class Layout(object): @@ -139,6 +140,7 @@ def generate_nx_layout(self): names_spectral = ["spectral", "eigen", "spectral layout"] names_kk = ["kamada-kawai", "kamada_kawai", "kk", "kamada", "kamada layout"] names_fr = ["fruchterman-reingold", "fruchterman_reingold", "fr", "spring_layout", "spring layout", "spring"] + names_forceatlas2 = ["forceatlas2", "fa2", "forceatlas", "force-atlas", "force-atlas2", "fa 2", "fa 1"] if self.layout_type in names_rand: layout = nx.random_layout(nx_network, **self.kwargs) @@ -154,6 +156,10 @@ def generate_nx_layout(self): ) elif self.layout_type in names_fr: layout = nx.spring_layout(nx_network, weight="weight" if self.weight is not None else None, **self.kwargs) + elif self.layout_type in names_forceatlas2: + layout = nx.forceatlas2_layout( + nx_network, weight="weight" if self.weight is not None else None, **self.kwargs + ) else: raise ValueError(f"Layout '{self.layout_type}' not recognized.") diff --git a/src/pathpyG/visualisations/network_plot.py b/src/pathpyG/visualisations/network_plot.py index 16f5f4585..e279dbe57 100644 --- a/src/pathpyG/visualisations/network_plot.py +++ b/src/pathpyG/visualisations/network_plot.py @@ -171,7 +171,7 @@ def _convert_color(self, color: tuple[int, int, int]) -> str: def _compute_layout(self) -> None: """Create layout.""" - # get layout form the config + # get layout from the config layout = self.config.get("layout") # if no layout is considered stop this process diff --git a/src/pathpyG/visualisations/temporal_network_plot.py b/src/pathpyG/visualisations/temporal_network_plot.py index d089b40cd..41e0e1ab7 100644 --- a/src/pathpyG/visualisations/temporal_network_plot.py +++ b/src/pathpyG/visualisations/temporal_network_plot.py @@ -1,15 +1,21 @@ from __future__ import annotations +import logging +from math import ceil from typing import TYPE_CHECKING, Any import pandas as pd +from pathpyG.visualisations.layout import layout as network_layout from pathpyG.visualisations.network_plot import NetworkPlot # pseudo load class for type checking if TYPE_CHECKING: from pathpyG.core.temporal_graph import TemporalGraph +# create logger +logger = logging.getLogger("root") + class TemporalNetworkPlot(NetworkPlot): """Network plot class for a temporal network.""" @@ -25,7 +31,8 @@ def generate(self) -> None: """Generate the plot.""" self._compute_edge_data() self._compute_node_data() - # self._compute_layout() + self._compute_layout() + self._fill_node_values() self._compute_config() def _compute_node_data(self) -> None: @@ -60,10 +67,23 @@ def _compute_node_data(self) -> None: start_nodes[attribute] = self.network.data[f"node_{attribute}"] # combine start nodes and new nodes - new_nodes = new_nodes.set_index(new_nodes.index.map(lambda x: (x.split("-")[0], int(x.split("-")[1])))) - new_nodes.index.set_names(["uid", "time"], inplace=True) - nodes = pd.concat([start_nodes, new_nodes]) - # fill missing values with last known value + if not new_nodes.empty: + new_nodes = new_nodes.set_index(new_nodes.index.map(lambda x: (x.split("-")[0], int(x.split("-")[1])))) + new_nodes.index.set_names(["uid", "time"], inplace=True) + nodes = pd.concat([start_nodes, new_nodes]) + else: + nodes = start_nodes + + # convert attributes to useful values + nodes["color"] = self._convert_to_rgb_tuple(nodes["color"]) + nodes["color"] = nodes["color"].map(self._convert_color) + + # save node data + self.data["nodes"] = nodes + + def _fill_node_values(self) -> pd.DataFrame: + """Fill all NaN/None values with the previous value and add start/end time columns.""" + nodes = self.data["nodes"] nodes = nodes.sort_values(by=["uid", "time"]).groupby("uid", sort=False).ffill() nodes["start"] = nodes.index.get_level_values("time") nodes = nodes.droplevel("time") @@ -73,12 +93,6 @@ def _compute_node_data(self) -> None: if max_node_time < self.network.data.time[-1].item(): max_node_time = self.network.data.time[-1].item() + 1 nodes["end"] = nodes["end"].fillna(max_node_time) - - # convert attributes to useful values - nodes["color"] = self._convert_to_rgb_tuple(nodes["color"]) - nodes["color"] = nodes["color"].map(self._convert_color) - - # save node data self.data["nodes"] = nodes def _compute_edge_data(self) -> None: @@ -115,7 +129,6 @@ def _compute_edge_data(self) -> None: else: edges[attribute] = self.edge_args["weight"] - # convert needed attributes to useful values edges["color"] = self._convert_to_rgb_tuple(edges["color"]) edges["color"] = edges["color"].map(self._convert_color) @@ -126,6 +139,58 @@ def _compute_edge_data(self) -> None: # save edge data self.data["edges"] = edges + def _compute_layout(self) -> None: + """Create temporal layout.""" + # get layout from the config + layout_type = self.config.get("layout") + max_time = int(max(self.data["nodes"].index.get_level_values("time").max() + 1, self.data["edges"]["end"].max())) + window_size = self.config.get("layout_window_size") + if isinstance(window_size, int): + window_size = [ceil(window_size/2), window_size//2] + elif isinstance(window_size, list | tuple): + if window_size[0] < 0: + window_size[0] = max_time # use all previous time steps + if window_size[1] < 0: + window_size[1] = max_time # use all following time steps + elif not isinstance(window_size, (list, tuple)): + logger.error("The provided layout_window_size is not valid!") + raise AttributeError + + # if no layout is considered stop this process + if layout_type is None: + return + + pos = network_layout(self.network, layout="random") # initial layout + num_steps = max_time - window_size[1] + layout_df = pd.DataFrame() + for step in range(num_steps+1): + # only compute layout if there are edges in the current window, otherwise use the previous layout + if ((max(0, step - window_size[0]) <= self.network.data.time) & (self.network.data.time <= step + window_size[1] + 1)).sum() > 0: + # get subgraph for the current time step + sub_graph = self.network.get_window(start_time=max(0, step - window_size[0]), end_time=step + window_size[1] + 1) + + # get layout dict for each node + if isinstance(layout_type, str): + pos = network_layout(sub_graph, layout=layout_type, pos=pos) + elif not isinstance(layout_type, dict): + logger.error("The provided layout is not valid!") + raise AttributeError + + # update x,y position of the nodes + new_layout_df = pd.DataFrame.from_dict(pos, orient="index", columns=["x", "y"]) + if self.network.order > 1 and not isinstance(new_layout_df.index[0], str): + new_layout_df.index = new_layout_df.index.map(lambda x: self.config["higher_order"]["separator"].join(map(str, x))) + # scale x and y to [0,1] + new_layout_df["x"] = (new_layout_df["x"] - new_layout_df["x"].min()) / (new_layout_df["x"].max() - new_layout_df["x"].min()) + new_layout_df["y"] = (new_layout_df["y"] - new_layout_df["y"].min()) / (new_layout_df["y"].max() - new_layout_df["y"].min()) + # add time for the layout + new_layout_df["time"] = step + # append to layout df + layout_df = pd.concat([layout_df, new_layout_df]) + # join layout with node data + layout_df = layout_df.reset_index().rename(columns={"index": "uid"}).set_index(["uid", "time"]) + self.data["nodes"] = self.data["nodes"].join(layout_df, how="outer") + def _compute_config(self) -> None: """Add additional configs.""" self.config["directed"] = True From 4ca903d97bdbf67e98352f17b561aa29849a8dc9 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Tue, 14 Oct 2025 09:09:08 +0000 Subject: [PATCH 15/44] add networkx as dependency --- pyproject.toml | 19 ++++++++++--------- uv.lock | 3 +++ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f5150fc18..26d19a69d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,15 +20,16 @@ requires-python = ">=3.10" # We are using `match` statements version = "0.2.0" dependencies = [ 'singledispatchmethod', # Adds decorator that allows to use different methods for different types of arguments (similar to method overloading in Java) - 'zstandard', # Compression library - 'numpy', # Numerical computing library - 'scipy', # Scientific computing library - 'scikit-learn', # Machine learning library - 'pandas', # Data analysis library - 'matplotlib', # Plotting library - 'seaborn', # High-level plotting library - 'jupyter', # To run the tutorial notebooks - 'torch_geometric', # PyTorch Geometric library for graph deep learning + 'zstandard', # Compression library + 'numpy', # Numerical computing library + 'scipy', # Scientific computing library + 'scikit-learn', # Machine learning library + 'pandas', # Data analysis library + 'matplotlib', # Plotting library + 'seaborn', # High-level plotting library + 'jupyter', # To run the tutorial notebooks + 'torch_geometric', # PyTorch Geometric library for graph deep learning + "networkx", # NetworkX for basic network analysis and visualisation ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index 730c3f66a..0ef2f35cd 100644 --- a/uv.lock +++ b/uv.lock @@ -3293,6 +3293,8 @@ source = { editable = "." } dependencies = [ { name = "jupyter" }, { name = "matplotlib" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-7-pathpyg-cpu' and extra == 'extra-7-pathpyg-cu129')" }, + { name = "networkx", version = "3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-7-pathpyg-cpu' and extra == 'extra-7-pathpyg-cu129')" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-7-pathpyg-cpu' and extra == 'extra-7-pathpyg-cu129')" }, { name = "numpy", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-7-pathpyg-cpu' and extra == 'extra-7-pathpyg-cu129')" }, { name = "pandas" }, @@ -3361,6 +3363,7 @@ requires-dist = [ { name = "jupyter" }, { name = "manim", marker = "extra == 'vis'" }, { name = "matplotlib" }, + { name = "networkx", specifier = ">=3.4.2" }, { name = "numpy" }, { name = "pandas" }, { name = "pyg-lib", marker = "extra == 'cpu'", index = "https://data.pyg.org/whl/torch-2.8.0+cpu.html", conflict = { package = "pathpyg", extra = "cpu" } }, From 66cfbdb27140e3c69f3eb7c4665ff323fa0699c6 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Tue, 14 Oct 2025 10:31:27 +0000 Subject: [PATCH 16/44] update manim backend --- src/pathpyG/visualisations/_manim/backend.py | 7 +- .../_manim/temporal_graph_scene.py | 150 +++++++++++++----- src/pathpyG/visualisations/layout.py | 8 +- .../visualisations/temporal_network_plot.py | 15 +- 4 files changed, 134 insertions(+), 46 deletions(-) diff --git a/src/pathpyG/visualisations/_manim/backend.py b/src/pathpyG/visualisations/_manim/backend.py index c3dff7783..b5b050321 100644 --- a/src/pathpyG/visualisations/_manim/backend.py +++ b/src/pathpyG/visualisations/_manim/backend.py @@ -81,10 +81,11 @@ def save(self, filename: str) -> None: temp_file, temp_dir = self.render_video() if filename.endswith(".gif"): self.convert_to_gif(temp_file) + temp_file = temp_file.with_suffix(".gif") shutil.copy(temp_file, filename) shutil.rmtree(temp_dir) - def convert_to_gif(self, filename: str) -> None: + def convert_to_gif(self, filename: Path) -> None: """Convert the rendered mp4 video to a gif file.""" try: subprocess.run( @@ -93,12 +94,12 @@ def convert_to_gif(self, filename: str) -> None: "-i", filename, "-vf", - "fps=20,scale=720:-1:flags=lanczos", + "fps=30,scale=1080:-1:flags=lanczos", "-y", "-hide_banner", "-loglevel", "error", - filename.replace(".mp4", ".gif"), + filename.with_suffix(".gif"), ], check=True, ) diff --git a/src/pathpyG/visualisations/_manim/temporal_graph_scene.py b/src/pathpyG/visualisations/_manim/temporal_graph_scene.py index ba181cf39..ce2b14e9d 100644 --- a/src/pathpyG/visualisations/_manim/temporal_graph_scene.py +++ b/src/pathpyG/visualisations/_manim/temporal_graph_scene.py @@ -1,7 +1,9 @@ import logging -import pandas as pd -from manim import Arrow, Create, Graph, Scene, Uncreate, GrowArrow +import numpy as np +from manim import BLACK, RIGHT, UP, Arrow, Create, Dot, GrowArrow, LabeledDot, Scene, Text, Transform, Uncreate + +from pathpyG.visualisations.layout import Layout # set manim log level to warning logging.getLogger("manim").setLevel(logging.WARNING) @@ -11,50 +13,118 @@ class TemporalGraphScene(Scene): def __init__(self, data: dict, config: dict, show_labels: bool): super().__init__() self.data = data - self.data["edges"].index = pd.MultiIndex.from_tuples( - self.data["edges"][["source", "target"]].itertuples(index=False) + self.data["nodes"]["size"] *= 0.025 # scale sizes down + self.data["nodes"] = self.data["nodes"].rename( + columns={"size": "radius", "color": "fill_color", "opacity": "fill_opacity"} + ) + if "x" in self.data["nodes"] and "y" in self.data["nodes"]: + self.data["nodes"][["x", "y"]] = (self.data["nodes"][["x", "y"]] - 0.5) * 5 # scale layout + self.data["edges"] = self.data["edges"].rename( + columns={"color": "stroke_color", "opacity": "stroke_opacity", "size": "stroke_width"} ) self.config = config self.show_labels = show_labels def construct(self): """Constructs the Manim scene for the temporal graph.""" - self.data["nodes"]["size"] *= 0.025 # scale sizes down - vertex_config = ( - self.data["nodes"][["size", "color", "opacity"]] - .rename(columns={"size": "radius", "color": "fill_color", "opacity": "fill_opacity"}) - .to_dict(orient="index") - ) - g = Graph( - vertices=self.data["nodes"].index.tolist(), - edges=[], - vertex_config=vertex_config, - labels=self.show_labels, - ) - self.play(Create(g)) - self.wait() + # Add initial nodes + start_node_df = self.data["nodes"][self.data["nodes"]["start"] == 0] + if "x" in self.data["nodes"] and "y" in self.data["nodes"]: + layout = {node: np.concatenate([pos.values, [0]]) for node, pos in start_node_df[["x", "y"]].iterrows()} + else: + # Use random layout if no positions are given + layout = Layout(nodes=start_node_df.index.tolist()).generate_layout() + # add z coordinate for manim and scale layout + layout = {node: (np.concatenate([pos, [0]]) - 0.5) * 5 for node, pos in layout.items()} + vertex_config = start_node_df[["radius", "fill_color", "fill_opacity"]].to_dict(orient="index") + if self.show_labels: + nodes = {node: LabeledDot(label=node, point=layout[node], **vertex_config[node]) for node in vertex_config} + else: + nodes = {node: Dot(point=layout[node], **vertex_config[node]) for node in vertex_config} + self.play(*[Create(node) for node in nodes.values()]) + + # Iterate over time steps and update nodes and edges + time_text = Text(f"Time: {0}", font_size=24, color=BLACK).to_corner(UP + RIGHT) for t in range(self.data["edges"]["end"].max() + 1): - # Gather all new edges to be added - animations = [] - new_edges = self.data["edges"][self.data["edges"]["start"] == t] - new_edge_config = ( - new_edges[["color", "opacity", "size"]] - .rename(columns={"color": "stroke_color", "opacity": "stroke_opacity", "size": "stroke_width"}) - .to_dict(orient="index") - ) - if not new_edges.empty: - edge_list = [(row[0], row[1]) for row in new_edges[["source", "target"]].itertuples(index=False)] - animations.append(g.animate(animation=GrowArrow).add_edges(*edge_list, edge_config=new_edge_config, edge_type=Arrow)) + # Add time step text + self.play(Transform(time_text, Text(f"Time: {t}", font_size=24, color=BLACK).to_corner(UP + RIGHT)), run_time=0.02) + + # Add edges for current time step + new_edge_df = self.data["edges"][(self.data["edges"]["start"] == t)] + new_edge_config = new_edge_df[["stroke_color", "stroke_opacity", "stroke_width"]].to_dict(orient="index") + if not new_edge_df.empty: + arrows = { + (source, target): Arrow( + start=self.get_boundary_point( + center=layout[source], + direction=layout[target] - layout[source], + radius=nodes[source].radius/2, + ), + end=self.get_boundary_point( + center=layout[target], + direction=layout[source] - layout[target], + radius=nodes[target].radius/2, + ), + **new_edge_config[(source, target)], + ) + for source, target in new_edge_df.index + } + self.play(*[GrowArrow(arrow) for arrow in arrows.values()], run_time=self.config["temporal"]["delta"]/(4*1000)) + else: + self.wait(self.config["temporal"]["delta"]/(4*1000)) + + # Update node positions for the next time step + new_nodes = self.data["nodes"][self.data["nodes"]["start"] == (t + 1)] + if not new_nodes.empty: + new_vertex_config = new_nodes[["radius", "fill_color", "fill_opacity"]].to_dict(orient="index") + if "x" in new_nodes and "y" in new_nodes: + layout.update({node: np.concatenate([pos.values, [0]]) for node, pos in new_nodes[["x", "y"]].iterrows()}) + + if self.show_labels: + new_nodes = { + node: LabeledDot(label=node, point=layout[node], **new_vertex_config[node]) + for node in new_vertex_config + } + else: + new_nodes = {node: Dot(point=layout[node], **new_vertex_config[node]) for node in new_vertex_config} + movement_animations = [Transform(nodes[node], new_nodes[node]) for node in new_nodes] + + # Update edge positions with moving nodes + if not new_edge_df.empty: + new_arrows = { + (source, target): Arrow( + start=self.get_boundary_point( + center=layout[source], + direction=layout[target] - layout[source], + radius=new_nodes[source].radius/2, + ), + end=self.get_boundary_point( + center=layout[target], + direction=layout[source] - layout[target], + radius=new_nodes[target].radius/2, + ), + **new_edge_config[(source, target)], + ) + for source, target in new_edge_df.index + if (source, target) in arrows + } + movement_animations.extend([Transform(arrows[index], new_arrows[index]) for index in new_arrows]) + self.play(*movement_animations, run_time=self.config["temporal"]["delta"]/(2*1000) - 0.02) # 0.02 for time text update + else: + self.wait(self.config["temporal"]["delta"]/(2*1000) - 0.02) # 0.02 for time text update # Gather all old edges to be removed - old_edges = self.data["edges"][self.data["edges"]["end"] == t] - if not old_edges.empty: - edge_list = [(row[0], row[1]) for row in old_edges[["source", "target"]].itertuples(index=False)] - removed_edges = g.remove_edges(*edge_list) - animations.extend([removed_edge.animate.scale(0, scale_tips=True, about_point=removed_edge.get_end()) for removed_edge in removed_edges]) - - # play all animations - if animations: - self.play(*animations) - self.wait() - self.play(Uncreate(g)) + if not new_edge_df.empty: + self.play( + *[arrow.animate.scale(0, scale_tips=True, about_point=arrow.get_end()) for arrow in arrows.values()], + run_time=self.config["temporal"]["delta"]/(4*1000) + ) + else: + self.wait(self.config["temporal"]["delta"]/(4*1000)) + + self.play(Uncreate(node) for node in nodes.values()) + + def get_boundary_point(self, center, direction, radius): + """Calculate the boundary point of a circle in a given direction.""" + direction = direction / np.linalg.norm(direction) + return center + direction * radius diff --git a/src/pathpyG/visualisations/layout.py b/src/pathpyG/visualisations/layout.py index ae2aabd7a..aae08cb3f 100644 --- a/src/pathpyG/visualisations/layout.py +++ b/src/pathpyG/visualisations/layout.py @@ -26,6 +26,7 @@ import numpy as np import torch from torch import Tensor +from torch_geometric import EdgeIndex from torch_geometric.utils import to_scipy_sparse_matrix from pathpyG.core.graph import Graph @@ -105,11 +106,14 @@ class Layout(object): to the layout function. """ - def __init__(self, nodes: list, edge_index: Tensor, layout_type: str = "random", weight: Optional[Tensor] = None, **kwargs): + def __init__(self, nodes: list, edge_index: Optional[Tensor] = None, layout_type: str = "random", weight: Optional[Tensor] = None, **kwargs): """Initialize the Layout class.""" # initialize variables self.nodes = nodes - self.edge_index = edge_index + if edge_index is None: + self.edge_index = EdgeIndex(torch.empty((2, 0), dtype=torch.long)) + else: + self.edge_index = edge_index self.weight = weight self.layout_type = layout_type self.kwargs = kwargs diff --git a/src/pathpyG/visualisations/temporal_network_plot.py b/src/pathpyG/visualisations/temporal_network_plot.py index 41e0e1ab7..1eb779be6 100644 --- a/src/pathpyG/visualisations/temporal_network_plot.py +++ b/src/pathpyG/visualisations/temporal_network_plot.py @@ -8,6 +8,7 @@ from pathpyG.visualisations.layout import layout as network_layout from pathpyG.visualisations.network_plot import NetworkPlot +from pathpyG.visualisations.utils import rgb_to_hex # pseudo load class for type checking if TYPE_CHECKING: @@ -70,7 +71,7 @@ def _compute_node_data(self) -> None: if not new_nodes.empty: new_nodes = new_nodes.set_index(new_nodes.index.map(lambda x: (x.split("-")[0], int(x.split("-")[1])))) new_nodes.index.set_names(["uid", "time"], inplace=True) - nodes = pd.concat([start_nodes, new_nodes]) + nodes = new_nodes.combine_first(start_nodes) else: nodes = start_nodes @@ -81,6 +82,18 @@ def _compute_node_data(self) -> None: # save node data self.data["nodes"] = nodes + def _convert_color(self, color: tuple[int, int, int]) -> str: + """Convert color rgb tuple to hex.""" + if isinstance(color, tuple): + return rgb_to_hex(color[:3]) + elif isinstance(color, str): + return color + elif color is None or pd.isna(color): + return pd.NA # will be filled with self._fill_node_values() + else: + logger.error(f"The provided color {color} is not valid!") + raise AttributeError + def _fill_node_values(self) -> pd.DataFrame: """Fill all NaN/None values with the previous value and add start/end time columns.""" nodes = self.data["nodes"] From 5b327ab03e1425ef0d9c0dca3ea3a3992cd45800 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Tue, 14 Oct 2025 10:54:03 +0000 Subject: [PATCH 17/44] fix window and batch functions for temporal graphs --- src/pathpyG/core/temporal_graph.py | 65 +++++++++++++++++++++++++++--- tests/core/test_temporal_graph.py | 20 +++++++-- 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/src/pathpyG/core/temporal_graph.py b/src/pathpyG/core/temporal_graph.py index 7513a6456..64ca39a21 100644 --- a/src/pathpyG/core/temporal_graph.py +++ b/src/pathpyG/core/temporal_graph.py @@ -189,21 +189,74 @@ def to_undirected(self) -> TemporalGraph: return TemporalGraph(data=Data(edge_index=edge_index, time=times), mapping=self.mapping) def get_batch(self, start_idx: int, end_idx: int) -> TemporalGraph: - """Return an instance of the TemporalGraph that captures all time-stamped + """Return a batch of temporal edges based on start and end indices. + + Return an instance of the TemporalGraph that captures all time-stamped edges in a given batch defined by start and (non-inclusive) end, where start - and end refer to the index of the first and last event in the time-ordered list of events.""" + and end refer to the index of the first and last event in the time-ordered list of events. + + Args: + start_idx: The starting index of the batch (inclusive). + end_idx: The ending index of the batch (exclusive). + + Examples: + Get a batch of temporal edges: + + >>> g = pp.TemporalGraph.from_edge_list([('a', 'b', 1), ('b', 'c', 2), ('c', 'a', 3)]) + >>> batch = g.get_batch(0, 2) + >>> print(batch.temporal_edges) + [('a', 'b', 1), ('b', 'c', 2)] + """ + # Create new Data object with the selected batch of edges and times + data = Data(edge_index=self.data.edge_index[:, start_idx:end_idx], time=self.data.time[start_idx:end_idx]) + + # Copy all node attributes + for node_attr in self.node_attrs(): + data[node_attr] = self.data[node_attr] + # Copy only edge attributes for the selected batch + for edge_attr in self.edge_attrs(): + data[edge_attr] = self.data[edge_attr][start_idx:end_idx] return TemporalGraph( - data=Data(edge_index=self.data.edge_index[:, start_idx:end_idx], time=self.data.time[start_idx:end_idx]), + data=data, mapping=self.mapping, ) def get_window(self, start_time: int, end_time: int) -> TemporalGraph: - """Return an instance of the TemporalGraph that captures all time-stamped + """Return a time window of temporal edges based on start and end timestamps. + + Return an instance of the TemporalGraph that captures all time-stamped edges in a given time window defined by start and (non-inclusive) end, where start - and end refer to the time stamps""" + and end refer to the time stamps. + + Args: + start_time: The starting timestamp of the window (inclusive). + end_time: The ending timestamp of the window (exclusive). + + Examples: + Get a time window of temporal edges: - return TemporalGraph(data=self.data.snapshot(start_time, end_time), mapping=self.mapping) + >>> g = pp.TemporalGraph.from_edge_list([('a', 'b', 1), ('b', 'c', 2), ('c', 'a', 3)]) + >>> window = g.get_window(0, 2) + >>> print(window.temporal_edges) + [('a', 'b', 1)] + """ + # While there is a PyG function `Data.snapshot`, + # we do it manually since it cannot handle numpy arrays as edge attributes. + edge_mask = (self.data.time >= start_time).logical_and(self.data.time < end_time) + # Create a new Data object with the selected edges and times + data = Data( + edge_index=self.data.edge_index[:, edge_mask], + time=self.data.time[edge_mask], + ) + # Copy all node attributes + for node_attr in self.node_attrs(): + data[node_attr] = self.data[node_attr] + # Copy only edge attributes for the selected edges + for edge_attr in self.edge_attrs(): + data[edge_attr] = self.data[edge_attr][edge_mask] + + return TemporalGraph(data=data, mapping=self.mapping) def __getitem__(self, key: Union[tuple, str]) -> Any: """Return node, edge, temporal edge, or graph attribute. diff --git a/tests/core/test_temporal_graph.py b/tests/core/test_temporal_graph.py index b0d016bd8..827d5dca9 100644 --- a/tests/core/test_temporal_graph.py +++ b/tests/core/test_temporal_graph.py @@ -94,12 +94,26 @@ def test_get_batch(long_temporal_graph): assert t_2.n == 9 assert t_2.m == 4 + # Check that edge attributes are also batched correctly + long_temporal_graph.data.edge_tensor = torch.arange(long_temporal_graph.m) + long_temporal_graph.data.edge_array = np.arange(long_temporal_graph.m) + t_3 = long_temporal_graph.get_batch(1, 9) + assert (t_3.data.edge_tensor == torch.tensor([1, 2, 3, 4, 5, 6, 7, 8])).all() + assert (t_3.data.edge_array == np.array([1, 2, 3, 4, 5, 6, 7, 8])).all() + def test_get_window(long_temporal_graph): - t_1 = long_temporal_graph.get_window(1, 9) + t_1 = long_temporal_graph.get_window(1, 10) assert t_1.m == 4 - t_2 = long_temporal_graph.get_window(9, 13) - assert t_2.m == 4 + t_2 = long_temporal_graph.get_window(10, 14) + assert t_2.m == 2 + + # Check that edge attributes are also windowed correctly + long_temporal_graph.data.edge_tensor = torch.arange(long_temporal_graph.m) + long_temporal_graph.data.edge_array = np.arange(long_temporal_graph.m) + t_3 = long_temporal_graph.get_window(2, 10) + assert (t_3.data.edge_tensor == torch.tensor([1, 2, 3])).all() + assert (t_3.data.edge_array == np.array([1, 2, 3])).all() def test_str(simple_temporal_graph): From e7a9b3d6052417d48385a7b3efedb81b0ec577fe Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Tue, 14 Oct 2025 14:44:17 +0000 Subject: [PATCH 18/44] minor fixes --- src/pathpyG/core/temporal_graph.py | 8 +- .../_manim/temporal_graph_scene.py | 15 ++- src/pathpyG/visualisations/layout.py | 3 + src/pathpyG/visualisations/network_plot.py | 106 +++++++++++++----- .../visualisations/temporal_network_plot.py | 106 ++++++++---------- src/pathpyG/visualisations/utils.py | 25 +++++ 6 files changed, 167 insertions(+), 96 deletions(-) diff --git a/src/pathpyG/core/temporal_graph.py b/src/pathpyG/core/temporal_graph.py index 64ca39a21..6f24ecfc3 100644 --- a/src/pathpyG/core/temporal_graph.py +++ b/src/pathpyG/core/temporal_graph.py @@ -60,8 +60,12 @@ def __init__(self, data: Data, mapping: IndexMap | None = None) -> None: (e[0].item(), e[1].item(), t.item()): i for i, (e, t) in enumerate(zip([e for e in self.data.edge_index.t()], self.data.time)) } - self.start_time = self.data.time[0].item() - self.end_time = self.data.time[-1].item() + if self.data.time.size(0) > 0: + self.start_time = self.data.time[0].item() + self.end_time = self.data.time[-1].item() + else: + self.start_time = 0 + self.end_time = 0 @staticmethod def from_edge_list(edge_list, num_nodes: Optional[int] = None, device: Optional[torch.device] = None) -> TemporalGraph: # type: ignore diff --git a/src/pathpyG/visualisations/_manim/temporal_graph_scene.py b/src/pathpyG/visualisations/_manim/temporal_graph_scene.py index ce2b14e9d..b98029e8e 100644 --- a/src/pathpyG/visualisations/_manim/temporal_graph_scene.py +++ b/src/pathpyG/visualisations/_manim/temporal_graph_scene.py @@ -7,6 +7,8 @@ # set manim log level to warning logging.getLogger("manim").setLevel(logging.WARNING) +# set root logger +logger = logging.getLogger("root") class TemporalGraphScene(Scene): @@ -38,7 +40,7 @@ def construct(self): layout = {node: (np.concatenate([pos, [0]]) - 0.5) * 5 for node, pos in layout.items()} vertex_config = start_node_df[["radius", "fill_color", "fill_opacity"]].to_dict(orient="index") if self.show_labels: - nodes = {node: LabeledDot(label=node, point=layout[node], **vertex_config[node]) for node in vertex_config} + nodes = {node: LabeledDot(label=str(node), point=layout[node], **vertex_config[node]) for node in vertex_config} else: nodes = {node: Dot(point=layout[node], **vertex_config[node]) for node in vertex_config} self.play(*[Create(node) for node in nodes.values()]) @@ -51,6 +53,10 @@ def construct(self): # Add edges for current time step new_edge_df = self.data["edges"][(self.data["edges"]["start"] == t)] + # drop duplicate edges + if new_edge_df.index.duplicated().any(): + logger.warning(f"Dropping duplicate edges at time {t}.") + new_edge_df = new_edge_df[~new_edge_df.index.duplicated(keep='first')] new_edge_config = new_edge_df[["stroke_color", "stroke_opacity", "stroke_width"]].to_dict(orient="index") if not new_edge_df.empty: arrows = { @@ -82,7 +88,7 @@ def construct(self): if self.show_labels: new_nodes = { - node: LabeledDot(label=node, point=layout[node], **new_vertex_config[node]) + node: LabeledDot(label=str(node), point=layout[node], **new_vertex_config[node]) for node in new_vertex_config } else: @@ -126,5 +132,8 @@ def construct(self): def get_boundary_point(self, center, direction, radius): """Calculate the boundary point of a circle in a given direction.""" - direction = direction / np.linalg.norm(direction) + distance = np.linalg.norm(direction) + if distance == 0: + return center # Avoid division by zero + direction = direction / distance return center + direction * radius diff --git a/src/pathpyG/visualisations/layout.py b/src/pathpyG/visualisations/layout.py index aae08cb3f..6b9b32847 100644 --- a/src/pathpyG/visualisations/layout.py +++ b/src/pathpyG/visualisations/layout.py @@ -159,6 +159,9 @@ def generate_nx_layout(self): nx_network, weight="weight" if self.weight is not None else None, **self.kwargs ) elif self.layout_type in names_fr: + if "k" not in self.kwargs: + # set optimal distance between nodes + self.kwargs["k"] = np.sqrt(len(self.nodes)) layout = nx.spring_layout(nx_network, weight="weight" if self.weight is not None else None, **self.kwargs) elif self.layout_type in names_forceatlas2: layout = nx.forceatlas2_layout( diff --git a/src/pathpyG/visualisations/network_plot.py b/src/pathpyG/visualisations/network_plot.py index e279dbe57..4e362de47 100644 --- a/src/pathpyG/visualisations/network_plot.py +++ b/src/pathpyG/visualisations/network_plot.py @@ -11,15 +11,17 @@ # ============================================================================= from __future__ import annotations +import os import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Sized import matplotlib.pyplot as plt import pandas as pd +from matplotlib.colors import to_rgb from pathpyG.visualisations.layout import layout as network_layout from pathpyG.visualisations.pathpy_plot import PathPyPlot -from pathpyG.visualisations.utils import rgb_to_hex +from pathpyG.visualisations.utils import rgb_to_hex, image_to_base64 # pseudo load class for type checking if TYPE_CHECKING: @@ -62,6 +64,7 @@ def generate(self) -> None: self._compute_edge_data() self._compute_node_data() self._compute_layout() + self._post_process_node_data() self._compute_config() def _compute_node_data(self) -> None: @@ -77,23 +80,26 @@ def _compute_node_data(self) -> None: nodes[attribute] = [self.config.get("node").get(attribute, None)] * len(nodes) # type: ignore[union-attr] else: nodes[attribute] = self.config.get("node").get(attribute, None) # type: ignore[union-attr] - # check if attribute is given as argument - if attribute in self.node_args: - if isinstance(self.node_args[attribute], dict): - nodes[attribute] = nodes.index.map(self.node_args[attribute]) - else: - nodes[attribute] = self.node_args[attribute] # check if attribute is given as node attribute - elif f"node_{attribute}" in self.network.node_attrs(): + if f"node_{attribute}" in self.network.node_attrs(): nodes[attribute] = self.network.data[f"node_{attribute}"] - - # convert attributes to useful values - nodes["color"] = self._convert_to_rgb_tuple(nodes["color"]) - nodes["color"] = nodes["color"].map(self._convert_color) + # check if attribute is given as argument + if attribute in self.node_args: + nodes = self.assign_argument(attribute, self.node_args[attribute], nodes) # save node data self.data["nodes"] = nodes + def _post_process_node_data(self) -> None: + """Post-process specific node attributes after constructing the DataFrame.""" + # convert colors to uniform hex values + self.data["nodes"]["color"] = self._convert_to_rgb_tuple(self.data["nodes"]["color"]) + self.data["nodes"]["color"] = self.data["nodes"]["color"].map(self._convert_color) + + # load any local images to base64 strings + if self.data["nodes"]["image"].notna().any(): + self.data["nodes"]["image"] = self.data["nodes"]["image"].map(self._load_image) + def _compute_edge_data(self) -> None: """Generate the data structure for the edges.""" # initialize values @@ -107,26 +113,17 @@ def _compute_edge_data(self) -> None: edges[attribute] = [self.config.get("edge").get(attribute, None)] * len(edges) # type: ignore[union-attr] else: edges[attribute] = self.config.get("edge").get(attribute, None) # type: ignore[union-attr] - # check if attribute is given as argument - if attribute in self.edge_args: - if isinstance(self.edge_args[attribute], dict): - new_attrs = edges.index.map(lambda x: f"{x[0]}-{x[1]}").map(self.edge_args[attribute]) - edges.loc[~new_attrs.isna(), attribute] = new_attrs[~new_attrs.isna()] - else: - edges[attribute] = self.edge_args[attribute] # check if attribute is given as edge attribute - elif f"edge_{attribute}" in self.network.edge_attrs(): + if f"edge_{attribute}" in self.network.edge_attrs(): edges[attribute] = self.network.data[f"edge_{attribute}"] # special case for size: If no edge_size is given use edge_weight if available - elif attribute == "size": - if "edge_weight" in self.network.edge_attrs(): - edges[attribute] = self.network.data["edge_weight"] - elif "weight" in self.edge_args: - if isinstance(self.edge_args["weight"], dict): - new_attrs = edges.index.map(lambda x: f"{x[0]}-{x[1]}").map(self.edge_args["weight"]) - edges.loc[~new_attrs.isna(), attribute] = new_attrs[~new_attrs.isna()] - else: - edges[attribute] = self.edge_args["weight"] + elif attribute == "size" and "edge_weight" in self.network.edge_attrs(): + edges[attribute] = self.network.data["edge_weight"] + # check if attribute is given as argument + if attribute in self.edge_args: + edges = self.assign_argument(attribute, self.edge_args[attribute], edges) + elif attribute == "size" and "weight" in self.edge_args: + edges = self.assign_argument("size", self.edge_args["weight"], edges) # convert attributes to useful values edges["color"] = self._convert_to_rgb_tuple(edges["color"]) @@ -146,6 +143,31 @@ def _compute_edge_data(self) -> None: # save edge data self.data["edges"] = edges + def assign_argument(self, attr_key: str, attr_value: Any, df: pd.DataFrame) -> pd.DataFrame: + """Assign argument to node or edge attribute. + + Assigns the given value to the specified attribute key in the provided DataFrame. + `attr_value` can be a constant value, a list of values (of length equal to the number of nodes/edges), + or a dictionary mapping node/edge identifiers to values. + + Args: + attr_key (str): Attribute key. + attr_value (Any): Attribute value. + df (pd.DataFrame): DataFrame to assign the attribute to (nodes or edges). + """ + if isinstance(attr_value, dict): + # if dict does not contain values for all edges, only update those that are given + new_attrs = df.index.map(attr_value) + df.loc[~new_attrs.isna(), attr_key] = new_attrs[~new_attrs.isna()] + elif isinstance(attr_value, Sized) and not isinstance(attr_value, str): + if len(attr_value) != len(df): + logger.error(f"The provided list for {attr_key} has length {len(attr_value)}, but there are {len(df)} nodes/edges!") + raise AttributeError + df[attr_key] = attr_value + else: + df[attr_key] = attr_value + return df + def _convert_to_rgb_tuple(self, colors: pd.Series) -> dict: """Convert colors to rgb colormap if given as numerical values.""" # check if colors are given as numerical values @@ -164,10 +186,32 @@ def _convert_color(self, color: tuple[int, int, int]) -> str: if isinstance(color, tuple): return rgb_to_hex(color[:3]) elif isinstance(color, str): - return color + if color.startswith("#"): + return color + else: + # try to convert color name to hex + try: + rgb = to_rgb(color) + return rgb_to_hex(rgb) + except ValueError: + logger.error(f"The provided color {color} is not valid!") + raise AttributeError + elif color is None or pd.isna(color): + return pd.NA # will be filled with self._fill_node_values() else: logger.error(f"The provided color {color} is not valid!") raise AttributeError + + def _load_image(self, image_path: str) -> str: + """Check if image path is a URL or local file and load local files to base64 strings.""" + if image_path.startswith("http://") or image_path.startswith("https://") or image_path.startswith("data:"): + return image_path # already a URL or base64 string + else: + # check if file exists + if not os.path.isfile(image_path): + logger.error(f"The provided image path {image_path} does not exist!") + raise AttributeError + return image_to_base64(image_path) def _compute_layout(self) -> None: """Create layout.""" diff --git a/src/pathpyG/visualisations/temporal_network_plot.py b/src/pathpyG/visualisations/temporal_network_plot.py index 1eb779be6..ae2788ad0 100644 --- a/src/pathpyG/visualisations/temporal_network_plot.py +++ b/src/pathpyG/visualisations/temporal_network_plot.py @@ -8,7 +8,6 @@ from pathpyG.visualisations.layout import layout as network_layout from pathpyG.visualisations.network_plot import NetworkPlot -from pathpyG.visualisations.utils import rgb_to_hex # pseudo load class for type checking if TYPE_CHECKING: @@ -28,18 +27,12 @@ def __init__(self, network: TemporalGraph, **kwargs: Any) -> None: """Initialize network plot class.""" super().__init__(network, **kwargs) - def generate(self) -> None: - """Generate the plot.""" - self._compute_edge_data() - self._compute_node_data() - self._compute_layout() - self._fill_node_values() - self._compute_config() - def _compute_node_data(self) -> None: """Generate the data structure for the nodes.""" # initialize values with index `node-0` to indicate time step 0 - start_nodes: pd.DataFrame = pd.DataFrame(index=pd.MultiIndex.from_tuples([(node, 0) for node in self.network.nodes], names=["uid", "time"])) + start_nodes: pd.DataFrame = pd.DataFrame( + index=pd.MultiIndex.from_tuples([(node, 0) for node in self.network.nodes], names=["uid", "time"]) + ) new_nodes: pd.DataFrame = pd.DataFrame() # add attributes to start nodes and new nodes if given as dictionary for attribute in self.attributes: @@ -48,6 +41,9 @@ def _compute_node_data(self) -> None: start_nodes[attribute] = [self.config.get("node").get(attribute, None)] * len(start_nodes) # type: ignore[union-attr] else: start_nodes[attribute] = self.config.get("node").get(attribute, None) # type: ignore[union-attr] + # check if attribute is given as node attribute + if f"node_{attribute}" in self.network.node_attrs(): + start_nodes[attribute] = self.network.data[f"node_{attribute}"] # check if attribute is given as argument if attribute in self.node_args: if isinstance(self.node_args[attribute], dict): @@ -60,12 +56,11 @@ def _compute_node_data(self) -> None: ) else: # add node attributes to start nodes according to node keys - start_nodes[attribute] = start_nodes.index.get_level_values("uid").map(self.node_args[attribute]) + start_nodes[attribute] = start_nodes.index.get_level_values("uid").map( + self.node_args[attribute] + ) else: start_nodes[attribute] = self.node_args[attribute] - # check if attribute is given as node attribute - elif f"node_{attribute}" in self.network.node_attrs(): - start_nodes[attribute] = self.network.data[f"node_{attribute}"] # combine start nodes and new nodes if not new_nodes.empty: @@ -75,27 +70,15 @@ def _compute_node_data(self) -> None: else: nodes = start_nodes - # convert attributes to useful values - nodes["color"] = self._convert_to_rgb_tuple(nodes["color"]) - nodes["color"] = nodes["color"].map(self._convert_color) - # save node data self.data["nodes"] = nodes - def _convert_color(self, color: tuple[int, int, int]) -> str: - """Convert color rgb tuple to hex.""" - if isinstance(color, tuple): - return rgb_to_hex(color[:3]) - elif isinstance(color, str): - return color - elif color is None or pd.isna(color): - return pd.NA # will be filled with self._fill_node_values() - else: - logger.error(f"The provided color {color} is not valid!") - raise AttributeError + def _post_process_node_data(self) -> pd.DataFrame: + """Post-process specific node attributes after constructing the DataFrame.""" + # Post-processing from parent class + super()._post_process_node_data() - def _fill_node_values(self) -> pd.DataFrame: - """Fill all NaN/None values with the previous value and add start/end time columns.""" + # Fill all NaN/None values with the previous value and add start/end time columns. nodes = self.data["nodes"] nodes = nodes.sort_values(by=["uid", "time"]).groupby("uid", sort=False).ffill() nodes["start"] = nodes.index.get_level_values("time") @@ -111,36 +94,26 @@ def _fill_node_values(self) -> pd.DataFrame: def _compute_edge_data(self) -> None: """Generate the data structure for the edges.""" # initialize values - edges: pd.DataFrame = pd.DataFrame(index=pd.MultiIndex.from_tuples(self.network.temporal_edges, names=["source", "target", "time"])) + edges: pd.DataFrame = pd.DataFrame( + index=pd.MultiIndex.from_tuples(self.network.temporal_edges, names=["source", "target", "time"]) + ) for attribute in self.attributes: # set default value for each attribute based on the pathpyG.toml config if isinstance(self.config.get("edge").get(attribute, None), list | tuple): # type: ignore[union-attr] edges[attribute] = [self.config.get("edge").get(attribute, None)] * len(edges) # type: ignore[union-attr] else: edges[attribute] = self.config.get("edge").get(attribute, None) # type: ignore[union-attr] - # check if attribute is given as argument - if attribute in self.edge_args: - if isinstance(self.edge_args[attribute], dict): - # if dict does not contain values for all edges, only update those that are given - new_attrs = edges.index.map(lambda x: f"{x[0]}-{x[1]}-{x[2]}").map(self.edge_args[attribute]) - edges.loc[~new_attrs.isna(), attribute] = new_attrs[~new_attrs.isna()] - else: - edges[attribute] = self.edge_args[attribute] # check if attribute is given as edge attribute - elif f"edge_{attribute}" in self.network.edge_attrs(): + if f"edge_{attribute}" in self.network.edge_attrs(): edges[attribute] = self.network.data[f"edge_{attribute}"] # special case for size: If no edge_size is given use edge_weight if available - elif attribute == "size": - if "edge_weight" in self.network.edge_attrs(): - edges[attribute] = self.network.data["edge_weight"] - elif "weight" in self.edge_args: - if isinstance(self.edge_args["weight"], dict): - new_attrs = edges.index.map(lambda x: f"{x[0]}-{x[1]}-{x[2]}").map( - self.edge_args["weight"] - ) - edges.loc[~new_attrs.isna(), attribute] = new_attrs[~new_attrs.isna()] - else: - edges[attribute] = self.edge_args["weight"] + elif attribute == "size" and "edge_weight" in self.network.edge_attrs(): + edges[attribute] = self.network.data["edge_weight"] + # check if attribute is given as argument + if attribute in self.edge_args: + edges = self.assign_argument(attribute, self.edge_args[attribute], edges) + elif attribute == "size" and "weight" in self.edge_args: + edges = self.assign_argument("size", self.edge_args["weight"], edges) # convert needed attributes to useful values edges["color"] = self._convert_to_rgb_tuple(edges["color"]) @@ -156,10 +129,12 @@ def _compute_layout(self) -> None: """Create temporal layout.""" # get layout from the config layout_type = self.config.get("layout") - max_time = int(max(self.data["nodes"].index.get_level_values("time").max() + 1, self.data["edges"]["end"].max())) + max_time = int( + max(self.data["nodes"].index.get_level_values("time").max() + 1, self.data["edges"]["end"].max()) + ) window_size = self.config.get("layout_window_size") if isinstance(window_size, int): - window_size = [ceil(window_size/2), window_size//2] + window_size = [ceil(window_size / 2), window_size // 2] elif isinstance(window_size, list | tuple): if window_size[0] < 0: window_size[0] = max_time # use all previous time steps @@ -176,11 +151,16 @@ def _compute_layout(self) -> None: pos = network_layout(self.network, layout="random") # initial layout num_steps = max_time - window_size[1] layout_df = pd.DataFrame() - for step in range(num_steps+1): + for step in range(num_steps + 1): # only compute layout if there are edges in the current window, otherwise use the previous layout - if ((max(0, step - window_size[0]) <= self.network.data.time) & (self.network.data.time <= step + window_size[1] + 1)).sum() > 0: + if ( + (max(0, step - window_size[0]) <= self.network.data.time) + & (self.network.data.time <= step + window_size[1] + 1) + ).sum() > 0: # get subgraph for the current time step - sub_graph = self.network.get_window(start_time=max(0, step - window_size[0]), end_time=step + window_size[1] + 1) + sub_graph = self.network.get_window( + start_time=max(0, step - window_size[0]), end_time=step + window_size[1] + 1 + ) # get layout dict for each node if isinstance(layout_type, str): @@ -192,10 +172,16 @@ def _compute_layout(self) -> None: # update x,y position of the nodes new_layout_df = pd.DataFrame.from_dict(pos, orient="index", columns=["x", "y"]) if self.network.order > 1 and not isinstance(new_layout_df.index[0], str): - new_layout_df.index = new_layout_df.index.map(lambda x: self.config["higher_order"]["separator"].join(map(str, x))) + new_layout_df.index = new_layout_df.index.map( + lambda x: self.config["higher_order"]["separator"].join(map(str, x)) + ) # scale x and y to [0,1] - new_layout_df["x"] = (new_layout_df["x"] - new_layout_df["x"].min()) / (new_layout_df["x"].max() - new_layout_df["x"].min()) - new_layout_df["y"] = (new_layout_df["y"] - new_layout_df["y"].min()) / (new_layout_df["y"].max() - new_layout_df["y"].min()) + new_layout_df["x"] = (new_layout_df["x"] - new_layout_df["x"].min()) / ( + new_layout_df["x"].max() - new_layout_df["x"].min() + ) + new_layout_df["y"] = (new_layout_df["y"] - new_layout_df["y"].min()) / ( + new_layout_df["y"].max() - new_layout_df["y"].min() + ) # add time for the layout new_layout_df["time"] = step # append to layout df diff --git a/src/pathpyG/visualisations/utils.py b/src/pathpyG/visualisations/utils.py index fe3ef3dcd..c33b2e029 100644 --- a/src/pathpyG/visualisations/utils.py +++ b/src/pathpyG/visualisations/utils.py @@ -8,8 +8,10 @@ # Copyright (c) 2016-2023 Pathpy Developers # ============================================================================= +import base64 import os import tempfile +from pathlib import Path from typing import Callable @@ -86,6 +88,29 @@ def unit_str_to_float(value: str, unit: str) -> float: raise ValueError(f"The provided conversion '{conversion_key}' is not supported.") +def image_to_base64(image_path): + """Convert local image to base64 data URL.""" + path = Path(image_path) + if not path.exists(): + raise FileNotFoundError(f"Image not found: {image_path}") + + # Detect image type + suffix = path.suffix.lower() + mime_types = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml' + } + mime_type = mime_types.get(suffix, 'image/png') + + # Read and encode + with open(image_path, 'rb') as f: + encoded = base64.b64encode(f.read()).decode() + + return f"data:{mime_type};base64,{encoded}" + # ============================================================================= # eof # From 112e10658f929d44ec10bfc57f445e45b66d1d70 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Tue, 14 Oct 2025 14:57:23 +0000 Subject: [PATCH 19/44] start documentation update --- docs/gen_ref_pages.py | 2 +- docs/reference/ignored_modules.yaml | 16 - .../reference/pathpyG/visualisations/index.md | 328 +++++++++ docs/reference/pathpyG/visualisations/plot.md | 186 ------ .../plot/d3js_custom_node_images.html | 506 ++++++++++++++ .../visualisations/plot/d3js_demo.html | 405 ----------- .../visualisations/plot/d3js_static.html | 506 ++++++++++++++ .../visualisations/plot/d3js_temporal.html | 628 ++++++++++++++++++ .../visualisations/plot/demo_manim.mp4 | Bin 424951 -> 0 bytes .../visualisations/plot/demo_matplotlib.png | Bin 17873 -> 0 bytes .../plot/matplotlib_undirected.png | Bin 0 -> 20754 bytes .../plot/tikz_custom_properties.svg | 45 ++ 12 files changed, 2014 insertions(+), 608 deletions(-) create mode 100644 docs/reference/pathpyG/visualisations/index.md delete mode 100644 docs/reference/pathpyG/visualisations/plot.md create mode 100644 docs/reference/pathpyG/visualisations/plot/d3js_custom_node_images.html delete mode 100644 docs/reference/pathpyG/visualisations/plot/d3js_demo.html create mode 100644 docs/reference/pathpyG/visualisations/plot/d3js_static.html create mode 100644 docs/reference/pathpyG/visualisations/plot/d3js_temporal.html delete mode 100644 docs/reference/pathpyG/visualisations/plot/demo_manim.mp4 delete mode 100644 docs/reference/pathpyG/visualisations/plot/demo_matplotlib.png create mode 100644 docs/reference/pathpyG/visualisations/plot/matplotlib_undirected.png create mode 100644 docs/reference/pathpyG/visualisations/plot/tikz_custom_properties.svg diff --git a/docs/gen_ref_pages.py b/docs/gen_ref_pages.py index f2059e22c..fcce196a1 100644 --- a/docs/gen_ref_pages.py +++ b/docs/gen_ref_pages.py @@ -12,7 +12,7 @@ ignored_modules = yaml.safe_load(ignored_modules_path.read_text("utf-8")) for path in sorted(Path("src").rglob("*.py")): - if str(path.relative_to(".")) in ignored_modules: + if ignored_modules and str(path.relative_to(".")) in ignored_modules: print(f"Skipping {path} as it is in the ignored modules list.") continue module_path = path.relative_to("src").with_suffix("") diff --git a/docs/reference/ignored_modules.yaml b/docs/reference/ignored_modules.yaml index d54a85b16..e69de29bb 100644 --- a/docs/reference/ignored_modules.yaml +++ b/docs/reference/ignored_modules.yaml @@ -1,16 +0,0 @@ -- src/pathpyG/visualisations/_matplotlib/__init__.py -- src/pathpyG/visualisations/_matplotlib/network_plots.py -- src/pathpyG/visualisations/_matplotlib/core.py -- src/pathpyG/visualisations/_tikz/__init__.py -- src/pathpyG/visualisations/_tikz/network_plots.py -- src/pathpyG/visualisations/_tikz/core.py -- src/pathpyG/visualisations/_d3js/__init__.py -- src/pathpyG/visualisations/_d3js/network_plots.py -- src/pathpyG/visualisations/_d3js/core.py -- src/pathpyG/visualisations/_manim/network_plots.py -- src/pathpyG/visualisations/_manim/core.py -- src/pathpyG/visualisations/network_plots.py -- src/pathpyG/visualisations/hist_plots.py -- src/pathpyG/visualisations/utils.py -- src/pathpyG/visualisations/layout.py - diff --git a/docs/reference/pathpyG/visualisations/index.md b/docs/reference/pathpyG/visualisations/index.md new file mode 100644 index 000000000..b57d09486 --- /dev/null +++ b/docs/reference/pathpyG/visualisations/index.md @@ -0,0 +1,328 @@ +# PathpyG Visualisations + +This page provides an overview of the available visualisations and the supported backends. +It also describes which displaying and saving options are available as well as the supported keyword arguments for customized plot styling. + +--- + +## Overview + +The main plotting function is `pathpyG.plot()`, which can be used to create visualisations of both static and temporal networks. +The function supports multiple backends, each with its own capabilities and output formats. +The backend will be automatically chosen depending on the input data and the specified options. + +The default backend is `d3.js`, which is suitable for both static and temporal networks and produces interactive visualisations that can be viewed in a web browser. + +!!! example "Interactive Temporal Graph Visualisation with d3.js" + + ```python + import pathpyG as pp + + # Example temporal network data + tedges = [ + ("a", "b", 1), + ("a", "b", 2), + ("b", "a", 3), + ("b", "c", 3), + ("d", "c", 4), + ("a", "b", 4), + ("c", "b", 4), + ("c", "d", 5), + ("b", "a", 5), + ("c", "b", 6), + ] + t = pp.TemporalGraph.from_edge_list(tedges) + + # Create temporal plot and display inline + pp.plot(t) + ``` + + + ??? example "Interactive Static Graph Visualisation with d3.js" + + ```python + import pathpyG as pp + + # Example network data + edges = [ + ("a", "b"), + ("a", "c"), + ("b", "c"), + ("c", "d"), + ("d", "e"), + ("e", "a"), + ] + g = pp.Graph.from_edge_list(edges) + pp.plot(g) + ``` + + +## Customisation and Other Backends + +For more advanced visualisations, `PathpyG` offers customisation options for node and edge properties (like `color`, `size`, and `opacity`), as well as support for additional backends, including `manim`, `matplotlib`, and `tikz`. +We provide some usage examples below, and a detailed overview of the supported keyword arguments for each backend in section [Customisation Options](#customisation-options). + +### Visualising Undirected Networks + +We provide support for directed and undirected static networks. +Directed networks are visualised with arrows, while undirected networks use simple lines in all backends. +We provide an example using `matplotlib` below. + +!!! example "Undirected Static Graph Visualisation with `matplotlib`" + + You will see below that compared to the examples above, the nodes do not have arrows indicating directionality. + ```python + import torch + import pathpyG as pp + + # Example undirected network data + edge_index = torch.tensor([[0, 1, 3, 3], [1, 2, 1, 0]]) + g = pp.Graph.from_edge_index(edge_index).to_undirected() + + # Create static plot and display inline + pp.plot(g, backend="matplotlib") + ``` + Example Matplotlib Undirected + + !!! tip "Node Labels" + In the above picture, the nodes do not have labels. + This is because labels are automatically generated based on the node IDs provided in `g.mapping.node_ids`. + When we created the graph using the `from_edge_index()` method, we did not provide any specific node IDs, so no IDs were assigned and no labels were generated. + You can override the default behaviour by specifying `show_labels=True` in the `pp.plot()` function call. + +### Node and Edge Customisation +#### Static Networks + +In all backends, you can customise the `size`, `color`, and `opacity` of nodes and edges. +You can specify these properties in three different ways either as arguments in the `pp.plot()` function or as attributes of the graph object: + +- A single value (applied uniformly to all nodes/edges) +- A list of values with length equal to the number of nodes/edges (values are applied in order) +- A dictionary mapping node/edge IDs to values (values are applied based on the IDs) + +For `color`, you can use color names (e.g., `"blue"`), HEX codes (e.g., `"#ff0000"`), or RGB tuples (e.g., `(255, 0, 0)`). +You can also pass numeric values, which will be mapped to colors using a `matplotlib` colormap (specified via `cmap`). + +!!! example "Custom Node and Edge Properties" + + In the example below, we set custom properties for nodes and edges using all three methods. + ```python + import torch + import pathpyG as pp + + # Example network data + edges = [ + ("a", "b"), + ("a", "c"), + ("b", "d"), + ("c", "d"), + ("d", "a"), + ] + g = pp.Graph.from_edge_list(edges) + + # Add properties as attributes to the graph + g.data["node_size"] = torch.tensor([10, 15, 20, 15]) + g.data["edge_color"] = torch.tensor([0, 1, 2, 1, 0]) + g.data["node_opacity"] = torch.zeros(g.n) + + # Create static plot with custom settings and display inline + pp.plot( + g, + backend="tikz", + node_color={"a": "red", "b": "#00FF00"}, + edge_opacity={("a", "b"): 0.1, ("a", "c"): 0.5, ("b", "d"): 1.0}, + node_opacity=1.0, # override graph attribute + edge_size=torch.tensor([1, 2, 3, 2, 1]), + ) + ``` + Example TikZ Custom Properties + + ??? tip "Display Images inside your Nodes" + `d3.js` additionally supports images as node representations. + You can specify the image source using the `node_image` argument. + The image source can be a URL or a local file path. + ```python + import torch + import pathpyG as pp + + # Example network data + edges = [ + ("b", "a"), + ("c", "a"), + ] + mapping = pp.IndexMap(["a", "b", "c", "d"]) + g = pp.Graph.from_edge_list(edges, mapping=mapping) + g.data["node_size"] = torch.tensor([25]*4) + pp.plot( + g, + node_size={"d": 50}, + edge_size=5, + node_image={ + "a": "https://avatars.githubusercontent.com/u/52822508?s=48&v=4", + "b": "https://raw.githubusercontent.com/pyg-team/pyg_sphinx_theme/master/pyg_sphinx_theme/static/img/pyg_logo.png", + "c": "https://pytorch-geometric.readthedocs.io/en/latest/_static/img/pytorch_logo.svg", + "d": "docs/img/pathpy_logo_new.png", + }, + show_labels=False, + ) + ``` + + +#### Temporal Networks + +For temporal networks, you can also customise the `size`, `color`, and `opacity` of nodes and edges at each timestep. +In our understanding, a temporal network has a fixed set of nodes, but edges appear at different timesteps. +Thus, all nodes exist at all times, but edges may only exist at certain timesteps. +Therefore, edge properties can be specified for each timestep where the edge exists. +In contrast, node properties can change at specified points in time, but will remain the same for all subsequent timesteps until they are changed again. + +!!! example "Custom Node and Edge Properties in Temporal Networks" + +## Customisation Options + +| Backend | Static Networks | Temporal Networks | Available File Formats| +|---------------|------------|-------------|--------------| +| **d3.js** | ✔️ | ✔️ | `html` | +| **manim** | ❌ | ✔️ | `mp4`, `gif` | +| **matplotlib**| ✔️ | ❌ | `png` | +| **tikz** | ✔️ | ❌ | `svg`, `pdf`, `tex`| + + + +## Keyword Arguments Overview +| Argument | d3.js | manim | matplotlib | tikz | Short Description | +| ------------------------- | :-----: | :-----: | :-----: | :-----: | --------------------------------------------- | +| **General** | | | | | | +| `delta` | ✔️ | ✔️ | ❌ | | Duration of timestep (ms) | +| `start` | ✔️ | ✔️ | ❌ | | Animation start timestep | +| `end` | ✔️ | ✔️ | ❌ | | Animation end timestep (last edge by default) | +| `intervals` | ✔️ | ✔️ | ❌ | | Number of animation intervals | +| `dynamic_layout_interval` | ❌ | ✔️ | ❌ | | Steps between layout recalculations | +| `background_color` | ❌ | ✔️ | ❌ | | Background color (name, hex, RGB) | +| `width` | ✔️ | ❌ | ❌ | | Width of the output | +| `height` | ✔️ | ❌ | ❌ | | Height of the output | +| `lookahead` | ❌ | ✔️ | ❌ | ❌ | for layout computation | +| `lookbehind` | ❌ | ✔️ | ❌ | ❌ | for layout computation | +| **Nodes** | | | | | | +| `node_size` | ✔️ | ✔️ | ✔️ | ✔️ | Radius of nodes (uniform or per-node) | +| `node_color` | 🟨 | ✔️ | 🟨 | 🟨 | Node fill color | +| `node_cmap` | ✔️ | ✔️ | ✔️ | ✔️ | Colormap for scalar node values | +| `node_opacity` | ✔️ | ✔️ | ✔️ | ✔️ | Node fill opacity (0 transparent, 1 solid) | +| `node_label` | ✔️ | ✔️ | ❌ | | Label text shown with nodes | +| **Edges** | | | | | | +| `edge_size` | ✔️ | ✔️ | ✔️ | ✔️ | Edge width (uniform or per-edge) | +| `edge_color` | ✔️ | ✔️ | ✔️ | ✔️ | Edge line color | +| `edge_cmap` | ✔️ | ✔️ | ✔️ | ✔️ | Colormap for scalar edge values | +| `edge_opacity` | ✔️ | ✔️ | ✔️ | ✔️ | Edge line opacity (0 transparent, 1 solid) | + +**Legend:** ✔️ Supported 🟨 Partially Supported ❌ Not Supported + +### Detailed Description of Keywords +The default values may differ for each individual Backend. + +#### General + +- `delta` (int): Duration (in milliseconds) of each animation timestep. +- `start` (int): Starting timestep of the animation sequence. +- `end`(int or None): Ending timestep; defaults to the last timestamp of the input data. +- `intervals`(int): Number of discrete animation steps. +- `dynamic_layout_interval` (int): How often (in timesteps) the layout recomputes. +- `background_color`(str or tuple): Background color of the plot, accepts color names, hex codes or RGB tuples. +- `width` (int): width of the output +- `height` (int): height of the output +- `look_ahead` (int): timesteps in the future to include while calculating layout +- `look_behind` (int): timesteps into the past to include while calculating layout + + + +#### Nodes + +- `node_size`: Node radius; either a single float applied to all nodes or a dictionary with sizes per node ID. +- `node_color`: Fill color(s) for nodes. Can be a single color string referred to by name (`"blue"`), HEX (`"#ff0000"`), RGB(`(255,0,0)`), float, a list of colors cycling through nodes or a dictionary with color per node in one of the given formats. +**Manim** additionally supports timed node color changes in the format `{"node_id-timestep": color}` (i.e. `{a-2.0" : "yellow"}`) +- `node_cmap`: Colormap used when node colors are numeric. +- `node_opacity`: Opacity level for nodes, either uniform or per node. +- `node_label` (dict): Assign text labels to nodes + +#### Edges + +- `edge_size`: Width of edges, can be uniform or specified per edge in a dictionary with size per edge ID. +- `edge_color`: Color(s) of edges; supports single or multiple colors (see `node_color` above). +- `edge_cmap`: Colormap used when edge colors are numeric. +- `edge_opacity`: Opacity for edges, uniform or per edge. + +--- +## Usage Examples + + + +**manim** +```python +import pathpyG as pp + +# Example network data +tedges = [('a', 'b', 1),('a', 'b', 2), ('b', 'a', 3), ('b', 'c', 3), ('d', 'c', 4), ('a', 'b', 4), ('c', 'b', 4), + ('c', 'd', 5), ('b', 'a', 5), ('c', 'b', 6)] +t = pp.TemporalGraph.from_edge_list(tedges) + +# Create temporal plot with custom settings and display inline +pp.plot( + t, + backend="manim", + dynamic_layout_interval=1, + edge_color={"b-a-3.0": "red", "c-b-4.0": (220,30,50)}, + node_color = {"c-3.0" : "yellow"}, + edge_size=6, + node_label={"a": "a", "b": "b", "c": "c", "d" : "d"}, + font_size=20, +) +``` + + + +
+ +
+ + +**matplotlib** +```python +import pathpyG as pp + +# Example network data (static) +g = Graph.from_edge_index(torch.tensor([[0,1,0], [2,2,1]])) + +# Create static plot with custom settings and display inline +pp.plot( + g, + backend= 'matplotlib', + edge_color= "grey", + node_color = "blue" +) +``` +Example Matplotlib + + + + + +--- +For more details and usage examples, see [Manim Visualisation Tutorial](/tutorial/manim_tutorial),[Visualisation Tutorial](/tutorial/visualisation) and [Develop your own plot Functions](/plot_tutorial) diff --git a/docs/reference/pathpyG/visualisations/plot.md b/docs/reference/pathpyG/visualisations/plot.md deleted file mode 100644 index 2c5823ade..000000000 --- a/docs/reference/pathpyG/visualisations/plot.md +++ /dev/null @@ -1,186 +0,0 @@ -# PathpyG Visualisations - -This page provides an overview of the available visualisations and the supported backends. -It also describes which displaying and saving options are available as well as the supported keyword arguments for customized plot styling. - ---- - -**Methods** - -- `show(**kwargs)`: Show Visualisation -- `save(filename: str, **kwargs)`: Save Visualisation to hard drive - -**kwargs** for saving Manim plots: - -- `filename` (`str`): Name to assign to the output file. This keyword is necessary for saving. - -For display use the `show()` method instead of `save()`. - - -## Supported Features by Backend - -| Backend | Static Networks | Temporal Networks | Available File Formats| -|---------------|------------|-------------|----------------------| -| **d3.js** | ✔️ | ✔️ | `svg`, `html`, `json` (dynamic) | -| **manim** | ❌ | ✔️ | `mp4`, `gif` | -| **matplotlib**| ✔️ | ❌ | `png`, `svg`, `pdf`, etc.| -| **tikz** | ✔️ | ❌ | `pdf`, `tex`| - - - -## Keyword Arguments Overview -| Argument | d3.js | manim | matplotlib | tikz | Short Description | -| ------------------------- | :-----: | :-----: | :-----: | :-----: | --------------------------------------------- | -| **General** | | | | | | -| `delta` | ✔️ | ✔️ | ❌ | | Duration of timestep (ms) | -| `start` | ✔️ | ✔️ | ❌ | | Animation start timestep | -| `end` | ✔️ | ✔️ | ❌ | | Animation end timestep (last edge by default) | -| `intervals` | ✔️ | ✔️ | ❌ | | Number of animation intervals | -| `dynamic_layout_interval` | ❌ | ✔️ | ❌ | | Steps between layout recalculations | -| `background_color` | ❌ | ✔️ | ❌ | | Background color (name, hex, RGB) | -| `width` | ✔️ | ❌ | ❌ | | Width of the output | -| `height` | ✔️ | ❌ | ❌ | | Height of the output | -| `lookahead` | ❌ | ✔️ | ❌ | ❌ | for layout computation | -| `lookbehind` | ❌ | ✔️ | ❌ | ❌ | for layout computation | -| **Nodes** | | | | | | -| `node_size` | ✔️ | ✔️ | ✔️ | ✔️ | Radius of nodes (uniform or per-node) | -| `node_color` | 🟨 | ✔️ | 🟨 | 🟨 | Node fill color | -| `node_cmap` | ✔️ | ✔️ | ✔️ | ✔️ | Colormap for scalar node values | -| `node_opacity` | ✔️ | ✔️ | ✔️ | ✔️ | Node fill opacity (0 transparent, 1 solid) | -| `node_label` | ✔️ | ✔️ | ❌ | | Label text shown with nodes | -| **Edges** | | | | | | -| `edge_size` | ✔️ | ✔️ | ✔️ | ✔️ | Edge width (uniform or per-edge) | -| `edge_color` | ✔️ | ✔️ | ✔️ | ✔️ | Edge line color | -| `edge_cmap` | ✔️ | ✔️ | ✔️ | ✔️ | Colormap for scalar edge values | -| `edge_opacity` | ✔️ | ✔️ | ✔️ | ✔️ | Edge line opacity (0 transparent, 1 solid) | - -**Legend:** ✔️ Supported 🟨 Partially Supported ❌ Not Supported - -### Detailed Description of Keywords -The default values may differ for each individual Backend. - -#### General - -- `delta` (int): Duration (in milliseconds) of each animation timestep. -- `start` (int): Starting timestep of the animation sequence. -- `end`(int or None): Ending timestep; defaults to the last timestamp of the input data. -- `intervals`(int): Number of discrete animation steps. -- `dynamic_layout_interval` (int): How often (in timesteps) the layout recomputes. -- `background_color`(str or tuple): Background color of the plot, accepts color names, hex codes or RGB tuples. -- `width` (int): width of the output -- `height` (int): height of the output -- `look_ahead` (int): timesteps in the future to include while calculating layout -- `look_behind` (int): timesteps into the past to include while calculating layout - - - -#### Nodes - -- `node_size`: Node radius; either a single float applied to all nodes or a dictionary with sizes per node ID. -- `node_color`: Fill color(s) for nodes. Can be a single color string referred to by name (`"blue"`), HEX (`"#ff0000"`), RGB(`(255,0,0)`), float, a list of colors cycling through nodes or a dictionary with color per node in one of the given formats. -**Manim** additionally supports timed node color changes in the format `{"node_id-timestep": color}` (i.e. `{a-2.0" : "yellow"}`) -- `node_cmap`: Colormap used when node colors are numeric. -- `node_opacity`: Opacity level for nodes, either uniform or per node. -- `node_label` (dict): Assign text labels to nodes - -#### Edges - -- `edge_size`: Width of edges, can be uniform or specified per edge in a dictionary with size per edge ID. -- `edge_color`: Color(s) of edges; supports single or multiple colors (see `node_color` above). -- `edge_cmap`: Colormap used when edge colors are numeric. -- `edge_opacity`: Opacity for edges, uniform or per edge. - ---- -## Usage Examples -**d3.js** -```python -import pathpyG as pp - -# Example network data -tedges = [('a', 'b', 1),('a', 'b', 2), ('b', 'a', 3), ('b', 'c', 3), ('d', 'c', 4), ('a', 'b', 4), ('c', 'b', 4), - ('c', 'd', 5), ('b', 'a', 5), ('c', 'b', 6)] -t = pp.TemporalGraph.from_edge_list(tedges) - -# Create temporal plot with custom settings and display inline -pp.plot( - t, - backend= 'd3js', - node_size = {"a": 15, "b": 5}, - node_color = "grey", - edge_opacity = 0.7, -) -``` - - - - -**manim** -```python -import pathpyG as pp - -# Example network data -tedges = [('a', 'b', 1),('a', 'b', 2), ('b', 'a', 3), ('b', 'c', 3), ('d', 'c', 4), ('a', 'b', 4), ('c', 'b', 4), - ('c', 'd', 5), ('b', 'a', 5), ('c', 'b', 6)] -t = pp.TemporalGraph.from_edge_list(tedges) - -# Create temporal plot with custom settings and display inline -pp.plot( - t, - backend="manim", - dynamic_layout_interval=1, - edge_color={"b-a-3.0": "red", "c-b-4.0": (220,30,50)}, - node_color = {"c-3.0" : "yellow"}, - edge_size=6, - node_label={"a": "a", "b": "b", "c": "c", "d" : "d"}, - font_size=20, -) -``` - - - -
- -
- - -**matplotlib** -```python -import pathpyG as pp - -# Example network data (static) -g = Graph.from_edge_index(torch.tensor([[0,1,0], [2,2,1]])) - -# Create static plot with custom settings and display inline -pp.plot( - g, - backend= 'matplotlib', - edge_color= "grey", - node_color = "blue" -) -``` -Example Matplotlib - - - - - ---- -For more details and usage examples, see [Manim Visualisation Tutorial](/tutorial/manim_tutorial),[Visualisation Tutorial](/tutorial/visualisation) and [Develop your own plot Functions](/plot_tutorial) diff --git a/docs/reference/pathpyG/visualisations/plot/d3js_custom_node_images.html b/docs/reference/pathpyG/visualisations/plot/d3js_custom_node_images.html new file mode 100644 index 000000000..742af0d3f --- /dev/null +++ b/docs/reference/pathpyG/visualisations/plot/d3js_custom_node_images.html @@ -0,0 +1,506 @@ + + +
+ + \ No newline at end of file diff --git a/docs/reference/pathpyG/visualisations/plot/d3js_demo.html b/docs/reference/pathpyG/visualisations/plot/d3js_demo.html deleted file mode 100644 index 3a958b429..000000000 --- a/docs/reference/pathpyG/visualisations/plot/d3js_demo.html +++ /dev/null @@ -1,405 +0,0 @@ - - -
- - \ No newline at end of file diff --git a/docs/reference/pathpyG/visualisations/plot/d3js_static.html b/docs/reference/pathpyG/visualisations/plot/d3js_static.html new file mode 100644 index 000000000..a552c77e3 --- /dev/null +++ b/docs/reference/pathpyG/visualisations/plot/d3js_static.html @@ -0,0 +1,506 @@ + + +
+ + \ No newline at end of file diff --git a/docs/reference/pathpyG/visualisations/plot/d3js_temporal.html b/docs/reference/pathpyG/visualisations/plot/d3js_temporal.html new file mode 100644 index 000000000..53fe6040f --- /dev/null +++ b/docs/reference/pathpyG/visualisations/plot/d3js_temporal.html @@ -0,0 +1,628 @@ + + +
+ + \ No newline at end of file diff --git a/docs/reference/pathpyG/visualisations/plot/demo_manim.mp4 b/docs/reference/pathpyG/visualisations/plot/demo_manim.mp4 deleted file mode 100644 index aadd2e700889dba16b6e13662fc552431403dfd7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 424951 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 diff --git a/docs/reference/pathpyG/visualisations/plot/matplotlib_undirected.png b/docs/reference/pathpyG/visualisations/plot/matplotlib_undirected.png new file mode 100644 index 0000000000000000000000000000000000000000..e16f359168bdd75da8b0e969311b50a958c05b88 GIT binary patch literal 20754 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 mpNwM$O#A=b59kf + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +a + + +b + + +c + + +d + + \ No newline at end of file From a447edd2394e1097779510b9dae2515cd981034f Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Wed, 15 Oct 2025 12:03:22 +0000 Subject: [PATCH 20/44] update docs and minor fixes --- .../reference/pathpyG/visualisations/index.md | 400 +++++++++----- .../plot/d3js_custom_node_images.html | 4 +- .../plot/d3js_node_opacity.html | 506 ++++++++++++++++++ .../visualisations/plot/d3js_static.html | 4 +- .../visualisations/plot/d3js_temporal.html | 6 +- .../plot/documentation_plots.ipynb | 408 ++++++++++++++ .../plot/manim_custom_properties.gif | Bin 0 -> 160128 bytes .../plot/manim_temporal_fa2.gif | Bin 0 -> 488117 bytes .../plot/matplotlib_undirected.png | Bin 20754 -> 24320 bytes .../plot/tikz_circle_layout.svg | 131 +++++ .../plot/tikz_custom_properties.svg | 44 +- .../visualisations/plot/tikz_fa2_layout.svg | 131 +++++ .../visualisations/plot/tikz_grid_layout.svg | 131 +++++ .../visualisations/plot/tikz_kk_layout.svg | 131 +++++ .../visualisations/plot/tikz_layout.svg | 45 ++ .../plot/tikz_random_layout.svg | 131 +++++ .../visualisations/plot/tikz_shell_layout.svg | 131 +++++ .../plot/tikz_spectral_layout.svg | 131 +++++ .../plot/tikz_spring_layout.svg | 131 +++++ src/pathpyG/core/temporal_graph.py | 5 +- src/pathpyG/pathpyG.toml | 8 +- .../visualisations/_d3js/templates/network.js | 2 +- .../_d3js/templates/temporal.js | 2 +- .../_manim/temporal_graph_scene.py | 17 +- src/pathpyG/visualisations/layout.py | 8 +- src/pathpyG/visualisations/network_plot.py | 6 + .../visualisations/temporal_network_plot.py | 50 +- 27 files changed, 2354 insertions(+), 209 deletions(-) create mode 100644 docs/reference/pathpyG/visualisations/plot/d3js_node_opacity.html create mode 100644 docs/reference/pathpyG/visualisations/plot/documentation_plots.ipynb create mode 100644 docs/reference/pathpyG/visualisations/plot/manim_custom_properties.gif create mode 100644 docs/reference/pathpyG/visualisations/plot/manim_temporal_fa2.gif create mode 100644 docs/reference/pathpyG/visualisations/plot/tikz_circle_layout.svg create mode 100644 docs/reference/pathpyG/visualisations/plot/tikz_fa2_layout.svg create mode 100644 docs/reference/pathpyG/visualisations/plot/tikz_grid_layout.svg create mode 100644 docs/reference/pathpyG/visualisations/plot/tikz_kk_layout.svg create mode 100644 docs/reference/pathpyG/visualisations/plot/tikz_layout.svg create mode 100644 docs/reference/pathpyG/visualisations/plot/tikz_random_layout.svg create mode 100644 docs/reference/pathpyG/visualisations/plot/tikz_shell_layout.svg create mode 100644 docs/reference/pathpyG/visualisations/plot/tikz_spectral_layout.svg create mode 100644 docs/reference/pathpyG/visualisations/plot/tikz_spring_layout.svg diff --git a/docs/reference/pathpyG/visualisations/index.md b/docs/reference/pathpyG/visualisations/index.md index b57d09486..71b5a0b1c 100644 --- a/docs/reference/pathpyG/visualisations/index.md +++ b/docs/reference/pathpyG/visualisations/index.md @@ -57,7 +57,36 @@ The default backend is `d3.js`, which is suitable for both static and temporal n ``` -## Customisation and Other Backends +### Backends + +We currently support a total of four plotting backends, each with different capabilities making them suitable for different use cases. +The table below provides an overview of the supported backends and their available file formats: + +| Backend | Static Networks | Temporal Networks | Available File Formats| +|---------------|------------|-------------|--------------| +| **d3.js** | ✔️ | ✔️ | `html` | +| **manim** | ❌ | ✔️ | `mp4`, `gif` | +| **matplotlib**| ✔️ | ❌ | `png` | +| **tikz** | ✔️ | ❌ | `svg`, `pdf`, `tex`| + +#### Details + +- **d3.js**: The default backend, suitable for both static and temporal networks. It produces interactive visualisations that can be viewed in a web browser. +- **matplotlib**: A widely used plotting library in Python. It is suitable for static networks and produces raster graphics files. +- **manim**: A backend specifically designed for creating animations of temporal graphs, producing high-quality video files. +- **tikz**: A backend for creating publication-quality vector graphics with LaTeX-compatible output or directly compiled output as PDF or SVG. + +!!! note + The `manim` and the `tikz` backends require additional dependencies to be installed. + Please refer to the respective sections in the [Installation Guide](/getting_started/#optional-visualisation-backends) for more information. + +## Saving a Plot + +You can save plots to files by specifying the `filename` argument in the `pp.plot()` function call. +The file format will be automatically determined based on the file extension. +If no filename is provided, the plot will be displayed inline (in a Jupyter notebook or similar environment). + +## Customisation For more advanced visualisations, `PathpyG` offers customisation options for node and edge properties (like `color`, `size`, and `opacity`), as well as support for additional backends, including `manim`, `matplotlib`, and `tikz`. We provide some usage examples below, and a detailed overview of the supported keyword arguments for each backend in section [Customisation Options](#customisation-options). @@ -91,10 +120,15 @@ We provide an example using `matplotlib` below. You can override the default behaviour by specifying `show_labels=True` in the `pp.plot()` function call. ### Node and Edge Customisation + +You can customise the appearance of nodes and edges in both static and temporal networks. +We describe the different options below. + #### Static Networks +

In all backends, you can customise the `size`, `color`, and `opacity` of nodes and edges. -You can specify these properties in three different ways either as arguments in the `pp.plot()` function or as attributes of the graph object: +You can specify these properties either as attributes of the `PyG` graph object `PathpyG.Graph.data` (as `torch.Tensor` or `numpy.ndarray` with one value per node/edge) or as arguments in the `pp.plot()` function call in three different ways: (1) - A single value (applied uniformly to all nodes/edges) - A list of values with length equal to the number of nodes/edges (values are applied in order) @@ -102,6 +136,9 @@ You can specify these properties in three different ways either as arguments in For `color`, you can use color names (e.g., `"blue"`), HEX codes (e.g., `"#ff0000"`), or RGB tuples (e.g., `(255, 0, 0)`). You can also pass numeric values, which will be mapped to colors using a `matplotlib` colormap (specified via `cmap`). +
+ +1. If both the graph attribute and the function argument are provided, the function argument takes precedence. !!! example "Custom Node and Edge Properties" @@ -176,153 +213,268 @@ Thus, all nodes exist at all times, but edges may only exist at certain timestep Therefore, edge properties can be specified for each timestep where the edge exists. In contrast, node properties can change at specified points in time, but will remain the same for all subsequent timesteps until they are changed again. +The customisation options work similarly to static networks, with the exception that passing a dictionary for node/edge properties requires adding the timestep to the key: + !!! example "Custom Node and Edge Properties in Temporal Networks" + In the example below, we set the starting `node_color` and `node_size` for all nodes using graph attributes. + We further customise the `edge_color` for each edge at each timestep using a graph attribute. + Next, we override the `node_color` for node `"b"` at timestep `2` and for node `"a"` from the start using function arguments. + Finally, we use a dictionary with a tuple consisting of the source node, target node, and timestep to set the `edge_size` for two specific edges at specific timesteps. -## Customisation Options + ```python + import torch + import numpy as np + import pathpyG as pp -| Backend | Static Networks | Temporal Networks | Available File Formats| -|---------------|------------|-------------|--------------| -| **d3.js** | ✔️ | ✔️ | `html` | -| **manim** | ❌ | ✔️ | `mp4`, `gif` | -| **matplotlib**| ✔️ | ❌ | `png` | -| **tikz** | ✔️ | ❌ | `svg`, `pdf`, `tex`| + # Example temporal network data + tedges = [ + ("a", "b", 1), + ("a", "b", 2), + ("b", "a", 3), + ("b", "c", 3), + ] + t = pp.TemporalGraph.from_edge_list(tedges) + t.data["node_size"] = torch.tensor([15, 8, 19]) + t.data["node_color"] = np.array(["blue", "green", "orange"]) + t.data["edge_color"] = torch.tensor([0, 1, 2, 1]) + # Create temporal plot and display inline + pp.plot( + t, + backend="manim", + node_opacity=0.5, + edge_size={("a", "b", 1): 10, ("a", "b", 2): 1}, + node_color={("b", 2): "red", "a": "purple"}, # node_color for node 'a' is set to 'purple' from the start + ) + ``` +
+ Manim Custom Properties Animation +
+## Layouts -## Keyword Arguments Overview -| Argument | d3.js | manim | matplotlib | tikz | Short Description | -| ------------------------- | :-----: | :-----: | :-----: | :-----: | --------------------------------------------- | -| **General** | | | | | | -| `delta` | ✔️ | ✔️ | ❌ | | Duration of timestep (ms) | -| `start` | ✔️ | ✔️ | ❌ | | Animation start timestep | -| `end` | ✔️ | ✔️ | ❌ | | Animation end timestep (last edge by default) | -| `intervals` | ✔️ | ✔️ | ❌ | | Number of animation intervals | -| `dynamic_layout_interval` | ❌ | ✔️ | ❌ | | Steps between layout recalculations | -| `background_color` | ❌ | ✔️ | ❌ | | Background color (name, hex, RGB) | -| `width` | ✔️ | ❌ | ❌ | | Width of the output | -| `height` | ✔️ | ❌ | ❌ | | Height of the output | -| `lookahead` | ❌ | ✔️ | ❌ | ❌ | for layout computation | -| `lookbehind` | ❌ | ✔️ | ❌ | ❌ | for layout computation | -| **Nodes** | | | | | | -| `node_size` | ✔️ | ✔️ | ✔️ | ✔️ | Radius of nodes (uniform or per-node) | -| `node_color` | 🟨 | ✔️ | 🟨 | 🟨 | Node fill color | -| `node_cmap` | ✔️ | ✔️ | ✔️ | ✔️ | Colormap for scalar node values | -| `node_opacity` | ✔️ | ✔️ | ✔️ | ✔️ | Node fill opacity (0 transparent, 1 solid) | -| `node_label` | ✔️ | ✔️ | ❌ | | Label text shown with nodes | -| **Edges** | | | | | | -| `edge_size` | ✔️ | ✔️ | ✔️ | ✔️ | Edge width (uniform or per-edge) | -| `edge_color` | ✔️ | ✔️ | ✔️ | ✔️ | Edge line color | -| `edge_cmap` | ✔️ | ✔️ | ✔️ | ✔️ | Colormap for scalar edge values | -| `edge_opacity` | ✔️ | ✔️ | ✔️ | ✔️ | Edge line opacity (0 transparent, 1 solid) | +By default, `PathpyG` uses the Fruchterman-Reingold force-directed algorithm to compute node positions for static networks. +For temporal networks, the layout is computed dynamically at each timestep using the `d3.js` backend, while the `manim` backend uses a Fruchterman-Reingold layout computed on the aggregated static network by default. -**Legend:** ✔️ Supported 🟨 Partially Supported ❌ Not Supported +### Static Networks +You can change the layout algorithm for static networks using the `layout` argument in the `pp.plot()` function call. -### Detailed Description of Keywords -The default values may differ for each individual Backend. +**networkx layouts:** -#### General +We currently support most layouts via the `networkx` library. +See the examples below for usage. -- `delta` (int): Duration (in milliseconds) of each animation timestep. -- `start` (int): Starting timestep of the animation sequence. -- `end`(int or None): Ending timestep; defaults to the last timestamp of the input data. -- `intervals`(int): Number of discrete animation steps. -- `dynamic_layout_interval` (int): How often (in timesteps) the layout recomputes. -- `background_color`(str or tuple): Background color of the plot, accepts color names, hex codes or RGB tuples. -- `width` (int): width of the output -- `height` (int): height of the output -- `look_ahead` (int): timesteps in the future to include while calculating layout -- `look_behind` (int): timesteps into the past to include while calculating layout +=== "Random" + Use `"random"`, `"rand"` or `None` to specify a random layout. + ```python + import pathpyG as pp + from torch_geometric import seed_everything + seed_everything(42) + g = pp.algorithms.generative_models.watts_strogatz(30, 2, 0.25) + pp.plot(g, backend="tikz", layout="random") + ``` + Example TikZ Random Layout -#### Nodes +=== "Circular" -- `node_size`: Node radius; either a single float applied to all nodes or a dictionary with sizes per node ID. -- `node_color`: Fill color(s) for nodes. Can be a single color string referred to by name (`"blue"`), HEX (`"#ff0000"`), RGB(`(255,0,0)`), float, a list of colors cycling through nodes or a dictionary with color per node in one of the given formats. -**Manim** additionally supports timed node color changes in the format `{"node_id-timestep": color}` (i.e. `{a-2.0" : "yellow"}`) -- `node_cmap`: Colormap used when node colors are numeric. -- `node_opacity`: Opacity level for nodes, either uniform or per node. -- `node_label` (dict): Assign text labels to nodes + Use `"circular"`, `"circle"`, `"ring"`, `"1d-lattice"`, or `"lattice-1d"` to specify a circular layout. + ```python + import pathpyG as pp + from torch_geometric import seed_everything + seed_everything(42) -#### Edges + g = pp.algorithms.generative_models.watts_strogatz(30, 2, 0.25) + pp.plot(g, backend="tikz", layout="circular") + ``` + Example TikZ Circular Layout -- `edge_size`: Width of edges, can be uniform or specified per edge in a dictionary with size per edge ID. -- `edge_color`: Color(s) of edges; supports single or multiple colors (see `node_color` above). -- `edge_cmap`: Colormap used when edge colors are numeric. -- `edge_opacity`: Opacity for edges, uniform or per edge. +=== "Shell" ---- -## Usage Examples - - - -**manim** -```python -import pathpyG as pp - -# Example network data -tedges = [('a', 'b', 1),('a', 'b', 2), ('b', 'a', 3), ('b', 'c', 3), ('d', 'c', 4), ('a', 'b', 4), ('c', 'b', 4), - ('c', 'd', 5), ('b', 'a', 5), ('c', 'b', 6)] -t = pp.TemporalGraph.from_edge_list(tedges) - -# Create temporal plot with custom settings and display inline -pp.plot( - t, - backend="manim", - dynamic_layout_interval=1, - edge_color={"b-a-3.0": "red", "c-b-4.0": (220,30,50)}, - node_color = {"c-3.0" : "yellow"}, - edge_size=6, - node_label={"a": "a", "b": "b", "c": "c", "d" : "d"}, - font_size=20, -) -``` - - - -
- -
+ Use `"shell"`, `"concentric"`, `"concentric-circles"`, or `"shell layout"` to specify a shell layout. + ```python + import pathpyG as pp + from torch_geometric import seed_everything + seed_everything(42) + + g = pp.algorithms.generative_models.watts_strogatz(30, 2, 0.25) + pp.plot(g, backend="tikz", layout="shell") + ``` + Example TikZ Shell Layout + +=== "Spectral" + + Use `"spectral"`, `"eigen"`, or `"spectral layout"` to specify a spectral layout. + ```python + import pathpyG as pp + from torch_geometric import seed_everything + seed_everything(42) + + g = pp.algorithms.generative_models.watts_strogatz(30, 2, 0.25) + pp.plot(g, backend="tikz", layout="spectral") + ``` + Example TikZ Spectral Layout + +=== "Kamada-Kawai" + + Use `"kamada-kawai"`, `"kamada_kawai"`, `"kk"`, `"kamada"`, or `"kamada layout"` to specify a Kamada-Kawai layout. + ```python + import pathpyG as pp + from torch_geometric import seed_everything + seed_everything(42) + + g = pp.algorithms.generative_models.watts_strogatz(30, 2, 0.25) + pp.plot(g, backend="tikz", layout="kamada-kawai") + ``` + Example TikZ Kamada-Kawai Layout + +=== "Fruchterman-Reingold" + + Use `"fruchterman-reingold"`, `"fruchterman_reingold"`, `"fr"`, `"spring_layout"`, `"spring layout"`, or `"spring"` to specify a Fruchterman-Reingold layout. + ```python + import pathpyG as pp + from torch_geometric import seed_everything + seed_everything(42) + + g = pp.algorithms.generative_models.watts_strogatz(30, 2, 0.25) + pp.plot(g, backend="tikz", layout="fruchterman-reingold") + ``` + Example TikZ Fruchterman-Reingold Layout + +=== "ForceAtlas2" + + Use `"forceatlas2"`, `"fa2"`, `"forceatlas"`, `"force-atlas"`, `"force-atlas2"`, or `"fa 2"` to specify a ForceAtlas2 layout. + ```python + import pathpyG as pp + from torch_geometric import seed_everything + seed_everything(42) + + g = pp.algorithms.generative_models.watts_strogatz(30, 2, 0.25) + pp.plot(g, backend="tikz", layout="forceatlas2") + ``` + Example TikZ ForceAtlas2 Layout + +**Other layouts:** +In addition to the `networkx` layouts, we also support: + +- Grid layout + ??? example -**matplotlib** -```python -import pathpyG as pp + ```python + import pathpyG as pp + from torch_geometric import seed_everything + seed_everything(42) -# Example network data (static) -g = Graph.from_edge_index(torch.tensor([[0,1,0], [2,2,1]])) + g = pp.algorithms.generative_models.watts_strogatz(30, 2, 0.25) + pp.plot(g, backend="tikz", layout="grid", filename="tikz_grid_layout.svg") + ``` + Example TikZ Grid Layout -# Create static plot with custom settings and display inline -pp.plot( - g, - backend= 'matplotlib', - edge_color= "grey", - node_color = "blue" -) -``` -Example Matplotlib +- Custom layout (by providing a dictionary mapping node IDs to positions) + ??? example + + ```python + + import pathpyG as pp + + g = pp.Graph.from_edge_list([("a", "b"), ("a", "c"), ("b", "d"), ("c", "d"), ("d", "a")]) + # Provide custom x and y coordinates for a layout + layout = { + "a": (0, 0), + "b": (1, 0), + "c": (0, 1), + "d": (1, 1) + } + pp.plot(g, backend="tikz", layout=layout, filename="tikz_layout.svg") + ``` + Example TikZ Custom Layout +### Temporal Networks +We apply a sliding window approach to compute layouts for temporal networks. +At each timestep, we consider a window of past and future timesteps (controlled via the `layout_window_size` argument) and aggregate all edges inside this window to a static graph to compute the layout. +You can either pass a fixed integer value, which will then be split equally into past and future timesteps, or a tuple specifying the number of past and future timesteps separately. +The layout algorithm can be any of the supported static layout algorithms described above. + +!!! example "Custom Layout for Temporal Networks" + + In the example below, we use a sliding window of `2`, meaning that we aggregate the current and one previous timestep to compute the layout at each timestep. + ```python + import pathpyG as pp + + # Example temporal network data + tedges = [ + ("a", "b", 1), + ("a", "b", 2), + ("b", "a", 3), + ("b", "c", 3), + ("d", "c", 4), + ("a", "b", 4), + ("c", "b", 4), + ("c", "d", 5), + ("b", "a", 5), + ("c", "b", 6), + ] + t = pp.TemporalGraph.from_edge_list(tedges) + + # Create temporal plot and display inline + pp.plot(t, backend="manim", layout_window_size=2, layout="fa2") + ``` +
+ Manim Custom Properties Animation +
+ +## Customisation Options + +Below is full list of supported keyword arguments for each backend and their descriptions. + +| Argument | d3.js | manim | matplotlib | tikz | Short Description | +| ------------------------- | :-----: | :-----: | :-----: | :-----: | --------------------------------------------- | +| **General** | | | | | | +| `default_backend` | ✔️ | ✔️ | ✔️ | ✔️ | Backend to use when none is specified | +| `cmap` | ✔️ | ✔️ | ✔️ | ✔️ | Colormap (string that refers to matplotlib cmap) for scalar node/edge values | +| `layout` | ✔️ | ✔️ | ✔️ | ✔️ | Layout algorithm for static networks (see [Layouts](#layouts)) | +| `width` | ✔️ | ❌ | ✔️ | ✔️ | Width of the output | +| `height` | ✔️ | ❌ | ✔️ | ✔️ | Height of the output | +| `latex_class_options` | ❌ | ❌ | ❌ | ✔️ | LaTeX document class options (e.g., `"border=2mm"`) for `tikz` backend | +| `margin` | ✔️ | ❌ | ✔️ | ✔️ | Margin around the plot area (in pixels for `d3.js`, in points for `matplotlib` and `tikz`) | +| `curvature` | ✔️ | ❌ | ❌ | ✔️ | Curvature of edges (0: straight, >0: curved) | +| `layout_window_size` | ✔️ | ✔️ | ❌ | ❌ | Size of sliding window for temporal network layouts (int or tuple of int) | +| `delta` | ✔️ | ✔️ | ❌ | ❌ | Duration of timestep in milliseconds (ms) | +| `separator` | ✔️ | ✔️ | ✔️ | ✔️ | Separator for higher-order node labels | +| **Nodes** | | | | | | +| `size` | ✔️ | ✔️ | ✔️ | ✔️ | Radius of nodes (uniform or per-node) | +| `color` | ✔️ | ✔️ | ✔️ | ✔️ | Node fill color | +| `opacity` | ✔️ | ✔️ | ✔️ | ✔️ | Node fill opacity (0 transparent, 1 solid) | +| `image_padding` | ✔️ | ❌ | ❌ | ❌ | Padding around node images (in pixels) | +| **Edges** | | | | | | +| `size` | ✔️ | ✔️ | ✔️ | ✔️ | Edge width (uniform or per-edge) | +| `color` | ✔️ | ✔️ | ✔️ | ✔️ | Edge line color | +| `opacity` | ✔️ | ✔️ | ✔️ | ✔️ | Edge line opacity (0 transparent, 1 solid) | + +**Legend:** ✔️ Supported ❌ Not Supported + +You can find the default values for each argument in `pathpyG.toml` located in the `pathpyG` installation directory. + +!!! note "Node and Edge Keyword Arguments" + + The node and edge keyword arguments listed above represent the default options that are specified via the `pathpyG.toml` configuration file. + You can change these defaults using keyword arguments in the `pp.plot()` function call as follows: + ```python + import pathpyG as pp + + # Example network data + g = pp.Graph.from_edge_list([("a", "b"), ("a", "c")]) + + # Create network plot and display inline + pp.plot(g, node={"opacity": 0.2}, filename="d3js_node_opacity.html") + ``` + + However, if you want to change either `color`, `size`, or `opacity` for nodes or edges, the preferred way is to use the dedicated keyword arguments described in the previous sections. --- For more details and usage examples, see [Manim Visualisation Tutorial](/tutorial/manim_tutorial),[Visualisation Tutorial](/tutorial/visualisation) and [Develop your own plot Functions](/plot_tutorial) diff --git a/docs/reference/pathpyG/visualisations/plot/d3js_custom_node_images.html b/docs/reference/pathpyG/visualisations/plot/d3js_custom_node_images.html index 742af0d3f..575799af2 100644 --- a/docs/reference/pathpyG/visualisations/plot/d3js_custom_node_images.html +++ b/docs/reference/pathpyG/visualisations/plot/d3js_custom_node_images.html @@ -6,7 +6,7 @@ } -
+
+ \ No newline at end of file diff --git a/docs/reference/pathpyG/visualisations/plot/d3js_static.html b/docs/reference/pathpyG/visualisations/plot/d3js_static.html index a552c77e3..0ca847239 100644 --- a/docs/reference/pathpyG/visualisations/plot/d3js_static.html +++ b/docs/reference/pathpyG/visualisations/plot/d3js_static.html @@ -6,7 +6,7 @@ } -
+
\n", + "
\n", + "\n", "\n", + "
\n", + "\n", "\n", + "
\n", + "\n", "\n", + "
\n", + "\n", "\n", + "
\n", + "\n", "\n", + "
\n", + "\n", "\n", + "
\n", + "\n", "\n", + "
\n", + "\n", "\n", + "
\n", + "\n", " + \ No newline at end of file diff --git a/docs/reference/pathpyG/visualisations/plot/temporal_network.mp4 b/docs/reference/pathpyG/visualisations/plot/temporal_network.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..70391de9e6fa310c2c70acc407c0ec2a93094ff4 GIT binary patch literal 290983 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 d z=L$8knFk|27xb`jdwt|SW6{^T|7Yfs zcO-{C*II_Lv5%YiR?8i3|GB89z$>G?=s~C`hyAF}j?c3T(tizi#*-HWF3!%g8j}or z8$%))v3JM1&JtICX8Wcq?S#EUz1*ai-^m5x|;6~lxO`o zWoWj&t1sihvRQO}m!xN}JGo!$qVmbgZTG+EL{_@JR*Loi(ek?1vtMAwk;7zpF_!RV zmc#olkKK1L(GeTN)^k&-~66%c&=A4stI8UH+{?Lh| zHCOF&UkN!Z-G13`iOtQ9FUBfa=Qh#xw(xj(sESCe`*B>A6{d#EW~P0D?X$?o{(=Y| z8X01ZiT5>LS*ZB0xSh(hzLfkrzNH~^gTnEr#f@z{-)1V*X3;OW&h$a1m6vDM{Kz-lwD8+U9~v{4Pf}gc{0s;0x-RX6 zW!iFehJx$u&x;KRe)h0Ve&DpGF}_pjv6Q)i%<(LX`?u-+43=4`9XNDET9@QmqPZ(- zbER&Dn!E55hh*XMCj&BtN;-i@o#I-n&tKS{&G=KOb(qckX5J;PHKtVz3-Z75IUf(N zo412cYe4!*rD)PaHMSVj;@z5pkvHF2H(JNa)~HC5R@{G-HFKrXt{|x!A_uk?M2DrB zEVO!eWy6hD-=Df2sSf+!4jxZ;$~(e;%JB(N^`igF)*3(`Exw<7_d3m|$ zdXh2QboZM%h52bg+^zLv?qTm&Z7$9UlAHUI(fZ71gX9vgt(tSXm-@t$6)G!BHLMo~ zo^lrckgsHt?O5!4>f>EztuoK_&=%%Hb|l~4gE|k&tGDlJtGw*JsJhH-enzM)BNJW#?wZ6+!ytuZe#lvTbdh@YO zpUu%fLYRl!C*&Z_Fus1FKkjq=zZPHfr zJ>FE`D45ex^j&|C!(B7&w1U&qb=3esiyW!@=sp*ru#Lk6*(S>8JH?DFPv zO8NIcuvzl*&|~2X$F4=tCvS?%c$D{0mN%M4*51ihI-l?3URS;* z|L&N#X?08VP4_okz8$Pqiz`o;+uyo<@8weGhl_cZiOepO?6}w`xnccfX7145d2Kh8 zioQpzOXmVMs&_xJ;R7A%T(I`rk^ycnUxy?^gE;+&nq=&4X#_H zyv*fhD)%buQ)d|j?rFMyxnx$;Ar{5rL%#fxQznbC_)SpTm$c5ayD#nC-tU$#^N1yS z@W`bqyL)u~3kvyXtvhN*ayOLPe$wIeQhT`HPQBJ`C&D;tXPI?{J72k+v44|F!20M7 z8nYB-pZ+om;?2C$1-%R@pyUqj0GgI*;}&izWV-7a%rU4(Tw1ttNlu*X2bEX z@7wJvn_E%Ru|w%RAM5;wCrU^f9Xs!SV3=o{H+z1G&B7zv5z>d&F+F3uVqvcmpxs9v zRA)2a$-ODsQ@Wj@h5bPJn$>D=Mi~l1`p+@lG2C}5{g}RPx`+*koX=~PAwAv;(=eBCpim&$PI+w=f z-4dqjU*`VnrDtBl)9de(7DWy16|PhajuOk921Qdi&;O5lf^>b^1o>hQ#w=IZ?mKSAjfBWyS_a`7E01V z**tTD{O&||Du+5|(DS%m=@eRL6y+($oxWMzd-{IE(Uv<01>!7})npQE3<7UXw`e`n zM;AIUw9oI&aAr~b-i{0JTMgGb-;BApQMxf@Y;eB)tJ+bqlu?$p;kk!(uRHe?E;?bp z@zgc%c4yJZdEvoj-GZNI+!5wCj7p!M{Bh$euPC(@nM=BY&&N*Cd+L>!=cPi63uHCxZFBBjno)WHRJBAYYD**BW)UMukJZj=T-vGHeD$c7kO!&$ z;!6FZ_h)Kn+B`Lwaear^r61kj`<>EdjW{E9tx<@Y05AZ4)>m&6 z+mQGxgw=9qZRhparkuwI3+i+Hc*oc;?K+j!pls07ORps--?Swm{eI#p-3mvA>;~a= zkIpY}stPrXt*|wJ#9qKq(>}XJF<$G$Vm9Ut^n2*p^*1v;HYpI|F=zd#^`qj*nfIG) zxgN~!wvjLOXBxb+QiVNk<3Mrx>eFFo`8Ic7xRzt){e5Pb^;v$ct34aM*J;J?I)Ca) zGl?WjUrgTP?$~_HeAnu#pdUu_%eO6fP;R^X>8yb{Vj2ANs>hfHE?mgdxsXt%+}@d3 z`zdI6P$!~WyZ_P&p_}hr`xYlFCXL8ebsn%>yh7xS^g?gdo9g^uk_sYUvwq=pxi?UL z??u9_wI2RV8hX|XxW1@exIItwDM`)QHYNXMnal1Z5=m5t`Qev~x0JY>f?H-gug}ST zFXiw?sPF|(MOdt#pJZRxjbW)xwqXH#(;vPueV~7)L&i$EesE#rW{JfIE=R7fKhwJQ z`qNFhyScvZeCJkIoU69|?M^GyyrWgWpm>?nlT>eDb zt#P?jqH<8Sp?mMj`orgM3%u@m*!jWUz|Sc~`LsaJj%{=aLF95)&zA|aHnr^0<8aiJ zSy;s>=)lR=7$cf|QNn$iE{SSUNeIF& z6%)&8Bk>S^?|}wcD3K#zCk$&5 zHOK;A3;#3YM(~pnbs_a*EK8i5YS*dM9g5Tm3Jj{k)f@ZXNd!PqH~{p7Th0fYz4v0r zET4C-x;>#Ls$F|Jh%q8SNkrg3B4`9OC@ASKT$(s(Q(h0)&Br5s1q+p6Qosl_#Kc^L z_$H!7Ap1!QejO$QEnYS6>WlvO;dnZFTz+l#ZtBC<8P*X>R0T1EU4FWm$QXNBp1*)yp z{wqRIU@Syf0SWsDfu<0MQP5TL=_cff$A}uI0RVugaR4X?w+awd5b>B0fr1yYW*^bb z)g%o@*n7(Sg0Q=2Q&19v9R@(mlcQ8yDp3JK`c!}@9TmXhk1rb(oz;;FfOVp{(n1ax z0N?;ol&djdV6l;yzNn>O#m{Zax@P!2%HEj`CB>A6+Y+d0Q;;$~5w!P;L1$l?CzaN5Xoa zynLp~?>Z2!?|Hv3*s@nXMpgC-Y98oGOs|y#wLF|S#U5t&>-m#N^A0JBt{)w#wCHJM@ zzAALu$fqLYqI2BL#$}D4Z`{}={_KmpQD&IqI|+{5B)VtY?axaf+Zb~H_bnIIMY_>= z;0?}cB(7gt13gmTXD@%0Xce!~@RhXwbwjK@=Y36y3!UY^`ljta`$NdMQasC|3 z!RjKu$~(C?cF{eZp)s8ye0G`%aw*Kf5#xYh29AgUf8#=7p!`idCd3ArgYb9HA!J*7 zsj_!j6)HWg^#KR{Tzbi}+&cMJJYsH>2OMJH_CTfDl#Dn^-+GE_r)DYuVy+N#h|*C3 zK@`*V>`9uJek1I5$d?EnKCjy{+ z93YP>9|fj!JYj##m)u3VM3=JV zl$mp8fwk7kC&O&BgX-u~$f~0xiF)OErizxN2fx}?OGxb|NA&k;_uQD4=VhvYsV2-Q zG49COPWtZnLnm0cO$@X$cNL2_E@ZkM^B}$8iKjTlpdhmjzKoB`@kJ>;n{SE^IoO(v zSW2{>U))od$;vK}z?$=nJHXDwKd0VxX!fw$(KL^b$)~>^)~VPya#nHFFZsv9vkkWx zPrjTh)>I)42?}c@3e35lalhHC;~3KIX^#!LnhLZ;3AZafMOGQG2Vc#pWX^1)FX# z%NVHj4!sM$E*rzPRoVa6)+_1Fwt|>qwI|dxKE$9*g?P)nifD#?p&@~=FEk_&_JxK7 z!oEz^=3pej`HhAImKskb!G;*)5~DF_cF=e?Z>! zx8h})8SWdF`0iYKwlH;rpwv+tr!6HqaVL6Su<}+t6u7rlFywnoBwI>r(A_!e4dt4~ z^r=@bUJ$u8uXeTW!a0)8bCowUA98ow8e@H6;cZT5(_c?iA1uxnmZ3|C*i8BolTx17 z9`DM)r*y#T`sYaRrOvy48t-}LeyqlCyQ=>|^UsTp?h7w&Fj!kKuPST(yO&-DbGVeR z9N3(9i?LaLAA4p)?!v1NY?ET`wG{OW2G*I*j$gKiS1!AsWtryCh6ROo1$UL%)DCgn zf4=X;*0qAo)o!XWpG}T4&DuB+TnQ^h!hZ4h?_9(AwAY_ke5|bYeMsxieeI;s(gVM~ znOMD*ddP6j#$ctk+?(pIjqM||4DVKS-eNe*Io~SJq1fxQ3xiVH=}jCz6s#(CpPPAM zt5@Wxo8bOUjdL!ByuWY0XOpvZ{ezdOoOkCf`&GN5<51V7CwH<^#A;;vMw*?iv-5-< zJY~mxSMx1Sy!-8@H=V7DeatBiUuo+j0p0$G9Ht3hKcDZlD)*&r_$Z$hW7V|{%gw5) z^w}=v-&S@S*?vYY*jbg=AEbK6^UHNt_Wq4!_ z^yj_kcyGLwQ3_e`!^w1iQ>S_G5Vrm3p#!%&f zpfPxa8{NK`pag(m`(kPc_T#07U`Jj81e0jkyO;t^=VhMprPaVds&>^WyNb2FDrXC&+q9~7we zUnd?zXdrCEWyHCA3o`cU??A>r@iGfcLgMyyk>;#_v?IXm##;Lb>3OFIEjf1|yS(0$ zK_bPakC`XBH>fkN@D&B&5xp6^P?zRr$dA$SM&m52azBq9La2nXCjMok#N|M32rnGalwKCmw3@c+U(i=`SBTspFH2p_beAXzhp?A znUAgDQ-|&vl^vJ!x0iffCEfaCPI~gtl^fGuzI0gMCE{LKp{iIZQqU$lvb=!ZJNd&t zHE|>FT-DQ`N4AODxl~L!P%`0vGxP@f|*i;-hbLs&d*S0x3r*SxqHn4*5QI=0qxZXrRU_{dV20| zwDE9LXYR)Gp51vF*9; zL#NA0;urb*HzZ1YTrW{yCa=)Cv1zb&_?ybsm%Hd+JJ~;2bvc0X!6MaVSEV#GNmr^> z9rpWNb**RG(781E#>!WRF0k~p=lb6o`FQf&y^x2lZ0`$?eLZjIcx%DWi#zImuG&_# zSGZ3*(|c~Znz8Erby=LLR%@D0l7gprsqhC!mAGp!ql)q`<}$1kv65!b zWIdK-X&Kj3b)bCK8BQ}TiM2PM4jzrrUw?bSgZRkjMOxJ_19p!JX{6pO-Fm58I4;}8 zS+sZH-0A6!4?kF#vz&SpbJdEM*I50RcR{tKwvYsY62aCC9*VG2HRr1 zInJf#$z1AKy7e*N+vnAe0^Um!-mUz8e_l(=;{9|N9A2by@hzkO)RlGf=>?nFi;itu zHaq)SA;tV+JFRA20#fu{9+hMts=({}Vq9pj<2Z7CCgvU}$n)$f&;L1HGQ)(*sj0V=b+-Qg)eZIQz z^~GCpTQlNKZ-@`hx%Ql)jcxh{!?g2U&GuVa{O0~#D8KndTg2%3sOX*AVZo+t@4PcC zH%2vj&GU~nS9q#Bo7W9_?o|=m8{|i#`p{{qZLhWIWg%buI2yT^-anx)La+I?->4-| zKHGfGHnQ=L*v2c{<3yC;G;U<nzH+L)aokwkMU#;GLpoHKNnQZ{rLWKY#XoowWJO19LqK_XSQbVY*c#T z5ip-S;q0y!|=E6r}#7tr&U%zt|q1LlHnQlu~{}N{Q75WG0!y@mZZui(B1M0c`4M#oETDE zTI{m0W;>ft$oeR1>XxNtz zxn%(#@|>3JPb~R7dRK{@mD|Cy#!i=U*VAk9XC=H6jz8-jeR<;eg=;?Ivft* zK|Weh0FL%1entr>+%0s-h~#2Y-@%nunEys$DT&3`i-Gml`*M-g$fTtY_gu)B=k(^` zleo(32M*4UpItXBEk|cRVsWsu=(%vaZOf)B!igKVT2zkyqVIXIL)K^YJ0VZmg9@K= zBG*?99L;)T`b#b@`kUyEu`LoRtH}HVyhgp3B9`#Z6z{0s;CN-l^6*7o{ac~%@NE)0 z4q8RMbx!J_Uqso^ZE&>)0EUA&06I(nRa6(;@d~uVAd`%_AENw)jBL!}Na{v{$L{nU za$8)cci7zDD%B!2M*eO6^Cqp5e6EE3Wl|dos`7p7tIS&6<=RiStf{>BR5WRJ%dQ>S zP#$n&k)yk=BX6!a*w5PlE_LtU+bD4&5DVyV@ zWxn#OsV!@^?uoi|p`*r=BYWE8)t6W7A9EuQf3uM)jd^5|a>X-r@vMdy0cy>TRhMQj z&rZ8{zT0EeA5$pw4qVN!zCi?_Z{Pr!DumFQDoiDi2jeH*M3^cpi;|I9bcsygp8eQT zxx0y7o0RT9qA&E#oV5hX1X~0`Fu@?)@SVv)xQ#<`sV;N%y7mRd6{G`u4$l*Zoao?~ zkP|;R_GzLuIHo0m$bvM^fxv<^BrrIhs@B-xn1%!f$5TmWql06jg#2;vcEdjFZ=}Fz z*9}iiq0(;Xc$bh%yhtg>5=v;`%z*B(?W$p^oQ~^dy zgd6=^Kkgu8ONQnYXeD#Fyeh{n>8z~94cu89ci!Z;}OP}V- zJy_HD{!ma*z25uXPMaEYc6FcU^-*m#?x{9;b9B4FjN?+is;xHGC*i_#kpA=C5*Y`P zdr6|oXY;n(T8-4^k*}_ym?En|tc)XRDXyNXaA6kqwreKBfKjwf!yl@H1&f6!cNUxSieXzFm2|VQvJG=2CLlDA8u{e zI8eRm_)ypV!0$6Y72nD|wwuHL>Dh#)@Tv{5htz$#F4vS7zLg%_a%$6I;dIG04bP2I zfAQ8h-$veeLKT2Eh{XjAtjZ(|$b*Q1ZMlXGMv#wb9Iyxof4m5>E#1gqWX^@*m0hoE zHRfz|yOL9Ms6v;anO#|MoA@F*l^d}=mtMFekDT1HR@P-{+PfFA6-#1E;A+zmOEO}D z3_HXwxBoJ1ZBpLrBm)AVH3&P12=Im@>KWA+R4M?Pn3x0Fmk2=P;wpm~4k7@}iUYvZ z!T16Lh7CuVzb#X6SwL#%*wVUp)K10ggNA;WX=Y$El-M7&5J*nOgF6PM*>QM~1R5jy zg4n%!>?()X+xJ}`)gfZeqYgZwuPznH(Ck{ER%#$~u7~ z)#yF@n;Q0+Rql-r*!*GS#-YayZhPLV#%$XHdco`^A^Y#@<04byZvp4vB2(feU@wtC zi&SqGDA2^41t484IwibWKs*8VKslgkvG|l=|N2_Ex5tPvcR9BY#9D)l8!>rE?8-7l z!ek!_Ie8@!9B8CO4Bw|>&N+JeN7P0J@$(w}1^EC;BM~sNU_z`CD7)DmH zHP)V!@k7Nl5L}Fg$;Jg)yS7X_e*4>7^K(TDIYao6X+LJ1h!??((8jSO)y^_|8T ze`3iERftdMpv0J;e?wHz8L0Y@>X189a(niolnGVgs*0(K2!N_^0Fd?= z)l>=@T>-)N(h`J9j}fL)*cWIxL!PymvPK5K_ygtYsRTegyk&mBsAfo%uh{~zv?N0M zv?QW*v?RdQQ`HjVY8n#YY8n#YY8n#w3P3{w(-;~Opk*2opyjC~uy|8sf|kdpG2_t0 zG=>|Y<#=owLz!9dB2DxjzUgDEJaTIr?0AClF@_SRAfa0(_;_LpG8q%npNxspO~zoU z8L`kPA5X?$!P%sIu;z@2fq8+YnHWaaP~CC%Irs}7hQxm4dIQ}2PpAg<@K;GfxDx$fnxYUerO-nAfE~VB@%Oh28jTu z4_7j{3#@!60-!h|09wNVU?o6h2!OEe{H|YY&e2$T!AAg>4`mE00Q7{*0ZLko4Y$W9 zPgKXCB<$-yBFB`}0Xz!inq0EDc|u7DMo`jss#j>CL;#dT1VBkd0F;C)8Kxv607@bP zpd=iCDG32EZk@xP4@E>86_kX_hbf5&fRbg)5HTPON8*5%>KOcEF;+#^f1} zU$#{84%_OAnGsQ)-dyyB=k_7v2~lLwbb9<#07Ae*?{+*R2^`GYal4HZ$$>KQL=I{u z_B=|9iD^lM^l3>T<_C?d0TWN90E~%gNPvlHNPvl_l9(aGt_cao#AjMslgNjHOP2+0 zna=pk>Vo)EALgcz=kn8ibQ(SuKRsvlIz#5z{>zse&Z!9X8oe13_;{+zIWV$i?5z!` z4rB1C?u9Wp4GA##R1#qD9TN;bF)*e;69Z%9wUf+o3WIaXWke*AfA%ar(y|xI!-F(- zmnCI{3{?IKKyHM}F_17o4xc_jSpM=39NW_4ua;W$mcZ|NEIEV82bZ&h{ z5XMn^F`qlX`$Vst71bV&QCFwW?H zdrB$G17Hw{%Zbqy5ddA`08rFvjMEXrhJ0qgAB&TrAD%-weLAPkG%|PTxo0Z2GEF+h zyFD6*vu>cxgsRScg6ENeAKVw5YY~i~x^${{(cwe@R7V6rbwmJEhbt|nIwAn7BLbj0 z9Du%=KmnBJRfgL?^{*>ZIGu$R8nlPYjA@Sufc9_zko^TJvPYH}0o(RYDG)=*{yY`g zFQ|&4`|;Il@T?}XlF<`+yw6|5`SE!9b&50IvD-6^`8`jw?}m^&ymvcRP76AxB3clC zcBodg!jW%NPS`8no$kxlNglQgIx)9vY2W5KGPX|z8H8rk{ES-G@ZEfOama)Hh0i}N zI_|5tL^ilX_@^UQQ0#*4!0Mn%mPIs5KBuUq)$sCN=HiqW??EFVrGGc z1k3^r37CbcBu`PZFfKv;WazTw`boR?ZkQ+LkZhY~msfd+Nj8Wsqy z=f(;$`SX7oE%sKc2(YWt;A5+?QQgtuRQ4#o*|<38-HbFZ`x{T`t~J%kacy~@%-^3n zJgs=p_RfpSXjM`cYg6Pp28q?uj~>kV&X+ppZI^4L{o`B*lXunph4ilqI({VU7C+hZ zGHOW5LOurCNEw}4jMoXBT1bB3Dj{dLl&&7}7TI&AwcwV%UA(GEhx4vgs~0$KEWsKU zW*vl+et)e7n>CdLY}TU*n>8`(pg-c;VSAw9r&~$@g%ZQiomdjUfUAfYkbM`G zRm2^mRAiuQLL>1MLa$%@mxSnH7Td|P*k=tGWbx-GmvnZ~p> zY>emnaKTvUb)TVjR$lY=QaBei0qD7S6R`C-mGz_opwWmqpv8y)G#9SSmP&`?AG zS_uc3qt+7vQ0v+L^hykU>b1+$o8Fc7-;DPg$*WfAYO7okC1qag{^rvbzga78KdKjL zKHap~bo*Mtp^b_O;fAeO2ks1u-T$%vcvBJ7RTzEwf?SfZz_g}>bnBE!>)YPXW$of6 zh=s)L2(AbKK-=H|Xqb;wGb`dTcb?8W^R&8J4*_7pS9)W9Czt3 zUAf@tv%l4bzv(_BurE%}y03C4>yjO>?bnVtnIOylv4%m;#?QgFo}-$BQ322}#2nBt zL;xBFS7xkXhyXMU5rBrl0Z=V?b`SuYgRK-_smo+bf7Yn?N03=wD~ZQT>MC7@{BvfT zqtjL<_v}lRp-*E~oU<(HPz+WrcrtL8!?aEWKrL&Zh6rHg@Fd^cd0SiG z?+N=#QpvA<5n)pjUZF62Qsl6}vqbvCULAK^3*j_$p%%e2*Za;(x)q}e@opn38Hdfw znOzKj4IYD>=qznK=OlAH$(?k<9Mh6a;hai2XA0+3iew7sRElH@=TwSh3g=Xc1kCYN zn%s+;<8cXUj+s*;0!SKb@96C+A5%+?DO}Cep;*gu<6x)Bu9!KlcFa2alHaqn@{6;X zh}G`3kLQY5k~mGne{Oc-JmznwDx8b_BSRYZgXd^fF z#ky<;_seSzaOfJ&9J^-bXx-0nNjXKJc1uyg;ZemHK&iwDC=w zcPV%=e5yANO-%J*I)}M6BA=!?)k-^%p(wgW9r0{L z>}E@o6Dh8RBs5>jSn$j;?w8(K%BrdFISSv*_z_=r@Z_g&Ta%oHZXSozxRF^Pb{b(8 zh`1V3K0Qy&0w-gblOi!6=B7x*;Oi=}UZ}yGjKMe7N%`P2D-p9tdIAMQW`VaOlStj1 z(*7YkEV#`E3sY)0RrlnSD;&Lc&FDb`w|@93Ia@9EOu0d&HxA!ByyZ-OR6kE#bg!x> zp|sX|;aZmSSL5qNhqMvEZ`U19i?D2AKo@b z8_?U~!0UE_Q)FBg2{4qBfo(9Y@b2>+v zjTPKu1*+5z#M;@fu#I8%*pM(V5J>0J@A3Gixnb%m2G*iFeW$S3m0Be`i|!e#OZnC< z>yxkZv^ywjZn2Be(_8Ji-!}ChWf2KKj0Pr@k6e$S{99CWQ!0nbzrp2l6=MHTWq|!- zcP(0Cltg|g`fZz!F;80W0&g4DS$5wOM+*X4$OSJp`*$VV+)&(-Fx0Y;)zvYZ^fBNS zv*~WBoVT%U{`@&sZ3o!fC7`l1k&y{{O~#_ILw89s+%_*FNqLX;kVgY+EL>{<0F8wM zaQ=?tA2picL}Y$D;pc-Fz>=un{*Ke5(Jw(+BWpc^7vDhM(Cg^I^D*wF7l-Uov2o$~ z!{Q`g19dTu*!J82E;H7{4LPv30XP@?zv=$S#^>irqFYZoVmH>FxWEp|m!@VAp!3xU z$Gq`i!xRb5&5ufgbMvE;;N1MEBse!eeAUtU>Xb?W3C_)r>KvS#A70{x(Bj0^Py?yC zMAVi?LX`PpHmP+>@qyhlcuu^P8nr6RG&)^;a!5AyJ}(njRI&N`>nQzJBq&CjXTcyG@j9S~4UhrW=%C>ce?_;R}KqzKMcR zNpRj?c*(!!?S&dXinkYuQ(38gF%$%P)`W8wd&{(3vuM5!j=3P% zVa_|Ixp|IEuMD(*2|9cgN)(SjtLQkPS81wg8I=Q$Kr9vT*Zb^Xdc`@iEJXE+0OXG3 z=6%`8JIi87e1`6cJ1yi|*erLuH>xfFrTe(5_qld;jYH(>x{}-vL0rYjSKYB%L4UQ(Js}GC^Ir?jpZ8nrZ2w{|Q=a;y5 zAk>e%bGE zoq^H)J?sH*&tl5J`M40S2c^I{xloI6UM>XDzvkw`jm(WGZZ4GdVPG}Jy;kVhRWnGd z^`Ll=??2f?1v3sr_l^aqalkOdIDEP2P12X$v}Dx!(M!qBL<12$jzqzlpO=$bH1-UB z=eN#y5+C~gcva(Zs4iv%h}Xl6z$6SFEg}ZPZ=72KI{YRcQ`}0hJ>2*Z*1{Y`maY)6 zY#Yfc6NLi6L;N4%CIaOE8-Odn8Ol`%fV|tYk&q_w#HjV<+O2d>U&S*pJlEdkz4gWb z-q&y5{y4sV!QhOgT-#cN$6CHzxS786!hTnQl(c^SgiM9ayj$EvQiOD|sse%GZUOfM z(?A43%Qyg3jPp1^Rg61E?JVl+LE_&QRd z!Ifhks$ZlwU<+Q+_d9p*FEb-q-%hu@JNE9!gKJCPjm_9I{qUCg)f1}2RUK0u5dhWU z0GxZq(AZD-97az@rfleZW87naO7Pn~Lx*_AZ{Ljl(S%EBPdIH*34VKJNWLSS@!Khb zO7Pn!gG%t*B?B)2YlHL1z*ht$K=V^o5hOT|461W*9vOHE7)0QB!qr&EPVW2=iA)LM zg23ejAw|s~!TDd{F9;Hx{{>$1ulZkaBSij@Fqx7!|PpV%!Gn)6~q)o z1VBN*0fBNKKfho&)!Qf7!xesU|LD8myVW`p8Fq@qp~M3a5QHR>E15gw!%AX6tZTEju>zlMjAYw$Y<1z=ib7_K5A3B2V3gHVkla=1M? zheTq01Alu!MUa+2{s}-rilzf;2_!Q}n;?ZjLiX6$4e2x_WKR~vtK&YT7DzB(BY~MG zL+>HO@RBSK3HslI2sDwqhVr*K{1#D;k-s6)t%hGe zNSct$;rKHArbAi=39d&E^EUEPNHLJKK;8?#7a+Mpf;l0bEc`;*(DUWs_ZlPzNXXxi zcpBjs=63?f+$|0g%mv8`kdS#@J|vhEqJJaD{KRQP0 ziL&+6Qh|iD;_LPE+G2MKu&v>@Gs z1iIp>gnUqM0ccM$a!mtB$lp+3`n&LZ1QK#iJRFBZg8DJcg5$k#o(TLVgLkGk{36dQ z(huN1g|rG1v=JTXg02`6v>!PVQZyvk z#EQHX5^^5W##f=fHIT;;>Pt_NErDO8%%YHxGW{#h?=i@S+ykzI_6;fcMcViOQGNfN zvI-bOx#0a0+U5w)h(rCE;MW-v(m#NI=&r!;Ye*X**(D!>v=S0>-F8S|PslEikbVqpiazH^-Jm`s{=gs?=%eUAGW-|X zmUL{3YmgKC9`tcTe-WVs{;epyNeyuF_lJJ``=6&L_UcvYT#z4f!C9_O=*h@MNh_R! z-8GaI;D1WU4Rp@A_yobPIzG>8H(yt`05@0h5YHeF@s&=#o<8CRem*{1d_9B0#DkR- Jlou)}{T~RoQ5^sP literal 0 HcmV?d00001 diff --git a/docs/reference/pathpyG/visualisations/plot/tikz_backend_example.svg b/docs/reference/pathpyG/visualisations/plot/tikz_backend_example.svg new file mode 100644 index 000000000..6136d2cbb --- /dev/null +++ b/docs/reference/pathpyG/visualisations/plot/tikz_backend_example.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + +A + + +B + + +C + + \ No newline at end of file diff --git a/docs/reference/pathpyG/visualisations/plot/tikz_init_advanced.svg b/docs/reference/pathpyG/visualisations/plot/tikz_init_advanced.svg new file mode 100644 index 000000000..1e93a28e3 --- /dev/null +++ b/docs/reference/pathpyG/visualisations/plot/tikz_init_advanced.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +A + + +B + + +C + + +D + + \ No newline at end of file diff --git a/docs/reference/pathpyG/visualisations/plot/tikz_init_basic.svg b/docs/reference/pathpyG/visualisations/plot/tikz_init_basic.svg new file mode 100644 index 000000000..f2a5b8732 --- /dev/null +++ b/docs/reference/pathpyG/visualisations/plot/tikz_init_basic.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +A + + +B + + +C + + \ No newline at end of file diff --git a/src/pathpyG/visualisations/_manim/__init__.py b/src/pathpyG/visualisations/_manim/__init__.py index f47622abc..623aa2474 100644 --- a/src/pathpyG/visualisations/_manim/__init__.py +++ b/src/pathpyG/visualisations/_manim/__init__.py @@ -1 +1,64 @@ -"""Manim visualisations for pathpyG.""" +"""Manim Backend for PathpyG Visualizations. + +High-quality animation backend using Manim for temporal networks and dynamic visualizations. +Perfect for creating engaging presentations, educational content, and scientific animations. + +!!! info "Output Formats" + - **MP4**: High-quality video animations for presentations + - **GIF**: Animated graphics for web and social media + +!!! warning "Requirements" + - Manim Community Edition (`pip install manim`) + - FFmpeg for video rendering + - LaTeX distribution for mathematical text + +## Basic Usage + +```python +import pathpyG as pp + +# Simple temporal network animation +tedges = [("a", "b", 1), ("b", "c", 2), ("c", "a", 3)] +tg = pp.TemporalGraph.from_edge_list(tedges) +pp.plot(tg, backend="manim", filename="temporal_network.mp4") +``` + + + +## Advanced Example + +```python +import pathpyG as pp + +# Temporal network with evolving properties +tedges = [ + ("a", "b", 1), ("b", "c", 1), + ("c", "d", 2), ("d", "a", 2), + ("a", "c", 3), ("b", "d", 3) +] +tg = pp.TemporalGraph.from_edge_list(tedges) + +pp.plot( + tg, + backend="manim", + delta=2000, # 2 seconds per timestep + node_size={("a", 1): 20, ("b", 2): 7}, + node_color=["red", "blue", "green", "orange"], + edge_opacity=0.7, + edge_color={("a", "b", 1): "purple", ("c", "d", 2): "orange"}, + filename="dynamic_network.mp4" +) +``` + + +!!! warning "Rendering Time" + High-quality animations can take significant time to render. + A 60-second animation of a medium-sized network at high quality + may take 5-30 minutes depending on the hardware specifications. +""" diff --git a/src/pathpyG/visualisations/_matplotlib/__init__.py b/src/pathpyG/visualisations/_matplotlib/__init__.py index 397f5f636..6d1419ea5 100644 --- a/src/pathpyG/visualisations/_matplotlib/__init__.py +++ b/src/pathpyG/visualisations/_matplotlib/__init__.py @@ -1,22 +1,19 @@ -"""Initialize matplotlib plotting functions.""" +"""Matplotlib Backend for PathpyG Visualizations. -# !/usr/bin/python -tt -# -*- coding: utf-8 -*- -# ============================================================================= -# File : __init__.py -- matplotlib plotting cunctions -# Author : Jürgen Hackl -# Time-stamp: -# -# Copyright (c) 2016-2023 Pathpy Developers -# ============================================================================= +Raster graphics backend using matplotlib for static network images. +!!! info "Output Formats" + - **PNG**: High-quality raster images for presentations -# ============================================================================= -# eof -# -# Local Variables: -# mode: python -# mode: linum -# mode: auto-fill -# fill-column: 79 -# End: +## Basic Usage + +```python +import pathpyG as pp + +# Simple network visualization +edges = [("A", "B"), ("B", "C"), ("C", "A")] +g = pp.Graph.from_edge_list(edges) +pp.plot(g, backend="matplotlib") +``` +Example Matplotlib Backend Output +""" diff --git a/src/pathpyG/visualisations/_matplotlib/backend.py b/src/pathpyG/visualisations/_matplotlib/backend.py index 4057e43ea..c1eb1e7eb 100644 --- a/src/pathpyG/visualisations/_matplotlib/backend.py +++ b/src/pathpyG/visualisations/_matplotlib/backend.py @@ -1,3 +1,9 @@ +"""Matplotlib backend for raster graphics network visualization. + +High-performance matplotlib implementation with optimized collections for +efficient rendering. Supports both directed and undirected networks with +curved edges, proper arrowheads, and comprehensive styling options. +""" from __future__ import annotations import logging @@ -20,27 +26,71 @@ class MatplotlibBackend(PlotBackend): - """Matplotlib plotting backend.""" + """Matplotlib backend for network visualization with optimized rendering. + + Uses matplotlib collections (EllipseCollection, LineCollection, PathCollection) + for efficient batch rendering of network elements. Provides high-quality + output with proper edge-node intersection handling and curved edge support. + + Features: + - Batch rendering via matplotlib collections + - Bezier curves for directed edges + - Automatic edge shortening to avoid node overlap + + !!! note "Performance Optimization" + Uses collections instead of individual plot calls for 10-100x + faster rendering on networks with many edges. + """ def __init__(self, plot: PathPyPlot, show_labels: bool): + """Initialize matplotlib backend with plot validation. + + Args: + plot: PathPyPlot instance containing network data + show_labels: Whether to display node labels + + Raises: + ValueError: If plot type not supported by matplotlib backend + """ super().__init__(plot, show_labels=show_labels) - self._kind = SUPPORTED_KINDS.get(type(plot), None) + self._kind = SUPPORTED_KINDS.get(type(plot), None) # type: ignore[arg-type] if self._kind is None: logger.error(f"Plot of type {type(plot)} not supported by Matplotlib backend.") raise ValueError(f"Plot of type {type(plot)} not supported.") def save(self, filename: str) -> None: - """Save the plot to the hard drive.""" + """Save plot to file with automatic format detection. + + Args: + filename: Output file path (format inferred from extension) + """ fig, ax = self.to_fig() fig.savefig(filename) def show(self) -> None: - """Show the plot on the device.""" + """Display plot in interactive matplotlib window. + + Opens plot in default matplotlib backend for interactive exploration. + """ fig, ax = self.to_fig() plt.show() def to_fig(self) -> tuple[plt.Figure, plt.Axes]: - """Convert data to figure.""" + """Generate complete matplotlib figure with network visualization. + + Creates figure with proper sizing, renders edges and nodes using optimized + collections, adds labels if enabled, and sets appropriate axis limits. + + Returns: + tuple: (Figure, Axes) matplotlib objects ready for display/saving + + !!! info "Rendering Pipeline" + 1. **Setup**: Create figure with configured dimensions and DPI + 2. **Edges**: Render using LineCollection (undirected) or PathCollection (directed) + 3. **Nodes**: Render using EllipseCollection for precise sizing + 4. **Labels**: Add text annotations at node centers + 5. **Layout**: Set axis limits with margin configuration + """ size_factor = 1 / 200 # scale node size to reasonable values fig, ax = plt.subplots( figsize=(unit_str_to_float(self.config["width"], "in"), unit_str_to_float(self.config["height"], "in")), @@ -95,7 +145,21 @@ def to_fig(self) -> tuple[plt.Figure, plt.Axes]: return fig, ax def add_undirected_edges(self, source_coords, target_coords, ax, size_factor): - """Add undirected edges to the plot based on LineCollection.""" + """Render undirected edges using LineCollection for efficiency. + + Computes edge shortening to prevent overlap with nodes and renders + all edges in a single matplotlib LineCollection for optimal performance. + + Args: + source_coords: Source node coordinates array + target_coords: Target node coordinates array + ax: Matplotlib axes for rendering + size_factor: Scaling factor for node size calculations + + !!! tip "Edge Shortening" + Automatically shortens edges by node radius to create clean + visual separation between edges and node boundaries. + """ # shorten edges so they don't overlap with nodes vec = target_coords - source_coords dist = np.linalg.norm(vec, axis=1, keepdims=True) @@ -116,7 +180,22 @@ def add_undirected_edges(self, source_coords, target_coords, ax, size_factor): ) def add_directed_edges(self, source_coords, target_coords, ax, size_factor): - """Add directed edges with arrowheads to the plot based on Bezier curves.""" + """Render directed edges using Bezier curves with arrowheads. + + Creates curved edges using quadratic Bezier curves and adds proportional + arrowheads. Handles edge shortening and automatic fallback to straight + edges when curves would be too short. + + Args: + source_coords: Source node coordinates array + target_coords: Target node coordinates array + ax: Matplotlib axes for rendering + size_factor: Scaling factor for node size calculations + + !!! warning "Curve Limitations" + Falls back to straight edges when arrowheads would be too large + relative to edge length to maintain visual clarity. + """ # get bezier curve vertices and codes head_length = 0.02 vertices, codes = self.get_bezier_curve( @@ -166,19 +245,31 @@ def get_bezier_curve( head_length, shorten=0.005, ): - """Calculates the vertices and codes for a quadratic Bézier curve path. + """Generate quadratic Bezier curve paths for directed edges. + + Computes control points for smooth curved edges with automatic shortening + to accommodate node sizes and arrowheads. Uses perpendicular offset for + curve control points based on curvature configuration. Args: - source_coords (np.array): Start points (x, y) for all edges. - target_coords (np.array): End points (x, y) for all edges. - source_node_size (np.array): Size of the source nodes to adjust the curve shortening. - target_node_size (np.array): Size of the target nodes to adjust the curve shortening. - head_length (float): Length of the arrowhead to adjust the curve shortening. - shorten (float): Amount to shorten the curve at both ends to avoid overlap with nodes. - Will shorten double at the target end to make space for the arrowhead. + source_coords: Start points (x, y) for all edges + target_coords: End points (x, y) for all edges + source_node_size: Source node radii for edge shortening + target_node_size: Target node radii for edge shortening + head_length: Arrowhead length for target-end shortening + shorten: Additional shortening amount to prevent visual overlap Returns: - tuple: A tuple containing (vertices, codes) for the Path object. + tuple: (vertices, codes) for matplotlib Path objects + + !!! info "Bezier Curve Mathematics" + Uses quadratic Bezier curves with control point positioned + perpendicular to edge midpoint. Curvature parameter controls + the distance of control point from edge midpoint. + + !!! note "Fallback Behavior" + Returns straight line paths when curves would be too short + for proper arrowhead placement. """ # Start and end points for the Bézier curve P0 = source_coords @@ -225,15 +316,24 @@ def get_bezier_curve( return vertices, codes def get_arrowhead(self, vertices, head_length=0.01, head_width=0.02): - """Calculates the vertices and codes for a triangular arrowhead path. + """Generate triangular arrowhead paths for directed edges. + + Creates proportional arrowheads at curve endpoints using tangent vectors + for proper orientation. Arrowhead size scales with edge width for + consistent visual appearance across different edge weights. Args: - vertices (list): List of vertices from the Bézier curve. - head_length (float): Length of the arrowhead. - head_width (float): Width of the arrowhead. + vertices: Bezier curve vertices list for tangent calculation + head_length: Base arrowhead length (scaled by edge size) + head_width: Base arrowhead width (scaled by edge size) Returns: - tuple: A tuple containing (vertices, codes) for the Path object. + tuple: (vertices, codes) for matplotlib Path objects + + !!! tip "Proportional Scaling" + Arrowhead dimensions automatically scale with edge width + to maintain consistent visual proportions across different + edge weights in the same network. """ # Extract the last segment of the Bézier curve P1, P2 = vertices[-2], vertices[-1] diff --git a/src/pathpyG/visualisations/_tikz/__init__.py b/src/pathpyG/visualisations/_tikz/__init__.py index 8d233e496..73edc2ed5 100644 --- a/src/pathpyG/visualisations/_tikz/__init__.py +++ b/src/pathpyG/visualisations/_tikz/__init__.py @@ -1,22 +1,58 @@ -"""Initialize tikz plotting functions.""" - -# !/usr/bin/python -tt -# -*- coding: utf-8 -*- -# ============================================================================= -# File : __init__.py -- tikz plotting cunctions -# Author : Jürgen Hackl -# Time-stamp: -# -# Copyright (c) 2016-2023 Pathpy Developers -# ============================================================================= - - -# ============================================================================= -# eof -# -# Local Variables: -# mode: python -# mode: linum -# mode: auto-fill -# fill-column: 79 -# End: +"""TikZ Backend for PathpyG Visualizations. + +Publication-quality vector graphics backend using LaTeX's TikZ package for static networks. +Ideal for academic publications and high-quality print materials. + +!!! info "Output Formats" + - **SVG**: Scalable vector graphics for web and presentations + - **PDF**: Print-ready documents with embedded fonts + - **TeX**: Raw LaTeX code for document integration + +!!! warning "Requirements" + - LaTeX distribution with TikZ package + - `dvisvgm` for SVG output (included with TeX Live) + - `pdflatex` for PDF output + +## Basic Usage + +```python +import pathpyG as pp + +# Simple network visualization +edges = [("A", "B"), ("B", "C"), ("C", "A")] +g = pp.Graph.from_edge_list(edges) +pp.plot(g, backend="tikz") +``` +Example TikZ Custom Properties + +## Advanced Example + +```python +import pathpyG as pp +import torch + +# Graph with custom styling +edges = [("A", "B"), ("B", "C"), ("C", "D"), ("D", "A")] +g = pp.Graph.from_edge_list(edges) +g.data["node_size"] = torch.tensor([15, 20, 25, 20]) + +pp.plot( + g, + backend="tikz", + node_color={"A": "red", "B": "#00FF00"}, + edge_opacity=0.7, + curvature=0.2, + width="8cm", + height="6cm", + filename="custom_network.svg" +) +``` +Example TikZ Custom Properties + +## Templates + +PathpyG uses LaTeX templates to generate TikZ visualizations. Templates define standalone LaTeX documents with placeholders for dynamic content. +Templates are located in the `pathpyG/visualisations/_tikz/templates/` directory. +Currently supported templates: +- `static.tex`: For static networks without time dynamics. +""" diff --git a/src/pathpyG/visualisations/_tikz/backend.py b/src/pathpyG/visualisations/_tikz/backend.py index 165c1d498..1e0a1a740 100644 --- a/src/pathpyG/visualisations/_tikz/backend.py +++ b/src/pathpyG/visualisations/_tikz/backend.py @@ -1,3 +1,38 @@ +"""TikZ/LaTeX Backend for High-Quality Network Visualizations. + +This backend generates publication-ready vector graphics using LaTeX's TikZ package. +It provides precise control over visual elements and produces scalable output suitable +for academic papers, presentations, and professional documentation. + +!!! abstract "Backend Capabilities" + - **Static networks only** - Temporal networks not supported + - **Vector output** - SVG, PDF, and raw TeX formats + - **LaTeX compilation** - Automatic document generation and compilation + - **Custom styling** - Full control over colors, sizes, and layouts + +The backend handles the complete workflow from graph data to compiled output, +including template processing, LaTeX compilation, and format conversion. + +## Workflow Overview + +```mermaid +graph LR + A[Graph Data] --> B[TikZ Template] + B --> C[LaTeX Document] + C --> D[Compilation] + D --> E[PDF Output] + D --> F[DVI Output] + F --> H[Conversion] + H --> I[SVG Output] + C --> G[TeX Output] +``` + +!!! tip "Performance Considerations" + - Compilation time scales with network complexity + - Large networks (>500 nodes) may require significant processing time + - Consider `matplotlib` backend for rapid prototyping of complex networks +""" + from __future__ import annotations import logging @@ -8,6 +43,8 @@ import webbrowser from string import Template +import pandas as pd + from pathpyG import config from pathpyG.visualisations.network_plot import NetworkPlot from pathpyG.visualisations.pathpy_plot import PathPyPlot @@ -23,18 +60,79 @@ class TikzBackend(PlotBackend): - """Backend for tikz/latex output.""" + """TikZ/LaTeX Backend for Publication-Quality Network Graphics. + + Generates high-quality vector graphics using LaTeX's TikZ package. + The backend mainly uses the [`tikz-network`](https://github.com/hackl/tikz-network) + package to create detailed and customizable visualizations. This backend + is optimized for static networks and provides publication-ready output with + precise control over visual elements. + + !!! info "Supported Operations" + - **Formats**: SVG, PDF, TeX + - **Networks**: Static graphs only + - **Styling**: Full customization support + - **Layouts**: All pathpyG layout algorithms + + The backend automatically handles LaTeX compilation, temporary file management, + and format conversion to deliver clean, scalable graphics suitable for + academic publications and professional presentations. + + Attributes: + plot: The PathPyPlot instance containing graph data and configuration + show_labels: Whether to display node labels in the output + _kind: Type of plot being processed (for now only "static" supported) + + Example: + ```python + # The backend is typically used via pp.plot() + import pathpyG as pp + g = pp.Graph.from_edge_list([("A", "B"), ("B", "C")]) + pp.plot(g, backend="tikz") + ``` + Example TikZ Backend Output + """ def __init__(self, plot: PathPyPlot, show_labels: bool): - """Initialize the backend with a plot.""" + """Initialize the TikZ backend with plot data and configuration. + + Sets up the backend to process the provided plot data and validates + that the plot type is supported by the TikZ backend. + + Args: + plot: PathPyPlot instance containing graph data, layout, and styling + show_labels: Whether to display node labels in the generated output + + Raises: + ValueError: If the plot type is not supported by the TikZ backend + + Note: + Currently only static NetworkPlot instances are supported. + Temporal networks require, e.g. the manim backend instead. + """ super().__init__(plot, show_labels=show_labels) - self._kind = SUPPORTED_KINDS.get(type(plot), None) + self._kind = SUPPORTED_KINDS.get(type(plot), None) # type: ignore[arg-type] if self._kind is None: logger.error(f"Plot of type {type(plot)} not supported by Tikz backend.") raise ValueError(f"Plot of type {type(plot)} not supported.") def save(self, filename: str) -> None: - """Save the plot to the hard drive.""" + """Save the network visualization to a file in the specified format. + + Automatically detects the output format from the file extension and + performs the necessary compilation steps. Supports TeX (raw LaTeX), + PDF (compiled document), and SVG (vector graphics) formats. + + Args: + filename: Output file path with extension (.tex, .pdf, or .svg) + + Raises: + NotImplementedError: If the file extension is not supported + + Note: + PDF and SVG compilation requires LaTeX toolchain installation. + The method handles temporary file creation and cleanup automatically. + """ if filename.endswith("tex"): with open(filename, "w+") as new: new.write(self.to_tex()) @@ -56,7 +154,23 @@ def save(self, filename: str) -> None: raise NotImplementedError def show(self) -> None: - """Show the plot on the device.""" + """Display the network visualization in the current environment. + + Compiles the network to SVG format and displays it either inline + (in Jupyter notebooks) or opens it in the default web browser. + The display method is automatically chosen based on the environment. + + The method creates temporary files for compilation and cleans them + up automatically after display. + + Environment Detection: + - **Interactive (Jupyter)**: Displays SVG inline using IPython.display + - **Non-interactive**: Opens SVG file in default web browser + + Note: + Requires LaTeX toolchain with TikZ and dvisvgm for SVG compilation. + Temporary files are automatically cleaned up after a brief delay. + """ # compile temporary pdf temp_file, temp_dir = self.compile_svg() @@ -80,7 +194,27 @@ def show(self) -> None: shutil.rmtree(temp_dir) def compile_svg(self) -> tuple: - """Compile svg from tex.""" + """Compile LaTeX source to SVG format using the LaTeX toolchain. + + Performs a complete compilation workflow: TeX → DVI → SVG conversion. + Uses latexmk for robust LaTeX compilation and dvisvgm for high-quality + SVG conversion with proper text rendering. + + Returns: + tuple: (svg_file_path, temp_directory_path) for the compiled SVG + + Raises: + AttributeError: If LaTeX compilation fails or required tools are missing + + Compilation Steps: + 1. Generate temporary directory and save TeX source + 2. Run latexmk to compile TeX → DVI + 3. Use dvisvgm to convert DVI → SVG + 4. Return paths for file access and cleanup + + Note: + Both latexmk and dvisvgm must be available in the system PATH. + """ temp_dir, current_dir = prepare_tempfile() # save the tex file self.save("default.tex") @@ -117,7 +251,21 @@ def compile_svg(self) -> tuple: return os.path.join(temp_dir, "default.svg"), temp_dir def compile_pdf(self) -> tuple: - """Compile pdf from tex.""" + """Compile LaTeX source to PDF format using pdflatex. + + Generates a high-quality PDF document suitable for printing and + publication. Uses latexmk with PDF mode for robust compilation + and automatic dependency handling. + + Returns: + tuple: (pdf_file_path, temp_directory_path) for the compiled PDF + + Raises: + AttributeError: If LaTeX compilation fails or pdflatex is not available + + Note: + Requires latexmk and a PDF-capable LaTeX engine (pdflatex, xelatex, etc.). + """ temp_dir, current_dir = prepare_tempfile() # save the tex file self.save("default.tex") @@ -144,7 +292,31 @@ def compile_pdf(self) -> tuple: return os.path.join(temp_dir, "default.pdf"), temp_dir def to_tex(self) -> str: - """Convert data to tex.""" + """Generate complete LaTeX document with TikZ network visualization. + + Combines the network data with a LaTeX template to create a complete + document ready for compilation. The template includes all necessary + packages, document setup, and TikZ drawing commands. + + Returns: + str: Complete LaTeX document source code + + Process: + 1. **Load template** - Retrieves the appropriate template for the plot type + 2. **Generate TikZ** - Converts network data to TikZ drawing commands + 3. **Template substitution** - Fills template variables with graph data + 4. **Return final string** - Complete LaTeX document ready for compilation + + Template Variables: + - `$classoptions`: LaTeX class options + - `$width`, `$height`: Document dimensions + - `$margin`: Margin around the drawing area + - `$tikz`: TikZ drawing commands for nodes and edges + + Note: + The generated document is self-contained and includes all necessary + TikZ packages and configuration for network visualization. + """ # get path to the pathpy templates template_dir = os.path.join( os.path.dirname(os.path.dirname(__file__)), @@ -161,8 +333,8 @@ def to_tex(self) -> str: # fill template with data tex = Template(tex_template).substitute( classoptions=self.config.get("latex_class_options"), - width=unit_str_to_float(self.config.get("width"), "cm"), - height=unit_str_to_float(self.config.get("height"), "cm"), + width=unit_str_to_float(self.config.get("width"), "cm"), # type: ignore[arg-type] + height=unit_str_to_float(self.config.get("height"), "cm"), # type: ignore[arg-type] margin=self.config.get("margin"), tikz=data, ) @@ -170,10 +342,27 @@ def to_tex(self) -> str: return tex def to_tikz(self) -> str: - """Convert to Tex.""" + r"""Generate TikZ drawing commands for the network visualization. + + Converts the processed graph data (nodes, edges, layout) into TikZ-specific + drawing commands. Handles node positioning, styling, edge routing, and + label placement according to the configured visualization parameters. + + Returns: + str: TikZ drawing commands ready for inclusion in LaTeX document + + Generated Elements: + - **Node commands** - `\Vertex` with labels, positions, colors, and sizes + - **Edge commands** - `\Edge` with styling and optional curvature + + Note: + The output assumes the tikz-network package is loaded in the template. + Coordinates are assumed to be normalized to [0, 1] range and scaled + according to the specified document dimensions. + """ tikz = "" # generate node strings - node_strings = "\\Vertex[" + node_strings: pd.Series = "\\Vertex[" # show labels if specified if self.show_labels: node_strings += ( @@ -203,7 +392,7 @@ def to_tikz(self) -> str: tikz += node_strings.str.cat() # generate edge strings - edge_strings = "\\Edge[" + edge_strings: pd.Series = "\\Edge[" if self.config["directed"]: edge_strings += "bend=15,Direct," if self.data["edges"]["color"].str.startswith("#").all(): @@ -231,9 +420,9 @@ def _replace_with_LaTeX_math_symbol(self, node_label: str) -> str: "<=>": r"\Leftrightarrow ", "!=": r"\neq ", } - if self.config["higher_order"]["separator"].strip() in replacements: + if self.config["separator"].strip() in replacements: node_label = node_label.replace( - self.config["higher_order"]["separator"], - replacements[self.config["higher_order"]["separator"].strip()], + self.config["separator"], + replacements[self.config["separator"].strip()], ) return node_label diff --git a/src/pathpyG/visualisations/hist_plots.py b/src/pathpyG/visualisations/hist_plots.py deleted file mode 100644 index f1360bd65..000000000 --- a/src/pathpyG/visualisations/hist_plots.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Histogram plot classes.""" - -from __future__ import annotations -import logging - -from typing import TYPE_CHECKING, Any -from pathpyG.visualisations.plot import PathPyPlot - -# pseudo load class for type checking -if TYPE_CHECKING: - from pathpyG.core.graph import Graph - -# create logger -logger = logging.getLogger("root") - - -def hist(network: Graph, key: str = "indegrees", bins: int = 10, **kwargs: Any) -> HistogramPlot: - """Plot a histogram.""" - return HistogramPlot(network, key, bins, **kwargs) - - -class HistogramPlot(PathPyPlot): - """Histogram plot class for a network property.""" - - _kind = "hist" - - def __init__(self, network: Graph, key: str = "indegrees", bins: int = 10, **kwargs: Any) -> None: - """Initialize network plot class.""" - super().__init__() - self.network = network - self.config = kwargs - self.config["bins"] = bins - self.config["key"] = key - self.generate() - - def generate(self) -> None: - """Generate the plot.""" - logger.debug("Generate histogram.") - - data: dict = {} - - match self.config["key"]: - case "indegrees": - logger.debug("Generate data for in-degrees") - data["values"] = list(self.network.degrees(mode="in").values()) - case "outdegrees": - logger.debug("Generate data for out-degrees") - data["values"] = list(self.network.degrees(mode="out").values()) - case _: - logger.error( - f"The <{self.config['key']}> property", - "is currently not supported for hist plots.", - ) - raise KeyError - - data["title"] = self.config["key"] - self.data["data"] = data diff --git a/src/pathpyG/visualisations/layout.py b/src/pathpyG/visualisations/layout.py index ec06ce97d..9e4131ce7 100644 --- a/src/pathpyG/visualisations/layout.py +++ b/src/pathpyG/visualisations/layout.py @@ -1,3 +1,37 @@ +"""Network layout algorithms for node positioning. + +Provides comprehensive layout computation for network visualization using various +algorithms from NetworkX and custom implementations. Supports both weighted and +unweighted networks with flexible parameter configuration. + +!!! abstract "Key Features" + - NetworkX integration for proven algorithms + - Custom grid layout for regular structures + - Weighted layout support for better positioning + - Automatic algorithm selection and validation + +!!! info "Available Algorithms" + - All layouts that are implemented in `networkx` + - Random layout + - Circular layout + - Shell layout + - Spectral layout + - Kamada-Kawai layout + - Fruchterman-Reingold force-directed algorithm + - ForceAtlas2 layout algorithm + - Grid layout + +Examples: + Compute a spring layout for a simple graph: + + >>> from pathpyG import Graph + >>> from pathpyG.visualisations import layout + >>> + >>> g = Graph.from_edge_list([('a', 'b'), ('b', 'c')]) + >>> positions = layout(g, layout='spring', k=0.5) + >>> print(positions) + {'a': array([ 0.61899711, -1. ]), 'b': array([-0.00132282, 0.00213747]), 'c': array([-0.61767429, 0.99786253])} +""" #!/usr/bin/python -tt # -*- coding: utf-8 -*- # ============================================================================= @@ -33,36 +67,49 @@ def layout(network: Graph, layout: str = "random", weight: None | str | Iterable = None, **kwargs): - """Function to generate a layout for the network. + """Generate node positions using specified layout algorithm. - This function generates a layout configuration for the nodes in the - network. Thereby, different layouts and options can be chosen. The layout - function is directly included in the plot function or can be separately - called. + Computes 2D coordinates for all nodes in the network using various layout + algorithms. Supports edge weighting for physics-based layouts and provides + flexible parameter passing to underlying algorithms. - Currently supported algorithms are: + Args: + network: Graph instance to generate layout for + layout: Algorithm name (see supported algorithms below) + weight: Edge weights as attribute name, iterable, or None + **kwargs: Algorithm-specific parameters passed to layout function - - All layouts that are implemented in `networkx` - - Random layout - - Circular layout - - Shell layout - - Spectral layout - - Kamada-Kawai layout - - Fruchterman-Reingold force-directed algorithm - - ForceAtlas2 layout algorithm - - Grid layout + Returns: + dict: Node positions as {node_id: (x, y)} coordinate mapping - The appearance of the layout can be modified by keyword arguments which will - be explained in more detail below. + Raises: + ValueError: If weight attribute not found or weight length mismatch + ValueError: If layout algorithm not recognized - Args: - network (network object): Network to be drawn. - weight (str or Iterable): Edge attribute that should be used as weight. - If a string is provided, the attribute must be present in the edge - attributes of the network. If an iterable is provided, it must have - the same length as the number of edges in the network. - layout (str): Layout algorithm that should be used. - **kwargs (Optional dict): Attributes that will be passed to the layout function. + Examples: + ```python + # Basic spring layout + pos = layout(graph, 'spring') + + # Weighted layout with edge attribute + pos = layout(graph, 'kamada-kawai', weight='edge_weight') + + # Custom parameters + pos = layout(graph, 'spring', k=0.3, iterations=100) + ``` + + !!! note "Supported Algorithms" + + | Algorithm | Aliases | Best For | + |-----------|---------|----------| + | `spring` | `fruchterman-reingold`, `fr` | General networks | + | `kamada-kawai` | `kk`, `kamada` | Small/medium networks | + | `forceatlas2` | `fa2`, `force-atlas2` | Large networks | + | `circular` | `circle`, `ring` | Cycle structures | + | `shell` | `concentric` | Hierarchical data | + | `grid` | `lattice-2d` | Regular structures | + | `spectral` | `eigen` | Community detection | + | `random` | `rand` | Testing/baseline | """ # initialize variables if isinstance(weight, str): @@ -72,7 +119,7 @@ def layout(network: Graph, layout: str = "random", weight: None | str | Iterable raise ValueError(f"Weight attribute '{weight}' not found in edge attributes.") elif isinstance(weight, Iterable) and not isinstance(weight, torch.Tensor): n_edges = network.m * 2 if network.is_undirected() else network.m - if len(weight) == n_edges: + if len(weight) == n_edges: # type: ignore[arg-type] weight = torch.tensor(weight) else: raise ValueError("Length of weight iterable does not match number of edges in the network.") @@ -86,29 +133,37 @@ def layout(network: Graph, layout: str = "random", weight: None | str | Iterable class Layout(object): - """Default class to create layouts. + """Layout computation engine for network node positioning. - The [`Layout`][pathpyG.visualisations.layout.Layout] class is used to generate node a layout drawer and - return the calculated node positions as a dictionary, where the keywords - represents the node ids and the values represents a two dimensional tuple - with the x and y coordinates for the associated nodes. + Core class that handles algorithm selection, parameter management, and + coordinate generation. Integrates with NetworkX for proven algorithms + while providing custom implementations for specialized cases. Args: - nodes (list): list with node ids. - The list contain a list of unique node ids. - edge_index (Tensor): Edge index of the network. - The edge index is a tensor of shape [2, num_edges] and contains the - source and target nodes of each edge. - - weight (Tensor): Edge weights of the network. - The edge weights is a tensor of shape [num_edges] and contains the - weight of each edge. - **kwargs (dict): Keyword arguments to modify the layout. Will be passed - to the layout function. + nodes: List of unique node identifiers + edge_index: Tensor containing source/target indices for each edge + layout_type: Algorithm name for position computation + weight: Optional edge weights as tensor with shape [num_edges] + **kwargs: Algorithm-specific parameters + + Attributes: + nodes: Node identifier list + edge_index: Edge connectivity tensor + weight: Edge weight tensor (optional) + layout_type: Selected algorithm name + kwargs: Algorithm parameters """ def __init__(self, nodes: list, edge_index: Optional[Tensor] = None, layout_type: str = "random", weight: Optional[Tensor] = None, **kwargs): - """Initialize the Layout class.""" + """Initialize layout computation with network data and parameters. + + Args: + nodes: List of unique node identifiers + edge_index: Edge connectivity tensor (creates empty if None) + layout_type: Algorithm name for position computation + weight: Optional edge weights tensor + **kwargs: Algorithm-specific parameters + """ # initialize variables self.nodes = nodes if edge_index is None: @@ -120,7 +175,14 @@ def __init__(self, nodes: list, edge_index: Optional[Tensor] = None, layout_type self.kwargs = kwargs def generate_layout(self): - """Function to pick and generate the right layout.""" + """Select and execute appropriate layout algorithm. + + Routes computation to either custom grid implementation or + NetworkX-based algorithms based on layout_type specification. + + Returns: + dict: Node positions as {node_id: (x, y)} coordinate mapping + """ # method names names_grid = ["grid", "2d-lattice", "lattice-2d"] # check which layout should be plotted @@ -132,7 +194,21 @@ def generate_layout(self): return self.layout def generate_nx_layout(self): - """Function to generate a layout using networkx.""" + """Compute layout using NetworkX algorithms with weight support. + + Converts pathpyG network to NetworkX format, applies selected algorithm + with proper weight handling, and returns position dictionary. + + Returns: + dict: Node positions from NetworkX layout algorithm + + Raises: + ValueError: If layout algorithm name not recognized + + !!! note "Algorithm Mapping" + Multiple aliases map to the same underlying NetworkX function + for user convenience and compatibility with different naming conventions. + """ import networkx as nx sp_matrix = to_scipy_sparse_matrix(self.edge_index.as_tensor(), edge_attr=self.weight, num_nodes=len(self.nodes)) @@ -171,12 +247,13 @@ def generate_nx_layout(self): return layout def grid(self): - """Position nodes on a two-dimensional grid. + """Position nodes on regular 2D grid for lattice-like structures. - This algorithm can be enabled with the keywords: `grid`, `lattice-2d`, `2d-lattice`, `lattice` + Arranges nodes in a square grid pattern with uniform spacing. + Useful for regular networks, lattices. Returns: - layout (dict): A dictionary of positions keyed by node + dict: Grid positions as {node_id: (x, y)} coordinates """ n = len(self.nodes) width = 1.0 diff --git a/src/pathpyG/visualisations/network_plot.py b/src/pathpyG/visualisations/network_plot.py index b82707ece..40e7a2c66 100644 --- a/src/pathpyG/visualisations/network_plot.py +++ b/src/pathpyG/visualisations/network_plot.py @@ -1,4 +1,18 @@ -"""Network plot classes.""" +"""Static network visualization classes. + +Provides comprehensive plotting functionality for static (non-temporal) networks. +Handles data preparation, attribute assignment, layout computation, and backend +integration for Graph objects. + +!!! abstract "Key Features" + - Automatic attribute extraction from network data + - Flexible node/edge styling (colors, sizes, images) + - Layout algorithm integration + - Multi-backend compatibility + +!!! note "Attribute Sources" + Attributes are resolved in order (highest priority to the leftmost): user arguments → network attributes → config defaults +""" # !/usr/bin/python -tt # -*- coding: utf-8 -*- @@ -11,8 +25,8 @@ # ============================================================================= from __future__ import annotations -import os import logging +import os from typing import TYPE_CHECKING, Any, Sized import matplotlib.pyplot as plt @@ -21,7 +35,7 @@ from pathpyG.visualisations.layout import layout as network_layout from pathpyG.visualisations.pathpy_plot import PathPyPlot -from pathpyG.visualisations.utils import rgb_to_hex, image_to_base64 +from pathpyG.visualisations.utils import image_to_base64, rgb_to_hex # pseudo load class for type checking if TYPE_CHECKING: @@ -33,12 +47,36 @@ class NetworkPlot(PathPyPlot): - """Network plot class for a static network.""" + """Static network visualization with comprehensive styling options. + + Prepares Graph objects for visualization by extracting node/edge data, + computing layouts, and processing visual attributes. Supports both + simple and higher-order networks with flexible attribute assignment. + + Attributes: + network: Graph instance being visualized + node_args: Node-specific styling arguments + edge_args: Edge-specific styling arguments + attributes: Standard visual attributes (color, size, opacity, image) + + !!! tip "Attribute Assignment" + Use `node_color`, `edge_size` etc. for convenient styling. + Attributes support constants, lists, or node/edge mappings. + """ _kind = "network" def __init__(self, network: Graph, **kwargs: Any) -> None: - """Initialize network plot class.""" + """Initialize network plot with graph and styling options. + + Processes node/edge arguments, updates configuration, and generates + plot data structures. Arguments prefixed with 'node_' or 'edge_' + are automatically assigned to respective components. + + Args: + network: Graph instance to visualize + **kwargs: Styling options (node_color, edge_size, layout, etc.) + """ super().__init__() self.network = network self.node_args = {} @@ -66,7 +104,11 @@ def __init__(self, network: Graph, **kwargs: Any) -> None: self.generate() def generate(self) -> None: - """Generate the plot.""" + """Generate complete plot data through processing pipeline. + + Orchestrates data preparation: edges → nodes → layout → post-processing → config. + Creates final data structures ready for backend rendering. + """ self._compute_edge_data() self._compute_node_data() self._compute_layout() @@ -74,12 +116,17 @@ def generate(self) -> None: self._compute_config() def _compute_node_data(self) -> None: - """Generate the data structure for the nodes.""" + """Build node DataFrame with visual attributes. + + Creates indexed DataFrame for all nodes, handling higher-order networks + by converting tuple nodes to string representation. Assigns attributes + from config defaults, network data, and user arguments. + """ # initialize values nodes: pd.DataFrame = pd.DataFrame(index=self.network.nodes) # if higher-order network, convert node tuples to string representation if self.network.order > 1: - nodes.index = nodes.index.map(lambda x: self.config["higher_order"]["separator"].join(map(str, x))) + nodes.index = nodes.index.map(lambda x: self.config["separator"].join(map(str, x))) for attribute in self.attributes: # set default value for each attribute based on the pathpyG.toml config if isinstance(self.config.get("node").get(attribute, None), list | tuple): # type: ignore[union-attr] @@ -91,13 +138,17 @@ def _compute_node_data(self) -> None: nodes[attribute] = self.network.data[f"node_{attribute}"] # check if attribute is given as argument if attribute in self.node_args: - nodes = self.assign_argument(attribute, self.node_args[attribute], nodes) + nodes = self._assign_argument(attribute, self.node_args[attribute], nodes) # save node data self.data["nodes"] = nodes def _post_process_node_data(self) -> None: - """Post-process specific node attributes after constructing the DataFrame.""" + """Finalize node attributes for backend compatibility. + + Converts colors to uniform hex format and loads local images + to base64 strings for embedding in output formats. + """ # convert colors to uniform hex values self.data["nodes"]["color"] = self._convert_to_rgb_tuple(self.data["nodes"]["color"]) self.data["nodes"]["color"] = self.data["nodes"]["color"].map(self._convert_color) @@ -107,12 +158,20 @@ def _post_process_node_data(self) -> None: self.data["nodes"]["image"] = self.data["nodes"]["image"].map(self._load_image) def _compute_edge_data(self) -> None: - """Generate the data structure for the edges.""" + """Build edge DataFrame with visual attributes and deduplication. + + Creates MultiIndex DataFrame for edges, handles higher-order networks, + assigns attributes, and removes duplicates for undirected graphs. + Special handling for edge weights as size defaults. + + !!! warning "No support for networks with multiedges" + For efficiency, duplicate edges are removed. + """ # initialize values edges: pd.DataFrame = pd.DataFrame(index=pd.MultiIndex.from_tuples(self.network.edges, names=["source", "target"])) # if higher-order network, convert node tuples to string representation if self.network.order > 1: - edges.index = edges.index.map(lambda x: (self.config["higher_order"]["separator"].join(map(str, x[0])), self.config["higher_order"]["separator"].join(map(str, x[1])))) + edges.index = edges.index.map(lambda x: (self.config["separator"].join(map(str, x[0])), self.config["separator"].join(map(str, x[1])))) for attribute in self.attributes: # set default value for each attribute based on the pathpyG.toml config if isinstance(self.config.get("edge").get(attribute, None), list | tuple): # type: ignore[union-attr] @@ -127,9 +186,9 @@ def _compute_edge_data(self) -> None: edges[attribute] = self.network.data["edge_weight"] # check if attribute is given as argument if attribute in self.edge_args: - edges = self.assign_argument(attribute, self.edge_args[attribute], edges) + edges = self._assign_argument(attribute, self.edge_args[attribute], edges) elif attribute == "size" and "weight" in self.edge_args: - edges = self.assign_argument("size", self.edge_args["weight"], edges) + edges = self._assign_argument("size", self.edge_args["weight"], edges) # convert attributes to useful values edges["color"] = self._convert_to_rgb_tuple(edges["color"]) @@ -149,23 +208,29 @@ def _compute_edge_data(self) -> None: # save edge data self.data["edges"] = edges - def assign_argument(self, attr_key: str, attr_value: Any, df: pd.DataFrame) -> pd.DataFrame: - """Assign argument to node or edge attribute. + def _assign_argument(self, attr_key: str, attr_value: Any, df: pd.DataFrame) -> pd.DataFrame: + """Assign user arguments to node/edge attributes flexibly. - Assigns the given value to the specified attribute key in the provided DataFrame. - `attr_value` can be a constant value, a list of values (of length equal to the number of nodes/edges), - or a dictionary mapping node/edge identifiers to values. + Handles multiple value types: constants, lists/arrays, or dictionaries + mapping node/edge IDs to values. Special handling for RGB color tuples + and proper length validation for sequence types. Args: - attr_key (str): Attribute key. - attr_value (Any): Attribute value. - df (pd.DataFrame): DataFrame to assign the attribute to (nodes or edges). + attr_key: Attribute name (color, size, opacity, image) + attr_value: Value to assign (constant, list, or dict mapping) + df: Target DataFrame (nodes or edges) + + Returns: + Updated DataFrame with assigned attributes + + Raises: + AttributeError: If list length doesn't match DataFrame size """ if isinstance(attr_value, dict): # if dict does not contain values for all edges, only update those that are given new_attrs = df.index.map(attr_value) # Check if all values are assigned - if new_attrs.size == df.shape[0]: + if (~new_attrs.isna()).sum() == df.shape[0]: # If all values are assigned, directly set the column to make sure that dtype is correct df[attr_key] = new_attrs else: @@ -188,7 +253,17 @@ def assign_argument(self, attr_key: str, attr_value: Any, df: pd.DataFrame) -> p return df def _convert_to_rgb_tuple(self, colors: pd.Series) -> dict: - """Convert colors to rgb colormap if given as numerical values.""" + """Convert numeric color values to RGB tuples via colormap. + + Maps numerical values to colors using matplotlib colormap when + colors are provided as numeric data (for value-based coloring). + + Args: + colors: Series containing color values (numeric or already processed) + + Returns: + Series with RGB tuple colors or original non-numeric colors + """ # check if colors are given as numerical values if pd.api.types.is_numeric_dtype(colors): # load colormap to map numerical values to color @@ -201,7 +276,21 @@ def _convert_to_rgb_tuple(self, colors: pd.Series) -> dict: return colors def _convert_color(self, color: tuple[int, int, int]) -> str: - """Convert color rgb tuple to hex.""" + """Normalize colors to hex format for backend consistency. + + Converts RGB tuples, color names, or existing hex values to + standardized hex format. Handles matplotlib color names via + automatic RGB conversion. + + Args: + color: Color as RGB tuple, hex string, or named color + + Returns: + Hex color string (e.g., "#ff0000") + + Raises: + AttributeError: If color format is invalid or unrecognized + """ if isinstance(color, tuple): return rgb_to_hex(color[:3]) elif isinstance(color, str): @@ -222,7 +311,20 @@ def _convert_color(self, color: tuple[int, int, int]) -> str: raise AttributeError def _load_image(self, image_path: str) -> str: - """Check if image path is a URL or local file and load local files to base64 strings.""" + """Load local images to base64 or pass through URLs. + + Converts local image files to base64 data URLs for embedding + while preserving existing URLs and data URLs unchanged. + + Args: + image_path: Local file path, URL, or data URL + + Returns: + Base64 data URL for local files, original string for URLs + + Raises: + AttributeError: If local file path doesn't exist + """ if image_path.startswith("http://") or image_path.startswith("https://") or image_path.startswith("data:"): return image_path # already a URL or base64 string else: @@ -233,7 +335,12 @@ def _load_image(self, image_path: str) -> str: return image_to_base64(image_path) def _compute_layout(self) -> None: - """Create layout.""" + """Compute and normalize node positions using layout algorithms. + + Applies layout algorithm from config, normalizes coordinates to [0,1] + range, and joins position data with node attributes. Handles both + string layout names and pre-computed position dictionaries. + """ # get layout from the config layout = self.config.get("layout") @@ -251,7 +358,7 @@ def _compute_layout(self) -> None: # update x,y position of the nodes layout_df = pd.DataFrame.from_dict(layout, orient="index", columns=["x", "y"]) if self.network.order > 1 and not isinstance(layout_df.index[0], str): - layout_df.index = layout_df.index.map(lambda x: self.config["higher_order"]["separator"].join(map(str, x))) + layout_df.index = layout_df.index.map(lambda x: self.config["separator"].join(map(str, x))) # scale x and y to [0,1] layout_df["x"] = (layout_df["x"] - layout_df["x"].min()) / (layout_df["x"].max() - layout_df["x"].min()) layout_df["y"] = (layout_df["y"] - layout_df["y"].min()) / (layout_df["y"].max() - layout_df["y"].min()) @@ -259,7 +366,12 @@ def _compute_layout(self) -> None: self.data["nodes"] = self.data["nodes"].join(layout_df, how="left") def _compute_config(self) -> None: - """Add additional configs.""" + """Set network-specific visualization configuration. + + Configures directedness, edge curvature, and simulation mode (for `d3.js` backend) + based on network properties. Directed networks use curved edges, + simulation mode activates when no layout is specified. + """ self.config["directed"] = self.network.is_directed() self.config["curved"] = self.network.is_directed() self.config["simulation"] = self.config["layout"] is None diff --git a/src/pathpyG/visualisations/pathpy_plot.py b/src/pathpyG/visualisations/pathpy_plot.py index 99af1fd52..4bbbf7134 100644 --- a/src/pathpyG/visualisations/pathpy_plot.py +++ b/src/pathpyG/visualisations/pathpy_plot.py @@ -1,3 +1,9 @@ +"""Abstract base class for plot data preparation. + +Provides common foundation for assembling plot data and configuration +before backend-specific rendering. Handles configuration loading and +data structure initialization. +""" import logging from pathpyG import config @@ -6,15 +12,22 @@ class PathPyPlot: - """Abstract class for assembling plots. + """Abstract base class for plot data assembly. + Prepares network data and configuration for backend rendering. + Subclasses implement specific plot types (static, temporal, histogram, etc.). + Attributes: - data (pd.DataFrame): Data of the plot object. - config (dict): Configuration for the plot. + data: Dictionary containing processed plot data + config: Visualization configuration from pathpyG settings """ def __init__(self) -> None: - """Initialize plot class.""" + """Initialize plot with empty data and default configuration. + + Loads visualization config and normalizes color settings from + lists to tuples for consistency across backends. + """ self.data: dict = {} self.config: dict = config.get("visualisation", {}).copy() if isinstance(self.config["node"]["color"], list): @@ -24,5 +37,9 @@ def __init__(self) -> None: logger.debug(f"Intialising PathpyPlot with config: {self.config}") def generate(self) -> None: - """Generate the plot.""" + """Generate plot data structures. + + Raises: + NotImplementedError: Must be implemented by subclasses + """ raise NotImplementedError diff --git a/src/pathpyG/visualisations/plot_backend.py b/src/pathpyG/visualisations/plot_backend.py index 4924ba3cc..b0147eaed 100644 --- a/src/pathpyG/visualisations/plot_backend.py +++ b/src/pathpyG/visualisations/plot_backend.py @@ -1,20 +1,57 @@ -"""Base class for all plot backends.""" +"""Abstract base class for visualization backends. + +Defines the common interface that all visualization backends (matplotlib, TikZ, +d3.js, manim) must implement. Handles plot data extraction and provides +standardized save/show methods. + +Example: + ```python + class CustomBackend(PlotBackend): + def save(self, filename: str) -> None: + # Implementation for saving + pass + + def show(self) -> None: + # Implementation for display + pass + ``` +""" from pathpyG.visualisations.pathpy_plot import PathPyPlot class PlotBackend: - """Base class for all plot backends.""" + """Abstract base class for all visualization backends. + + Provides common interface for matplotlib, TikZ, d3.js, and manim backends. + Extracts plot data and configuration for backend-specific rendering. + """ def __init__(self, plot: PathPyPlot, show_labels: bool) -> None: - """Initialize the backend with a plot.""" + """Initialize backend with plot data and configuration. + + Args: + plot: PathPyPlot instance containing network data + show_labels: Whether to display node labels + """ self.data = plot.data self.config = plot.config self.show_labels = show_labels def save(self, filename: str) -> None: - """Save the plot to the hard drive.""" + """Save plot to file. + + Args: + filename: Output file path + + Raises: + NotImplementedError: Must be implemented by subclasses + """ raise NotImplementedError("Subclasses should implement this method.") def show(self) -> None: - """Show the plot on the device.""" + """Display plot on screen. + + Raises: + NotImplementedError: Must be implemented by subclasses + """ raise NotImplementedError("Subclasses should implement this method.") diff --git a/src/pathpyG/visualisations/plot_function.py b/src/pathpyG/visualisations/plot_function.py index 13fa89bed..cf9377217 100644 --- a/src/pathpyG/visualisations/plot_function.py +++ b/src/pathpyG/visualisations/plot_function.py @@ -1,4 +1,43 @@ -"""Class to plot pathpy networks.""" +"""Network visualization orchestration module. + +Provides the main plotting interface for pathpyG networks with automatic backend +selection and plot type detection. Serves as the unified entry point for all +visualization functionality across different backends and graph types. + +Key Features: + - Multi-backend support (matplotlib, TikZ, d3.js, manim) + - Automatic plot type detection (static vs temporal) + - File format-based backend inference + - Unified plotting interface for all graph types + +Supported Backends: + - **matplotlib**: PNG, JPG plots for static visualization + - **TikZ**: PDF, SVG, TEX for publication-quality vector graphics + - **d3.js**: HTML for interactive web visualization + - **manim**: MP4, GIF for animated temporal networks + +Examples: + Plot a static network with the matplotlib backend and save it as `network.png`: + + >>> import pathpyG as pp + >>> g = pp.Graph.from_edge_list([('a', 'b'), ('b', 'c')]) + >>> pp.plot(g, filename='network.png') + + Example static network plot + + Plot a temporal network with the default d3.js backend: + + >>> import pathpyG as pp + >>> tg = pp.TemporalGraph.from_edge_list([('a', 'b', 1), ('b', 'c', 2), ('a', 'c', 3)]) + >>> pp.plot(tg) + + + ``` + +!!! tip "Backend Selection" + Backends are auto-selected from file extensions or can be explicitly + specified via the `backend` parameter. +""" # !/usr/bin/python -tt # -*- coding: utf-8 -*- @@ -28,7 +67,11 @@ # supported backends class Backends(str, Enum): - """Supported backends.""" + """Enumeration of supported visualization backends. + + Defines the available backend engines for network visualization, + each optimized for different output formats and use cases. + """ d3js = "d3js" matplotlib = "matplotlib" tikz = "tikz" @@ -36,7 +79,14 @@ class Backends(str, Enum): @staticmethod def is_backend(backend: str) -> bool: - """Check if value is a valid backend.""" + """Check if string is a valid backend identifier. + + Args: + backend: Backend name to validate + + Returns: + True if backend is supported, False otherwise + """ return backend in Backends.__members__.values() # supported file formats @@ -57,7 +107,24 @@ def is_backend(backend: str) -> bool: } def _get_plot_backend(backend: Optional[str], filename: Optional[str], default: str) -> type[PlotBackend]: - """Return the plotting backend to use.""" + """Determine and import the appropriate plotting backend. + + Resolves backend selection based on explicit backend parameter, + file extension inference, or default fallback. Dynamically imports + the selected backend module. + + Args: + backend: Explicit backend name or None for auto-detection + filename: Output filename for extension-based inference + default: Fallback backend when no preference specified + + Returns: + Backend class ready for instantiation + + Raises: + KeyError: If specified backend is not supported + ImportError: If backend module cannot be imported + """ # check if backend is valid backend type based on enum if backend is not None and not Backends.is_backend(backend): logger.error(f"The backend <{backend}> was not found.") @@ -100,46 +167,49 @@ def plot(graph: Graph, kind: Optional[str] = None, show_labels=None, **kwargs: A and potentially other types if specified in `kind`. Args: - graph (Graph): A `pathpyG` object representing the network data. This can + graph: A `pathpyG` object representing the network data. This can be a `Graph` or `TemporalGraph` object, or other compatible types. - kind (Optional[str], optional): A string keyword defining the type of - plot to generate. Options include: - - 'static' : Generates a static (aggregated) network plot. Ideal - for `Graph` objects. - - 'temporal' : Creates a temporal network plot, which includes time - components. Suitable for `TemporalGraph` objects. - - 'hist' : Produces a histogram of network properties. (Note: - Implementation for 'hist' is not present in the given function - code, it's mentioned for possible extension.) - The default behavior (when `kind` is None) is to infer the plot type from the graph type. - show_labels (Optional[bool], optional): Whether to display node labels - on the plot. If None, the function will decide based on the IndexMap. - **kwargs (Any): Optional keyword arguments to customize the plot. These - arguments are passed directly to the plotting class. Common options - could include layout parameters, color schemes, and plot size. + kind: A string keyword defining the type of plot to generate. Options include: + **'static'**, and **'temporal'**. + show_labels: Whether to display node labels (None uses graph.mapping.has_ids) + **kwargs: Backend-specific plotting parameters including: + **filename**: Output file path (triggers backend auto-selection); + **backend**: Explicit backend choice; + **layout**: Layout algorithm name; + **style**: Various styling parameters (colors, sizes, etc.) Returns: - PathPyPlot: A `PathPyPlot` object representing the generated plot. - This could be an instance of a plot class from - `pathpyG.visualisations.network_plots`, depending on the kind of - plot generated. + Configured backend instance ready for display or saving Raises: - NotImplementedError: If the `kind` is not recognized or if the function - cannot infer the plot type from the `graph` type. + NotImplementedError: If graph type cannot be auto-detected for plotting + KeyError: If specified backend is not supported + ImportError: If required backend cannot be loaded Examples: This will create a static network plot of the `graph` and save it to 'graph.png'. >>> import pathpyG as pp - >>> graph = Graph.from_edge_list([["a", "b"], ["b", "c"], ["a", "c"]]) - >>> plot(graph, kind="static", filename="graph.png") + >>> graph = pp.Graph.from_edge_list([["a", "b"], ["b", "c"], ["a", "c"]]) + >>> pp.plot(graph, kind="static", filename="graph.png") + Example static network plot + Note: - If a 'filename' is provided in `kwargs`, the plot will be saved to that file. Otherwise, it will be displayed using `plt.show()`. - The function's behavior and the available options in `kwargs` might change based on the type of plot being generated. + + !!! abstract "Backend Auto-Selection" + When filename is provided, backend is inferred from extension: + + | Extension | Backend | Best For | + |-----------|---------|----------| + | .png, .jpg | matplotlib | Quick visualization | + | .pdf, .svg, .tex | tikz | Publication quality | + | .html | d3js | Interactive exploration | + | .mp4, .gif | manim | Animated sequences | """ if kind is None: if isinstance(graph, TemporalGraph): diff --git a/src/pathpyG/visualisations/temporal_network_plot.py b/src/pathpyG/visualisations/temporal_network_plot.py index 9579123ff..6911379e5 100644 --- a/src/pathpyG/visualisations/temporal_network_plot.py +++ b/src/pathpyG/visualisations/temporal_network_plot.py @@ -1,3 +1,9 @@ +"""Temporal network visualization module. + +Prepares temporal graphs for visualization, handling time-based +node and edge dynamics, windowed layout computation, and +attribute interpolation. +""" from __future__ import annotations import logging @@ -18,17 +24,37 @@ class TemporalNetworkPlot(NetworkPlot): - """Network plot class for a temporal network.""" + """Temporal network visualization with time-based node and edge dynamics. + + Extends NetworkPlot to handle temporal graphs where edges appear at + fixed times. Provides windowed layout computation and + time-aware attribute interpolation. + + !!! info "Temporal Features" + - Node lifetime tracking (start/end times) + - Windowed layout computation + - Time-based attribute interpolation + """ _kind = "temporal" network: TemporalGraph def __init__(self, network: TemporalGraph, **kwargs: Any) -> None: - """Initialize network plot class.""" + """Initialize temporal network plot. + + Args: + network: TemporalGraph instance to visualize + **kwargs: Additional plotting parameters + """ super().__init__(network, **kwargs) def _compute_node_data(self) -> None: - """Generate the data structure for the nodes.""" + """Generate temporal node data with time-based attributes. + + Creates multi-index DataFrame with (node_id, time) structure. + Handles node appearance times and attribute assignment from + network data, config defaults, and user arguments. + """ # initialize values with index `node-0` to indicate time step 0 start_nodes: pd.DataFrame = pd.DataFrame( index=pd.MultiIndex.from_tuples([(node, 0) for node in self.network.nodes], names=["uid", "time"]) @@ -62,7 +88,14 @@ def _compute_node_data(self) -> None: self.data["nodes"] = new_nodes.combine_first(start_nodes) def _post_process_node_data(self) -> pd.DataFrame: - """Post-process specific node attributes after constructing the DataFrame.""" + """Add node lifetime information and forward-fill attributes. + + Computes start/end times for each node appearance and fills + missing attribute values using forward-fill within node groups. + + Returns: + Processed DataFrame with start/end time columns + """ # Post-processing from parent class super()._post_process_node_data() @@ -80,7 +113,12 @@ def _post_process_node_data(self) -> pd.DataFrame: self.data["nodes"] = nodes def _compute_edge_data(self) -> None: - """Generate the data structure for the edges.""" + """Generate temporal edge data with time-based attributes. + + Creates edge DataFrame with temporal index (source, target, time). + Handles edge attributes from network data, config defaults, and + user arguments. Adds start/end time columns for edge lifetime. + """ # initialize values edges: pd.DataFrame = pd.DataFrame( index=pd.MultiIndex.from_tuples(self.network.temporal_edges, names=["source", "target", "time"]) @@ -99,9 +137,9 @@ def _compute_edge_data(self) -> None: edges[attribute] = self.network.data["edge_weight"] # check if attribute is given as argument if attribute in self.edge_args: - edges = self.assign_argument(attribute, self.edge_args[attribute], edges) + edges = self._assign_argument(attribute, self.edge_args[attribute], edges) elif attribute == "size" and "weight" in self.edge_args: - edges = self.assign_argument("size", self.edge_args["weight"], edges) + edges = self._assign_argument("size", self.edge_args["weight"], edges) # convert needed attributes to useful values edges["color"] = self._convert_to_rgb_tuple(edges["color"]) @@ -114,7 +152,17 @@ def _compute_edge_data(self) -> None: self.data["edges"] = edges def _compute_layout(self) -> None: - """Create temporal layout.""" + """Compute time-aware node layout using sliding window approach. + + Uses configurable time windows to create smooth layout transitions. + For each time step, considers edges from surrounding time steps + based on layout_window_size configuration. + + !!! tip "Window Configuration" + - Integer: symmetric window around current time + - [past, future]: asymmetric window sizes + - Negative values: use all past/future time steps + """ # get layout from the config layout_type = self.config.get("layout") max_time = int( @@ -126,9 +174,11 @@ def _compute_layout(self) -> None: window_size = [window_size // 2, ceil(window_size / 2)] elif isinstance(window_size, list | tuple): if window_size[0] < 0: - window_size[0] = max_time # use all previous time steps + # use all previous time steps + window_size[0] = max_time # type: ignore[index] if window_size[1] < 0: - window_size[1] = max_time # use all following time steps + # use all following time steps + window_size[1] = max_time # type: ignore[index] elif not isinstance(window_size, (list, tuple)): logger.error("The provided layout_window_size is not valid!") raise AttributeError @@ -158,9 +208,7 @@ def _compute_layout(self) -> None: # update x,y position of the nodes new_layout_df = pd.DataFrame.from_dict(pos, orient="index", columns=["x", "y"]) if self.network.order > 1 and not isinstance(new_layout_df.index[0], str): - new_layout_df.index = new_layout_df.index.map( - lambda x: self.config["higher_order"]["separator"].join(map(str, x)) - ) + new_layout_df.index = new_layout_df.index.map(lambda x: self.config["separator"].join(map(str, x))) # scale x and y to [0,1] new_layout_df["x"] = (new_layout_df["x"] - new_layout_df["x"].min()) / ( new_layout_df["x"].max() - new_layout_df["x"].min() @@ -177,7 +225,11 @@ def _compute_layout(self) -> None: self.data["nodes"] = self.data["nodes"].join(layout_df, how="outer") def _compute_config(self) -> None: - """Add additional configs.""" + """Set temporal-specific visualization configuration. + + Forces directed=True and curved=False for temporal networks. + Enables simulation mode (for `d3js` backend) when no layout algorithm is specified. + """ self.config["directed"] = True self.config["curved"] = False self.config["simulation"] = self.config["layout"] is None diff --git a/src/pathpyG/visualisations/utils.py b/src/pathpyG/visualisations/utils.py index c33b2e029..c2cf612b2 100644 --- a/src/pathpyG/visualisations/utils.py +++ b/src/pathpyG/visualisations/utils.py @@ -1,4 +1,41 @@ -"""Helper functions for plotting.""" +"""Visualization Utilities for PathpyG. + +Essential helper functions for network visualization backends. This module provides +utilities for file management, color conversion, unit conversion, and image processing +to support the various visualization backends in PathpyG. + +!!! abstract "Key Utilities" + - :material-folder-cog: **File Management** - Temporary directory handling for compilation + - :material-palette: **Color Conversion** - RGB/Hex color format transformations + - :material-ruler: **Unit Conversion** - Between cm, inches, and pixels + - :material-image: **Image Processing** - Base64 encoding for web compatibility + +These utilities are primarily used internally by visualization backends but can also +be useful for custom visualization development and data preprocessing. + +## Usage Examples + +!!! example "Color Format Conversion" + ```python + from pathpyG.visualisations.utils import rgb_to_hex, hex_to_rgb + + # Convert RGB to hex + hex_color = rgb_to_hex((255, 0, 0)) # "#ff0000" + hex_color = rgb_to_hex((1.0, 0.0, 0.0)) # Also "#ff0000" + + # Convert hex to RGB + rgb_color = hex_to_rgb("#ff0000") # (255, 0, 0) + ``` + +!!! example "Unit Conversions for Layout" + ```python + from pathpyG.visualisations.utils import unit_str_to_float + + # Convert between different units + width_px = unit_str_to_float("12cm", "px") # Converts 12cm to pixels + height_in = unit_str_to_float("800px", "in") # Converts 800px to inches + ``` +""" # ============================================================================= # File : utils.py -- Helpers for the plotting functions @@ -16,7 +53,21 @@ def prepare_tempfile() -> tuple[str, str]: - """Prepare temporary directory and filename for compilation.""" + """Prepare temporary directory for backend compilation processes. + + Creates a secure temporary directory and changes the working directory + to it. This is essential for LaTeX compilation and other backends that + generate intermediate files during the rendering process. + + Returns: + tuple[str, str]: (temp_directory_path, original_directory_path) + + !!! warning "Directory Management" + The caller is responsible for: + + - Restoring the original working directory + - Cleaning up the temporary directory when done + """ # get current directory current_dir = os.getcwd() @@ -30,10 +81,32 @@ def prepare_tempfile() -> tuple[str, str]: def rgb_to_hex(rgb: tuple) -> str: - """Convert rgb color tuple to hex string. + """Convert RGB color tuple to hexadecimal color string. + + Accepts RGB values in either 0-1 float range (matplotlib style) or + 0-255 integer range (web/PIL style) and converts to standard hex format. Args: - rgb (tuple): RGB color tuple either in range 0-1 or 0-255. + rgb: RGB color tuple - either (r, g, b) with values 0-1 or 0-255 + + Returns: + str: Hexadecimal color string (e.g., "#ff0000" for red) + + Raises: + ValueError: If RGB values are outside valid ranges + + Examples: + ```python + # Float values (matplotlib/numpy style) + hex_color = rgb_to_hex((1.0, 0.0, 0.0)) # "#ff0000" (red) + + # Integer values (web/PIL style) + hex_color = rgb_to_hex((255, 128, 0)) # "#ff8000" (orange) + ``` + + !!! tip "Format Detection" + The function automatically detects whether input values are in 0-1 + or 0-255 range and converts appropriately. """ if all(0.0 <= val <= 1.0 for val in rgb): rgb = tuple(int(val * 255) for val in rgb) @@ -43,34 +116,175 @@ def rgb_to_hex(rgb: tuple) -> str: def hex_to_rgb(value: str) -> tuple: - """Convert hex string to rgb color tuple.""" + """Convert hexadecimal color string to RGB color tuple. + + Parses standard hex color strings (with or without '#' prefix) and + returns RGB values in 0-255 integer range suitable for most graphics libraries. + + Args: + value: Hexadecimal color string (e.g., "#ff0000" or "ff0000") + + Returns: + tuple: RGB color tuple with values in range 0-255 + + Examples: + ```python + # Standard hex with hash + rgb = hex_to_rgb("#ff0000") # (255, 0, 0) - red + + # Hex without hash + rgb = hex_to_rgb("00ff00") # (0, 255, 0) - green + + # Short hex notation + rgb = hex_to_rgb("#f0f") # (255, 0, 255) - magenta + ``` + """ value = value.lstrip("#") _l = len(value) return tuple(int(value[i : i + _l // 3], 16) for i in range(0, _l, _l // 3)) def cm_to_inch(value: float) -> float: - """Convert cm to inch.""" + """Convert centimeters to inches. + + Converts metric length measurements to imperial inches for compatibility + with systems that use imperial units. + + Args: + value: Length in centimeters + + Returns: + float: Equivalent length in inches (1 cm = 0.393701 in) + + Examples: + ```python + # Convert A4 width to inches + width_in = cm_to_inch(21.0) # 8.268 inches + + # Convert small measurement + thickness_in = cm_to_inch(0.1) # 0.039 inches + ``` + """ return value / 2.54 def inch_to_cm(value: float) -> float: - """Convert inch to cm.""" + """Convert inches to centimeters. + + Converts imperial length measurements to metric centimeters for + standardization and international compatibility. + + Args: + value: Length in inches + + Returns: + float: Equivalent length in centimeters (1 in = 2.54 cm) + + Examples: + ```python + # Convert US letter width to cm + width_cm = inch_to_cm(8.5) # 21.59 cm + + # Convert screen size + screen_cm = inch_to_cm(15.6) # 39.624 cm + ``` + """ return value * 2.54 def inch_to_px(value: float, dpi: int = 96) -> float: - """Convert inch to px.""" + """Convert inches to pixels based on DPI resolution. + + Converts physical measurements to screen pixels using dots-per-inch + resolution for accurate display sizing across different screens. + + Args: + value: Length in inches + dpi: Resolution in dots per inch (default: 96 - standard web DPI) + + Returns: + float: Equivalent length in pixels + + Examples: + ```python + # Standard web resolution + width_px = inch_to_px(8.5) # 816.0 pixels (96 DPI) + + # High-resolution display + width_px = inch_to_px(8.5, 300) # 2550.0 pixels (300 DPI) + ``` + """ return value * dpi def px_to_inch(value: float, dpi: int = 96) -> float: - """Convert px to inch.""" + """Convert pixels to inches based on DPI resolution. + + Converts screen pixels to physical measurements using dots-per-inch + resolution for print layout and physical sizing calculations. + + Args: + value: Length in pixels + dpi: Resolution in dots per inch (default: 96 - standard web DPI) + + Returns: + float: Equivalent length in inches + + Examples: + ```python + # Standard web resolution + width_in = px_to_inch(800) # 8.333 inches (96 DPI) + + # Print resolution conversion + width_in = px_to_inch(2400, 300) # 8.0 inches (300 DPI) + ``` + """ return value / dpi def unit_str_to_float(value: str, unit: str) -> float: - """Convert string with unit to float in `unit`.""" + """Convert string with unit suffix to float in target unit. + + Parses strings containing numeric values with unit suffixes (e.g., "10px", "5cm") + and converts to the specified target unit using appropriate conversion functions. + + Args: + value: String with numeric value and 2-character unit suffix + unit: Target unit for conversion ("px", "cm", "in") + + Returns: + float: Converted numeric value in target unit + + Raises: + ValueError: If conversion between units is not supported + + Examples: + ```python + # Convert pixel string to centimeters + cm_value = unit_str_to_float("800px", "cm") # 21.17 cm (96 DPI) + + # Convert cm string to inches + in_value = unit_str_to_float("21cm", "in") # 8.268 inches + + # Same unit (no conversion needed) + px_value = unit_str_to_float("100px", "px") # 100.0 + ``` + + !!! warning "Supported Conversions" + Only supports conversions between "px", "cm", and "in" units. + Pixel conversions assume 96 DPI by default. + + Supported conversion patterns: + + | From | To | Function | + |------|----| ---------| + | cm | in | `cm_to_inch()` | + | in | cm | `inch_to_cm()` | + | in | px | `inch_to_px()` | + | px | in | `px_to_inch()` | + | cm | px | `cm_to_inch() + inch_to_px()` | + | px | cm | `px_to_inch() + inch_to_cm()` | + """ conversion_functions: dict[str, Callable[[float], float]] = { "cm_to_in": cm_to_inch, "in_to_cm": inch_to_cm, @@ -89,28 +303,66 @@ def unit_str_to_float(value: str, unit: str) -> float: def image_to_base64(image_path): - """Convert local image to base64 data URL.""" + """Convert local image file to base64 data URL for embedding. + + Reads an image file from disk and converts it to a base64-encoded data URL + that can be embedded directly in HTML, SVG, or other formats without + requiring external file references. + + Args: + image_path: Path to the image file (str or Path object) + + Returns: + str: Base64 data URL (e.g., "data:image/png;base64,iVBORw0KGgoAAAA...") + + Raises: + FileNotFoundError: If the specified image file does not exist + + Examples: + ```python + # Convert PNG logo to data URL + logo_data = image_to_base64("logo.png") + # Returns: "data:image/png;base64,iVBORw0KGgoAAAA..." + + # Use in HTML template + html = f'Logo' + + # Use in SVG embedding + svg_image = f'' + ``` + + !!! info "Supported Formats" + Automatically detects MIME types for PNG, JPEG, GIF, and SVG files + based on file extension. Defaults to PNG for unknown extensions. + + !!! tip "Use Cases" + - Embedding images in standalone HTML/SVG files + - Creating self-contained visualizations + - Avoiding external file dependencies in templates + - Allows visualizations in VSCode Jupyter notebook- and browser-environments where local file access is restricted + """ path = Path(image_path) if not path.exists(): raise FileNotFoundError(f"Image not found: {image_path}") - + # Detect image type suffix = path.suffix.lower() mime_types = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.svg': 'image/svg+xml' + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", } - mime_type = mime_types.get(suffix, 'image/png') - + mime_type = mime_types.get(suffix, "image/png") + # Read and encode - with open(image_path, 'rb') as f: + with open(image_path, "rb") as f: encoded = base64.b64encode(f.read()).decode() - + return f"data:{mime_type};base64,{encoded}" + # ============================================================================= # eof # From 41625031ac16db8ca662a5f78e685f524dc39e32 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Wed, 15 Oct 2025 19:46:48 +0000 Subject: [PATCH 24/44] finish visualisation code reference --- .../plot/documentation_plots.ipynb | 712 +++++++++++++++++- .../visualisations/plot/dynamic_network.html | 628 +++++++++++++++ .../visualisations/plot/simple_network.html | 506 +++++++++++++ .../visualisations/plot/temporal_network.gif | Bin 0 -> 159092 bytes src/pathpyG/visualisations/_d3js/__init__.py | 119 ++- src/pathpyG/visualisations/_d3js/backend.py | 180 ++++- src/pathpyG/visualisations/_manim/backend.py | 147 +++- .../_manim/temporal_graph_scene.py | 46 +- .../visualisations/_matplotlib/backend.py | 11 + 9 files changed, 2280 insertions(+), 69 deletions(-) create mode 100644 docs/reference/pathpyG/visualisations/plot/dynamic_network.html create mode 100644 docs/reference/pathpyG/visualisations/plot/simple_network.html create mode 100644 docs/reference/pathpyG/visualisations/plot/temporal_network.gif diff --git a/docs/reference/pathpyG/visualisations/plot/documentation_plots.ipynb b/docs/reference/pathpyG/visualisations/plot/documentation_plots.ipynb index 0e7b3a119..b8a813d0c 100644 --- a/docs/reference/pathpyG/visualisations/plot/documentation_plots.ipynb +++ b/docs/reference/pathpyG/visualisations/plot/documentation_plots.ipynb @@ -616,7 +616,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 15, "id": "e32343c6", "metadata": {}, "outputs": [ @@ -630,10 +630,10 @@ { "data": { "text/plain": [ - "" + "" ] }, - "execution_count": 13, + "execution_count": 15, "metadata": {}, "output_type": "execute_result" } @@ -644,7 +644,7 @@ "# Simple temporal network animation\n", "tedges = [(\"a\", \"b\", 1), (\"b\", \"c\", 2), (\"c\", \"a\", 3)]\n", "tg = pp.TemporalGraph.from_edge_list(tedges)\n", - "pp.plot(tg, backend=\"manim\", filename=\"temporal_network.mp4\")" + "pp.plot(tg, backend=\"manim\", filename=\"temporal_network.gif\")" ] }, { @@ -696,9 +696,711 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 16, "id": "06363bf4", "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pathpyG as pp\n", + "\n", + "# Simple network visualization\n", + "edges = [(\"A\", \"B\"), (\"B\", \"C\"), (\"C\", \"A\")]\n", + "g = pp.Graph.from_edge_list(edges)\n", + "pp.plot(g, filename=\"simple_network.html\") # Uses d3.js backend by default" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "8488043d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + "
\n", + "\n", + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import torch\n", + "import pathpyG as pp\n", + "\n", + "# Temporal network with evolving properties\n", + "tedges = [\n", + " (\"a\", \"b\", 1), (\"b\", \"c\", 1),\n", + " (\"c\", \"d\", 2), (\"d\", \"a\", 2), \n", + " (\"a\", \"c\", 3), (\"b\", \"d\", 3)\n", + "]\n", + "tg = pp.TemporalGraph.from_edge_list(tedges)\n", + "tg.data[\"edge_color\"] = torch.arange(tg.m) # Assign a unique color index to each edge\n", + "\n", + "pp.plot(\n", + " tg,\n", + " delta=750, # 0.75 seconds per timestep\n", + " node_size={(\"a\", 1): 20, (\"b\", 2): 7},\n", + " node_color=[\"red\", \"blue\", \"green\", \"orange\"],\n", + " edge_opacity=0.7,\n", + " # filename=\"dynamic_network.html\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "89628069", + "metadata": {}, "outputs": [], "source": [] } diff --git a/docs/reference/pathpyG/visualisations/plot/dynamic_network.html b/docs/reference/pathpyG/visualisations/plot/dynamic_network.html new file mode 100644 index 000000000..5ae9683a4 --- /dev/null +++ b/docs/reference/pathpyG/visualisations/plot/dynamic_network.html @@ -0,0 +1,628 @@ + + +
+ + \ No newline at end of file diff --git a/docs/reference/pathpyG/visualisations/plot/simple_network.html b/docs/reference/pathpyG/visualisations/plot/simple_network.html new file mode 100644 index 000000000..aecd53e01 --- /dev/null +++ b/docs/reference/pathpyG/visualisations/plot/simple_network.html @@ -0,0 +1,506 @@ + + +
+ + \ No newline at end of file diff --git a/docs/reference/pathpyG/visualisations/plot/temporal_network.gif b/docs/reference/pathpyG/visualisations/plot/temporal_network.gif new file mode 100644 index 0000000000000000000000000000000000000000..1b430d928f579544235c8a59b0a7609f8db4e745 GIT binary patch literal 159092 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 w{~wa^SA=r;|6h8|2jaZ6R -# Time-stamp: -# -# Copyright (c) 2016-2023 Pathpy Developers -# ============================================================================= - - -# ============================================================================= -# eof -# -# Local Variables: -# mode: python -# mode: linum -# mode: auto-fill -# fill-column: 79 -# End: +"""D3.js Backend for PathpyG Visualizations. + +Interactive web-based visualization backend using D3.js for both static and temporal networks. +Default backend providing rich interactivity, real-time exploration, and web-compatible output. + +!!! info "Output Formats" + - **HTML**: Interactive web visualizations where nodes can be dragged around and temporal graphs can be paused/played. + +!!! note "Default Backend" + D3.js is the default visualization backend for PathpyG, automatically + selected when no specific backend is specified. No additional + dependencies required beyond web browser. + +## Basic Usage + +```python +import pathpyG as pp + +# Simple network visualization +edges = [("A", "B"), ("B", "C"), ("C", "A")] +g = pp.Graph.from_edge_list(edges) +pp.plot(g) # Uses d3.js backend by default +``` + + +## Advanced Temporal Network Example + +```python +import torch +import pathpyG as pp + +# Temporal network with evolving properties +tedges = [ + ("a", "b", 1), ("b", "c", 1), + ("c", "d", 2), ("d", "a", 2), + ("a", "c", 3), ("b", "d", 3) +] +tg = pp.TemporalGraph.from_edge_list(tedges) +tg.data["edge_color"] = torch.arange(tg.m) # Assign a unique color index to each edge + +pp.plot( + tg, + delta=750, # 0.75 seconds per timestep + node_size={("a", 1): 20, ("b", 2): 7}, + node_color=["red", "blue", "green", "orange"], + edge_opacity=0.7, + filename="dynamic_network.html" +) +``` + + +## Network Visualization with custom Images + +```python +import torch +import pathpyG as pp + +# Example network data +edges = [ + ("b", "a"), + ("c", "a"), +] +mapping = pp.IndexMap(["a", "b", "c", "d"]) +g = pp.Graph.from_edge_list(edges, mapping=mapping) +g.data["node_size"] = torch.tensor([25]*4) +pp.plot( + g, + node_size={"d": 50}, + edge_size=5, + node_image={ + "a": "https://avatars.githubusercontent.com/u/52822508?s=48&v=4", + "b": "https://raw.githubusercontent.com/pyg-team/pyg_sphinx_theme/master/pyg_sphinx_theme/static/img/pyg_logo.png", + "c": "https://pytorch-geometric.readthedocs.io/en/latest/_static/img/pytorch_logo.svg", + "d": "docs/img/pathpy_logo_new.png", + }, + show_labels=False, +) +``` + + +!!! tip "Deployment Options" + - **Standalone**: Self-contained HTML with embedded resources + - **Jupyter**: Direct display in notebook cells + - **Web Apps**: Easy integration into existing websites + +## Templates +PathpyG uses HTML templates to generate D3.js visualizations located in the `templates` directory. +Templates define the overall structure and include placeholders for dynamic content. +Currently used templates: + +- `network.js`: A basic template for static and temporal networks +- `setup.js`: Loads requireJS and D3.js libraries +- `styles.css`: Basic CSS styling for the visualizations +- `static.js`: Template for static networks that initializes the network from `network.js` +- `temporal.js`: Template for temporal networks that initializes the network from `network.js` with temporal controls +- `d3.v7.min.js`: D3.js library (version 7) for using D3.js functionalities without internet connection +""" \ No newline at end of file diff --git a/src/pathpyG/visualisations/_d3js/backend.py b/src/pathpyG/visualisations/_d3js/backend.py index ddf0289e8..0b742ce8c 100644 --- a/src/pathpyG/visualisations/_d3js/backend.py +++ b/src/pathpyG/visualisations/_d3js/backend.py @@ -1,3 +1,15 @@ +"""D3.js backend for interactive web-based network visualization. + +Template-driven HTML generation using D3.js library for rich interactive +visualizations. Supports both static and temporal networks with embedded +JavaScript, and CSS styling. + +Features: + - Interactive HTML output with drag-and-drop node movement + - Template-based architecture for extensibility + - Both static and temporal network support + - Jupyter notebook integration with inline display +""" from __future__ import annotations import json @@ -26,9 +38,55 @@ class D3jsBackend(PlotBackend): - """D3js plotting backend.""" + """D3.js backend for interactive web visualization with template system. + + Generates self-contained HTML files with embedded D3.js visualizations + using modular template architecture. Supports both static and temporal + networks with rich interactivity and web-standard compatibility. + + Features: + - Template-driven HTML generation (CSS + JavaScript + data) + - Multiple output modes: standalone HTML, Jupyter display, browser + - JSON data serialization with proper type conversion + + Example: + ```python + import pathpyG as pp + + # Simple network visualization + edges = [("A", "B"), ("B", "C"), ("C", "A")] + g = pp.Graph.from_edge_list(edges) + pp.plot(g) # Uses d3.js backend by default + ``` + + + !!! info "Template Architecture" + Uses modular templates for extensibility: + + - `styles.css`: Visual styling and responsive design + - `setup.js`: Environment detection and D3.js loading + - `network.js`: Core network visualization logic + - `static.js` / `temporal.js`: Plot-type specific functionality + + !!! note "Web Standards" + Generates standards-compliant HTML5 with SVG graphics, + compatible with all modern browsers without plugins. + """ def __init__(self, plot: PathPyPlot, show_labels: bool): + """Initialize D3.js backend with plot validation and configuration. + + Args: + plot: PathPyPlot instance (NetworkPlot or TemporalNetworkPlot) + show_labels: Whether to display node labels in visualization + + Raises: + ValueError: If plot type not supported by D3.js backend + + !!! tip "Supported Plot Types" + - **NetworkPlot**: Static network visualization + - **TemporalNetworkPlot**: Animated temporal network evolution + """ super().__init__(plot, show_labels) self._kind = SUPPORTED_KINDS.get(type(plot), None) if self._kind is None: @@ -36,12 +94,37 @@ def __init__(self, plot: PathPyPlot, show_labels: bool): raise ValueError(f"Plot of type {type(plot)} not supported.") def save(self, filename: str) -> None: - """Save the plot to the hard drive.""" + """Save interactive visualization as standalone HTML file. + + Creates self-contained HTML file with embedded D3.js visualization, + complete with styling, JavaScript, and data. File can be opened + in any web browser or served from web servers. + + Args: + filename: Output HTML file path + + !!! tip "Deployment Ready" + Generated HTML files are standalone and can be: + + - Opened directly in browsers + - Served from web servers + - Embedded in websites or documentation + - Shared without additional dependencies + """ with open(filename, "w+") as new: new.write(self.to_html()) def show(self) -> None: - """Show the plot on the device.""" + """Display visualization in appropriate environment. + + Automatically detects environment and displays visualization: + - Jupyter notebooks: Inline HTML display with IPython widgets + - Scripts/terminals: Opens temporary HTML file in system browser + + !!! info "Environment Detection" + Uses pathpyG config to detect interactive environment + and choose appropriate display method automatically. + """ if config["environment"]["interactive"]: from IPython.display import display_html, HTML # noqa I001 @@ -55,7 +138,20 @@ def show(self) -> None: webbrowser.open(r"file:///" + temp_file.name) def _prepare_data(self) -> dict: - """Prepare the data for json conversion.""" + """Transform network data for JSON serialization and D3.js consumption. + + Converts pandas DataFrames to D3.js-compatible format with proper + node/edge structure. Handles coordinate renaming and unique ID generation + for JavaScript processing. + + Returns: + dict: Structured data with 'nodes' and 'edges' arrays + + !!! note "Data Structure" + **Nodes**: Include uid, coordinates (xpos/ypos), and all attributes + + **Edges**: Include uid, source/target references, and styling + """ node_data = self.data["nodes"].copy() node_data["uid"] = self.data["nodes"].index node_data = node_data.rename(columns={"x": "xpos", "y": "ypos"}) @@ -70,7 +166,19 @@ def _prepare_data(self) -> dict: return data_dict def _prepare_config(self) -> dict: - """Prepare the config for json conversion.""" + """Transform configuration for JavaScript compatibility. + + Converts pathpyG configuration to web-compatible format with proper + color conversion, unit normalization, and JavaScript-friendly types. + + Returns: + dict: Web-compatible configuration object + + !!! info "Configuration Processing" + - **Colors**: Convert to hex format for CSS compatibility + - **Units**: Convert to pixels for SVG rendering + - **Types**: Ensure JSON-serializable data types + """ config = deepcopy(self.config) config["node"]["color"] = rgb_to_hex(self.config["node"]["color"]) config["edge"]["color"] = rgb_to_hex(self.config["edge"]["color"]) @@ -80,13 +188,44 @@ def _prepare_config(self) -> dict: return config def to_json(self) -> tuple[str, str]: - """Convert data and config to json.""" + """Serialize network data and configuration to JSON strings. + + Processes both data and configuration through preparation methods + and converts to JSON format suitable for JavaScript consumption. + + Returns: + tuple: (data_json, config_json) string pair for template injection + + !!! tip "Template Integration" + JSON strings are injected directly into JavaScript templates + as `const data = {...}` and `const config = {...}` declarations. + """ data_dict = self._prepare_data() config_dict = self._prepare_config() return json.dumps(data_dict), json.dumps(config_dict) def to_html(self) -> str: - """Convert data to html.""" + """Generate complete standalone HTML visualization. + + Assembles full HTML document using template system with embedded CSS, + JavaScript, and data. Creates unique DOM IDs to prevent conflicts + when multiple visualizations exist on same page. + + Returns: + str: Complete HTML document with embedded visualization + + !!! info "HTML Structure" + 1. **CSS Styles**: Embedded styling + 2. **DOM Container**: Unique div element for visualization + 3. **D3.js Library**: CDN or local library loading + 4. **Setup Code**: Environment detection and module loading + 5. **Data/Config**: JSON-serialized network and configuration + 6. **Visualization**: Plot-specific JavaScript execution + + !!! note "Library Loading" + Supports both CDN and local (default) D3.js library embedding + based on `d3js_local` configuration parameter. + """ # generate unique dom uids dom_id = "#x" + uuid.uuid4().hex @@ -97,7 +236,7 @@ def to_html(self) -> str: ) # get d3js version - local = self.config.get("d3js_local", False) + local = self.config.get("d3js_local", True) if local: d3js = os.path.join(template_dir, "d3.v7.min.js") else: @@ -152,7 +291,30 @@ def to_html(self) -> str: return html def get_template(self, template_dir: str) -> str: - """Get the JavaScript template for the specific plot type.""" + """Load and combine JavaScript templates for visualization. + + Assembles modular JavaScript code by combining core network + functionality with plot-type specific features. Enables clean + separation of concerns and extensible template architecture. + + Args: + template_dir: Directory containing JavaScript template files + + Returns: + str: Combined JavaScript code for visualization + + !!! info "Template Composition" + **Core Template** (`network.js`): Base network visualization logic + + **Plot Templates**: Type-specific functionality: + + - `static.js`: Force simulation and interaction for static networks + - `temporal.js`: Timeline controls and animation for temporal networks + + !!! tip "Extensibility" + New plot types can be added by creating additional + JavaScript templates following the established patterns. + """ js_template = "" with open(os.path.join(template_dir, "network.js")) as template: js_template += template.read() diff --git a/src/pathpyG/visualisations/_manim/backend.py b/src/pathpyG/visualisations/_manim/backend.py index b5b050321..2e8970a9f 100644 --- a/src/pathpyG/visualisations/_manim/backend.py +++ b/src/pathpyG/visualisations/_manim/backend.py @@ -1,4 +1,26 @@ -"""Generic manim plot class.""" +"""Manim backend for high-quality temporal network animations. + +Professional animation backend using Manim Community Edition for creating +high-quality temporal network visualizations. Optimized for temporal +graphs with smooth transitions and customizable animation parameters. + +Features: + - High-quality video output (MP4, GIF) + - Temporal network animation with smooth transitions + - Jupyter notebook integration with inline video display + - FFmpeg integration for format conversion + +## Workflow Overview + +```mermaid +graph LR + A[Graph Data] --> B[Manim Scene Creation] + B --> C[Rendering] + C --> D[MP4 Output] + D --> E[Conversion] + E --> F[GIF Output] +``` +""" from __future__ import annotations @@ -29,14 +51,59 @@ class ManimBackend(PlotBackend): - """Base class for Manim Plots integrated with Jupyter notebooks. - - This class defines the interface for Manim plots that are generated - from data and can be rendered for either saving or displaying inline. + """Manim backend for temporal network animation. + + Integrates Manim Community Edition for creating smooth temporal network + animations. Supports both MP4 and GIF output formats with Jupyter notebook + integration for inline display. + + Features: + - Temporal network animation with smooth node/edge transitions + - Multiple output formats (MP4, GIF via FFmpeg) + - Jupyter integration with base64 video embedding + + Example: + Create and display a simple temporal network animation: + ```python + import pathpyG as pp + + tedges = [("a", "b", 1), ("b", "c", 2), ("c", "a", 3)] + tg = pp.TemporalGraph.from_edge_list(tedges) + pp.plot(tg, backend="manim", filename="temporal_network.gif") + ``` + Example Matplotlib Backend Output + + !!! note "Temporal Networks Only" + This backend is specifically designed for TemporalNetworkPlot + objects and does not support static network visualization. + + !!! warning "Performance Requirements" + High-quality animations require significant computational resources. + Rendering time scales with network size, animation duration, and quality settings. """ def __init__(self, plot: PathPyPlot, show_labels: bool): - """Initializes the Manim backend with a given plot.""" + """Initialize Manim backend with temporal network validation and configuration. + + Sets up Manim configuration parameters including resolution, frame rate, + quality settings, and background color. Validates that the plot type + is supported (currently only TemporalNetworkPlot). + + Args: + plot: PathPyPlot instance (must be TemporalNetworkPlot) + show_labels: Whether to display node labels in animation + + Raises: + ValueError: If plot type is not supported by Manim backend + + !!! info "Manim Configuration" + Automatically configures Manim settings using pathpyG config and fixed defaults: + + - **Resolution**: From width/height config parameters + - **Frame Rate**: Default 15 fps for smooth playback + - **Quality**: High quality + - **Background**: White background for clarity + """ super().__init__(plot, show_labels=show_labels) self._kind = SUPPORTED_KINDS.get(type(plot), None) if self._kind is None: @@ -44,18 +111,28 @@ def __init__(self, plot: PathPyPlot, show_labels: bool): raise ValueError(f"Plot of type {type(plot)} not supported.") # Optional config settings - manim_config.pixel_height = int(unit_str_to_float(self.config.get("height"), "px")) - manim_config.pixel_width = int(unit_str_to_float(self.config.get("width"), "px")) + manim_config.pixel_height = int(unit_str_to_float(self.config.get("height"), "px")) # type: ignore[arg-type] + manim_config.pixel_width = int(unit_str_to_float(self.config.get("width"), "px")) # type: ignore[arg-type] manim_config.frame_rate = 15 manim_config.quality = "high_quality" - manim_config.background_color = self.config.get("background_color", WHITE) + manim_config.background_color = WHITE + + def render_video(self) -> tuple[Path, str]: + """Render temporal network animation using Manim engine. + + Creates temporary directory, configures Manim settings, instantiates + TemporalGraphScene, and renders the complete animation sequence. + Handles all Manim-specific setup and teardown. - def render_video( - self, - ): - """Renders the Manim animation. + Returns: + tuple: (video_file_path, temp_directory_path) for post-processing - This method sets up the scene and prepares it for rendering. + !!! info "Rendering Pipeline" + 1. **Setup**: Create temporary directory for Manim output + 2. **Configuration**: Set output path and filename + 3. **Scene Creation**: Instantiate TemporalGraphScene with data + 4. **Rendering**: Execute Manim rendering process + 5. **Cleanup**: Return paths for further processing and returns to original directory """ temp_dir, current_dir = prepare_tempfile() manim_config.media_dir = temp_dir @@ -66,16 +143,18 @@ def render_video( return Path(temp_dir) / "videos" / "1080p60" / "default.mp4", temp_dir def save(self, filename: str) -> None: - """Renders and saves a Manim animation to the working directory. + """Render and save temporal network animation to specified file. - This method creates a temporary scene using the instance's `raw data`, - renders it with Manim, and saves the resulting video. + Creates high-quality animation video and saves to disk. Supports both + MP4 and GIF formats with automatic format detection from filename + extension. GIF conversion uses FFmpeg. Args: - filename (str): Name for the File that will be saved. Is necessary for this function to work. + filename: Output file path with extension (.mp4 or .gif) - Tip: - - use `**kwargs` to control aspects of the scene such as animation timing, layout, or styling + !!! warning "GIF Conversion" + GIF creation requires FFmpeg to be installed and available in PATH. + Conversion may take additional time for long animations. """ # render temporary .mp4 temp_file, temp_dir = self.render_video() @@ -86,7 +165,18 @@ def save(self, filename: str) -> None: shutil.rmtree(temp_dir) def convert_to_gif(self, filename: Path) -> None: - """Convert the rendered mp4 video to a gif file.""" + """Convert rendered MP4 video to animated GIF using FFmpeg. + + Uses FFmpeg with optimized settings for web-friendly GIF output: + 30 fps for smooth animation, Lanczos scaling for quality preservation, + and 1080p resolution maintenance. + + Args: + filename: Path to source MP4 file (output GIF uses same path with .gif extension) + + Raises: + Exception: If FFmpeg conversion fails (logged as error) + """ try: subprocess.run( [ @@ -107,16 +197,11 @@ def convert_to_gif(self, filename: Path) -> None: logger.error(f"GIF conversion failed: {e}") def show(self) -> None: - """Renders and displays a Manim animation. - - This method creates a temporary scene using the instance's `raw data`, - renders it with Manim, and embeds the resulting video in the notebook. - It is specifically for use in Juypter Environment - and will warn if used elsewhere. + """Display temporal network animation in interactive environment. - Notes: - - The scene is renderd into a temporary directory and not saved permanently - - Manim is expected to output the video under `videos/1080p60/TemporalNetworkPlot.mp4` which is the default + Renders animation and displays inline in Jupyter notebooks using base64 + video embedding, or opens in system browser for non-interactive environments. + Automatically cleans up temporary files after display. """ temp_file, temp_dir = self.render_video() @@ -134,5 +219,5 @@ def show(self) -> None: display(HTML(video_html)) else: # open the file in the webbrowser - webbrowser.open(r"file:///" + temp_file) + webbrowser.open(r"file:///" + temp_file.as_posix()) shutil.rmtree(temp_dir) diff --git a/src/pathpyG/visualisations/_manim/temporal_graph_scene.py b/src/pathpyG/visualisations/_manim/temporal_graph_scene.py index 37bc11f00..02e906947 100644 --- a/src/pathpyG/visualisations/_manim/temporal_graph_scene.py +++ b/src/pathpyG/visualisations/_manim/temporal_graph_scene.py @@ -1,3 +1,9 @@ +"""Manim scene implementation for temporal graph animation. + +Core animation scene that renders temporal networks with time-based node/edge +evolution. Handles smooth transitions, proper edge-node boundary calculations, +and time indicator display. +""" import logging import numpy as np @@ -12,7 +18,20 @@ class TemporalGraphScene(Scene): + """Manim scene for animated temporal network visualization. + + Creates time-based animations showing network evolution with nodes + appearing/moving and edges being added/removed over time. Handles + proper scaling, positioning, and smooth transitions between timesteps. + """ def __init__(self, data: dict, config: dict, show_labels: bool): + """Initialize temporal graph scene with network data and configuration. + + Args: + data: Network data with nodes/edges DataFrames in a dictionary + config: Animation configuration (timing, colors, etc.) + show_labels: Whether to display node labels + """ super().__init__() self.data = data self.data["nodes"]["size"] *= 0.025 # scale sizes down @@ -28,7 +47,17 @@ def __init__(self, data: dict, config: dict, show_labels: bool): self.show_labels = show_labels def construct(self): - """Constructs the Manim scene for the temporal graph.""" + """Create temporal network animation with time-based evolution. + + Main animation sequence: + 1. Initialize nodes at t=0 with layout positioning + 2. For each timestep: update time display, add new edges, + transform node positions, remove old edges + 3. Clean up final frame + + Uses smooth transitions and proper edge-node boundary calculations + for professional animation quality. + """ # Add initial nodes start_node_df = self.data["nodes"][self.data["nodes"]["start"] == 0] if "x" in self.data["nodes"] and "y" in self.data["nodes"]: @@ -130,7 +159,20 @@ def construct(self): self.play(Uncreate(node) for node in nodes.values()) def get_boundary_point(self, center, direction, radius): - """Calculate the boundary point of a circle in a given direction.""" + """Calculate edge attachment point on node boundary. + + Computes where edges should connect to nodes to avoid visual + overlap with node circles. Uses vector normalization to find + the intersection point on the node's circumference. + + Args: + center: Node center coordinates (x, y, z) + direction: Direction vector to target node + radius: Node radius for boundary calculation + + Returns: + Boundary point coordinates for clean edge attachment + """ distance = np.linalg.norm(direction) if distance == 0: return center # Avoid division by zero diff --git a/src/pathpyG/visualisations/_matplotlib/backend.py b/src/pathpyG/visualisations/_matplotlib/backend.py index c1eb1e7eb..26ce5be57 100644 --- a/src/pathpyG/visualisations/_matplotlib/backend.py +++ b/src/pathpyG/visualisations/_matplotlib/backend.py @@ -37,6 +37,17 @@ class MatplotlibBackend(PlotBackend): - Bezier curves for directed edges - Automatic edge shortening to avoid node overlap + Example: + Plot a simple directed network with curved edges: + ```python + import pathpyG as pp + + edges = [("A", "B"), ("B", "C"), ("C", "A")] + g = pp.Graph.from_edge_list(edges) + pp.plot(g, backend="matplotlib") + ``` + Example Matplotlib Backend Output + !!! note "Performance Optimization" Uses collections instead of individual plot calls for 10-100x faster rendering on networks with many edges. From 3e6835acda86fa43c1b20e334f8fa280395dec03 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Thu, 16 Oct 2025 10:51:13 +0000 Subject: [PATCH 25/44] update plot dev tutorial --- docs/plot_tutorial.md | 342 +++++++++++++++++++++--------------------- 1 file changed, 171 insertions(+), 171 deletions(-) diff --git a/docs/plot_tutorial.md b/docs/plot_tutorial.md index bfff91c8b..cbc946f64 100644 --- a/docs/plot_tutorial.md +++ b/docs/plot_tutorial.md @@ -1,238 +1,238 @@ -# Develop Custom Plot Functions +# Developing your own Plots -This tutorial guides you through the process of creating your own plotting functions in pathpyG. +!!! abstract "Overview" + Add a new histogram plot to pathpyG’s visualisation stack, wire it into [`pp.plot(...)`][pathpyG.plot], and render it with Matplotlib. This guide explains the data-prep vs. rendering split and shows the minimal pieces to implement. -The visualization framework of pathpyg is designed in such a way that is easy to extend it according your own needs. +This tutorial shows how to add a new plotting capability to pathpyG’s visualisation backend by implementing a histogram plot. You’ll learn how plot types, backends, and configuration work together, and how to add a new plot into the public [`pp.plot(...)`][pathpyG.plot] entry point. -For this tutorial we want to implement capabilities to plot histograms. +**What you’ll do** -You will learn: +- :material-family-tree: Understand the new visualisation architecture +- :material-chart-bar: Implement a new `HistogramPlot` that prepares data +- :material-vector-link: Wire it into the plot orchestrator and select backends +- :material-image-multiple: Add Matplotlib rendering support for the new type +- :material-test-tube: Use and (optionally) test your new plot -- How to set up a generic plot function -- How to convert `pathpyG` data to plot data -- How to plot with `d3js` -- How to plot with `tikz` -- How to plot with `matplotlib` +!!! tip "Scope" + This guide focuses on Matplotlib for rendering histograms (a natural fit). You can add other backends later following the same pattern. -## Structure +## Visualisation architecture at a glance -Plotting commands and functions are located under `/src/pathpyG/visualisation/` +The visualisation module is built around two core abstractions and a single entry point: -```tree -visualisation - __init__.py - _d3js -   ... - _matplotlib -   ... - _tikz - ... - layout.py - network_plots.py - plot.py - utils.py -``` - -Folders with `_...` indicate the supported backends. We will have a look at them later. - -The `layout.py` file includes algorithms to calculate the positions of the nodes. +- :material-database-cog: [`PathPyPlot`][pathpyG.visualisations.pathpy_plot.PathPyPlot] prepares data/config for rendering. Subclass it for each plot type. +- :material-cog: [`PlotBackend`][pathpyG.visualisations.plot_backend.PlotBackend] renders a given [`PathPyPlot`][pathpyG.visualisations.pathpy_plot.PathPyPlot] using a concrete engine ([Matplotlib][pathpyG.visualisations._matplotlib], [TikZ][pathpyG.visualisations._tikz], [d3.js][pathpyG.visualisations._d3js], [Manim][pathpyG.visualisations._manim]). +- :material-play-circle: [`plot(...)`][pathpyG.plot] is the public API. It chooses a plot class (kind) and a backend (by argument or filename extension), instantiates both, then saves or shows. -In the `utils.py` file are useful helper functions collected. E.g. among others a function that converts `hex_to_rgb`, `rgb_to_hex`, or a simple [`Colormap`][pathpyG.visualisations.utils.Colormap] class. If your plot needs generic functions which might be helpful for other plots as well, this would be a good place to store them. +!!! info "Reference" + See the [module overview](/reference/pathpyG/visualisations) for supported backends, formats, and styling options. For existing plot types, see [`NetworkPlot`][pathpyG.visualisations.network_plot.NetworkPlot] (static) and [`TemporalNetworkPlot`][pathpyG.visualisations.temporal_network_plot.TemporalNetworkPlot] (temporal). For existing backends, see e.g. [`MatplotlibBackend`][pathpyG.visualisations._matplotlib.backend.MatplotlibBackend] which we will be using. -The `network_plots.py` file includes all plots related to network visualization. We will create in this tutorial a similar collection for histograms. +## Define a new plot type: HistogramPlot -Finally, the `plot.py` file contains our generic [`PathPyPlot`][pathpyG.visualisations.plot.PathPyPlot] class which we will use to build our own class. +Start by creating a new subclass of [`PathPyPlot`][pathpyG.visualisations.pathpy_plot.PathPyPlot] (e.g., in `src/pathpyG/visualisations/histogram_plot.py`). Its job is to: -This abstract class has a property `_kind` which will specify the type of plot for the generic plot function. Similar to `pandas` we should be able to call: - -```python -pp.plot(graph, kind="hist") -``` +- Accept the input object(s) (typically a [`Graph`][pathpyG.core.graph.Graph]) and user options +- Compute or collect the values to be binned +- Populate `self.data` with a clean, backend-agnostic structure +- Update `self.config` with plot configuration (bins, labels, etc.) -This abstract class has two dict variables `self.data` and `self.config`. The `self.data` variable is used to store the data needed for the plot, while the `self.config` stores all the configurations passed to the plot. +!!! info "Minimal class attributes" + Inputs: `graph: Graph`, `key: str` (what to measure), `bins: int | sequence`, plus style options via `**kwargs`. -Furthermore this class has three abstract methods we have to define later for our supported backends: `generate` to generate the plot, `save` to save the plot to a file, `show` to show the current plot. - - -## Let's get started - -In order to get started, we have to create a new python file where we will store our histogram plots. So let's generate a new file `hist_plots.py` - -``` -touch hist_plots.py -``` + Data format (suggested): + - `self.data["hist_values"]: list[float | int]` — the values to bin + - optionally precomputed bins/edges (if you want backend-agnostic binning) + - `self.config` should include `title`, `xlabel`, `ylabel`, and `bins` -We start with creating a function which allows us later to plot a histogram. - -This function will take a `Graph` object as input and has the parameters `key` and `bins` as well as a dict of `kwargs` for furthermore specifications. - -We will use the `key` variable to define the data type of the histogram e.g. `by='betweenes'` to get the betweenes centrality plotted. With the `bins` parameters we will change the amount of bins in the histogram. all other options will by passed to the function as keyword arguments and can be backend specific. +### Example outline: ```python -"""Histogram plot classes.""" +# src/pathpyG/visualisations/histogram_plot.py from __future__ import annotations - import logging +from typing import Any +from pathpyG.visualisations.pathpy_plot import PathPyPlot +from pathpyG.core.graph import Graph -from typing import TYPE_CHECKING, Any - -# pseudo load class for type checking -if TYPE_CHECKING: - from pathpyG.core.graph import Graph - -# create logger -logger = logging.getLogger("pathpyG") - - -def hist(network: Graph, key: str = 'degree', bins: int = 10, **kwargs: Any) -> HistogramPlot: - """Plot a histogram.""" - return HistogramPlot(network, key, bins, **kwargs) - -``` - -pathpyG is using logging to print out messages and errors. It's a good habit to use it also for your plotting function. - -Our `hist` function will be callable via the package. e.g. `pp.hist(...)`. Itself it will return a plotting class which we have to create. - +logger = logging.getLogger("root") -```python -from pathpyG.visualisations.plot import PathPyPlot class HistogramPlot(PathPyPlot): - """Histogram plot class for a network properties.""" + """Prepare data for histogram visualisation. - _kind = "hist" + Collects values from a Graph according to `key` and exposes them in + `self.data["hist_values"]` for backends to render. + """ - def __init__(self, network: Graph, key: str = 'degree', bins: int = 10, **kwargs: Any) -> None: - """Initialize network plot class.""" + _kind = "histogram" + + def __init__(self, graph: Graph, key: str = "degree", bins: int | list[int] = 10, **kwargs: Any) -> None: super().__init__() - self.network = network - self.config = kwargs - self.config['bins'] = bins - self.config['key'] = key + self.graph = graph + # merge kwargs into config; ensure required fields are present + self.config.update({ + "bins": bins, + "title": kwargs.pop("title", f"{key.title()} distribution"), + "xlabel": kwargs.pop("xlabel", key), + "ylabel": kwargs.pop("ylabel", "count"), + }) + self.key = key + self.config.update(kwargs) self.generate() def generate(self) -> None: - """Generate the plot.""" - logger.debug("Generate histogram.") -``` + # Compute values to bin based on `key` + if self.key in ("degree", "degrees"): + values = list(self.graph.degrees().values()) + elif self.key in ("in_degree", "indegree", "in-degrees"): + values = list(self.graph.degrees(mode="in").values()) + elif self.key in ("out_degree", "outdegree", "out-degrees"): + values = list(self.graph.degrees(mode="out").values()) + else: + logger.error(f"Histogram key '{self.key}' not supported.") + raise KeyError(self.key) -The `HistogramPlot` plotting class is a child from our abstract `PathPyPlot` function. We will overwrite the abstract `generate()` function in order to get the data needed for our plot. - -By convention we assume `d3js` will be the default plot backend, hence the final data generated by this function should provide the necessary data structure for this backend. - -For other backends, this data might be needed to be converted e.g. keywords might be different. We will address this later in our tutorial. + self.data["hist_values"] = values +``` +!!! note + - Keep the class small: gather values and fill `self.data`/`self.config`. + - Choose names that are clear for backends (`hist_values`, `bins`, labels). -## Testing, Testing, Testing +## Add the new plot to the public API -Before we start developing our histogram plot, we should set up a test environment so that we can directly develop the unit test next to our plot function. +[`plot(...)`][pathpyG.plot] uses the `PLOT_CLASSES` mapping to instantiate the right plot class for a given `kind`. Extend it with your new class: -Therefore we are going to our testing folder an create a new test file. +```python +# src/pathpyG/visualisations/plot_function.py +from pathpyG.visualisations.histogram_plot import HistogramPlot -``` -cd ../../../tests/ -touch test_hist.py +PLOT_CLASSES: dict = { + "static": NetworkPlot, + "temporal": TemporalNetworkPlot, + "histogram": HistogramPlot, # add this line +} ``` -Now we can create a simple test environment with a simple graph and call our `hist(...)` function. +??? example "Usage" -```python -from pathpyG.core.graph import Graph -from pathpyG.visualisations.hist_plots import hist + ```python + import pathpyG as pp + g = pp.Graph.from_edge_list([("a", "b"), ("b", "c"), ("a", "c")]) + # Matplotlib is the natural backend for histograms + pp.plot(g, kind="histogram", backend="matplotlib", key="degree", bins=10, filename="degree_hist.png") + ``` -def test_hist_plot() -> None: - """Test to plot a histogram.""" - net = Graph.from_edge_list([["a", "b"], ["b", "c"], ["a", "c"]]) - hist(net) -``` +!!! tip "Backend selection" + [`plot(...)`][pathpyG.plot] auto-selects a backend from the filename extension if you omit `backend`. For histograms, prefer PNG via Matplotlib by passing `filename="...png"` or `backend="matplotlib"`. -Note: If you only want to run this function and not all other test you can use: +## Add Matplotlib support for HistogramPlot -``` -pytest -s -k 'test_hist_plot' -``` +Backends validate supported plot types. The [Matplotlib backend][pathpyG.visualisations._matplotlib] currently supports `NetworkPlot` and renders nodes/edges. We’ll extend it to also support `HistogramPlot`. + +Implementation approach: -## Generating the plot data +1. Add `HistogramPlot` to `SUPPORTED_KINDS` so the backend accepts the plot type. +2. Branch in [`to_fig()`](/reference/pathpyG/visualisations/_matplotlib/backend/#pathpyG.visualisations._matplotlib.backend.MatplotlibBackend.to_fig) (or factor out into a helper) to draw a histogram when the plot is a `HistogramPlot`. -To plot our histogram we first have to generate the required data from our graph. +Sketch of the required changes (condensed for illustration): -In the future we might want to add more options for histograms, hence we use the `match`-`case` function form python. - ```python - def generate(self) -> None: - """Generate the plot.""" - logger.debug("Generate histogram.") - - data: dict = {} - - match self.config["key"]: - case "indegrees": - logger.debug("Generate data for in-degrees") - data["values"] = list(self.network.degrees(mode="in").values()) - case "outdegrees": - logger.debug("Generate data for out-degrees") - data["values"] = list(self.network.degrees(mode="out").values()) - case _: - logger.error( - f"The <{self.config['key']}> property", - "is currently not supported for hist plots.", - ) - raise KeyError - - data["title"] = self.config["key"] - self.data["data"] = data +# src/pathpyG/visualisations/_matplotlib/backend.py +from pathpyG.visualisations.histogram_plot import HistogramPlot + +SUPPORTED_KINDS = { + NetworkPlot: "static", + HistogramPlot: "histogram", # add support +} + +class MatplotlibBackend(PlotBackend): + ... + def to_fig(self) -> tuple[plt.Figure, plt.Axes]: + # If histogram: render using ax.hist + if self._kind == "histogram": + return self._to_fig_histogram() + # Else: existing network rendering + return self._to_fig_network() + + def _to_fig_histogram(self) -> tuple[plt.Figure, plt.Axes]: + fig, ax = plt.subplots( + figsize=(unit_str_to_float(self.config["width"], "in"), unit_str_to_float(self.config["height"], "in")), + dpi=150, + ) + ax.set_axis_on() + ax.hist(self.data["hist_values"], bins=self.config.get("bins", 10), color=rgb_to_hex(self.config["node"]["color"]), alpha=0.9) + ax.set_title(self.config.get("title", "Histogram")) + ax.set_xlabel(self.config.get("xlabel", "value")) + ax.set_ylabel(self.config.get("ylabel", "count")) + return fig, ax + + def _to_fig_network(self) -> tuple[plt.Figure, plt.Axes]: + # move existing implementation of `to_fig` here + ... ``` -First we initialize a dictionary `data` to store our values. In this case we are interested in the in and out-degrees of our graph, which are already implemented in `pathpyG` (state 2023-11-26). +!!! tip "Tips" + - Reuse `unit_str_to_float` so sizing behaves like other plots. + - Use a default color from `self.config["node"]["color"]` for consistency. + - Keep the new code path fully separate from the network drawing code to avoid regressions. -If the keyword is not supported the function will raise a `KeyError`. +??? info "If you want web or LaTeX histograms" + The current d3.js and TikZ backends are tailored to network visualisation (they expect `nodes`/`edges` in `self.data`). To add histogram support there, you would: -To provide a default title for our plot we also store the keyword in the data dict. If further data is required for the plot it can be stored here. + - Create a new JS or TeX template for histograms + - Extend the backend to accept `HistogramPlot` and dispatch to the new template -Finally, we add the data dict to our `self.data` variable of the plotting class. This variable will be used later in the backend classes. + Start with Matplotlib first — it's a good starting point. -With this our basic histogram plot function is finished. We are now able to call the plot function, get the data from our graph and create a data-set which can be passed down to the backend for visualization. +## Try it out -## The matplotlib backend +Once you’ve added the `HistogramPlot`, updated `PLOT_CLASSES`, and extended the Matplotlib backend as shown, you can create and save a histogram in a single call: -Let's open the `_matplotlib` folder located under `/src/pathpyG/visualisation/_matplotlib`, where all matplotlib functions are stored. +```python +import pathpyG as pp -```tree -_matplotlib - __init__.py - core.py - network_plots.py +g = pp.Graph.from_edge_list([("a", "b"), ("b", "c"), ("a", "c"), ("c", "d")]) +pp.plot( + g, + kind="histogram", + backend="matplotlib", # or infer via filename extension + key="degree", + bins=5, + title="Node Degree Distribution", + filename="degree_hist.png", +) ``` -The `_init_.py` holds the configuration for the plot function, which we will modify later. The `core.py` file contains the generic `MatplotlibPlot` class, which provides `save` and `show` functionalities for our plots. We do not need to modify these functions. Instead, we have to generate a translation function from our generic data dict (see above) to a histogram in matplotlib. To do so, lets create first a new python file named `hist_plots.py` +In notebooks, omit `filename` to show inline. -``` -cd _matplotlib -touch hist_plots.py -``` +## Testing (optional but recommended) -Here we will add our missing piece for a functional matplotlib plot. +Create a small unit test to exercise the new path end-to-end: ```python -"""Histogram plot classes.""" -from __future__ import annotations - -import logging +# tests/visualisations/test_histogram.py +import pathpyG as pp -from typing import TYPE_CHECKING, Any +def test_histogram_plot_matplotlib(tmp_path): + g = pp.Graph.from_edge_list([("a", "b"), ("b", "c"), ("a", "c")]) + out = tmp_path / "deg_hist.png" + pp.plot(g, kind="histogram", backend="matplotlib", key="degree", bins=3, filename=str(out)) + assert out.exists() +``` -# pseudo load class for type checking -if TYPE_CHECKING: - from pathpyG.core.graph import Graph +## Where to look for guidance and consistency -# create logger -logger = logging.getLogger("pathpyG") +- :material-cog-outline: Backends: see other backends like [`Matplotlib`][pathpyG.visualisations._matplotlib] and [`d3.js`][pathpyG.visualisations._d3js] for how plot instances are validated and rendered. +- :material-database-outline: Plot classes: study [`NetworkPlot`][pathpyG.visualisations.network_plot.NetworkPlot] and [`TemporalNetworkPlot`][pathpyG.visualisations.temporal_network_plot.TemporalNetworkPlot] to understand how [`PathPyPlot`][pathpyG.visualisations.pathpy_plot.PathPyPlot] subclasses fill `self.data` and `self.config`. +- :material-file-document: The [module overview](/reference/pathpyG/visualisations) explains backend selection, saving, and common styling options. +## Recap -def hist(network: Graph, key: str = 'degree', bins: int = 10, **kwargs: Any) -> HistogramPlot: - """Plot a histogram.""" - return HistogramPlot(network, key, bins, **kwargs) +- :material-plus-circle: New plots are [`PathPyPlot`][pathpyG.visualisations.pathpy_plot.PathPyPlot] subclasses that prepare data and config. +- :material-merge: Register your plot in `PLOT_CLASSES` so [`pp.plot(..., kind=...)`][pathpyG.plot] can instantiate it. +- :material-image-multiple: Extend at least one backend to render your plot type. For histograms, Matplotlib is a clean first target. +- :material-link-variant: Keep a small, clear data contract between your plot class and backend rendering. -``` +With this, you have a clean, maintainable path to add new visualisations to pathpyG while leveraging the unified [`pp.plot(...)`][pathpyG.plot] API and existing backend infrastructure. From ca2241136be6f55900316727e643ae399c043dda Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Thu, 16 Oct 2025 11:35:16 +0000 Subject: [PATCH 26/44] fix existing tests --- pyproject.toml | 4 + tests/visualisations/test_hist.py | 11 - tests/visualisations/test_manim.py | 664 +++++++++--------- tests/visualisations/test_pathpy_plot.py | 10 + .../{test_plot.py => test_plot_function.py} | 92 ++- 5 files changed, 391 insertions(+), 390 deletions(-) delete mode 100644 tests/visualisations/test_hist.py create mode 100644 tests/visualisations/test_pathpy_plot.py rename tests/visualisations/{test_plot.py => test_plot_function.py} (54%) diff --git a/pyproject.toml b/pyproject.toml index 26d19a69d..3e2c36f30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -203,6 +203,10 @@ docstring-code-format = true [tool.ruff.lint] select = ["E4", "E7", "E9", "F", "D", "I"] +# "S101": Disable checks for assert statements, which are standard in tests. +# "D100": Disable "Missing docstring in public module". +# "D103": Disable "Missing docstring in public function". +per-file-ignores = { "tests/*" = [ "S101", "D100", "D103" ] } [tool.ruff.lint.pydocstyle] convention = "google" diff --git a/tests/visualisations/test_hist.py b/tests/visualisations/test_hist.py deleted file mode 100644 index c995c3d6e..000000000 --- a/tests/visualisations/test_hist.py +++ /dev/null @@ -1,11 +0,0 @@ -from pathpyG.core.graph import Graph -from pathpyG.visualisations.hist_plots import hist - - -def test_hist_plot() -> None: - """Test to plot a histogram.""" - net = Graph.from_edge_list([["a", "b"], ["b", "c"], ["a", "c"]]) - deg = net.degrees() - - # print(deg) - # hist(net) diff --git a/tests/visualisations/test_manim.py b/tests/visualisations/test_manim.py index d20f34183..e6ff267f0 100644 --- a/tests/visualisations/test_manim.py +++ b/tests/visualisations/test_manim.py @@ -1,333 +1,333 @@ -import unittest -from unittest.mock import patch, MagicMock -import tempfile -import subprocess -from pathlib import Path -from manim import Scene, manim_colors, Graph, tempconfig -import numpy as np -import pathpyG as pp - -import pathpyG.visualisations._manim.core as core -from pathpyG.visualisations._manim.network_plots import NetworkPlot -from pathpyG.visualisations._manim.network_plots import TemporalNetworkPlot - - -class ManimTest(unittest.TestCase): - """Test class for manim visualizations""" - def setUp(self): - """setting up patch for manim.config and example input data""" - patcher = patch("manim.config") - self.mock_config = patcher.start() - self.addCleanup(patcher.stop) - - self.mock_config.media_dir = None # initializing with default values - self.mock_config.output_file = None - self.mock_config.pixel_height = 0 - self.mock_config.pixel_width = 0 - self.mock_config.frame_rate = 0 - self.mock_config.quality = "" - self.mock_config.background_color = None - - self.data = { # example input data - "nodes": [0, 1, 2, 3], - "edges": [{"source": 0, "target": 2, "start": 0}, - {"source": 1, "target": 2, "start": 1}, - {"source": 2, "target": 0, "start": 2}, - {"source": 1, "target": 3, "start": 3}, - {"source": 3, "target": 2, "start": 4}, - {"source": 2, "target": 1, "start": 5}] - } - - self.data3 = { - "nodes": [{"uid": "A", "label": "Alpha"}], - "edges": [{"source": "A", "target": "A", "start": 0, "end": 1}], - } - - def test_manim_network_plot(self): - """Test for initializing the NetworkPlot class""" - networkplot = NetworkPlot(self.data) - - self.assertIsInstance(networkplot, NetworkPlot) - self.assertIsInstance(networkplot.data, dict) - self.assertIsInstance(networkplot.config, dict) - self.assertIsInstance(networkplot.raw_data, dict) - - self.assertEqual(networkplot.data, {}) - self.assertEqual(networkplot.raw_data, self.data) - - def test_manim_network_plot_empty(self): - """Test for initializing the NetworkPlot class with empty input data""" - data = {} - networkplot = NetworkPlot(data) - - self.assertIsInstance(networkplot, NetworkPlot) - self.assertIsInstance(networkplot.data, dict) - self.assertIsInstance(networkplot.config, dict) - self.assertIsInstance(networkplot.raw_data, dict) - - self.assertEqual(networkplot.data, {}) - self.assertEqual(networkplot.raw_data, {}) - - def test_manim_temporal_network_plot(self): - """Test for initializing the TemporalNetworkPlot class""" - kwargs = { - "delta": 2000, - "start": 100, - "end": 10000, - "intervals": 30, - "dynamic_layout_interval": 10, - "background_color": "#3A1F8C", - "font_size": 12, - - "node_opacity": 0.6, - "node_size": 5.2, - "node_label": {0: 'x', 1: "A", 2: ">", 3: "x"}, - "node_color": (255, 0, 0), - "node_color_timed": [(0, (1, 0.5)), (1, (2, 0.2))], - - "edge_opacity": 0.75, - "edge_size": 4.0, - "edge_color": ['blue', 'pink'] - } - temp_network_plot = TemporalNetworkPlot(self.data, **kwargs) - - self.assertIsInstance(temp_network_plot, TemporalNetworkPlot) - self.assertIsInstance(temp_network_plot, NetworkPlot) - self.assertIsInstance(temp_network_plot, Scene) - - self.assertIsInstance(temp_network_plot.data, dict) - self.assertIsInstance(temp_network_plot.config, dict) - self.assertIsInstance(temp_network_plot.raw_data, dict) - - self.assertEqual(temp_network_plot.delta, 2000) - self.assertEqual(temp_network_plot.start, 100) - self.assertEqual(temp_network_plot.end, 10000) - self.assertEqual(temp_network_plot.intervals, 30) - self.assertEqual(temp_network_plot.dynamic_layout_interval, 10) - self.assertEqual(temp_network_plot.config.get("background_color"), "#3A1F8C") - self.assertEqual(temp_network_plot.config.get("font_size"), 12) - - self.assertEqual(temp_network_plot.config.get("node_opacity"), 0.6) - self.assertEqual(temp_network_plot.config.get("node_size"), 5.2) - self.assertEqual(temp_network_plot.config.get("node_label"), {0: 'x', 1: "A", 2: ">", 3: "x"}) - self.assertEqual(temp_network_plot.config.get("node_color"), (255, 0, 0)) - self.assertEqual(temp_network_plot.config.get("node_color_timed"), [(0, (1, 0.5)), (1, (2, 0.2))]) - - self.assertEqual(temp_network_plot.config.get("edge_opacity"), 0.75) - self.assertEqual(temp_network_plot.config.get("edge_size"), 4.0) - self.assertEqual(temp_network_plot.config.get("edge_color"), ['blue', 'pink']) - - def test_manim_temporal_network_plot_empty(self): - """Test for initializing the TemporalNetworkPlot class with empty input data""" - data = {} - tempnetworkplot = TemporalNetworkPlot(data) - - self.assertIsInstance(tempnetworkplot, TemporalNetworkPlot) - self.assertIsInstance(tempnetworkplot, NetworkPlot) - self.assertIsInstance(tempnetworkplot, Scene) - - self.assertIsInstance(tempnetworkplot.data, dict) - self.assertIsInstance(tempnetworkplot.config, dict) - self.assertIsInstance(tempnetworkplot.raw_data, dict) - - self.assertEqual(tempnetworkplot.delta, 1000) - self.assertEqual(tempnetworkplot.start, 0) - self.assertEqual(tempnetworkplot.end, None) - self.assertEqual(tempnetworkplot.intervals, None) - self.assertEqual(tempnetworkplot.dynamic_layout_interval, None) - self.assertEqual(tempnetworkplot.font_size, 8) - - self.assertEqual(tempnetworkplot.node_opacity, 1) - self.assertEqual(tempnetworkplot.edge_opacity, 1) - self.assertEqual(tempnetworkplot.node_size, 0.4) - self.assertEqual(tempnetworkplot.edge_size, 0.4) - self.assertEqual(tempnetworkplot.node_label, {})# - - def test_manim_temp_np_mock_config(self): - """Test for the TemporalNetworkPlot class""" - with tempfile.TemporaryDirectory() as temp_dir: - output_dir = Path(temp_dir) - output_file = "test_output.mp4" - - _ = TemporalNetworkPlot(self.data, output_dir=output_dir, output_file=output_file) - - self.assertEqual(Path(self.mock_config.media_dir).resolve(), output_dir.resolve()) - self.assertEqual(self.mock_config.output_file, output_file) - - self.assertEqual(self.mock_config.pixel_height, 1080) - self.assertEqual(self.mock_config.pixel_width, 1920) - self.assertEqual(self.mock_config.frame_rate, 15) - self.assertEqual(self.mock_config.quality, "high_quality") - self.assertEqual(self.mock_config.background_color, manim_colors.WHITE) - - def test_manim_temp_np_path(self): - """Test for the TemporalNetworkPlot class""" - with tempfile.TemporaryDirectory() as temp_dir: - output_dir = Path(temp_dir) - output_file = "test_output.mp4" - - _ = TemporalNetworkPlot(self.data, output_dir=output_dir, output_file=output_file) - - from manim import config as manim_config - - self.assertEqual(Path(manim_config.media_dir).resolve(), output_dir.resolve()) - self.assertEqual(manim_config.output_file, output_file) - - def test_manim_temp_np_edge_index(self): - """Test for the method compute_edge_index in the TemporalNetworkPlot class""" - edgelist = [(0, 2, 0), (1, 2, 1), (2, 0, 2), (1, 3, 3), (3, 2, 4), (2, 1, 5)] - temp_network_plot = TemporalNetworkPlot(self.data) - - self.assertEqual(temp_network_plot.compute_edge_index()[0], edgelist) - self.assertEqual(temp_network_plot.compute_edge_index()[1], 5) - - def test_manim_temp_np_layout(self): - """"Test for the method get_layout in the TemporalNetworkPlot class""" - edgelist = [(0, 2, 0), (1, 2, 1), (2, 0, 2), (1, 3, 3), (3, 2, 4), (2, 1, 5)] - nodes = {0, 1, 2, 3} - graph = pp.TemporalGraph.from_edge_list(edgelist) - temp_network_plot = TemporalNetworkPlot(self.data) - old_layout = {node: [0.0, 0.0] for node in graph.nodes} - - layout = temp_network_plot.get_layout(graph, old_layout=old_layout) - - self.assertIsInstance(layout, dict) - self.assertEqual(layout.keys(), nodes) - - for coordinate in layout.values(): - self.assertIsInstance(coordinate, np.ndarray) - self.assertEqual(coordinate.shape, (3,)) - - def test_manim_temp_np_get_color_at_time(self): - """Test for the method get_color_at_time int the TemporalNetworkPlot class""" - temp_network_plot = TemporalNetworkPlot(self.data) - node_data = {"color": manim_colors.RED} - self.assertEqual(temp_network_plot.get_color_at_time(node_data, 0), manim_colors.RED) - self.assertEqual(temp_network_plot.get_color_at_time({}, 0), manim_colors.BLUE) - - node_data_2 = { - "color": manim_colors.PURPLE, - "color_change": [ - {"time": 5, "color": manim_colors.TEAL}, - {"time": 10, "color": manim_colors.GREEN}, - ] - } - self.assertEqual(temp_network_plot.get_color_at_time(node_data_2, 1), manim_colors.PURPLE) - self.assertEqual(temp_network_plot.get_color_at_time(node_data_2, 5), manim_colors.TEAL) - self.assertEqual(temp_network_plot.get_color_at_time(node_data_2, 7), manim_colors.TEAL) - self.assertEqual(temp_network_plot.get_color_at_time(node_data_2, 10), manim_colors.GREEN) - self.assertEqual(temp_network_plot.get_color_at_time(node_data_2, 11), manim_colors.GREEN) - - @patch.object(TemporalNetworkPlot, "compute_edge_index") - @patch.object(TemporalNetworkPlot, "get_layout") - @patch.object(TemporalNetworkPlot, "get_color_at_time") - def test_manim_temp_np_construct(self, mock_color, mock_layout, mock_edge_index): - """Test for the construct method from the TemporalNetworkPlot class""" - with tempfile.TemporaryDirectory() as tmp_path: - with tempconfig({ - "disable_caching": True, - "dry_run": True, - "disable_output": True, - "media_dir": str(tmp_path), - }): - mock_edge_index.return_value = ([("A", "A", 0)], 1) - mock_layout.return_value = {"A": np.array([0, 0, 0])} - mock_color.return_value = (0, 0, 1) - - temp_network_plot = TemporalNetworkPlot(self.data3) - - temp_network_plot.add = MagicMock() - temp_network_plot.play = MagicMock() - temp_network_plot.wait = MagicMock() - temp_network_plot.remove = MagicMock() - - temp_network_plot.construct() - - self.assertTrue(temp_network_plot.add.called) - self.assertTrue(temp_network_plot.wait.called) - self.assertTrue(temp_network_plot.remove.called) - self.assertTrue(any(isinstance(call[0][0], Graph) for call in temp_network_plot.add.call_args_list)) - - def test_manim_plot_save_mp4(self): - """Test for saving a mp4 file with the method save from the ManimPlot class""" - with tempfile.TemporaryDirectory() as tmp_scene_dir, tempfile.TemporaryDirectory() as tmp_save_dir: - - scene_output = Path(tmp_scene_dir) - save_output = Path(tmp_save_dir) - output_file = "TemporalNetworkPlot" - - manim_plot = TemporalNetworkPlot(self.data, output_dir=scene_output, output_file=output_file) - - with patch.object(Scene, "render", new=render_side_effect): - manim_plot.save("testvideo.mp4", save_dir=save_output) - - target_path = save_output / "testvideo.mp4" - self.assertTrue(target_path.exists()) - self.assertEqual(target_path.read_text(), "test video") - - def test_manim_plot_save_gif(self): - """Test for saving a gif with the method save from the ManimPlot class""" - with tempfile.TemporaryDirectory() as tmp_scene_dir, tempfile.TemporaryDirectory() as tmp_save_dir: - - scene_output = Path(tmp_scene_dir) - save_output = Path(tmp_save_dir) - output_file = "TemporalNetworkPlot" - - manim_plot = TemporalNetworkPlot(self.data, output_dir=scene_output, output_file=output_file) - - with patch.object(Scene, "render", new=render_side_effect_gif): - manim_plot.save("testvideo.gif", save_dir=save_output, save_as=format) - - target_path = save_output / "testvideo.gif" - self.assertTrue(target_path.exists()) - #self.assertEqual(target_path.read_text(), "test video") - - @patch("pathpyG.visualisations._manim.core.display") - @patch.object(core, "in_jupyter_notebook", return_value=True) - def test_manim_plot_show(self, mock_jupyter, mock_display): - """Test for the method show from the ManimPlot class""" - manim_plot = TemporalNetworkPlot(self.data) - - with patch.object(Scene, "render", new=render_side_effect): - manim_plot.show() - - mock_display.assert_called_once() - - -def render_side_effect(*args): - """Mocking Scene.render""" - from manim import config as manim_config - - output_dir = Path(manim_config.media_dir) if manim_config.media_dir else Path.cwd() - - video_dir = output_dir / "videos" / "1080p60" - video_dir.mkdir(parents=True, exist_ok=True) - - video_file = video_dir / f"{TemporalNetworkPlot.__name__}.mp4" - video_file.write_text("test video") - - -def render_side_effect_gif(*args): - """Mocking Scene.render""" - from manim import config as manim_config - - output_dir = Path(manim_config.media_dir) if manim_config.media_dir else Path.cwd() - - video_dir = output_dir / "videos" / "1080p60" - video_dir.mkdir(parents=True, exist_ok=True) - video_file = video_dir / f"{TemporalNetworkPlot.__name__}.mp4" +# import unittest +# from unittest.mock import patch, MagicMock +# import tempfile +# import subprocess +# from pathlib import Path +# from manim import Scene, manim_colors, Graph, tempconfig +# import numpy as np +# import pathpyG as pp + +# import pathpyG.visualisations._manim.core as core +# from pathpyG.visualisations._manim.network_plots import NetworkPlot +# from pathpyG.visualisations._manim.network_plots import TemporalNetworkPlot + + +# class ManimTest(unittest.TestCase): +# """Test class for manim visualizations""" +# def setUp(self): +# """setting up patch for manim.config and example input data""" +# patcher = patch("manim.config") +# self.mock_config = patcher.start() +# self.addCleanup(patcher.stop) + +# self.mock_config.media_dir = None # initializing with default values +# self.mock_config.output_file = None +# self.mock_config.pixel_height = 0 +# self.mock_config.pixel_width = 0 +# self.mock_config.frame_rate = 0 +# self.mock_config.quality = "" +# self.mock_config.background_color = None + +# self.data = { # example input data +# "nodes": [0, 1, 2, 3], +# "edges": [{"source": 0, "target": 2, "start": 0}, +# {"source": 1, "target": 2, "start": 1}, +# {"source": 2, "target": 0, "start": 2}, +# {"source": 1, "target": 3, "start": 3}, +# {"source": 3, "target": 2, "start": 4}, +# {"source": 2, "target": 1, "start": 5}] +# } + +# self.data3 = { +# "nodes": [{"uid": "A", "label": "Alpha"}], +# "edges": [{"source": "A", "target": "A", "start": 0, "end": 1}], +# } + +# def test_manim_network_plot(self): +# """Test for initializing the NetworkPlot class""" +# networkplot = NetworkPlot(self.data) + +# self.assertIsInstance(networkplot, NetworkPlot) +# self.assertIsInstance(networkplot.data, dict) +# self.assertIsInstance(networkplot.config, dict) +# self.assertIsInstance(networkplot.raw_data, dict) + +# self.assertEqual(networkplot.data, {}) +# self.assertEqual(networkplot.raw_data, self.data) + +# def test_manim_network_plot_empty(self): +# """Test for initializing the NetworkPlot class with empty input data""" +# data = {} +# networkplot = NetworkPlot(data) + +# self.assertIsInstance(networkplot, NetworkPlot) +# self.assertIsInstance(networkplot.data, dict) +# self.assertIsInstance(networkplot.config, dict) +# self.assertIsInstance(networkplot.raw_data, dict) + +# self.assertEqual(networkplot.data, {}) +# self.assertEqual(networkplot.raw_data, {}) + +# def test_manim_temporal_network_plot(self): +# """Test for initializing the TemporalNetworkPlot class""" +# kwargs = { +# "delta": 2000, +# "start": 100, +# "end": 10000, +# "intervals": 30, +# "dynamic_layout_interval": 10, +# "background_color": "#3A1F8C", +# "font_size": 12, + +# "node_opacity": 0.6, +# "node_size": 5.2, +# "node_label": {0: 'x', 1: "A", 2: ">", 3: "x"}, +# "node_color": (255, 0, 0), +# "node_color_timed": [(0, (1, 0.5)), (1, (2, 0.2))], + +# "edge_opacity": 0.75, +# "edge_size": 4.0, +# "edge_color": ['blue', 'pink'] +# } +# temp_network_plot = TemporalNetworkPlot(self.data, **kwargs) + +# self.assertIsInstance(temp_network_plot, TemporalNetworkPlot) +# self.assertIsInstance(temp_network_plot, NetworkPlot) +# self.assertIsInstance(temp_network_plot, Scene) + +# self.assertIsInstance(temp_network_plot.data, dict) +# self.assertIsInstance(temp_network_plot.config, dict) +# self.assertIsInstance(temp_network_plot.raw_data, dict) + +# self.assertEqual(temp_network_plot.delta, 2000) +# self.assertEqual(temp_network_plot.start, 100) +# self.assertEqual(temp_network_plot.end, 10000) +# self.assertEqual(temp_network_plot.intervals, 30) +# self.assertEqual(temp_network_plot.dynamic_layout_interval, 10) +# self.assertEqual(temp_network_plot.config.get("background_color"), "#3A1F8C") +# self.assertEqual(temp_network_plot.config.get("font_size"), 12) + +# self.assertEqual(temp_network_plot.config.get("node_opacity"), 0.6) +# self.assertEqual(temp_network_plot.config.get("node_size"), 5.2) +# self.assertEqual(temp_network_plot.config.get("node_label"), {0: 'x', 1: "A", 2: ">", 3: "x"}) +# self.assertEqual(temp_network_plot.config.get("node_color"), (255, 0, 0)) +# self.assertEqual(temp_network_plot.config.get("node_color_timed"), [(0, (1, 0.5)), (1, (2, 0.2))]) + +# self.assertEqual(temp_network_plot.config.get("edge_opacity"), 0.75) +# self.assertEqual(temp_network_plot.config.get("edge_size"), 4.0) +# self.assertEqual(temp_network_plot.config.get("edge_color"), ['blue', 'pink']) + +# def test_manim_temporal_network_plot_empty(self): +# """Test for initializing the TemporalNetworkPlot class with empty input data""" +# data = {} +# tempnetworkplot = TemporalNetworkPlot(data) + +# self.assertIsInstance(tempnetworkplot, TemporalNetworkPlot) +# self.assertIsInstance(tempnetworkplot, NetworkPlot) +# self.assertIsInstance(tempnetworkplot, Scene) + +# self.assertIsInstance(tempnetworkplot.data, dict) +# self.assertIsInstance(tempnetworkplot.config, dict) +# self.assertIsInstance(tempnetworkplot.raw_data, dict) + +# self.assertEqual(tempnetworkplot.delta, 1000) +# self.assertEqual(tempnetworkplot.start, 0) +# self.assertEqual(tempnetworkplot.end, None) +# self.assertEqual(tempnetworkplot.intervals, None) +# self.assertEqual(tempnetworkplot.dynamic_layout_interval, None) +# self.assertEqual(tempnetworkplot.font_size, 8) + +# self.assertEqual(tempnetworkplot.node_opacity, 1) +# self.assertEqual(tempnetworkplot.edge_opacity, 1) +# self.assertEqual(tempnetworkplot.node_size, 0.4) +# self.assertEqual(tempnetworkplot.edge_size, 0.4) +# self.assertEqual(tempnetworkplot.node_label, {})# + +# def test_manim_temp_np_mock_config(self): +# """Test for the TemporalNetworkPlot class""" +# with tempfile.TemporaryDirectory() as temp_dir: +# output_dir = Path(temp_dir) +# output_file = "test_output.mp4" + +# _ = TemporalNetworkPlot(self.data, output_dir=output_dir, output_file=output_file) + +# self.assertEqual(Path(self.mock_config.media_dir).resolve(), output_dir.resolve()) +# self.assertEqual(self.mock_config.output_file, output_file) + +# self.assertEqual(self.mock_config.pixel_height, 1080) +# self.assertEqual(self.mock_config.pixel_width, 1920) +# self.assertEqual(self.mock_config.frame_rate, 15) +# self.assertEqual(self.mock_config.quality, "high_quality") +# self.assertEqual(self.mock_config.background_color, manim_colors.WHITE) + +# def test_manim_temp_np_path(self): +# """Test for the TemporalNetworkPlot class""" +# with tempfile.TemporaryDirectory() as temp_dir: +# output_dir = Path(temp_dir) +# output_file = "test_output.mp4" + +# _ = TemporalNetworkPlot(self.data, output_dir=output_dir, output_file=output_file) + +# from manim import config as manim_config + +# self.assertEqual(Path(manim_config.media_dir).resolve(), output_dir.resolve()) +# self.assertEqual(manim_config.output_file, output_file) + +# def test_manim_temp_np_edge_index(self): +# """Test for the method compute_edge_index in the TemporalNetworkPlot class""" +# edgelist = [(0, 2, 0), (1, 2, 1), (2, 0, 2), (1, 3, 3), (3, 2, 4), (2, 1, 5)] +# temp_network_plot = TemporalNetworkPlot(self.data) + +# self.assertEqual(temp_network_plot.compute_edge_index()[0], edgelist) +# self.assertEqual(temp_network_plot.compute_edge_index()[1], 5) + +# def test_manim_temp_np_layout(self): +# """"Test for the method get_layout in the TemporalNetworkPlot class""" +# edgelist = [(0, 2, 0), (1, 2, 1), (2, 0, 2), (1, 3, 3), (3, 2, 4), (2, 1, 5)] +# nodes = {0, 1, 2, 3} +# graph = pp.TemporalGraph.from_edge_list(edgelist) +# temp_network_plot = TemporalNetworkPlot(self.data) +# old_layout = {node: [0.0, 0.0] for node in graph.nodes} + +# layout = temp_network_plot.get_layout(graph, old_layout=old_layout) + +# self.assertIsInstance(layout, dict) +# self.assertEqual(layout.keys(), nodes) + +# for coordinate in layout.values(): +# self.assertIsInstance(coordinate, np.ndarray) +# self.assertEqual(coordinate.shape, (3,)) + +# def test_manim_temp_np_get_color_at_time(self): +# """Test for the method get_color_at_time int the TemporalNetworkPlot class""" +# temp_network_plot = TemporalNetworkPlot(self.data) +# node_data = {"color": manim_colors.RED} +# self.assertEqual(temp_network_plot.get_color_at_time(node_data, 0), manim_colors.RED) +# self.assertEqual(temp_network_plot.get_color_at_time({}, 0), manim_colors.BLUE) + +# node_data_2 = { +# "color": manim_colors.PURPLE, +# "color_change": [ +# {"time": 5, "color": manim_colors.TEAL}, +# {"time": 10, "color": manim_colors.GREEN}, +# ] +# } +# self.assertEqual(temp_network_plot.get_color_at_time(node_data_2, 1), manim_colors.PURPLE) +# self.assertEqual(temp_network_plot.get_color_at_time(node_data_2, 5), manim_colors.TEAL) +# self.assertEqual(temp_network_plot.get_color_at_time(node_data_2, 7), manim_colors.TEAL) +# self.assertEqual(temp_network_plot.get_color_at_time(node_data_2, 10), manim_colors.GREEN) +# self.assertEqual(temp_network_plot.get_color_at_time(node_data_2, 11), manim_colors.GREEN) + +# @patch.object(TemporalNetworkPlot, "compute_edge_index") +# @patch.object(TemporalNetworkPlot, "get_layout") +# @patch.object(TemporalNetworkPlot, "get_color_at_time") +# def test_manim_temp_np_construct(self, mock_color, mock_layout, mock_edge_index): +# """Test for the construct method from the TemporalNetworkPlot class""" +# with tempfile.TemporaryDirectory() as tmp_path: +# with tempconfig({ +# "disable_caching": True, +# "dry_run": True, +# "disable_output": True, +# "media_dir": str(tmp_path), +# }): +# mock_edge_index.return_value = ([("A", "A", 0)], 1) +# mock_layout.return_value = {"A": np.array([0, 0, 0])} +# mock_color.return_value = (0, 0, 1) + +# temp_network_plot = TemporalNetworkPlot(self.data3) + +# temp_network_plot.add = MagicMock() +# temp_network_plot.play = MagicMock() +# temp_network_plot.wait = MagicMock() +# temp_network_plot.remove = MagicMock() + +# temp_network_plot.construct() + +# self.assertTrue(temp_network_plot.add.called) +# self.assertTrue(temp_network_plot.wait.called) +# self.assertTrue(temp_network_plot.remove.called) +# self.assertTrue(any(isinstance(call[0][0], Graph) for call in temp_network_plot.add.call_args_list)) + +# def test_manim_plot_save_mp4(self): +# """Test for saving a mp4 file with the method save from the ManimPlot class""" +# with tempfile.TemporaryDirectory() as tmp_scene_dir, tempfile.TemporaryDirectory() as tmp_save_dir: + +# scene_output = Path(tmp_scene_dir) +# save_output = Path(tmp_save_dir) +# output_file = "TemporalNetworkPlot" + +# manim_plot = TemporalNetworkPlot(self.data, output_dir=scene_output, output_file=output_file) + +# with patch.object(Scene, "render", new=render_side_effect): +# manim_plot.save("testvideo.mp4", save_dir=save_output) + +# target_path = save_output / "testvideo.mp4" +# self.assertTrue(target_path.exists()) +# self.assertEqual(target_path.read_text(), "test video") + +# def test_manim_plot_save_gif(self): +# """Test for saving a gif with the method save from the ManimPlot class""" +# with tempfile.TemporaryDirectory() as tmp_scene_dir, tempfile.TemporaryDirectory() as tmp_save_dir: + +# scene_output = Path(tmp_scene_dir) +# save_output = Path(tmp_save_dir) +# output_file = "TemporalNetworkPlot" + +# manim_plot = TemporalNetworkPlot(self.data, output_dir=scene_output, output_file=output_file) + +# with patch.object(Scene, "render", new=render_side_effect_gif): +# manim_plot.save("testvideo.gif", save_dir=save_output, save_as=format) + +# target_path = save_output / "testvideo.gif" +# self.assertTrue(target_path.exists()) +# #self.assertEqual(target_path.read_text(), "test video") + +# @patch("pathpyG.visualisations._manim.core.display") +# @patch.object(core, "in_jupyter_notebook", return_value=True) +# def test_manim_plot_show(self, mock_jupyter, mock_display): +# """Test for the method show from the ManimPlot class""" +# manim_plot = TemporalNetworkPlot(self.data) + +# with patch.object(Scene, "render", new=render_side_effect): +# manim_plot.show() + +# mock_display.assert_called_once() + + +# def render_side_effect(*args): +# """Mocking Scene.render""" +# from manim import config as manim_config + +# output_dir = Path(manim_config.media_dir) if manim_config.media_dir else Path.cwd() + +# video_dir = output_dir / "videos" / "1080p60" +# video_dir.mkdir(parents=True, exist_ok=True) + +# video_file = video_dir / f"{TemporalNetworkPlot.__name__}.mp4" +# video_file.write_text("test video") + + +# def render_side_effect_gif(*args): +# """Mocking Scene.render""" +# from manim import config as manim_config + +# output_dir = Path(manim_config.media_dir) if manim_config.media_dir else Path.cwd() + +# video_dir = output_dir / "videos" / "1080p60" +# video_dir.mkdir(parents=True, exist_ok=True) +# video_file = video_dir / f"{TemporalNetworkPlot.__name__}.mp4" - command = [ - "ffmpeg", - "-f", - "lavfi", - "-i", - "color=c=black:s=320x240:d=1", - "-c:v", - "libx264", - "-pix_fmt", - "yuv420p", - "-y", - str(video_file) - ] - subprocess.run(command, check=True) +# command = [ +# "ffmpeg", +# "-f", +# "lavfi", +# "-i", +# "color=c=black:s=320x240:d=1", +# "-c:v", +# "libx264", +# "-pix_fmt", +# "yuv420p", +# "-y", +# str(video_file) +# ] +# subprocess.run(command, check=True) diff --git a/tests/visualisations/test_pathpy_plot.py b/tests/visualisations/test_pathpy_plot.py new file mode 100644 index 000000000..445788d2b --- /dev/null +++ b/tests/visualisations/test_pathpy_plot.py @@ -0,0 +1,10 @@ + +from pathpyG.visualisations.pathpy_plot import PathPyPlot + + +def test_PathPyPlot() -> None: + """Test PathPyPlot class.""" + plot = PathPyPlot() + + assert isinstance(plot.data, dict) + assert isinstance(plot.config, dict) diff --git a/tests/visualisations/test_plot.py b/tests/visualisations/test_plot_function.py similarity index 54% rename from tests/visualisations/test_plot.py rename to tests/visualisations/test_plot_function.py index ac42c5fa3..3f432e0ea 100644 --- a/tests/visualisations/test_plot.py +++ b/tests/visualisations/test_plot_function.py @@ -1,25 +1,13 @@ import torch import pytest -from types import ModuleType from pathpyG.core.graph import Graph from pathpyG.core.temporal_graph import TemporalGraph -from pathpyG.visualisations.plot import PathPyPlot -from pathpyG.visualisations.plot import _get_plot_backend -from pathpyG.visualisations.network_plots import ( - network_plot, - temporal_plot, - static_plot, -) -from pathpyG.visualisations import plot - - -def test_PathPyPlot() -> None: - """Test PathPyPlot class.""" - plot = PathPyPlot() - - assert isinstance(plot.data, dict) - assert isinstance(plot.config, dict) +from pathpyG.visualisations._matplotlib.backend import MatplotlibBackend +from pathpyG.visualisations._tikz.backend import TikzBackend +from pathpyG.visualisations._manim.backend import ManimBackend +from pathpyG.visualisations._d3js.backend import D3jsBackend +from pathpyG.visualisations.plot_function import _get_plot_backend, plot def test_get_plot_backend() -> None: @@ -27,37 +15,51 @@ def test_get_plot_backend() -> None: # backend which does not exist with pytest.raises(ImportError): - _get_plot_backend(default="does not exist") + _get_plot_backend(default="does not exist", backend=None, filename=None) # load matplotlib backend - plt = _get_plot_backend(backend="matplotlib") - assert isinstance(plt, ModuleType) + plt = _get_plot_backend(backend="matplotlib", default=None, filename=None) + assert plt == MatplotlibBackend # test .png file - png = _get_plot_backend(filename="test.png") - assert isinstance(png, ModuleType) - - assert png == plt + png = _get_plot_backend(filename="test.png", default=None, backend=None) + assert png == MatplotlibBackend # load d3js backend - d3js = _get_plot_backend(backend="d3js") - assert isinstance(d3js, ModuleType) + d3js = _get_plot_backend(backend="d3js", default=None, filename=None) + assert d3js == D3jsBackend # test .html file - html = _get_plot_backend(filename="test.html") - assert isinstance(html, ModuleType) - - assert d3js == html + html = _get_plot_backend(filename="test.html", default=None, backend=None) + assert html == D3jsBackend # load tikz backend - tikz = _get_plot_backend(backend="tikz") - assert isinstance(tikz, ModuleType) + tikz = _get_plot_backend(backend="tikz", default=None, filename=None) + assert tikz == TikzBackend # test .tex file - tex = _get_plot_backend(filename="test.tex") - assert isinstance(tex, ModuleType) + tex = _get_plot_backend(filename="test.tex", default=None, backend=None) + assert tex == TikzBackend + + # test .pdf file + pdf = _get_plot_backend(filename="test.pdf", default=None, backend=None) + assert pdf == TikzBackend + + # test .svg file + svg = _get_plot_backend(filename="test.svg", default=None, backend=None) + assert svg == TikzBackend + + # load manim backend + manim = _get_plot_backend(backend="manim", default=None, filename=None) + assert manim == ManimBackend + + # test .mp4 file + mp4 = _get_plot_backend(filename="test.mp4", default=None, backend=None) + assert mp4 == ManimBackend - assert tikz == tex + # test .gif file + gif = _get_plot_backend(filename="test.gif", default=None, backend=None) + assert gif == ManimBackend # Uses a default pytest fixture: see https://docs.pytest.org/en/6.2.x/tmpdir.html @@ -68,8 +70,8 @@ def test_network_plot_png(tmp_path) -> None: net.data["edge_size"] = torch.tensor([[3], [4], [5]]) net.data["node_size"] = torch.tensor([[90], [8], [7]]) - plot = network_plot(net, edge_color="green", layout="fr") - plot.save(tmp_path / "test.png") + out = plot(net, edge_color="green", layout="fr") + out.save(tmp_path / "test.png") assert (tmp_path / "test.png").exists() @@ -77,8 +79,8 @@ def test_network_plot_html(tmp_path) -> None: """Test to plot a static network as html file.""" net = Graph.from_edge_list([["a", "b"], ["b", "c"], ["a", "c"]]) net.data["node_size"] = torch.tensor([[90], [8], [7]]) - plot = network_plot(net) - plot.save(tmp_path / "test.html") + out = plot(net) + out.save(tmp_path / "test.html") assert (tmp_path / "test.html").exists() @@ -94,10 +96,8 @@ def test_network_plot_tex(tmp_path) -> None: """Test to plot a static network as tex file.""" net = Graph.from_edge_list([["a", "b"], ["b", "c"], ["a", "c"]]) - plot = network_plot(net, layout="fr") - # PDF probably not supported at github - # plot.save("test.pdf") - plot.save(tmp_path / "test.tex") + out = plot(net, layout="fr") + out.save(tmp_path / "test.tex") assert (tmp_path / "test.tex").exists() @@ -116,14 +116,12 @@ def test_temporal_plot(tmp_path) -> None: net.data["edge_size"] = torch.tensor([[3], [4], [5], [1], [2], [3]]) color = {"a": "blue", "b": "red", "c": "green", "d": "yellow"} - plot = temporal_plot( + out = plot( net, node_color=color, - start=3, - end=25, delta=1000, layout="fr", d3js_local=False, ) - plot.save(tmp_path / "temp.html") + out.save(tmp_path / "temp.html") assert (tmp_path / "temp.html").exists() From 3ef7c81c45e46e2b2ccd557e573ae4d450cd3111 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Thu, 16 Oct 2025 12:02:04 +0000 Subject: [PATCH 27/44] update setup yaml --- .github/actions/setup/action.yml | 41 ++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 6d5a1becb..509e953a8 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -1,6 +1,6 @@ name: Setup # Inspired by https://github.com/pyg-team/pytorch_geometric/blob/737707c37fc2bd712a2289b683ec14549926ff49/.github/actions/setup/action.yml -description: Set up Python, PyTorch and PyTorch Geometric. +description: Set up environment with dependencies inputs: # defaults are set to the version used in the dev container python-version: @@ -26,6 +26,14 @@ runs: python-version: ${{ inputs.python-version }} activate-environment: true + - name: Cache uv virtual environment + uses: actions/cache@v4 + with: + # The path to the virtual environment directory created by uv + path: .venv + # The unique key for the cache. It changes when dependencies change. + key: uv-venv-${{ runner.os }}-py-${{ inputs.python-version }}-cuda-${{ inputs.cuda-version }} + - name: Install pathpyG run: | uv sync --frozen --extra ${{ inputs.cuda-version }} @@ -38,9 +46,38 @@ runs: uv run python -c "import torch; print('CUDA:', torch.version.cuda)" shell: bash + - name: Set up TeX Live + uses: teatimeguest/setup-texlive-action@v3 + with: + # Use a minimal installation scheme + scheme: minimal + + # List only the packages your document needs + # mirrors package list in .devcontainer/devcontainer.json + packages: > + tikz-network + standalone + xcolor + xifthen + tools + ifmtarg + pgf + datatool + etoolbox + tracklang + amsmath + trimspaces + epstopdf-pkg + dvisvgm + preview + babel-english + + # Enable caching to speed up subsequent runs + cache: true + - name: Install extension packages if: ${{ inputs.full_install == 'true' }} - run: | # ToDo: Add LaTeX installation + run: | sudo apt-get update && sudo apt-get install -y build-essential python3-dev libcairo2-dev libpango1.0-dev ffmpeg uv sync --frozen --extra vis --extra ${{ inputs.cuda-version }} shell: bash From 81a44019b86b86c6f55f1ba454a95295eb7a58a3 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Thu, 16 Oct 2025 12:04:09 +0000 Subject: [PATCH 28/44] fix tex setup --- .github/actions/setup/action.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 509e953a8..73ea9717b 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -47,14 +47,11 @@ runs: shell: bash - name: Set up TeX Live - uses: teatimeguest/setup-texlive-action@v3 + uses: teatimeguest/setup-texlive-action@v2 with: - # Use a minimal installation scheme - scheme: minimal - # List only the packages your document needs # mirrors package list in .devcontainer/devcontainer.json - packages: > + packages: >- tikz-network standalone xcolor From 633bf1b5540e674d15a7ad0c99280eb2553940f0 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Thu, 16 Oct 2025 12:09:22 +0000 Subject: [PATCH 29/44] update tex action --- .github/actions/setup/action.yml | 25 +++---------------------- .github/texlive.packages | 16 ++++++++++++++++ .github/texlive.profile | 8 ++++++++ 3 files changed, 27 insertions(+), 22 deletions(-) create mode 100644 .github/texlive.packages create mode 100644 .github/texlive.profile diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 73ea9717b..b79a4d517 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -49,28 +49,9 @@ runs: - name: Set up TeX Live uses: teatimeguest/setup-texlive-action@v2 with: - # List only the packages your document needs - # mirrors package list in .devcontainer/devcontainer.json - packages: >- - tikz-network - standalone - xcolor - xifthen - tools - ifmtarg - pgf - datatool - etoolbox - tracklang - amsmath - trimspaces - epstopdf-pkg - dvisvgm - preview - babel-english - - # Enable caching to speed up subsequent runs - cache: true + profile-path: ${{ github.workspace }}/.github/texlive.profile + packages-path: ${{ github.workspace }}/.github/texlive.packages + cache-key: texlive-${{ runner.os }}-2025 - name: Install extension packages if: ${{ inputs.full_install == 'true' }} diff --git a/.github/texlive.packages b/.github/texlive.packages new file mode 100644 index 000000000..6b316d2cc --- /dev/null +++ b/.github/texlive.packages @@ -0,0 +1,16 @@ +tikz-network +standalone +xcolor +xifthen +tools +ifmtarg +pgf +datatool +etoolbox +tracklang +amsmath +trimspaces +epstopdf-pkg +dvisvgm +preview +babel-english diff --git a/.github/texlive.profile b/.github/texlive.profile new file mode 100644 index 000000000..b4952c7f6 --- /dev/null +++ b/.github/texlive.profile @@ -0,0 +1,8 @@ +# Install the scheme minimal: +selected_scheme scheme-minimal +# Omit documentation files: +tlpdbopt_install_docfiles 0 +# Omit source files: +tlpdbopt_install_srcfiles 0 +# Avoid doing backups: +tlpdbopt_autobackup 0 \ No newline at end of file From 7464f3aeb4f52e9c8f9cde0550ae2f6c81eb3c07 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Thu, 16 Oct 2025 12:10:20 +0000 Subject: [PATCH 30/44] fix --- .github/actions/setup/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index b79a4d517..ab666aa85 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -47,7 +47,7 @@ runs: shell: bash - name: Set up TeX Live - uses: teatimeguest/setup-texlive-action@v2 + uses: paolobrasolin/setup-texlive-action@v1 with: profile-path: ${{ github.workspace }}/.github/texlive.profile packages-path: ${{ github.workspace }}/.github/texlive.packages From f824a2c6380d37f8144cb684b0ef4d964f80c391 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Thu, 16 Oct 2025 12:15:54 +0000 Subject: [PATCH 31/44] fix tex live setup --- .github/actions/setup/action.yml | 24 ++++++++++++++++++++---- .github/texlive.packages | 16 ---------------- .github/texlive.profile | 8 -------- 3 files changed, 20 insertions(+), 28 deletions(-) delete mode 100644 .github/texlive.packages delete mode 100644 .github/texlive.profile diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index ab666aa85..aed44091b 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -47,11 +47,27 @@ runs: shell: bash - name: Set up TeX Live - uses: paolobrasolin/setup-texlive-action@v1 + uses: TeX-Live/setup-texlive-action@v3 with: - profile-path: ${{ github.workspace }}/.github/texlive.profile - packages-path: ${{ github.workspace }}/.github/texlive.packages - cache-key: texlive-${{ runner.os }}-2025 + cache: true + packages: | + tikz-network + standalone + xcolor + xifthen + tools + ifmtarg + pgf + datatool + etoolbox + tracklang + amsmath + trimspaces + epstopdf-pkg + dvisvgm + preview + babel-english + - name: Install extension packages if: ${{ inputs.full_install == 'true' }} diff --git a/.github/texlive.packages b/.github/texlive.packages deleted file mode 100644 index 6b316d2cc..000000000 --- a/.github/texlive.packages +++ /dev/null @@ -1,16 +0,0 @@ -tikz-network -standalone -xcolor -xifthen -tools -ifmtarg -pgf -datatool -etoolbox -tracklang -amsmath -trimspaces -epstopdf-pkg -dvisvgm -preview -babel-english diff --git a/.github/texlive.profile b/.github/texlive.profile deleted file mode 100644 index b4952c7f6..000000000 --- a/.github/texlive.profile +++ /dev/null @@ -1,8 +0,0 @@ -# Install the scheme minimal: -selected_scheme scheme-minimal -# Omit documentation files: -tlpdbopt_install_docfiles 0 -# Omit source files: -tlpdbopt_install_srcfiles 0 -# Avoid doing backups: -tlpdbopt_autobackup 0 \ No newline at end of file From aba043475584642a81750206480b3ac357472303 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Thu, 16 Oct 2025 12:33:10 +0000 Subject: [PATCH 32/44] set up optimized ffmpeg download --- .github/actions/setup/action.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index aed44091b..3d0953efb 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -47,6 +47,7 @@ runs: shell: bash - name: Set up TeX Live + if: ${{ inputs.full_install == 'true' }} uses: TeX-Live/setup-texlive-action@v3 with: cache: true @@ -68,10 +69,13 @@ runs: preview babel-english + - name: Set up FFmpeg + if: ${{ inputs.full_install == 'true' }} + uses: FedericoCarboni/setup-ffmpeg@v3 - name: Install extension packages if: ${{ inputs.full_install == 'true' }} run: | - sudo apt-get update && sudo apt-get install -y build-essential python3-dev libcairo2-dev libpango1.0-dev ffmpeg + sudo apt-get update && sudo apt-get install -y build-essential python3-dev libcairo2-dev libpango1.0-dev uv sync --frozen --extra vis --extra ${{ inputs.cuda-version }} shell: bash From 0ed67d7cdddfd0db0a5d8e963257ff234ed19bb8 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Thu, 16 Oct 2025 12:47:08 +0000 Subject: [PATCH 33/44] improve apt installation --- .github/actions/setup/action.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 3d0953efb..670fc65c2 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -69,13 +69,13 @@ runs: preview babel-english - - name: Set up FFmpeg - if: ${{ inputs.full_install == 'true' }} - uses: FedericoCarboni/setup-ffmpeg@v3 - - name: Install extension packages if: ${{ inputs.full_install == 'true' }} - run: | - sudo apt-get update && sudo apt-get install -y build-essential python3-dev libcairo2-dev libpango1.0-dev - uv sync --frozen --extra vis --extra ${{ inputs.cuda-version }} - shell: bash + uses: awalsh128/cache-apt-pkgs-action@latest + with: + packages: | + build-essential + python3-dev + libcairo2-dev + libpango1.0-dev + ffmpeg \ No newline at end of file From 4cd31c6d889445881b89b0a2077643657af5c2a7 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Thu, 16 Oct 2025 12:50:59 +0000 Subject: [PATCH 34/44] add optional python deps --- .github/actions/setup/action.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 670fc65c2..071b5e5df 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -29,9 +29,7 @@ runs: - name: Cache uv virtual environment uses: actions/cache@v4 with: - # The path to the virtual environment directory created by uv path: .venv - # The unique key for the cache. It changes when dependencies change. key: uv-venv-${{ runner.os }}-py-${{ inputs.python-version }}-cuda-${{ inputs.cuda-version }} - name: Install pathpyG @@ -71,11 +69,17 @@ runs: - name: Install extension packages if: ${{ inputs.full_install == 'true' }} - uses: awalsh128/cache-apt-pkgs-action@latest + uses: awalsh128/cache-apt-pkgs-action@v1 with: packages: | build-essential python3-dev libcairo2-dev libpango1.0-dev - ffmpeg \ No newline at end of file + ffmpeg + + - name: Install optional Python dependencies + if: ${{ inputs.full_install == 'true' }} + run: | + uv sync --frozen --extra vis --extra ${{ inputs.cuda-version }} + shell: bash From 67c1eda679aa106ce5be43a828a82eb3c4b848af Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Thu, 16 Oct 2025 15:00:53 +0000 Subject: [PATCH 35/44] add tests and minor fixes --- .../reference/pathpyG/visualisations/index.md | 2 +- pyproject.toml | 6 +- src/pathpyG/core/temporal_graph.py | 9 + .../visualisations/_matplotlib/__init__.py | 1 + src/pathpyG/visualisations/network_plot.py | 2 +- src/pathpyG/visualisations/plot_function.py | 6 +- .../visualisations/temporal_network_plot.py | 14 +- src/pathpyG/visualisations/utils.py | 6 +- tests/visualisations/test_layout.py | 84 ++++ tests/visualisations/test_network_plot.py | 338 ++++++++++++++++ tests/visualisations/test_pathpy_plot.py | 146 ++++++- tests/visualisations/test_plot_backend.py | 43 +++ tests/visualisations/test_plot_function.py | 103 ++--- .../test_temporal_network_plot.py | 258 +++++++++++++ tests/visualisations/test_utils.py | 360 ++++++++++++++++++ 15 files changed, 1307 insertions(+), 71 deletions(-) create mode 100644 tests/visualisations/test_layout.py create mode 100644 tests/visualisations/test_network_plot.py create mode 100644 tests/visualisations/test_plot_backend.py create mode 100644 tests/visualisations/test_temporal_network_plot.py create mode 100644 tests/visualisations/test_utils.py diff --git a/docs/reference/pathpyG/visualisations/index.md b/docs/reference/pathpyG/visualisations/index.md index 71b5a0b1c..5aeaf7cd1 100644 --- a/docs/reference/pathpyG/visualisations/index.md +++ b/docs/reference/pathpyG/visualisations/index.md @@ -66,7 +66,7 @@ The table below provides an overview of the supported backends and their availab |---------------|------------|-------------|--------------| | **d3.js** | ✔️ | ✔️ | `html` | | **manim** | ❌ | ✔️ | `mp4`, `gif` | -| **matplotlib**| ✔️ | ❌ | `png` | +| **matplotlib**| ✔️ | ❌ | `png`, `jpg` | | **tikz** | ✔️ | ❌ | `svg`, `pdf`, `tex`| #### Details diff --git a/pyproject.toml b/pyproject.toml index 3e2c36f30..44c791299 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -176,7 +176,7 @@ addopts = "--cov=src -m \"not benchmark and not gpu\"" branch = true [tool.coverage.report] -fail_under = 65 +fail_under = 85 exclude_lines = [ "pragma: no cover", "def __repr__", @@ -205,8 +205,10 @@ docstring-code-format = true select = ["E4", "E7", "E9", "F", "D", "I"] # "S101": Disable checks for assert statements, which are standard in tests. # "D100": Disable "Missing docstring in public module". +# "D101": Disable "Missing docstring in public class". +# "D102": Disable "Missing docstring in public method". # "D103": Disable "Missing docstring in public function". -per-file-ignores = { "tests/*" = [ "S101", "D100", "D103" ] } +per-file-ignores = { "tests/*" = [ "S101", "D100", "D101", "D102", "D103" ] } [tool.ruff.lint.pydocstyle] convention = "google" diff --git a/src/pathpyG/core/temporal_graph.py b/src/pathpyG/core/temporal_graph.py index 1fcb754ad..3b776adef 100644 --- a/src/pathpyG/core/temporal_graph.py +++ b/src/pathpyG/core/temporal_graph.py @@ -70,6 +70,15 @@ def __init__(self, data: Data, mapping: IndexMap | None = None) -> None: @staticmethod def from_edge_list(edge_list, num_nodes: Optional[int] = None, device: Optional[torch.device] = None) -> TemporalGraph: # type: ignore """Create a temporal graph from a list of tuples containing edges with timestamps.""" + if len(edge_list) == 0: + return TemporalGraph( + data=Data( + edge_index=torch.empty((2, 0), dtype=torch.long, device=device), + time=torch.empty((0,), dtype=torch.long, device=device), + num_nodes=num_nodes, + ), + ) + edge_array = np.array(edge_list) # Convert timestamps to tensor diff --git a/src/pathpyG/visualisations/_matplotlib/__init__.py b/src/pathpyG/visualisations/_matplotlib/__init__.py index 6d1419ea5..330160531 100644 --- a/src/pathpyG/visualisations/_matplotlib/__init__.py +++ b/src/pathpyG/visualisations/_matplotlib/__init__.py @@ -4,6 +4,7 @@ !!! info "Output Formats" - **PNG**: High-quality raster images for presentations + - **JPG**: Compressed raster images for web usage ## Basic Usage diff --git a/src/pathpyG/visualisations/network_plot.py b/src/pathpyG/visualisations/network_plot.py index 40e7a2c66..7eb91db14 100644 --- a/src/pathpyG/visualisations/network_plot.py +++ b/src/pathpyG/visualisations/network_plot.py @@ -304,7 +304,7 @@ def _convert_color(self, color: tuple[int, int, int]) -> str: except ValueError: logger.error(f"The provided color {color} is not valid!") raise AttributeError - elif color is None or pd.isna(color): + elif not isinstance(color, Sized) and (color is None or pd.isna(color)): return pd.NA # will be filled with self._fill_node_values() else: logger.error(f"The provided color {color} is not valid!") diff --git a/src/pathpyG/visualisations/plot_function.py b/src/pathpyG/visualisations/plot_function.py index cf9377217..06f55e448 100644 --- a/src/pathpyG/visualisations/plot_function.py +++ b/src/pathpyG/visualisations/plot_function.py @@ -96,6 +96,8 @@ def is_backend(backend: str) -> bool: ".pdf": Backends.tikz, ".svg": Backends.tikz, ".png": Backends.matplotlib, + ".jpg": Backends.matplotlib, + ".jpeg": Backends.matplotlib, ".mp4": Backends.manim, ".gif": Backends.manim, } @@ -136,8 +138,8 @@ def _get_plot_backend(backend: Optional[str], filename: Optional[str], default: # if no backend was given use the backend suggested for the file format else: # Get file ending and try to infer backend - if isinstance(filename, str): - _backend = FORMATS.get(os.path.splitext(filename)[1], default) + if isinstance(filename, str) and os.path.splitext(filename)[1] in FORMATS: + _backend = FORMATS[os.path.splitext(filename)[1]] logger.debug(f"Using backend <{_backend}> inferred from file ending.") else: # use default backend per default diff --git a/src/pathpyG/visualisations/temporal_network_plot.py b/src/pathpyG/visualisations/temporal_network_plot.py index 6911379e5..951d3f037 100644 --- a/src/pathpyG/visualisations/temporal_network_plot.py +++ b/src/pathpyG/visualisations/temporal_network_plot.py @@ -4,6 +4,7 @@ node and edge dynamics, windowed layout computation, and attribute interpolation. """ + from __future__ import annotations import logging @@ -107,7 +108,7 @@ def _post_process_node_data(self) -> pd.DataFrame: # add end time step with the start the node appears the next time or max time step + 1 nodes["end"] = nodes.groupby("uid")["start"].shift(-1) max_node_time = nodes["start"].max() + 1 - if max_node_time < self.network.data.time[-1].item() + 1: + if self.network.data.time.size(0) > 0 and max_node_time < self.network.data.time[-1].item() + 1: max_node_time = self.network.data.time[-1].item() + 1 nodes["end"] = nodes["end"].fillna(max_node_time) self.data["nodes"] = nodes @@ -165,6 +166,11 @@ def _compute_layout(self) -> None: """ # get layout from the config layout_type = self.config.get("layout") + + # if no layout is considered or the graph is empty stop this process + if layout_type is None or len(self.data["nodes"]) == 0: + return + max_time = int( max(self.data["nodes"].index.get_level_values("time").max() + 1, self.data["edges"]["end"].max()) ) @@ -183,12 +189,8 @@ def _compute_layout(self) -> None: logger.error("The provided layout_window_size is not valid!") raise AttributeError - # if no layout is considered stop this process - if layout_type is None: - return - pos = network_layout(self.network, layout="random") # initial layout - num_steps = max_time - window_size[1] + num_steps = max(max_time - window_size[1], 0) layout_df = pd.DataFrame() for step in range(num_steps + 1): start_time = max(0, step - window_size[0]) diff --git a/src/pathpyG/visualisations/utils.py b/src/pathpyG/visualisations/utils.py index c2cf612b2..6a67f8443 100644 --- a/src/pathpyG/visualisations/utils.py +++ b/src/pathpyG/visualisations/utils.py @@ -110,7 +110,7 @@ def rgb_to_hex(rgb: tuple) -> str: """ if all(0.0 <= val <= 1.0 for val in rgb): rgb = tuple(int(val * 255) for val in rgb) - elif not all(0 <= val <= 255 for val in rgb): + elif not all(0 <= val <= 255 for val in rgb) or any(not isinstance(val, int) for val in rgb): raise ValueError("RGB values must be in range 0-1 or 0-255.") return "#%02x%02x%02x" % rgb @@ -141,7 +141,7 @@ def hex_to_rgb(value: str) -> tuple: """ value = value.lstrip("#") _l = len(value) - return tuple(int(value[i : i + _l // 3], 16) for i in range(0, _l, _l // 3)) + return tuple((int(value[i : i + _l // 3], 16) + 1)**(6 // _l) - 1 for i in range(0, _l, _l // 3)) def cm_to_inch(value: float) -> float: @@ -291,7 +291,7 @@ def unit_str_to_float(value: str, unit: str) -> float: "in_to_px": inch_to_px, "px_to_in": px_to_inch, "cm_to_px": lambda x: inch_to_px(cm_to_inch(x)), - "px_to_cm": lambda x: cm_to_inch(px_to_inch(x)), + "px_to_cm": lambda x: inch_to_cm(px_to_inch(x)), } conversion_key = f"{value[-2:]}_to_{unit}" if conversion_key in conversion_functions: diff --git a/tests/visualisations/test_layout.py b/tests/visualisations/test_layout.py new file mode 100644 index 000000000..2302053bd --- /dev/null +++ b/tests/visualisations/test_layout.py @@ -0,0 +1,84 @@ +"""Unit tests for the layout module in pathpyG.visualisations.""" + +import numpy as np +import pytest + +from pathpyG.core.graph import Graph +from pathpyG.visualisations.layout import layout + + +class TestLayoutAlgorithms: + """We assume that the underlying algorithms in networkx are tested so we only test the interface.""" + + def setup_method(self): + # Simple triangle graph + self.g = Graph.from_edge_list([("a", "b"), ("b", "c"), ("c", "a")]) + + @pytest.mark.parametrize( + "algo", + [ + "spring", + "fruchterman-reingold", + "fr", + "kamada-kawai", + "kk", + "kamada", + "forceatlas2", + "fa2", + "force-atlas2", + "circular", + "circle", + "ring", + "shell", + "concentric", + "grid", + "lattice-2d", + "spectral", + "eigen", + "random", + "rand", + ], + ) + def test_supported_algorithms(self, algo): + pos = layout(self.g, layout=algo) + assert isinstance(pos, dict) + assert set(pos.keys()) == set(self.g.nodes) + for coords in pos.values(): + arr = np.array(coords) + assert arr.shape == (2,) + assert np.isfinite(arr).all() + + def test_grid_layout_positions(self): + g = Graph.from_edge_list([("a", "b"), ("b", "c"), ("c", "d"), ("d", "e")]) + pos = layout(g, layout="grid") + assert isinstance(pos, dict) + assert set(pos.keys()) == set(g.nodes) + coords = np.array(list(pos.values())) + # Should be on a grid: unique x and y values + assert len(np.unique(coords[:, 0])) > 1 + assert len(np.unique(coords[:, 1])) > 1 + + def test_invalid_algorithm_raises(self): + with pytest.raises(ValueError, match="not recognized"): + layout(self.g, layout="not-a-layout") + + def test_weight_attribute_missing_raises(self): + with pytest.raises(ValueError, match="not found"): + layout(self.g, layout="spring", weight="nonexistent_weight") + + def test_weight_iterable_length_mismatch_raises(self): + # Too short + with pytest.raises(ValueError, match="does not match"): + layout(self.g, layout="spring", weight=[1.0]) + + def test_custom_parameters_passed(self): + pos = layout(self.g, layout="spring", k=0.1, iterations=10) + assert isinstance(pos, dict) + assert set(pos.keys()) == set(self.g.nodes) + + def test_weight_as_iterable(self): + # Correct length + n_edges = self.g.data.edge_index.size(1) + pos = layout(self.g, layout="spring", weight=[1.0] * n_edges) + assert isinstance(pos, dict) + assert set(pos.keys()) == set(self.g.nodes) diff --git a/tests/visualisations/test_network_plot.py b/tests/visualisations/test_network_plot.py new file mode 100644 index 000000000..960cf9f62 --- /dev/null +++ b/tests/visualisations/test_network_plot.py @@ -0,0 +1,338 @@ +"""Unit tests for NetworkPlot class in pathpyG.visualisations.""" + +import numpy as np +import pandas as pd +import pytest + +from pathpyG.core.graph import Graph +from pathpyG.core.index_map import IndexMap +from pathpyG.core.multi_order_model import MultiOrderModel +from pathpyG.core.path_data import PathData +from pathpyG.visualisations.network_plot import NetworkPlot + + +class TestNetworkPlot: + def setup_method(self): + # Simple triangle graph + self.g = Graph.from_edge_list([("a", "b"), ("b", "c"), ("c", "a")]) + + def test_initialization_and_config(self): + plot = NetworkPlot(self.g, node_color="#ff0000", edge_size=2) + assert plot.network is self.g + assert plot.node_args["color"] == "#ff0000" + assert plot.edge_args["size"] == 2 + + def test_node_and_edge_data_structure(self): + plot = NetworkPlot(self.g) + nodes = plot.data["nodes"] + edges = plot.data["edges"] + assert isinstance(nodes, pd.DataFrame) + assert isinstance(edges, pd.DataFrame) + assert set(nodes.index) == set(self.g.nodes) + assert set(edges.index.names) == {"source", "target"} + + def test_node_default_attributes(self): + plot = NetworkPlot(self.g) + nodes = plot.data["nodes"] + # Default attributes should be present + for attr in ["color", "size", "opacity", "image"]: + assert attr in nodes.columns + + def test_edge_default_attributes(self): + plot = NetworkPlot(self.g) + edges = plot.data["edges"] + # Default attributes should be present + for attr in ["color", "size", "opacity"]: + assert attr in edges.columns + + def test_node_attribute_constant_assignment(self): + """Test assigning constant values to node attributes.""" + plot = NetworkPlot(self.g, node_color="#00ff00", node_size=10, node_opacity=0.8) + nodes = plot.data["nodes"] + assert (nodes["color"] == "#00ff00").all() + assert (nodes["size"] == 10).all() + assert (nodes["opacity"] == 0.8).all() + + def test_node_attribute_list_assignment(self): + """Test assigning lists to node attributes.""" + colors = ["#ff0000", "#00ff00", "#0000ff"] + sizes = [5, 10, 15] + plot = NetworkPlot(self.g, node_color=colors, node_size=sizes) + nodes = plot.data["nodes"] + assert len(nodes) == 3 + assert set(nodes["color"]) == set(colors) + assert set(nodes["size"]) == set(sizes) + + def test_node_attribute_dict_assignment(self): + """Test assigning dictionaries mapping node IDs to attributes.""" + color_map = {"a": "#ff0000", "b": "#00ff00"} + size_map = {"a": 10, "c": 20} + plot = NetworkPlot(self.g, node_color=color_map, node_size=size_map) + nodes = plot.data["nodes"] + assert nodes.loc["a", "color"] == "#ff0000" + assert nodes.loc["b", "color"] == "#00ff00" + assert nodes.loc["a", "size"] == 10 + assert nodes.loc["c", "size"] == 20 + + def test_node_attribute_rgb_tuple_assignment(self): + """Test assigning RGB tuple as constant color.""" + rgb_color = (255, 128, 0) + plot = NetworkPlot(self.g, node_color=rgb_color) + nodes = plot.data["nodes"] + # Should be converted to hex + assert (nodes["color"] == "#ff8000").all() + + def test_node_attribute_numeric_color_mapping(self): + """Test numeric values mapped to colors via colormap.""" + numeric_values = [0.0, 0.5, 1.0] + plot = NetworkPlot(self.g, node_color=numeric_values) + nodes = plot.data["nodes"] + # Should have hex colors from colormap + assert all(nodes["color"].str.startswith("#")) + # All should be different colors + assert len(nodes["color"].unique()) == 3 + + def test_node_attribute_list_wrong_length_raises(self): + """Test that wrong-length lists raise an error.""" + with pytest.raises(AttributeError): + NetworkPlot(self.g, node_size=[10, 20]) # Only 2 values for 3 nodes + + def test_edge_attribute_constant_assignment(self): + """Test assigning constant values to edge attributes.""" + plot = NetworkPlot(self.g, edge_color="#0000ff", edge_size=5, edge_opacity=0.6) + edges = plot.data["edges"] + assert (edges["color"] == "#0000ff").all() + assert (edges["size"] == 5).all() + assert (edges["opacity"] == 0.6).all() + + def test_edge_attribute_list_assignment(self): + """Test assigning lists to edge attributes.""" + # Triangle has 3 edges (undirected will deduplicate to 3) + colors = ["#ff0000", "#00ff00", "#0000ff"] + plot = NetworkPlot(self.g, edge_color=colors) + edges = plot.data["edges"] + assert len(edges) == 3 + assert set(edges["color"]) == set(colors) + + def test_edge_attribute_dict_assignment(self): + """Test assigning dictionaries mapping edge tuples to attributes.""" + color_map = {("a", "b"): "#ff0000", ("b", "c"): "#00ff00"} + size_map = {("a", "b"): 10, ("c", "a"): 20} + plot = NetworkPlot(self.g, edge_color=color_map, edge_size=size_map) + edges = plot.data["edges"] + # Check at least one edge got the color + assert "#ff0000" in edges["color"].values or "#00ff00" in edges["color"].values + assert 10 in edges["size"].values or 20 in edges["size"].values + + def test_edge_weight_as_size(self): + """Test that edge_weight attribute is used as size when present.""" + # Create a weighted graph + g = Graph.from_edge_list([("a", "b"), ("b", "c"), ("c", "a")]) + import torch + g.data.edge_weight = torch.tensor([1.0, 2.0, 3.0]) + + plot = NetworkPlot(g) + edges = plot.data["edges"] + # Edge sizes should come from weights + assert set(edges["size"].unique()).issubset({1.0, 2.0, 3.0}) + + def test_edge_attribute_from_network_data(self): + """Test that edge attributes from network data are used.""" + g = Graph.from_edge_list([("a", "b"), ("b", "c"), ("c", "a")]) + import torch + g.data.edge_color = ["#ff0000", "#00ff00", "#0000ff"] + g.data.edge_size = torch.tensor([5, 10, 15]) + + plot = NetworkPlot(g) + edges = plot.data["edges"] + # Should use network attributes + assert set(edges["color"]).issubset({"#ff0000", "#00ff00", "#0000ff"}) + assert set(edges["size"]).issubset({5, 10, 15}) + + def test_node_attribute_from_network_data(self): + """Test that node attributes from network data are used.""" + g = Graph.from_edge_list([("a", "b"), ("b", "c"), ("c", "a")]) + import torch + g.data.node_color = ["#ff0000", "#00ff00", "#0000ff"] + g.data.node_size = torch.tensor([5, 10, 15]) + + plot = NetworkPlot(g) + nodes = plot.data["nodes"] + # Should use network attributes + assert set(nodes["color"]) == {"#ff0000", "#00ff00", "#0000ff"} + assert set(nodes["size"]) == {5, 10, 15} + + def test_node_kwargs_override_network_data(self): + """Test that kwargs override network data attributes.""" + g = Graph.from_edge_list([("a", "b"), ("b", "c"), ("c", "a")]) + import torch + g.data.node_size = torch.tensor([5, 10, 15]) + + # Override with kwargs + plot = NetworkPlot(g, node_size=20) + nodes = plot.data["nodes"] + assert (nodes["size"] == 20).all() + + def test_edge_kwargs_override_network_data(self): + """Test that kwargs override network data attributes.""" + g = Graph.from_edge_list([("a", "b"), ("b", "c"), ("c", "a")]) + import torch + g.data.edge_size = torch.tensor([5, 10, 15]) + + # Override with kwargs + plot = NetworkPlot(g, edge_size=20) + edges = plot.data["edges"] + assert (edges["size"] == 20).all() + + def test_layout_integration(self): + plot = NetworkPlot(self.g, layout="spring") + nodes = plot.data["nodes"] + assert "x" in nodes.columns and "y" in nodes.columns + assert np.all((nodes["x"] >= 0) & (nodes["x"] <= 1)) + assert np.all((nodes["y"] >= 0) & (nodes["y"] <= 1)) + + def test_higher_order_network(self): + # Create a higher-order network from path data + paths = PathData(IndexMap(["a", "b", "c", "d"])) + paths.append_walks([["a", "b", "c"], ["b", "c", "a"], ["c", "a", "b"], ["a", "d"]], weights=[1, 1, 1, 1]) + ho_g = MultiOrderModel.from_path_data(paths, max_order=2).layers[2] + # Create a plot for the higher-order graph + plot = NetworkPlot(ho_g, node_color="#123456") + nodes = plot.data["nodes"] + # Index should be stringified tuples + assert all(isinstance(idx, str) for idx in nodes.index) + + def test_invalid_image_path_raises(self): + with pytest.raises(AttributeError): + NetworkPlot(self.g, node_image="/nonexistent/path/to/image.png") + + +class TestNetworkPlotColorConversion: + """Test color conversion methods in NetworkPlot.""" + + def setup_method(self): + self.g = Graph.from_edge_list([("a", "b"), ("b", "c"), ("c", "a")]) + self.plot = NetworkPlot(self.g) + + def test_convert_color_hex_string(self): + """Test that hex strings are preserved.""" + assert self.plot._convert_color("#ff0000") == "#ff0000" + assert self.plot._convert_color("#00FF00") == "#00FF00" + assert self.plot._convert_color("#0000ff") == "#0000ff" + + def test_convert_color_rgb_tuple_float(self): + """Test conversion of RGB tuples with float values (0-1).""" + result = self.plot._convert_color((1.0, 0.0, 0.0)) + assert result == "#ff0000" + + result = self.plot._convert_color((0.0, 1.0, 0.0)) + assert result == "#00ff00" + + result = self.plot._convert_color((0.0, 0.0, 1.0)) + assert result == "#0000ff" + + def test_convert_color_rgb_tuple_int(self): + """Test conversion of RGB tuples with integer values (0-255).""" + result = self.plot._convert_color((255, 0, 0)) + assert result == "#ff0000" + + result = self.plot._convert_color((0, 255, 0)) + assert result == "#00ff00" + + result = self.plot._convert_color((0, 0, 255)) + assert result == "#0000ff" + + def test_convert_color_rgba_tuple(self): + """Test that RGBA tuples use only RGB components.""" + result = self.plot._convert_color((1.0, 0.5, 0.0, 0.8)) + assert result.startswith("#") + assert len(result) == 7 # Hex color without alpha + + def test_convert_color_named_colors(self): + """Test conversion of matplotlib named colors.""" + result = self.plot._convert_color("red") + assert result == "#ff0000" + + result = self.plot._convert_color("blue") + assert result == "#0000ff" + + result = self.plot._convert_color("green") + assert result.startswith("#") + + def test_convert_color_invalid_name_raises(self): + """Test that invalid color names raise AttributeError.""" + with pytest.raises(AttributeError): + self.plot._convert_color("not_a_real_color_name") + + def test_convert_color_none_returns_na(self): + """Test that None values return pd.NA.""" + result = self.plot._convert_color(None) + assert pd.isna(result) + + result = self.plot._convert_color(pd.NA) + assert pd.isna(result) + + def test_convert_color_invalid_type_raises(self): + """Test that invalid types raise AttributeError.""" + with pytest.raises(AttributeError): + self.plot._convert_color(123) + + # Lists should fail (only tuples are valid for RGB) + with pytest.raises(AttributeError): + self.plot._convert_color([255, 0, 0]) + + def test_convert_to_rgb_tuple_numeric_series(self): + """Test conversion of numeric series to RGB tuples via colormap.""" + numeric_colors = pd.Series([0.0, 0.5, 1.0]) + result = self.plot._convert_to_rgb_tuple(numeric_colors) + + # Should return a series with RGB tuples + assert isinstance(result, pd.Series) + assert len(result) == 3 + + # Each value should be a tuple with RGBA components + for val in result: + assert isinstance(val, tuple) + assert len(val) >= 3 # RGB or RGBA + + def test_convert_to_rgb_tuple_non_numeric_passthrough(self): + """Test that non-numeric series are returned unchanged.""" + hex_colors = pd.Series(["#ff0000", "#00ff00", "#0000ff"]) + result = self.plot._convert_to_rgb_tuple(hex_colors) + + # Should be unchanged + assert result.equals(hex_colors) + + def test_convert_to_rgb_tuple_with_custom_colormap(self): + """Test numeric to RGB conversion respects custom colormap setting.""" + # Set custom colormap + self.plot.config["cmap"] = "plasma" + numeric_colors = pd.Series([0.0, 0.5, 1.0]) + result = self.plot._convert_to_rgb_tuple(numeric_colors) + + # Should return RGB tuples + assert len(result) == 3 + for val in result: + assert isinstance(val, tuple) + + def test_convert_to_rgb_tuple_edge_values(self): + """Test conversion with extreme numeric values.""" + numeric_colors = pd.Series([-10.0, 0.0, 10.0]) + result = self.plot._convert_to_rgb_tuple(numeric_colors) + + # Should normalize and convert + assert len(result) == 3 + for val in result: + assert isinstance(val, tuple) + assert len(val) >= 3 + + def test_convert_to_rgb_tuple_single_value(self): + """Test conversion when all values are the same.""" + numeric_colors = pd.Series([5.0, 5.0, 5.0]) + result = self.plot._convert_to_rgb_tuple(numeric_colors) + + # Should handle constant values + assert len(result) == 3 + # All colors might be the same due to normalization + unique_colors = result.unique() + assert len(unique_colors) == 1 diff --git a/tests/visualisations/test_pathpy_plot.py b/tests/visualisations/test_pathpy_plot.py index 445788d2b..ee02e1049 100644 --- a/tests/visualisations/test_pathpy_plot.py +++ b/tests/visualisations/test_pathpy_plot.py @@ -1,10 +1,146 @@ +"""Unit tests for PathPyPlot base class.""" +import logging + +import pytest + +from pathpyG import config from pathpyG.visualisations.pathpy_plot import PathPyPlot -def test_PathPyPlot() -> None: - """Test PathPyPlot class.""" - plot = PathPyPlot() +class TestPathPyPlotInitialization: + """Test PathPyPlot initialization and configuration.""" + + def test_pathpy_plot_creates_empty_data(self) -> None: + """Test that PathPyPlot initializes with empty data dict.""" + plot = PathPyPlot() + assert isinstance(plot.data, dict) + assert len(plot.data) == 0 + + def test_pathpy_plot_loads_config(self) -> None: + """Test that PathPyPlot loads visualization config.""" + plot = PathPyPlot() + assert isinstance(plot.config, dict) + assert "node" in plot.config + assert "edge" in plot.config + + def test_pathpy_plot_config_is_copy(self) -> None: + """Test that config is a copy and modifications don't affect global config.""" + plot1 = PathPyPlot() + plot2 = PathPyPlot() + + # Modify plot1 config + plot1.config["custom_key"] = "custom_value" + + # plot2 should not have the custom key + assert "custom_key" not in plot2.config + + # Global config should not be affected + vis_config = config.get("visualisation", {}) + assert "custom_key" not in vis_config + + def test_pathpy_plot_normalizes_node_color_list_to_tuple(self) -> None: + """Test that list colors are converted to tuples.""" + # Get original config + original_config = config.get("visualisation", {}).copy() + + # Create plot + plot = PathPyPlot() + + # If node color is a tuple or was converted from list + if "node" in plot.config and "color" in plot.config["node"]: + node_color = plot.config["node"]["color"] + # Should be tuple if it was originally a list or tuple + if isinstance(original_config.get("node", {}).get("color"), (list, tuple)): + assert isinstance(node_color, tuple) + + def test_pathpy_plot_normalizes_edge_color_list_to_tuple(self) -> None: + """Test that list edge colors are converted to tuples.""" + plot = PathPyPlot() + + if "edge" in plot.config and "color" in plot.config["edge"]: + edge_color = plot.config["edge"]["color"] + # Should be tuple if config specified list or tuple + original_config = config.get("visualisation", {}) + if isinstance(original_config.get("edge", {}).get("color"), (list, tuple)): + assert isinstance(edge_color, tuple) + + def test_pathpy_plot_logs_initialization(self, caplog) -> None: + """Test that initialization logs debug message.""" + with caplog.at_level(logging.DEBUG, logger="root"): + _ = PathPyPlot() + + # Check that initialization was logged + assert any( + "Intialising PathpyPlot with config:" in record.message + for record in caplog.records + ) + + +class TestPathPyPlotGenerate: + """Test PathPyPlot generate method.""" + + def test_generate_not_implemented(self) -> None: + """Test that generate() raises NotImplementedError.""" + plot = PathPyPlot() + + with pytest.raises(NotImplementedError): + plot.generate() + + +class TestPathPyPlotSubclassing: + """Test PathPyPlot subclassing behavior.""" + + def test_subclass_can_override_generate(self) -> None: + """Test that subclasses can implement generate().""" + class CustomPlot(PathPyPlot): + def generate(self) -> None: + self.data["test_key"] = "test_value" + + plot = CustomPlot() + plot.generate() + + assert plot.data["test_key"] == "test_value" + + def test_subclass_inherits_data_and_config(self) -> None: + """Test that subclasses inherit data and config.""" + class CustomPlot(PathPyPlot): + def generate(self) -> None: + pass + + plot = CustomPlot() + + assert hasattr(plot, "data") + assert hasattr(plot, "config") + assert isinstance(plot.data, dict) + assert isinstance(plot.config, dict) + + def test_subclass_can_modify_config_in_init(self) -> None: + """Test that subclasses can modify config during initialization.""" + class CustomPlot(PathPyPlot): + def __init__(self, custom_option: str): + super().__init__() + self.config["custom_option"] = custom_option + + def generate(self) -> None: + pass + + plot = CustomPlot("my_value") + + assert plot.config["custom_option"] == "my_value" - assert isinstance(plot.data, dict) - assert isinstance(plot.config, dict) + def test_subclass_can_populate_data_in_generate(self) -> None: + """Test that subclasses can populate data in generate().""" + class DataPlot(PathPyPlot): + def generate(self) -> None: + self.data["values"] = [1, 2, 3, 4, 5] + self.data["labels"] = ["a", "b", "c", "d", "e"] + + plot = DataPlot() + assert len(plot.data) == 0 # Before generate + + plot.generate() + + assert len(plot.data) == 2 # After generate + assert plot.data["values"] == [1, 2, 3, 4, 5] + assert plot.data["labels"] == ["a", "b", "c", "d", "e"] diff --git a/tests/visualisations/test_plot_backend.py b/tests/visualisations/test_plot_backend.py new file mode 100644 index 000000000..2d3d44a20 --- /dev/null +++ b/tests/visualisations/test_plot_backend.py @@ -0,0 +1,43 @@ +"""Unit tests for PlotBackend base class.""" + +from unittest.mock import Mock + +import pytest + +from pathpyG.visualisations.pathpy_plot import PathPyPlot +from pathpyG.visualisations.plot_backend import PlotBackend + + +def test_plot_backend_initialization() -> None: + """Test that PlotBackend initializes correctly with plot and show_labels.""" + # Create a mock PathPyPlot instance + mock_plot = Mock(spec=PathPyPlot) + mock_plot.data = {"nodes": [], "edges": []} + mock_plot.config = {"node": {"color": "blue"}, "edge": {"color": "black"}} + + # Initialize PlotBackend + backend = PlotBackend(plot=mock_plot, show_labels=True) + + # Check that attributes are set correctly + assert backend.data == mock_plot.data + assert backend.config == mock_plot.config + assert backend.show_labels is True + + +def test_plot_backend_methods_not_implemented() -> None: + """Test that PlotBackend methods raise NotImplementedError.""" + # Create a mock PathPyPlot instance + mock_plot = Mock(spec=PathPyPlot) + mock_plot.data = {"nodes": [], "edges": []} + mock_plot.config = {"node": {"color": "blue"}, "edge": {"color": "black"}} + + # Initialize PlotBackend + backend = PlotBackend(plot=mock_plot, show_labels=False) + + # Test that save method raises NotImplementedError + with pytest.raises(NotImplementedError): + backend.save("output.png") + + # Test that show method raises NotImplementedError + with pytest.raises(NotImplementedError): + backend.show() diff --git a/tests/visualisations/test_plot_function.py b/tests/visualisations/test_plot_function.py index 3f432e0ea..d5bae36d0 100644 --- a/tests/visualisations/test_plot_function.py +++ b/tests/visualisations/test_plot_function.py @@ -1,18 +1,18 @@ -import torch +import logging + import pytest from pathpyG.core.graph import Graph from pathpyG.core.temporal_graph import TemporalGraph +from pathpyG.visualisations._d3js.backend import D3jsBackend +from pathpyG.visualisations._manim.backend import ManimBackend from pathpyG.visualisations._matplotlib.backend import MatplotlibBackend from pathpyG.visualisations._tikz.backend import TikzBackend -from pathpyG.visualisations._manim.backend import ManimBackend -from pathpyG.visualisations._d3js.backend import D3jsBackend -from pathpyG.visualisations.plot_function import _get_plot_backend, plot +from pathpyG.visualisations.plot_function import Backends, _get_plot_backend, plot def test_get_plot_backend() -> None: """Test to get a valid plot backend.""" - # backend which does not exist with pytest.raises(ImportError): _get_plot_backend(default="does not exist", backend=None, filename=None) @@ -63,65 +63,66 @@ def test_get_plot_backend() -> None: # Uses a default pytest fixture: see https://docs.pytest.org/en/6.2.x/tmpdir.html -def test_network_plot_png(tmp_path) -> None: - """Test to plot a static network as png file.""" +# Runs the test for all different file endings +@pytest.mark.parametrize("file_ending", [".jpg", ".png", ".html", ".tex", ".pdf", ".svg"]) +def test_network_plot_save(caplog, tmp_path, file_ending) -> None: + """Test to plot a static network as a file.""" net = Graph.from_edge_list([["a", "b"], ["b", "c"], ["a", "c"]]) - net.data["edge_weight"] = torch.tensor([[1], [1], [2]]) - net.data["edge_size"] = torch.tensor([[3], [4], [5]]) - net.data["node_size"] = torch.tensor([[90], [8], [7]]) - out = plot(net, edge_color="green", layout="fr") - out.save(tmp_path / "test.png") - assert (tmp_path / "test.png").exists() + with caplog.at_level(logging.DEBUG): + plot(net, filename=(tmp_path / ("test" + file_ending)).as_posix()) + assert (tmp_path / ("test" + file_ending)).exists() + assert "Using backend" in caplog.text -def test_network_plot_html(tmp_path) -> None: - """Test to plot a static network as html file.""" +def test_network_plot_save_fails(caplog, tmp_path) -> None: + """Test to plot a static network as a file with unsupported file type.""" net = Graph.from_edge_list([["a", "b"], ["b", "c"], ["a", "c"]]) - net.data["node_size"] = torch.tensor([[90], [8], [7]]) - out = plot(net) - out.save(tmp_path / "test.html") - assert (tmp_path / "test.html").exists() - -def test_plot_function(tmp_path) -> None: - """Test generic plot function.""" - net = Graph.from_edge_list([["a", "b"], ["b", "c"], ["a", "c"]]) - fig = plot(net) - fig.save(tmp_path / "generic.html") - assert (tmp_path / "generic.html").exists() + with caplog.at_level(logging.DEBUG): + plot(net, filename=(tmp_path / "test.exe").as_posix()) + assert (tmp_path / "test.exe").exists() + assert "Using default backend" in caplog.text -def test_network_plot_tex(tmp_path) -> None: - """Test to plot a static network as tex file.""" - net = Graph.from_edge_list([["a", "b"], ["b", "c"], ["a", "c"]]) +@pytest.mark.parametrize("file_ending", [".html", ".mp4", ".gif"]) +def test_temporal_plot_save(caplog, tmp_path, file_ending) -> None: + """Test to plot a temporal network.""" + net = TemporalGraph.from_edge_list( + [ + ("a", "b", 1), + ("b", "c", 2), + ("c", "d", 4), + ] + ) - out = plot(net, layout="fr") - out.save(tmp_path / "test.tex") - assert (tmp_path / "test.tex").exists() + with caplog.at_level(logging.DEBUG): + plot(net, filename=(tmp_path / ("temp" + file_ending)).as_posix()) + assert (tmp_path / ("temp" + file_ending)).exists() + assert "Using backend" in caplog.text -def test_temporal_plot(tmp_path) -> None: - """Test to plot a temporal network.""" +def test_temporal_plot_save_fails(caplog, tmp_path) -> None: + """Test to plot a temporal network with unsupported file type.""" net = TemporalGraph.from_edge_list( [ ("a", "b", 1), - ("b", "c", 5), - ("c", "d", 9), - ("d", "a", 9), - ("a", "b", 10), - ("b", "c", 10), + ("b", "c", 2), + ("c", "d", 4), ] ) - net.data["edge_size"] = torch.tensor([[3], [4], [5], [1], [2], [3]]) - - color = {"a": "blue", "b": "red", "c": "green", "d": "yellow"} - out = plot( - net, - node_color=color, - delta=1000, - layout="fr", - d3js_local=False, - ) - out.save(tmp_path / "temp.html") - assert (tmp_path / "temp.html").exists() + + with caplog.at_level(logging.DEBUG): + plot(net, filename=(tmp_path / "temp.exe").as_posix()) + assert (tmp_path / "temp.exe").exists() + assert "Using default backend" in caplog.text + + +def test_backend_enum() -> None: + """Test the Backends enum.""" + assert Backends.matplotlib == "matplotlib" + assert Backends.tikz == "tikz" + assert Backends.d3js == "d3js" + assert Backends.manim == "manim" + assert Backends.is_backend("matplotlib") + assert not Backends.is_backend("not_a_backend") diff --git a/tests/visualisations/test_temporal_network_plot.py b/tests/visualisations/test_temporal_network_plot.py new file mode 100644 index 000000000..e485c6e2e --- /dev/null +++ b/tests/visualisations/test_temporal_network_plot.py @@ -0,0 +1,258 @@ +"""Unit tests for TemporalNetworkPlot class in pathpyG.visualisations.""" + +import pandas as pd +import pytest +import torch + +from pathpyG.core.temporal_graph import TemporalGraph +from pathpyG.visualisations.temporal_network_plot import TemporalNetworkPlot + + +class TestTemporalNetworkPlot: + """Test TemporalNetworkPlot initialization and basic functionality.""" + + def setup_method(self): + """Create a simple temporal graph for testing.""" + # Create temporal graph with edges at different times + self.tg = TemporalGraph.from_edge_list( + [("a", "b", 0), ("b", "c", 1), ("c", "a", 2), ("a", "b", 3)] + ) + + def test_initialization(self): + """Test that TemporalNetworkPlot initializes correctly.""" + plot = TemporalNetworkPlot(self.tg) + assert plot.network is self.tg + assert plot._kind == "temporal" + assert isinstance(plot.data, dict) + + def test_node_data_structure(self): + """Test that node data has correct temporal structure.""" + plot = TemporalNetworkPlot(self.tg) + nodes = plot.data["nodes"] + assert isinstance(nodes, pd.DataFrame) + # Should have MultiIndex with uid + assert "uid" in nodes.index.names + # Should have temporal columns + assert "start" in nodes.columns + assert "end" in nodes.columns + + def test_edge_data_structure(self): + """Test that edge data has correct temporal structure.""" + plot = TemporalNetworkPlot(self.tg) + edges = plot.data["edges"] + assert isinstance(edges, pd.DataFrame) + # Should have MultiIndex with source and target + assert "source" in edges.index.names + assert "target" in edges.index.names + # Should have temporal columns + assert "start" in edges.columns + assert "end" in edges.columns + + def test_temporal_config(self): + """Test that temporal-specific config is set correctly.""" + plot = TemporalNetworkPlot(self.tg) + assert plot.config["directed"] is True + assert plot.config["curved"] is False + + def test_node_lifetime_tracking(self): + """Test that node start and end times are computed correctly.""" + plot = TemporalNetworkPlot(self.tg) + nodes = plot.data["nodes"] + # All nodes should have valid start/end times + assert (nodes["start"] >= 0).all() + assert (nodes["end"] > nodes["start"]).all() + + def test_edge_lifetime_tracking(self): + """Test that edge start and end times are computed correctly.""" + plot = TemporalNetworkPlot(self.tg) + edges = plot.data["edges"] + # Edges should have valid start/end times + assert (edges["start"] >= 0).all() + assert (edges["end"] > edges["start"]).all() + # By default, edges last for one time step + assert (edges["end"] - edges["start"] == 1).all() + + +class TestTemporalNetworkPlotAttributes: + """Test temporal node and edge attribute assignment.""" + + def setup_method(self): + """Create a temporal graph for attribute testing.""" + self.tg = TemporalGraph.from_edge_list( + [("a", "b", 0), ("b", "c", 1), ("c", "a", 2)] + ) + + def test_node_constant_attributes(self): + """Test assigning constant attributes to all nodes.""" + plot = TemporalNetworkPlot(self.tg, node_color="#ff0000", node_size=10) + nodes = plot.data["nodes"] + assert (nodes["color"] == "#ff0000").all() + assert (nodes["size"] == 10).all() + + def test_node_temporal_dict_attributes(self): + """Test assigning node attributes by (node, time) tuples.""" + node_colors = { + ("a", 0): "#ff0000", + ("b", 1): "#00ff00", + ("c", 2): "#0000ff", + } + plot = TemporalNetworkPlot(self.tg, node_color=node_colors) + nodes = plot.data["nodes"] + # Should have assigned colors at specific times + assert "#ff0000" in nodes["color"].values or "#00ff00" in nodes["color"].values + + def test_node_static_dict_attributes(self): + """Test assigning node attributes by node ID (applies to all times).""" + node_sizes = {"a": 10, "b": 20, "c": 30} + plot = TemporalNetworkPlot(self.tg, node_size=node_sizes) + nodes = plot.data["nodes"] + # Check that at least some nodes have the assigned sizes + assert 10 in nodes["size"].values or 20 in nodes["size"].values + + def test_edge_constant_attributes(self): + """Test assigning constant attributes to all edges.""" + plot = TemporalNetworkPlot(self.tg, edge_color="#0000ff", edge_size=5) + edges = plot.data["edges"] + assert (edges["color"] == "#0000ff").all() + assert (edges["size"] == 5).all() + + def test_node_attribute_forward_fill(self): + """Test that node attributes are forward-filled over time.""" + # Assign color only at time 0 + node_colors = {("a", 0): "#ff0000"} + plot = TemporalNetworkPlot(self.tg, node_color=node_colors) + nodes = plot.data["nodes"] + # Node 'a' should have the color forward-filled + a_nodes = nodes[nodes.index.get_level_values("uid") == "a"] + if len(a_nodes) > 1: + # Color should be consistent across time steps + assert len(a_nodes["color"].unique()) == 1 + + +class TestTemporalNetworkPlotLayout: + """Test temporal layout computation with windowing.""" + + def setup_method(self): + """Create a temporal graph for layout testing.""" + self.tg = TemporalGraph.from_edge_list( + [("a", "b", 0), ("b", "c", 1), ("c", "a", 2), ("a", "d", 3)] + ) + + def test_layout_with_default_window(self): + """Test layout computation with default window size.""" + plot = TemporalNetworkPlot(self.tg, layout="spring") + nodes = plot.data["nodes"] + # Should have x, y coordinates + assert "x" in nodes.columns + assert "y" in nodes.columns + # Coordinates should be normalized to [0, 1] + assert nodes["x"].notna().any() + assert nodes["y"].notna().any() + + def test_layout_with_symmetric_window(self): + """Test layout with symmetric window size.""" + plot = TemporalNetworkPlot(self.tg, layout="spring", layout_window_size=2) + nodes = plot.data["nodes"] + assert "x" in nodes.columns and "y" in nodes.columns + + def test_layout_with_asymmetric_window(self): + """Test layout with asymmetric window [past, future].""" + plot = TemporalNetworkPlot(self.tg, layout="spring", layout_window_size=[1, 2]) + nodes = plot.data["nodes"] + assert "x" in nodes.columns and "y" in nodes.columns + + def test_layout_with_all_past_window(self): + """Test layout using all past time steps (negative value).""" + plot = TemporalNetworkPlot(self.tg, layout="spring", layout_window_size=[-1, 1]) + nodes = plot.data["nodes"] + assert "x" in nodes.columns and "y" in nodes.columns + + def test_layout_with_all_future_window(self): + """Test layout using all future time steps (negative value).""" + plot = TemporalNetworkPlot(self.tg, layout="spring", layout_window_size=[1, -1]) + nodes = plot.data["nodes"] + assert "x" in nodes.columns and "y" in nodes.columns + + def test_layout_none_skips_computation(self): + """Test that layout=None skips layout computation.""" + plot = TemporalNetworkPlot(self.tg, layout=None) + nodes = plot.data["nodes"] + # Should not have x, y coordinates (or they should be NaN) + if "x" in nodes.columns: + assert nodes["x"].isna().all() + + def test_invalid_layout_window_raises(self): + """Test that invalid window size raises an error.""" + with pytest.raises(AttributeError): + TemporalNetworkPlot(self.tg, layout="spring", layout_window_size="invalid") + + def test_layout_coordinates_normalized(self): + """Test that layout coordinates are normalized to [0, 1].""" + plot = TemporalNetworkPlot(self.tg, layout="spring") + nodes = plot.data["nodes"] + x_coords = nodes["x"].dropna() + y_coords = nodes["y"].dropna() + if len(x_coords) > 0: + assert (x_coords >= 0).all() and (x_coords <= 1).all() + assert (y_coords >= 0).all() and (y_coords <= 1).all() + + +class TestTemporalNetworkPlotEdgeCases: + """Test edge cases and error handling.""" + + def test_empty_temporal_graph(self): + """Test handling of empty temporal graph.""" + tg = TemporalGraph.from_edge_list([]) + plot = TemporalNetworkPlot(tg) + assert len(plot.data["nodes"]) == 0 + assert len(plot.data["edges"]) == 0 + + def test_single_time_step(self): + """Test graph with edges at single time step.""" + tg = TemporalGraph.from_edge_list([("a", "b", 0), ("b", "c", 0)]) + plot = TemporalNetworkPlot(tg) + nodes = plot.data["nodes"] + edges = plot.data["edges"] + assert len(nodes) > 0 + assert len(edges) > 0 + + def test_large_time_gap(self): + """Test graph with large gaps in time steps.""" + tg = TemporalGraph.from_edge_list([("a", "b", 0), ("b", "c", 100)]) + plot = TemporalNetworkPlot(tg) + nodes = plot.data["nodes"] + # Nodes should span the time range + max_end = nodes["end"].max() + assert max_end > 100 + + def test_node_attribute_from_network_data(self): + """Test that node attributes from temporal graph data are used.""" + tg = TemporalGraph.from_edge_list([("a", "b", 0), ("b", "c", 1)]) + tg.data.node_color = ["#ff0000", "#00ff00", "#0000ff"] + plot = TemporalNetworkPlot(tg) + nodes = plot.data["nodes"] + # Should use network attributes + assert set(nodes["color"].unique()).intersection({"#ff0000", "#00ff00", "#0000ff"}) + + def test_edge_attribute_from_network_data(self): + """Test that edge attributes from temporal graph data are used.""" + tg = TemporalGraph.from_edge_list([("a", "b", 0), ("b", "c", 1)]) + tg.data.edge_size = torch.tensor([10, 20]) + plot = TemporalNetworkPlot(tg) + edges = plot.data["edges"] + # Should use network attributes + assert set(edges["size"].unique()).issubset({10, 20}) + + def test_simulation_mode_without_layout(self): + """Test that simulation mode is enabled when no layout is specified.""" + plot = TemporalNetworkPlot( + TemporalGraph.from_edge_list([("a", "b", 0)]), layout=None + ) + assert plot.config["simulation"] is True + + def test_simulation_mode_with_layout(self): + """Test that simulation mode is disabled when layout is specified.""" + plot = TemporalNetworkPlot( + TemporalGraph.from_edge_list([("a", "b", 0)]), layout="spring" + ) + assert plot.config["simulation"] is False diff --git a/tests/visualisations/test_utils.py b/tests/visualisations/test_utils.py new file mode 100644 index 000000000..a10dfabbb --- /dev/null +++ b/tests/visualisations/test_utils.py @@ -0,0 +1,360 @@ +"""Unit tests for visualization utilities.""" + +import base64 +import os + +import pytest + +from pathpyG.visualisations.utils import ( + cm_to_inch, + hex_to_rgb, + image_to_base64, + inch_to_cm, + inch_to_px, + prepare_tempfile, + px_to_inch, + rgb_to_hex, + unit_str_to_float, +) + + +class TestColorConversion: + """Test RGB/Hex color conversion utilities.""" + + def test_rgb_to_hex_float_values(self): + """Test RGB to hex with float values (0-1 range).""" + assert rgb_to_hex((1.0, 0.0, 0.0)) == "#ff0000" + assert rgb_to_hex((0.0, 1.0, 0.0)) == "#00ff00" + assert rgb_to_hex((0.0, 0.0, 1.0)) == "#0000ff" + assert rgb_to_hex((1.0, 1.0, 1.0)) == "#ffffff" + assert rgb_to_hex((0.0, 0.0, 0.0)) == "#000000" + + def test_rgb_to_hex_int_values(self): + """Test RGB to hex with integer values (0-255 range).""" + assert rgb_to_hex((255, 0, 0)) == "#ff0000" + assert rgb_to_hex((0, 255, 0)) == "#00ff00" + assert rgb_to_hex((0, 0, 255)) == "#0000ff" + assert rgb_to_hex((255, 128, 0)) == "#ff8000" + assert rgb_to_hex((128, 128, 128)) == "#808080" + + def test_rgb_to_hex_mixed_precision(self): + """Test RGB to hex with edge cases and precision.""" + assert rgb_to_hex((0.5, 0.5, 0.5)) == "#7f7f7f" + assert rgb_to_hex((0.25, 0.75, 0.5)) == "#3fbf7f" + + def test_rgb_to_hex_invalid_values(self): + """Test RGB to hex with invalid values.""" + with pytest.raises(ValueError, match="RGB values must be in range"): + rgb_to_hex((256, 0, 0)) + with pytest.raises(ValueError, match="RGB values must be in range"): + rgb_to_hex((1.5, 0.5, 0.5)) + with pytest.raises(ValueError, match="RGB values must be in range"): + rgb_to_hex((-1, 0, 0)) + + def test_hex_to_rgb_with_hash(self): + """Test hex to RGB with hash prefix.""" + assert hex_to_rgb("#ff0000") == (255, 0, 0) + assert hex_to_rgb("#00ff00") == (0, 255, 0) + assert hex_to_rgb("#0000ff") == (0, 0, 255) + assert hex_to_rgb("#ffffff") == (255, 255, 255) + assert hex_to_rgb("#000000") == (0, 0, 0) + + def test_hex_to_rgb_without_hash(self): + """Test hex to RGB without hash prefix.""" + assert hex_to_rgb("ff0000") == (255, 0, 0) + assert hex_to_rgb("00ff00") == (0, 255, 0) + assert hex_to_rgb("0000ff") == (0, 0, 255) + + def test_hex_to_rgb_short_notation(self): + """Test hex to RGB with short notation.""" + assert hex_to_rgb("#f0f") == (255, 0, 255) + assert hex_to_rgb("fff") == (255, 255, 255) + assert hex_to_rgb("000") == (0, 0, 0) + + def test_rgb_hex_roundtrip(self): + """Test that RGB->Hex->RGB conversion is stable.""" + original = (200, 100, 50) + hex_color = rgb_to_hex(original) + result = hex_to_rgb(hex_color) + assert result == original + + +class TestUnitConversion: + """Test length unit conversion utilities.""" + + def test_cm_to_inch(self): + """Test centimeters to inches conversion.""" + assert cm_to_inch(2.54) == pytest.approx(1.0) + assert cm_to_inch(10.0) == pytest.approx(3.937, rel=1e-3) + assert cm_to_inch(0) == 0 + assert cm_to_inch(21.0) == pytest.approx(8.268, rel=1e-3) + + def test_inch_to_cm(self): + """Test inches to centimeters conversion.""" + assert inch_to_cm(1.0) == pytest.approx(2.54) + assert inch_to_cm(0) == 0 + assert inch_to_cm(8.5) == pytest.approx(21.59) + assert inch_to_cm(11.0) == pytest.approx(27.94) + + def test_cm_inch_roundtrip(self): + """Test that cm->inch->cm conversion is stable.""" + original = 15.5 + result = inch_to_cm(cm_to_inch(original)) + assert result == pytest.approx(original) + + def test_inch_to_px_default_dpi(self): + """Test inches to pixels with default 96 DPI.""" + assert inch_to_px(1.0) == 96.0 + assert inch_to_px(0) == 0 + assert inch_to_px(8.5) == 816.0 + assert inch_to_px(0.5) == 48.0 + + def test_inch_to_px_custom_dpi(self): + """Test inches to pixels with custom DPI.""" + assert inch_to_px(1.0, dpi=300) == 300.0 + assert inch_to_px(8.5, dpi=300) == 2550.0 + assert inch_to_px(1.0, dpi=72) == 72.0 + + def test_px_to_inch_default_dpi(self): + """Test pixels to inches with default 96 DPI.""" + assert px_to_inch(96) == pytest.approx(1.0) + assert px_to_inch(0) == 0 + assert px_to_inch(800) == pytest.approx(8.333, rel=1e-3) + assert px_to_inch(48) == pytest.approx(0.5) + + def test_px_to_inch_custom_dpi(self): + """Test pixels to inches with custom DPI.""" + assert px_to_inch(300, dpi=300) == pytest.approx(1.0) + assert px_to_inch(2400, dpi=300) == pytest.approx(8.0) + assert px_to_inch(72, dpi=72) == pytest.approx(1.0) + + def test_px_inch_roundtrip(self): + """Test that px->inch->px conversion is stable.""" + original = 1000.0 + result = inch_to_px(px_to_inch(original)) + assert result == pytest.approx(original) + + def test_unit_str_to_float_same_unit(self): + """Test unit string conversion when units match.""" + assert unit_str_to_float("100px", "px") == 100.0 + assert unit_str_to_float("50cm", "cm") == 50.0 + assert unit_str_to_float("10in", "in") == 10.0 + + def test_unit_str_to_float_cm_to_in(self): + """Test cm to inches conversion.""" + result = unit_str_to_float("2.54cm", "in") + assert result == pytest.approx(1.0) + + def test_unit_str_to_float_in_to_cm(self): + """Test inches to cm conversion.""" + result = unit_str_to_float("1in", "cm") + assert result == pytest.approx(2.54) + + def test_unit_str_to_float_in_to_px(self): + """Test inches to pixels conversion.""" + assert unit_str_to_float("1in", "px") == 96.0 + assert unit_str_to_float("2in", "px") == 192.0 + + def test_unit_str_to_float_px_to_in(self): + """Test pixels to inches conversion.""" + result = unit_str_to_float("96px", "in") + assert result == pytest.approx(1.0) + + def test_unit_str_to_float_cm_to_px(self): + """Test cm to pixels conversion.""" + result = unit_str_to_float("2.54cm", "px") + assert result == pytest.approx(96.0) + + def test_unit_str_to_float_px_to_cm(self): + """Test pixels to cm conversion.""" + result = unit_str_to_float("96px", "cm") + assert result == pytest.approx(2.54) + + def test_unit_str_to_float_unsupported_conversion(self): + """Test that unsupported conversions raise ValueError.""" + with pytest.raises(ValueError, match="not supported"): + unit_str_to_float("100mm", "px") # mm not supported + + +class TestImageConversion: + """Test image to base64 conversion utilities.""" + + def test_image_to_base64_png(self, tmp_path): + """Test PNG image to base64 conversion.""" + # Create a minimal PNG file (1x1 red pixel) + png_data = bytes([ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, # PNG signature + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, # IHDR chunk + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, # IDAT chunk + 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, + 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xDD, 0x8D, + 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, # IEND chunk + 0x44, 0xAE, 0x42, 0x60, 0x82 + ]) + + img_path = tmp_path / "test.png" + img_path.write_bytes(png_data) + + result = image_to_base64(str(img_path)) + + # Check format + assert result.startswith("data:image/png;base64,") + + # Check that it's valid base64 + encoded_part = result.split(",")[1] + decoded = base64.b64decode(encoded_part) + assert decoded == png_data + + def test_image_to_base64_jpeg(self, tmp_path): + """Test JPEG image to base64 conversion.""" + # Create a minimal JPEG file + jpeg_data = bytes([ + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, + 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, + 0x00, 0x01, 0x00, 0x00, 0xFF, 0xD9 + ]) + + img_path = tmp_path / "test.jpg" + img_path.write_bytes(jpeg_data) + + result = image_to_base64(str(img_path)) + + # Check format + assert result.startswith("data:image/jpeg;base64,") + + # Check content + encoded_part = result.split(",")[1] + decoded = base64.b64decode(encoded_part) + assert decoded == jpeg_data + + def test_image_to_base64_jpeg_extensions(self, tmp_path): + """Test that .jpeg and .jpg both use image/jpeg mime type.""" + jpeg_data = bytes([0xFF, 0xD8, 0xFF, 0xD9]) + + for ext in [".jpg", ".jpeg"]: + img_path = tmp_path / f"test{ext}" + img_path.write_bytes(jpeg_data) + result = image_to_base64(str(img_path)) + assert result.startswith("data:image/jpeg;base64,") + + def test_image_to_base64_gif(self, tmp_path): + """Test GIF image to base64 conversion.""" + # Minimal GIF header + gif_data = bytes([ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, # GIF89a + 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x3B + ]) + + img_path = tmp_path / "test.gif" + img_path.write_bytes(gif_data) + + result = image_to_base64(str(img_path)) + assert result.startswith("data:image/gif;base64,") + + def test_image_to_base64_svg(self, tmp_path): + """Test SVG image to base64 conversion.""" + svg_content = b'' + + img_path = tmp_path / "test.svg" + img_path.write_bytes(svg_content) + + result = image_to_base64(str(img_path)) + assert result.startswith("data:image/svg+xml;base64,") + + def test_image_to_base64_unknown_extension(self, tmp_path): + """Test that unknown extensions default to image/png.""" + data = b"some image data" + img_path = tmp_path / "test.xyz" + img_path.write_bytes(data) + + result = image_to_base64(str(img_path)) + assert result.startswith("data:image/png;base64,") + + def test_image_to_base64_nonexistent_file(self): + """Test that nonexistent files raise FileNotFoundError.""" + with pytest.raises(FileNotFoundError, match="Image not found"): + image_to_base64("/nonexistent/path/to/image.png") + + def test_image_to_base64_with_path_object(self, tmp_path): + """Test that Path objects work correctly.""" + png_data = b"\x89PNG\x0D\x0A\x1A\x0A" + img_path = tmp_path / "test.png" + img_path.write_bytes(png_data) + + # Pass as Path object + result = image_to_base64(img_path) + assert result.startswith("data:image/png;base64,") + + +class TestTempfileManagement: + """Test temporary directory management utilities.""" + + def test_prepare_tempfile_creates_temp_dir(self): + """Test that prepare_tempfile creates a temporary directory.""" + original_cwd = os.getcwd() + + try: + temp_dir, stored_cwd = prepare_tempfile() + + # Check that temp_dir exists and is a directory + assert os.path.isdir(temp_dir) + + # Check that stored_cwd is the original directory + assert stored_cwd == original_cwd + + # Check that we're now in the temp directory + assert os.getcwd() == temp_dir + + finally: + # Clean up: restore original directory + os.chdir(original_cwd) + # Try to remove temp dir if it exists + if 'temp_dir' in locals(): + try: + os.rmdir(temp_dir) + except OSError: + pass # Directory might not be empty or already removed + + def test_prepare_tempfile_changes_cwd(self): + """Test that prepare_tempfile changes the current working directory.""" + original_cwd = os.getcwd() + + try: + temp_dir, _ = prepare_tempfile() + current_cwd = os.getcwd() + + # Verify we changed directory + assert current_cwd != original_cwd + assert current_cwd == temp_dir + + finally: + os.chdir(original_cwd) + if 'temp_dir' in locals(): + try: + os.rmdir(temp_dir) + except OSError: + pass + + def test_prepare_tempfile_returns_different_dirs(self): + """Test that multiple calls create different temp directories.""" + original_cwd = os.getcwd() + temp_dirs = [] + + try: + for _ in range(3): + temp_dir, _ = prepare_tempfile() + temp_dirs.append(temp_dir) + os.chdir(original_cwd) # Reset for next iteration + + # All temp directories should be unique + assert len(set(temp_dirs)) == 3 + + finally: + os.chdir(original_cwd) + for temp_dir in temp_dirs: + try: + os.rmdir(temp_dir) + except OSError: + pass From 86c9896592416c4de3e847499eb2abc753032b78 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Mon, 20 Oct 2025 10:07:33 +0000 Subject: [PATCH 36/44] update latex installation --- .github/actions/setup/action.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 071b5e5df..117f2e37f 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -50,6 +50,8 @@ runs: with: cache: true packages: | + scheme-basic + latexmk tikz-network standalone xcolor From fe1de79cda759f86b88925c8b6a7f3e7ca29acff Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Mon, 20 Oct 2025 10:14:01 +0000 Subject: [PATCH 37/44] fix ffmpeg installation --- .github/actions/setup/action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 117f2e37f..32e3ac070 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -72,6 +72,7 @@ runs: - name: Install extension packages if: ${{ inputs.full_install == 'true' }} uses: awalsh128/cache-apt-pkgs-action@v1 + execute_install_scripts: true with: packages: | build-essential From 32feb37693bb43876f94a37183688a9bbc355d8b Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Mon, 20 Oct 2025 10:15:59 +0000 Subject: [PATCH 38/44] - --- .github/actions/setup/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 32e3ac070..9dfd3c75d 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -72,8 +72,8 @@ runs: - name: Install extension packages if: ${{ inputs.full_install == 'true' }} uses: awalsh128/cache-apt-pkgs-action@v1 - execute_install_scripts: true with: + execute_install_scripts: true packages: | build-essential python3-dev From be37d9c4f4641a01eb73a49ce3ef96f2767148a4 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Mon, 20 Oct 2025 10:20:34 +0000 Subject: [PATCH 39/44] fix ffmpeg --- .github/actions/setup/action.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 9dfd3c75d..ad9992bd7 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -79,8 +79,11 @@ runs: python3-dev libcairo2-dev libpango1.0-dev - ffmpeg + - name: Install ffmpeg + if: ${{ inputs.full_install == 'true' }} + uses: FedericoCarboni/setup-ffmpeg@v3 + - name: Install optional Python dependencies if: ${{ inputs.full_install == 'true' }} run: | From 947b9c198aa53864e5b6c609d91f2a0cba4b8558 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Mon, 20 Oct 2025 14:50:13 +0000 Subject: [PATCH 40/44] backend tests --- src/pathpyG/visualisations/_d3js/backend.py | 2 +- src/pathpyG/visualisations/_manim/backend.py | 1 - .../_manim/temporal_graph_scene.py | 3 +- src/pathpyG/visualisations/_tikz/backend.py | 78 ++-- tests/visualisations/_d3js/__init__.py | 5 + tests/visualisations/_d3js/test_backend.py | 356 ++++++++++++++++++ tests/visualisations/_manim/__init__.py | 5 + tests/visualisations/_manim/test_backend.py | 171 +++++++++ .../_manim/test_temporal_graph_scene.py | 312 +++++++++++++++ tests/visualisations/_matplotlib/__init__.py | 5 + .../_matplotlib/test_backend.py | 303 +++++++++++++++ tests/visualisations/_tikz/__init__.py | 5 + tests/visualisations/_tikz/test_backend.py | 256 +++++++++++++ tests/visualisations/test_manim.py | 333 ---------------- 14 files changed, 1461 insertions(+), 374 deletions(-) create mode 100644 tests/visualisations/_d3js/__init__.py create mode 100644 tests/visualisations/_d3js/test_backend.py create mode 100644 tests/visualisations/_manim/__init__.py create mode 100644 tests/visualisations/_manim/test_backend.py create mode 100644 tests/visualisations/_manim/test_temporal_graph_scene.py create mode 100644 tests/visualisations/_matplotlib/__init__.py create mode 100644 tests/visualisations/_matplotlib/test_backend.py create mode 100644 tests/visualisations/_tikz/__init__.py create mode 100644 tests/visualisations/_tikz/test_backend.py delete mode 100644 tests/visualisations/test_manim.py diff --git a/src/pathpyG/visualisations/_d3js/backend.py b/src/pathpyG/visualisations/_d3js/backend.py index 0b742ce8c..904e0144c 100644 --- a/src/pathpyG/visualisations/_d3js/backend.py +++ b/src/pathpyG/visualisations/_d3js/backend.py @@ -4,7 +4,7 @@ visualizations. Supports both static and temporal networks with embedded JavaScript, and CSS styling. -Features: +!!! abstract "Features": - Interactive HTML output with drag-and-drop node movement - Template-based architecture for extensibility - Both static and temporal network support diff --git a/src/pathpyG/visualisations/_manim/backend.py b/src/pathpyG/visualisations/_manim/backend.py index 2e8970a9f..8e28dc4f6 100644 --- a/src/pathpyG/visualisations/_manim/backend.py +++ b/src/pathpyG/visualisations/_manim/backend.py @@ -113,7 +113,6 @@ def __init__(self, plot: PathPyPlot, show_labels: bool): # Optional config settings manim_config.pixel_height = int(unit_str_to_float(self.config.get("height"), "px")) # type: ignore[arg-type] manim_config.pixel_width = int(unit_str_to_float(self.config.get("width"), "px")) # type: ignore[arg-type] - manim_config.frame_rate = 15 manim_config.quality = "high_quality" manim_config.background_color = WHITE diff --git a/src/pathpyG/visualisations/_manim/temporal_graph_scene.py b/src/pathpyG/visualisations/_manim/temporal_graph_scene.py index 02e906947..b14f66417 100644 --- a/src/pathpyG/visualisations/_manim/temporal_graph_scene.py +++ b/src/pathpyG/visualisations/_manim/temporal_graph_scene.py @@ -5,6 +5,7 @@ and time indicator display. """ import logging +from copy import deepcopy import numpy as np from manim import BLACK, RIGHT, UP, Arrow, Create, Dot, GrowArrow, LabeledDot, Scene, Text, Transform, Uncreate @@ -33,7 +34,7 @@ def __init__(self, data: dict, config: dict, show_labels: bool): show_labels: Whether to display node labels """ super().__init__() - self.data = data + self.data = deepcopy(data) self.data["nodes"]["size"] *= 0.025 # scale sizes down self.data["nodes"] = self.data["nodes"].rename( columns={"size": "radius", "color": "fill_color", "opacity": "fill_opacity"} diff --git a/src/pathpyG/visualisations/_tikz/backend.py b/src/pathpyG/visualisations/_tikz/backend.py index 1e0a1a740..a96681be9 100644 --- a/src/pathpyG/visualisations/_tikz/backend.py +++ b/src/pathpyG/visualisations/_tikz/backend.py @@ -362,50 +362,52 @@ def to_tikz(self) -> str: """ tikz = "" # generate node strings - node_strings: pd.Series = "\\Vertex[" - # show labels if specified - if self.show_labels: + if not self.data["nodes"].empty: + node_strings: pd.Series = "\\Vertex[" + # show labels if specified + if self.show_labels: + node_strings += ( + "label=$" + self.data["nodes"].index.astype(str).map(self._replace_with_LaTeX_math_symbol) + "$," + ) + node_strings += ( + "fontsize=\\fontsize{" + str(int(0.6 * self.data["nodes"]["size"].mean())) + "}{10}\selectfont," + ) + # Convert hex colors to rgb if necessary + if self.data["nodes"]["color"].str.startswith("#").all(): + self.data["nodes"]["color"] = self.data["nodes"]["color"].map(hex_to_rgb) + node_strings += "RGB,color={" + self.data["nodes"]["color"].astype(str).str.strip("()") + "}," + else: + node_strings += "color=" + self.data["nodes"]["color"] + "," + # add other options + node_strings += "size=" + (self.data["nodes"]["size"] * 0.075).astype(str) + "," + node_strings += "opacity=" + self.data["nodes"]["opacity"].astype(str) + "," + # add position node_strings += ( - "label=$" + self.data["nodes"].index.astype(str).map(self._replace_with_LaTeX_math_symbol) + "$," + "x=" + ((self.data["nodes"]["x"] - 0.5) * unit_str_to_float(self.config["width"], "cm")).astype(str) + "," ) node_strings += ( - "fontsize=\\fontsize{" + str(int(0.6 * self.data["nodes"]["size"].mean())) + "}{10}\selectfont," + "y=" + ((self.data["nodes"]["y"] - 0.5) * unit_str_to_float(self.config["height"], "cm")).astype(str) + "]" ) - # Convert hex colors to rgb if necessary - if self.data["nodes"]["color"].str.startswith("#").all(): - self.data["nodes"]["color"] = self.data["nodes"]["color"].map(hex_to_rgb) - node_strings += "RGB,color={" + self.data["nodes"]["color"].astype(str).str.strip("()") + "}," - else: - node_strings += "color=" + self.data["nodes"]["color"] + "," - # add other options - node_strings += "size=" + (self.data["nodes"]["size"] * 0.075).astype(str) + "," - node_strings += "opacity=" + self.data["nodes"]["opacity"].astype(str) + "," - # add position - node_strings += ( - "x=" + ((self.data["nodes"]["x"] - 0.5) * unit_str_to_float(self.config["width"], "cm")).astype(str) + "," - ) - node_strings += ( - "y=" + ((self.data["nodes"]["y"] - 0.5) * unit_str_to_float(self.config["height"], "cm")).astype(str) + "]" - ) - # add node name - node_strings += "{" + self.data["nodes"].index.astype(str) + "}\n" - tikz += node_strings.str.cat() + # add node name + node_strings += "{" + self.data["nodes"].index.astype(str) + "}\n" + tikz += node_strings.str.cat() # generate edge strings - edge_strings: pd.Series = "\\Edge[" - if self.config["directed"]: - edge_strings += "bend=15,Direct," - if self.data["edges"]["color"].str.startswith("#").all(): - self.data["edges"]["color"] = self.data["edges"]["color"].map(hex_to_rgb) - edge_strings += "RGB,color={" + self.data["edges"]["color"].astype(str).str.strip("()") + "}," - else: - edge_strings += "color=" + self.data["edges"]["color"] + "," - edge_strings += "lw=" + self.data["edges"]["size"].astype(str) + "," - edge_strings += "opacity=" + self.data["edges"]["opacity"].astype(str) + "]" - edge_strings += ( - "(" + self.data["edges"].index.get_level_values("source").astype(str) + ")(" + self.data["edges"].index.get_level_values("target").astype(str) + ")\n" - ) - tikz += edge_strings.str.cat() + if not self.data["edges"].empty: + edge_strings: pd.Series = "\\Edge[" + if self.config["directed"]: + edge_strings += "bend=15,Direct," + if self.data["edges"]["color"].str.startswith("#").all(): + self.data["edges"]["color"] = self.data["edges"]["color"].map(hex_to_rgb) + edge_strings += "RGB,color={" + self.data["edges"]["color"].astype(str).str.strip("()") + "}," + else: + edge_strings += "color=" + self.data["edges"]["color"] + "," + edge_strings += "lw=" + self.data["edges"]["size"].astype(str) + "," + edge_strings += "opacity=" + self.data["edges"]["opacity"].astype(str) + "]" + edge_strings += ( + "(" + self.data["edges"].index.get_level_values("source").astype(str) + ")(" + self.data["edges"].index.get_level_values("target").astype(str) + ")\n" + ) + tikz += edge_strings.str.cat() return tikz diff --git a/tests/visualisations/_d3js/__init__.py b/tests/visualisations/_d3js/__init__.py new file mode 100644 index 000000000..7554407aa --- /dev/null +++ b/tests/visualisations/_d3js/__init__.py @@ -0,0 +1,5 @@ +"""Necessary to make Python treat the tests directory as a module. + +This is required since mypy doesn't support the same file name otherwise +It is also required to enable module specific overrides in pyproject.toml +""" diff --git a/tests/visualisations/_d3js/test_backend.py b/tests/visualisations/_d3js/test_backend.py new file mode 100644 index 000000000..1d4cd3de4 --- /dev/null +++ b/tests/visualisations/_d3js/test_backend.py @@ -0,0 +1,356 @@ +"""Unit tests for D3.js backend in pathpyG.visualisations.""" + +import json +import os +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +from pathpyG.core.graph import Graph +from pathpyG.core.temporal_graph import TemporalGraph +from pathpyG.visualisations._d3js.backend import D3jsBackend +from pathpyG.visualisations.network_plot import NetworkPlot +from pathpyG.visualisations.temporal_network_plot import TemporalNetworkPlot + + +class TestD3jsBackendInitialization: + """Test D3jsBackend initialization and configuration.""" + + def setup_method(self): + """Set up test fixtures.""" + # Create simple static network + edges = [("a", "b"), ("b", "c"), ("c", "a")] + self.g = Graph.from_edge_list(edges) + self.static_plot = NetworkPlot(self.g, layout="spring") + + # Create temporal network + tedges = [("a", "b", 1), ("b", "c", 2), ("c", "a", 3)] + self.tg = TemporalGraph.from_edge_list(tedges) + self.temp_plot = TemporalNetworkPlot(self.tg) + + def test_backend_initialization_with_static_plot(self): + """Test that D3jsBackend initializes with NetworkPlot.""" + backend = D3jsBackend(self.static_plot, show_labels=True) + + assert backend is not None + assert backend._kind == "static" + assert backend.show_labels is True + + def test_backend_initialization_with_temporal_plot(self): + """Test that D3jsBackend initializes with TemporalNetworkPlot.""" + backend = D3jsBackend(self.temp_plot, show_labels=False) + + assert backend is not None + assert backend._kind == "temporal" + assert backend.show_labels is False + + def test_backend_initialization_with_unsupported_plot_raises(self): + """Test that unsupported plot types raise ValueError.""" + # Create a custom unsupported plot type + from pathpyG.visualisations.pathpy_plot import PathPyPlot + + unsupported_plot = PathPyPlot() + + with pytest.raises(ValueError, match="not supported"): + D3jsBackend(unsupported_plot, show_labels=False) + + def test_backend_inherits_plot_data_and_config(self): + """Test that backend has access to plot data and config.""" + backend = D3jsBackend(self.static_plot, show_labels=True) + + assert hasattr(backend, "data") + assert hasattr(backend, "config") + assert isinstance(backend.data, dict) + assert isinstance(backend.config, dict) + + def test_backend_stores_show_labels(self): + """Test that backend correctly stores show_labels parameter.""" + backend_with_labels = D3jsBackend(self.static_plot, show_labels=True) + backend_without_labels = D3jsBackend(self.static_plot, show_labels=False) + + assert backend_with_labels.show_labels is True + assert backend_without_labels.show_labels is False + + +class TestD3jsBackendDataPreparation: + """Test data preparation methods for D3.js format.""" + + def setup_method(self): + """Set up test fixtures.""" + edges = [("a", "b"), ("b", "c"), ("c", "a")] + self.g = Graph.from_edge_list(edges) + self.static_plot = NetworkPlot(self.g, layout="spring") + self.backend = D3jsBackend(self.static_plot, show_labels=True) + + def test_prepare_data_structure(self): + """Test that _prepare_data returns correct structure.""" + data_dict = self.backend._prepare_data() + + assert isinstance(data_dict, dict) + assert "nodes" in data_dict + assert "edges" in data_dict + assert isinstance(data_dict["nodes"], list) + assert isinstance(data_dict["edges"], list) + + def test_prepare_data_node_structure(self): + """Test that nodes have correct structure.""" + data_dict = self.backend._prepare_data() + nodes = data_dict["nodes"] + + # Should have 3 nodes + assert len(nodes) == 3 + + # Each node should have uid and position + for node in nodes: + assert "uid" in node + assert "xpos" in node # x renamed to xpos + assert "ypos" in node # y renamed to ypos + + def test_prepare_data_edge_structure(self): + """Test that edges have correct structure.""" + data_dict = self.backend._prepare_data() + edges = data_dict["edges"] + + # Should have edges + assert len(edges) > 0 + + # Each edge should have uid, source, target + for edge in edges: + assert "uid" in edge + assert "source" in edge + assert "target" in edge + + def test_prepare_data_preserves_attributes(self): + """Test that node and edge attributes are preserved.""" + data_dict = self.backend._prepare_data() + nodes = data_dict["nodes"] + edges = data_dict["edges"] + + # Nodes should have color, size, opacity + for node in nodes: + assert "color" in node + assert "size" in node + assert "opacity" in node + + # Edges should have color, size, opacity + for edge in edges: + assert "color" in edge + assert "size" in edge + assert "opacity" in edge + + +class TestD3jsBackendConfigPreparation: + """Test configuration preparation for D3.js.""" + + def setup_method(self): + """Set up test fixtures.""" + edges = [("a", "b"), ("b", "c")] + self.g = Graph.from_edge_list(edges) + self.static_plot = NetworkPlot(self.g, layout="spring") + self.backend = D3jsBackend(self.static_plot, show_labels=True) + + def test_prepare_config_structure(self): + """Test that _prepare_config returns correct structure.""" + config_dict = self.backend._prepare_config() + + assert isinstance(config_dict, dict) + assert "node" in config_dict + assert "edge" in config_dict + assert "width" in config_dict + assert "height" in config_dict + assert "show_labels" in config_dict + + def test_prepare_config_converts_colors_to_hex(self): + """Test that colors are converted to hex format.""" + config_dict = self.backend._prepare_config() + + # Node and edge colors should be hex strings + node_color = config_dict["node"]["color"] + edge_color = config_dict["edge"]["color"] + + assert isinstance(node_color, str) + assert node_color.startswith("#") + assert isinstance(edge_color, str) + assert edge_color.startswith("#") + + def test_prepare_config_converts_dimensions_to_pixels(self): + """Test that width and height are converted to numeric pixels.""" + config_dict = self.backend._prepare_config() + + # Width and height should be numeric + assert isinstance(config_dict["width"], (int, float)) + assert isinstance(config_dict["height"], (int, float)) + assert config_dict["width"] > 0 + assert config_dict["height"] > 0 + + +class TestD3jsBackendJSONSerialization: + """Test JSON serialization methods.""" + + def setup_method(self): + """Set up test fixtures.""" + edges = [("a", "b"), ("b", "c")] + self.g = Graph.from_edge_list(edges) + self.static_plot = NetworkPlot(self.g, layout="spring") + self.backend = D3jsBackend(self.static_plot, show_labels=True) + + def test_to_json_returns_tuple(self): + """Test that to_json returns tuple of two strings.""" + result = self.backend.to_json() + + assert isinstance(result, tuple) + assert len(result) == 2 + assert isinstance(result[0], str) + assert isinstance(result[1], str) + + def test_to_json_produces_valid_json(self): + """Test that to_json produces valid JSON strings.""" + data_json, config_json = self.backend.to_json() + + # Should be parseable as JSON + data = json.loads(data_json) + config = json.loads(config_json) + + assert isinstance(data, dict) + assert isinstance(config, dict) + + def test_to_json_data_structure(self): + """Test that JSON data has correct structure.""" + data_json, _ = self.backend.to_json() + data = json.loads(data_json) + + assert "nodes" in data + assert "edges" in data + assert isinstance(data["nodes"], list) + assert isinstance(data["edges"], list) + + def test_to_json_config_structure(self): + """Test that JSON config has correct structure.""" + _, config_json = self.backend.to_json() + config = json.loads(config_json) + + assert "node" in config + assert "edge" in config + assert "show_labels" in config + + +class TestD3jsBackendTemplateSystem: + """Test template loading and assembly.""" + + def setup_method(self): + """Set up test fixtures.""" + edges = [("a", "b"), ("b", "c")] + self.g = Graph.from_edge_list(edges) + self.static_plot = NetworkPlot(self.g, layout="spring") + self.backend = D3jsBackend(self.static_plot, show_labels=True) + + def test_get_template_returns_string(self): + """Test that get_template returns JavaScript code.""" + template_dir = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "src/pathpyG/visualisations/_d3js/templates", + ) + + if os.path.exists(template_dir): + js_template = self.backend.get_template(template_dir) + assert isinstance(js_template, str) + assert len(js_template) > 0 + + +class TestD3jsBackendHTMLGeneration: + """Test HTML generation and assembly.""" + + def setup_method(self): + """Set up test fixtures.""" + edges = [("a", "b"), ("b", "c")] + self.g = Graph.from_edge_list(edges) + self.static_plot = NetworkPlot(self.g, layout="spring") + self.backend = D3jsBackend(self.static_plot, show_labels=True) + + def test_to_html_returns_string(self): + """Test that to_html returns HTML string.""" + html = self.backend.to_html() + + assert isinstance(html, str) + assert len(html) > 0 + + def test_to_html_contains_essential_elements(self): + """Test that HTML contains essential elements.""" + html = self.backend.to_html() + + # Should contain CSS + assert "" in html + + # Should contain div container + assert "" in html + + # Should contain script tags + assert "" in html + + def test_to_html_includes_d3js_library(self): + """Test that HTML includes D3.js library reference.""" + html = self.backend.to_html() + + # Should reference D3.js (either local or CDN) + assert "d3" in html.lower() + + def test_to_html_includes_data_and_config(self): + """Test that HTML includes embedded data and config.""" + html = self.backend.to_html() + + # Should contain data and config declarations + assert "const data" in html + assert "const config" in html + + def test_to_html_has_unique_dom_id(self): + """Test that generated HTML has unique DOM ID.""" + html1 = self.backend.to_html() + html2 = self.backend.to_html() + + # Extract div IDs (they should be different) + # Multiple calls should generate different IDs + assert 'id = "x' in html1 + assert 'id = "x' in html2 + assert html1 != html2 + + +class TestD3jsBackendFileOperations: + """Test file save and display operations.""" + + def setup_method(self): + """Set up test fixtures.""" + edges = [("a", "b"), ("b", "c")] + self.g = Graph.from_edge_list(edges) + self.static_plot = NetworkPlot(self.g, layout="spring") + self.backend = D3jsBackend(self.static_plot, show_labels=True) + + def test_save_creates_html_file(self): + """Test that save creates HTML file.""" + with tempfile.TemporaryDirectory() as tmp_dir: + output_file = Path(tmp_dir) / "test_output.html" + + self.backend.save(str(output_file)) + + # Verify file was created + assert output_file.exists() + + # Verify file contains HTML + content = output_file.read_text() + assert len(content) > 0 + assert " 0 + assert manim_config.pixel_width > 0 + assert manim_config.quality == "high_quality" + + def test_backend_inherits_plot_data_and_config(self): + """Test that backend has access to plot data and config.""" + backend = ManimBackend(self.temp_plot, show_labels=True) + + assert hasattr(backend, "data") + assert hasattr(backend, "config") + assert isinstance(backend.data, dict) + assert isinstance(backend.config, dict) + + +class TestManimBackendRendering: + """Test ManimBackend rendering and file operations.""" + + def setup_method(self): + """Set up test fixtures.""" + tedges = [("a", "b", 1), ("b", "c", 2), ("c", "a", 3)] + self.tg = TemporalGraph.from_edge_list(tedges) + self.temp_plot = TemporalNetworkPlot(self.tg) + + @patch("pathpyG.visualisations._manim.backend.TemporalGraphScene") + def test_render_video_creates_scene(self, mock_scene_class): + """Test that render_video creates and renders TemporalGraphScene.""" + mock_scene = MagicMock() + mock_scene_class.return_value = mock_scene + + backend = ManimBackend(self.temp_plot, show_labels=True) + + with tempfile.TemporaryDirectory() as tmp_dir: + # Mock the prepare_tempfile to use our temp dir + with patch("pathpyG.visualisations._manim.backend.prepare_tempfile") as mock_prepare: + mock_prepare.return_value = (tmp_dir, Path.cwd()) + + backend.render_video() + + # Verify scene was created and rendered + mock_scene_class.assert_called_once() + mock_scene.render.assert_called_once() + + def test_save_mp4_creates_file(self): + """Test that save() creates an MP4 file.""" + backend = ManimBackend(self.temp_plot, show_labels=False) + + with tempfile.TemporaryDirectory() as tmp_dir: + output_file = Path(tmp_dir) / "test_output.mp4" + + # Mock render_video to return a test file + with patch.object(backend, "render_video") as mock_render: + temp_video = Path(tmp_dir) / "temp_video.mp4" + temp_video.write_text("test video content") + temp_subdir = Path(tmp_dir) / "temp" + temp_subdir.mkdir() + mock_render.return_value = (temp_video, temp_subdir) + + backend.save(str(output_file)) + + # Verify file was created + assert output_file.exists() + assert output_file.read_text() == "test video content" + + def test_save_gif_calls_conversion(self): + """Test that save() with .gif extension calls convert_to_gif.""" + backend = ManimBackend(self.temp_plot, show_labels=False) + + with tempfile.TemporaryDirectory() as tmp_dir: + output_file = Path(tmp_dir) / "test_output.gif" + + # Mock render_video and convert_to_gif + with patch.object(backend, "render_video") as mock_render, \ + patch.object(backend, "convert_to_gif") as mock_convert: + temp_video = Path(tmp_dir) / "temp_video.mp4" + temp_video.write_text("test video") + temp_gif = temp_video.with_suffix(".gif") + temp_gif.write_text("test gif") + temp_subdir = Path(tmp_dir) / "temp" + temp_subdir.mkdir() + mock_render.return_value = (temp_video, temp_subdir) + + backend.save(str(output_file)) + + # Verify conversion was called + mock_convert.assert_called_once_with(temp_video) + + @patch("subprocess.run") + def test_convert_to_gif_calls_ffmpeg(self, mock_subprocess): + """Test that convert_to_gif calls ffmpeg with correct arguments.""" + backend = ManimBackend(self.temp_plot, show_labels=False) + + with tempfile.TemporaryDirectory() as tmp_dir: + test_file = Path(tmp_dir) / "test.mp4" + test_file.write_text("video") + + backend.convert_to_gif(test_file) + + # Verify subprocess.run was called + mock_subprocess.assert_called_once() + call_args = mock_subprocess.call_args[0][0] + + # Check ffmpeg command structure + assert call_args[0] == "ffmpeg" + assert "-i" in call_args + assert test_file in call_args + assert test_file.with_suffix(".gif") in call_args + + +class TestManimBackendIntegration: + """Integration tests for ManimBackend with real temporal networks.""" + + def test_backend_with_minimal_temporal_network(self): + """Test backend with minimal two-node temporal network.""" + tedges = [("a", "b", 0)] + tg = TemporalGraph.from_edge_list(tedges) + temp_plot = TemporalNetworkPlot(tg) + + backend = ManimBackend(temp_plot, show_labels=True) + + # Verify backend is properly initialized + assert backend._kind == "temporal" + assert "nodes" in backend.data + assert "edges" in backend.data diff --git a/tests/visualisations/_manim/test_temporal_graph_scene.py b/tests/visualisations/_manim/test_temporal_graph_scene.py new file mode 100644 index 000000000..c20166610 --- /dev/null +++ b/tests/visualisations/_manim/test_temporal_graph_scene.py @@ -0,0 +1,312 @@ +"""Unit tests for TemporalGraphScene in pathpyG.visualisations._manim.""" + +from unittest.mock import MagicMock, patch + +import numpy as np + +from pathpyG.core.temporal_graph import TemporalGraph +from pathpyG.visualisations._manim.temporal_graph_scene import TemporalGraphScene +from pathpyG.visualisations.temporal_network_plot import TemporalNetworkPlot + + +class TestTemporalGraphSceneInitialization: + """Test TemporalGraphScene initialization.""" + + def setup_method(self): + """Set up test fixtures.""" + # Create simple temporal network data + tedges = [("a", "b", 0), ("b", "c", 1), ("c", "a", 2)] + tg = TemporalGraph.from_edge_list(tedges) + self.temp_plot = TemporalNetworkPlot(tg, node_size=10) + + def test_scene_initialization_with_data(self): + """Test that scene initializes with temporal network data.""" + scene = TemporalGraphScene( + data=self.temp_plot.data, + config=self.temp_plot.config, + show_labels=True + ) + + assert scene is not None + assert hasattr(scene, "data") + assert hasattr(scene, "config") + assert scene.show_labels is True + + def test_scene_scales_node_sizes(self): + """Test that scene scales node sizes appropriately.""" + scene = TemporalGraphScene( + data=self.temp_plot.data, + config=self.temp_plot.config, + show_labels=False + ) + + # Verify node size was scaled + assert "radius" in scene.data["nodes"].columns + # Original sizes (10) should be multiplied by 0.025 + for _, node in scene.data["nodes"].iterrows(): + assert node["radius"] == 10 * 0.025 + + def test_scene_renames_node_columns(self): + """Test that scene renames columns for manim compatibility.""" + scene = TemporalGraphScene( + data=self.temp_plot.data, + config=self.temp_plot.config, + show_labels=False + ) + + # Check renamed columns + assert "radius" in scene.data["nodes"].columns + assert "fill_color" in scene.data["nodes"].columns + assert "fill_opacity" in scene.data["nodes"].columns + + def test_scene_renames_edge_columns(self): + """Test that scene renames edge columns for manim compatibility.""" + scene = TemporalGraphScene( + data=self.temp_plot.data, + config=self.temp_plot.config, + show_labels=False + ) + + # Check renamed edge columns + assert "stroke_color" in scene.data["edges"].columns + assert "stroke_opacity" in scene.data["edges"].columns + assert "stroke_width" in scene.data["edges"].columns + + def test_scene_scales_layout_coordinates(self): + """Test that scene scales and centers layout coordinates.""" + # Create plot with explicit layout + tedges = [("a", "b", 0), ("b", "c", 1)] + tg = TemporalGraph.from_edge_list(tedges) + temp_plot = TemporalNetworkPlot(tg, layout="spring") + + scene = TemporalGraphScene( + data=temp_plot.data, + config=temp_plot.config, + show_labels=False + ) + + # Verify layout was moved to center + if "x" in scene.data["nodes"] and "y" in scene.data["nodes"]: + x_coords = scene.data["nodes"]["x"] + assert (x_coords > 0 ).any() + assert (x_coords < 0 ).any() + + y_coords = scene.data["nodes"]["y"] + assert (y_coords > 0 ).any() + assert (y_coords < 0 ).any() + +class TestTemporalGraphSceneBoundaryCalculation: + """Test boundary point calculation for edge attachment.""" + + def setup_method(self): + """Set up test fixtures.""" + tedges = [("a", "b", 0)] + tg = TemporalGraph.from_edge_list(tedges) + self.temp_plot = TemporalNetworkPlot(tg) + self.scene = TemporalGraphScene( + data=self.temp_plot.data, + config=self.temp_plot.config, + show_labels=False + ) + + def test_get_boundary_point_basic(self): + """Test boundary point calculation with simple inputs.""" + center = np.array([0, 0, 0]) + direction = np.array([1, 0, 0]) + radius = 0.5 + + result = self.scene.get_boundary_point(center, direction, radius) + + # Should return point at radius distance in direction + expected = np.array([0.5, 0, 0]) + np.testing.assert_array_almost_equal(result, expected) + + def test_get_boundary_point_diagonal(self): + """Test boundary point calculation with diagonal direction.""" + center = np.array([0, 0, 0]) + direction = np.array([1, 1, 0]) + radius = 1.0 + + result = self.scene.get_boundary_point(center, direction, radius) + + # Should be normalized and scaled by radius + distance = np.linalg.norm(result - center) + assert abs(distance - radius) < 0.001 + + def test_get_boundary_point_zero_direction(self): + """Test boundary point with zero direction vector.""" + center = np.array([1, 2, 3]) + direction = np.array([0, 0, 0]) + radius = 0.5 + + result = self.scene.get_boundary_point(center, direction, radius) + + # Should return center when direction is zero + np.testing.assert_array_equal(result, center) + + def test_get_boundary_point_negative_direction(self): + """Test boundary point with negative direction.""" + center = np.array([0, 0, 0]) + direction = np.array([-2, 0, 0]) + radius = 1.0 + + result = self.scene.get_boundary_point(center, direction, radius) + + # Should point in negative x direction + expected = np.array([-1, 0, 0]) + np.testing.assert_array_almost_equal(result, expected) + + +class TestTemporalGraphSceneConstruction: + """Test scene construction and animation logic.""" + + def setup_method(self): + """Set up test fixtures.""" + tedges = [("a", "b", 0), ("b", "c", 1), ("c", "a", 2)] + tg = TemporalGraph.from_edge_list(tedges) + self.temp_plot = TemporalNetworkPlot(tg) + + @patch("pathpyG.visualisations._manim.temporal_graph_scene.Create") + @patch("pathpyG.visualisations._manim.temporal_graph_scene.Transform") + @patch("pathpyG.visualisations._manim.temporal_graph_scene.GrowArrow") + def test_construct_creates_initial_nodes(self, mock_grow, mock_transform, mock_create): + """Test that construct creates initial nodes.""" + scene = TemporalGraphScene( + data=self.temp_plot.data, + config=self.temp_plot.config, + show_labels=False + ) + + # Mock the scene methods + scene.play = MagicMock() + scene.wait = MagicMock() + + scene.construct() + + # Verify Create was called for initial nodes + assert mock_create.called + + @patch("pathpyG.visualisations._manim.temporal_graph_scene.Text") + @patch("pathpyG.visualisations._manim.temporal_graph_scene.Transform") + def test_construct_updates_time_display(self, mock_transform, mock_text): + """Test that construct updates time display.""" + scene = TemporalGraphScene( + data=self.temp_plot.data, + config=self.temp_plot.config, + show_labels=False + ) + + # Mock scene methods + scene.play = MagicMock() + scene.wait = MagicMock() + + scene.construct() + + # Verify Text was called for time display + assert mock_text.called + # Check that time text was created + call_args = [call[0][0] if call[0] else "" for call in mock_text.call_args_list] + assert any("Time:" in arg for arg in call_args) + + @patch("pathpyG.visualisations._manim.temporal_graph_scene.LabeledDot") + @patch("pathpyG.visualisations._manim.temporal_graph_scene.Dot") + @patch("pathpyG.visualisations._manim.temporal_graph_scene.Transform") + @patch("pathpyG.visualisations._manim.temporal_graph_scene.Create") + @patch("pathpyG.visualisations._manim.temporal_graph_scene.Arrow") + @patch("pathpyG.visualisations._manim.temporal_graph_scene.GrowArrow") + def test_construct_with_labels(self, mock_grow, mock_arrow, mock_create, mock_transform, mock_dot, mock_labeled_dot): + """Test construct with and without node labels.""" + scene = TemporalGraphScene( + data=self.temp_plot.data, + config=self.temp_plot.config, + show_labels=True + ) + + # Mock scene methods to prevent actual rendering + scene.play = MagicMock() + scene.wait = MagicMock() + scene.get_boundary_point = MagicMock() + + # Should not raise any errors + scene.construct() + + # Verify that LabeledDots were created for each node + assert mock_labeled_dot.call_count == len(scene.data["nodes"]) + assert mock_dot.call_count == 0 + + # Construct without labels + scene = TemporalGraphScene( + data=self.temp_plot.data, + config=self.temp_plot.config, + show_labels=False + ) + + # Mock scene methods to prevent actual rendering + scene.play = MagicMock() + scene.wait = MagicMock() + scene.get_boundary_point = MagicMock() + + # Should not raise any errors + scene.construct() + + # Verify that Dots were created for each node + assert mock_dot.call_count == len(scene.data["nodes"]) + + def test_construct_with_empty_network(self): + """Test construct with network that has no edges at t=0.""" + # Create temporal network with edges starting later + tedges = [("a", "b", 5), ("b", "c", 10)] + tg = TemporalGraph.from_edge_list(tedges) + temp_plot = TemporalNetworkPlot(tg) + + scene = TemporalGraphScene( + data=temp_plot.data, + config=temp_plot.config, + show_labels=False + ) + + # Mock scene methods + scene.play = MagicMock() + scene.wait = MagicMock() + + # Should handle empty initial state + scene.construct() + + +class TestTemporalGraphSceneEdgeCases: + """Test edge cases and error handling in TemporalGraphScene.""" + + def test_scene_handles_duplicate_edges(self): + """Test that scene handles duplicate edges gracefully.""" + # Create data with duplicate edges + tedges = [("a", "b", 1), ("a", "b", 1), ("b", "c", 2)] + tg = TemporalGraph.from_edge_list(tedges) + temp_plot = TemporalNetworkPlot(tg) + + scene = TemporalGraphScene( + data=temp_plot.data, + config=temp_plot.config, + show_labels=False + ) + + # Mock scene methods + scene.play = MagicMock() + scene.wait = MagicMock() + + # Should handle duplicates without crashing + scene.construct() + + def test_scene_with_custom_delta(self): + """Test scene with custom delta parameter.""" + tedges = [("a", "b", 0), ("b", "c", 1)] + tg = TemporalGraph.from_edge_list(tedges) + temp_plot = TemporalNetworkPlot(tg, delta=500) # Custom delta + + scene = TemporalGraphScene( + data=temp_plot.data, + config=temp_plot.config, + show_labels=False + ) + + # Verify config was set + assert scene.config["delta"] == 500 diff --git a/tests/visualisations/_matplotlib/__init__.py b/tests/visualisations/_matplotlib/__init__.py new file mode 100644 index 000000000..7554407aa --- /dev/null +++ b/tests/visualisations/_matplotlib/__init__.py @@ -0,0 +1,5 @@ +"""Necessary to make Python treat the tests directory as a module. + +This is required since mypy doesn't support the same file name otherwise +It is also required to enable module specific overrides in pyproject.toml +""" diff --git a/tests/visualisations/_matplotlib/test_backend.py b/tests/visualisations/_matplotlib/test_backend.py new file mode 100644 index 000000000..5e17019b4 --- /dev/null +++ b/tests/visualisations/_matplotlib/test_backend.py @@ -0,0 +1,303 @@ +"""Unit tests for Matplotlib backend in pathpyG.visualisations.""" + +import tempfile +from pathlib import Path +from unittest.mock import patch + +import matplotlib.pyplot as plt +import numpy as np +import pytest + +from pathpyG.core.graph import Graph +from pathpyG.core.temporal_graph import TemporalGraph +from pathpyG.visualisations._matplotlib.backend import MatplotlibBackend +from pathpyG.visualisations.network_plot import NetworkPlot +from pathpyG.visualisations.temporal_network_plot import TemporalNetworkPlot + + +class TestMatplotlibBackendInitialization: + """Test MatplotlibBackend initialization and configuration.""" + + def setup_method(self): + """Set up test fixtures.""" + # Create a simple graph + edges = [("a", "b"), ("b", "c"), ("c", "a")] + self.g = Graph.from_edge_list(edges) + self.plot = NetworkPlot(self.g) + + def test_backend_initialization_with_network_plot(self): + """Test that MatplotlibBackend initializes with NetworkPlot.""" + backend = MatplotlibBackend(self.plot, show_labels=True) + assert backend is not None + assert backend._kind == "static" + assert backend.show_labels is True + + def test_backend_initialization_with_temporal_plot_raises(self): + """Test that MatplotlibBackend raises error with temporal plot.""" + tedges = [("a", "b", 1), ("b", "c", 2)] + tg = TemporalGraph.from_edge_list(tedges) + temp_plot = TemporalNetworkPlot(tg) + + with pytest.raises(ValueError, match="not supported"): + MatplotlibBackend(temp_plot, show_labels=False) + + def test_backend_inherits_plot_data_and_config(self): + """Test that backend has access to plot data and config.""" + backend = MatplotlibBackend(self.plot, show_labels=True) + + assert hasattr(backend, "data") + assert hasattr(backend, "config") + assert isinstance(backend.data, dict) + assert isinstance(backend.config, dict) + assert "nodes" in backend.data + assert "edges" in backend.data + + def test_backend_initialization_with_directed_graph(self): + """Test backend initialization with directed graph.""" + # Default is directed + backend = MatplotlibBackend(self.plot, show_labels=False) + assert backend.config["directed"] is True + + def test_backend_initialization_with_undirected_graph(self): + """Test backend initialization with undirected graph.""" + g_undirected = self.g.to_undirected() + plot_undirected = NetworkPlot(g_undirected) + backend = MatplotlibBackend(plot_undirected, show_labels=False) + assert backend.config["directed"] is False + + +class TestMatplotlibBackendFigureCreation: + """Test MatplotlibBackend figure and axes creation.""" + + def setup_method(self): + """Set up test fixtures.""" + edges = [("a", "b"), ("b", "c"), ("c", "a")] + self.g = Graph.from_edge_list(edges) + self.plot = NetworkPlot(self.g, layout="spring") + + def test_to_fig_returns_figure_and_axes(self): + """Test that to_fig() returns matplotlib Figure and Axes.""" + backend = MatplotlibBackend(self.plot, show_labels=False) + fig, ax = backend.to_fig() + + assert isinstance(fig, plt.Figure) + assert isinstance(ax, plt.Axes) + + def test_to_fig_turns_off_axis(self): + """Test that axis frame is turned off.""" + backend = MatplotlibBackend(self.plot, show_labels=False) + fig, ax = backend.to_fig() + + # Axis should be off (frame invisible) + assert not ax.axison + + def test_to_fig_adds_collections(self): + """Test that figure contains collections for nodes and edges.""" + backend = MatplotlibBackend(self.plot, show_labels=False) + fig, ax = backend.to_fig() + + # Should have collections (edges and nodes) + collections = ax.collections + assert len(collections) > 0 + + def test_to_fig_with_labels_adds_annotations(self): + """Test that labels are added when show_labels=True.""" + backend = MatplotlibBackend(self.plot, show_labels=True) + fig, ax = backend.to_fig() + + # Should have text annotations for node labels + texts = ax.texts + assert len(texts) > 0 + # Should have one label per node + assert len(texts) == len(self.plot.data["nodes"]) + + def test_to_fig_without_labels_has_no_annotations(self): + """Test that no labels are added when show_labels=False.""" + backend = MatplotlibBackend(self.plot, show_labels=False) + fig, ax = backend.to_fig() + + # Should have no text annotations + texts = ax.texts + assert len(texts) == 0 + + +class TestMatplotlibBackendEdgeRendering: + """Test edge rendering for undirected and directed graphs.""" + + def setup_method(self): + """Set up test fixtures.""" + edges = [("a", "b"), ("b", "c")] + self.g = Graph.from_edge_list(edges) + + def test_undirected_edges_use_line_collection(self): + """Test that undirected graphs use LineCollection.""" + g_undirected = self.g.to_undirected() + plot = NetworkPlot(g_undirected, layout="spring") + backend = MatplotlibBackend(plot, show_labels=False) + + fig, ax = backend.to_fig() + + # Should contain LineCollection for undirected edges + from matplotlib.collections import LineCollection + line_collections = [c for c in ax.collections if isinstance(c, LineCollection)] + assert len(line_collections) > 0 + + def test_directed_edges_use_path_collection(self): + """Test that directed graphs use PathCollection.""" + plot = NetworkPlot(self.g, layout="spring") + backend = MatplotlibBackend(plot, show_labels=False) + + fig, ax = backend.to_fig() + + # Should contain PathCollection for directed edges + from matplotlib.collections import PathCollection + path_collections = [c for c in ax.collections if isinstance(c, PathCollection)] + assert len(path_collections) > 0 + + def test_directed_edges_have_arrowheads(self): + """Test that directed edges have arrowhead collections.""" + plot = NetworkPlot(self.g, layout="spring") + backend = MatplotlibBackend(plot, show_labels=False) + + fig, ax = backend.to_fig() + + # Directed graphs should have multiple PathCollections (edges + arrowheads) + from matplotlib.collections import PathCollection + path_collections = [c for c in ax.collections if isinstance(c, PathCollection)] + # Should have exactly 2 (edge paths and arrowheads) + assert len(path_collections) == 2 + + +class TestMatplotlibBackendBezierCurves: + """Test Bezier curve generation for directed edges.""" + + def setup_method(self): + """Set up test fixtures.""" + edges = [("a", "b"), ("b", "c")] + self.g = Graph.from_edge_list(edges) + self.plot = NetworkPlot(self.g, layout="spring") + self.backend = MatplotlibBackend(self.plot, show_labels=False) + + def test_get_bezier_curve_returns_vertices_and_codes(self): + """Test that get_bezier_curve returns proper format.""" + source_coords = np.array([[0, 0], [1, 1]], dtype=float) + target_coords = np.array([[1, 0], [2, 1]], dtype=float) + source_size = np.array([[0.1], [0.1]]) + target_size = np.array([[0.1], [0.1]]) + + vertices, codes = self.backend.get_bezier_curve( + source_coords, target_coords, source_size, target_size, head_length=0.02 + ) + + assert len(vertices) == 3 + assert len(codes) == 3 + + def test_get_bezier_curve_handles_zero_distance(self): + """Test that Bezier curve handles zero-length edges and fall back to straight lines.""" + source_coords = np.array([[0, 0], [0, 1]], dtype=float) + target_coords = np.array([[0, 0], [0, 0.99]], dtype=float) # Same point + source_size = np.array([[0.05], [0.05]]) + target_size = np.array([[0.05], [0.05]]) + + # Should not crash + vertices, codes = self.backend.get_bezier_curve( + source_coords, target_coords, source_size, target_size, head_length=0.02 + ) + + assert len(vertices) == 2 + assert len(codes) == 2 + + +class TestMatplotlibBackendArrowheads: + """Test arrowhead generation for directed edges.""" + + def setup_method(self): + """Set up test fixtures.""" + edges = [("a", "b"), ("b", "c")] + self.g = Graph.from_edge_list(edges) + self.plot = NetworkPlot(self.g, layout="spring") + self.backend = MatplotlibBackend(self.plot, show_labels=False) + + def test_get_arrowhead_returns_vertices_and_codes(self): + """Test that get_arrowhead returns proper format.""" + # Create simple Bezier curve vertices + P0 = np.array([[0, 0], [0, 0]], dtype=float) + P1 = np.array([[0.5, 0.5], [0.5, 0.5]], dtype=float) + P2 = np.array([[1, 0], [1, 0]], dtype=float) + vertices = [P0, P1, P2] + + arrow_vertices, arrow_codes = self.backend.get_arrowhead(vertices) + + assert len(arrow_vertices) == 4 + assert len(arrow_codes) == 4 + + def test_get_arrowhead_scales_with_edge_size(self): + """Test that arrowhead size scales with edge width.""" + P0 = np.array([[0, 0], [0, 0]]) + P1 = np.array([[0.5, 0.5], [0.5, 0.5]]) + P2 = np.array([[1, 0], [1, 0]]) + vertices = [P0, P1, P2] + + # Set different edge sizes + self.backend.data["edges"]["size"] = np.array([1.0, 5.0]) + + arrow_vertices, arrow_codes = self.backend.get_arrowhead(vertices) + + # First arrowhead vertices + arrow1_vertices = [v[0] for v in arrow_vertices] + arrow2_vertices = [v[1] for v in arrow_vertices] + + # Arrowheads should have different sizes + # Calculate width of each arrowhead + width1 = np.linalg.norm(arrow1_vertices[0] - arrow1_vertices[2]) + width2 = np.linalg.norm(arrow2_vertices[0] - arrow2_vertices[2]) + + # Second arrowhead should be larger + assert width2 > width1 + + +class TestMatplotlibBackendFileOperations: + """Test file saving and display operations.""" + + def setup_method(self): + """Set up test fixtures.""" + edges = [("a", "b"), ("b", "c")] + self.g = Graph.from_edge_list(edges) + self.plot = NetworkPlot(self.g, layout="spring") + + def test_save_creates_file(self): + """Test that save() creates a file.""" + backend = MatplotlibBackend(self.plot, show_labels=False) + + with tempfile.TemporaryDirectory() as tmp_dir: + output_file = Path(tmp_dir) / "test_plot.png" + backend.save(str(output_file)) + + # File should exist + assert output_file.exists() + # File should have content + assert output_file.stat().st_size > 0 + + def test_save_supports_multiple_formats(self): + """Test that save() works with different file formats.""" + backend = MatplotlibBackend(self.plot, show_labels=False) + + formats = ["png", "jpg"] + + with tempfile.TemporaryDirectory() as tmp_dir: + for fmt in formats: + output_file = Path(tmp_dir) / f"test_plot.{fmt}" + backend.save(str(output_file)) + + # File should exist + assert output_file.exists() + assert output_file.stat().st_size > 0 + + @patch("matplotlib.pyplot.show") + def test_show_calls_plt_show(self, mock_show): + """Test that show() calls matplotlib's show function.""" + backend = MatplotlibBackend(self.plot, show_labels=False) + backend.show() + + # plt.show() should have been called + mock_show.assert_called_once() diff --git a/tests/visualisations/_tikz/__init__.py b/tests/visualisations/_tikz/__init__.py new file mode 100644 index 000000000..7554407aa --- /dev/null +++ b/tests/visualisations/_tikz/__init__.py @@ -0,0 +1,5 @@ +"""Necessary to make Python treat the tests directory as a module. + +This is required since mypy doesn't support the same file name otherwise +It is also required to enable module specific overrides in pyproject.toml +""" diff --git a/tests/visualisations/_tikz/test_backend.py b/tests/visualisations/_tikz/test_backend.py new file mode 100644 index 000000000..87ba8b112 --- /dev/null +++ b/tests/visualisations/_tikz/test_backend.py @@ -0,0 +1,256 @@ +"""Unit tests for TikZ backend in pathpyG.visualisations.""" + +import os +import shutil +import tempfile + +import pytest +import torch + +from pathpyG.core.graph import Graph +from pathpyG.visualisations._tikz.backend import TikzBackend +from pathpyG.visualisations.network_plot import NetworkPlot +from pathpyG.visualisations.temporal_network_plot import TemporalNetworkPlot + + +class TestTikzBackendInitialization: + """Test TikZ backend initialization and validation.""" + + def setup_method(self): + """Create test graph and plot.""" + self.g = Graph.from_edge_list([("a", "b"), ("b", "c"), ("c", "a")]) + self.plot = NetworkPlot(self.g, layout="spring") + + def test_backend_accepts_network_plot(self): + """Test that TikZ backend initializes with NetworkPlot.""" + backend = TikzBackend(self.plot, show_labels=True) + assert backend.data is self.plot.data + assert backend.config is self.plot.config + assert backend.show_labels is True + assert backend._kind == "static" + + def test_backend_with_labels_disabled(self): + """Test initialization with labels disabled.""" + backend = TikzBackend(self.plot, show_labels=False) + assert backend.show_labels is False + + def test_backend_rejects_temporal_network_plot(self): + """Test that TikZ backend rejects TemporalNetworkPlot.""" + from pathpyG.core.temporal_graph import TemporalGraph + + tg = TemporalGraph.from_edge_list( + [("a", "b", 1), ("b", "c", 2), ("c", "a", 3)] + ) + temporal_plot = TemporalNetworkPlot(tg) + + with pytest.raises(ValueError, match="not supported"): + TikzBackend(temporal_plot, show_labels=True) + + +class TestTikzBackendTexGeneration: + """Test TeX and TikZ code generation.""" + + def setup_method(self): + """Create test graph and backend.""" + self.g = Graph.from_edge_list([("a", "b"), ("b", "c")]) + self.plot = NetworkPlot(self.g, layout="spring") + self.backend = TikzBackend(self.plot, show_labels=True) + + def test_to_tikz_generates_node_commands(self): + """Test that to_tikz generates Vertex commands for nodes.""" + tikz_code = self.backend.to_tikz() + assert "\\Vertex[" in tikz_code + # Should have commands for all three nodes + assert tikz_code.count("\\Vertex[") == 3 + + def test_to_tikz_generates_edge_commands(self): + """Test that to_tikz generates Edge commands.""" + tikz_code = self.backend.to_tikz() + assert "\\Edge[" in tikz_code + assert tikz_code.count("\\Edge[") == 2 + + def test_to_tikz_includes_node_labels_when_enabled(self): + """Test that node labels appear when show_labels=True.""" + backend = TikzBackend(self.plot, show_labels=True) + tikz_code = backend.to_tikz() + assert "label=" in tikz_code + # Node names should be in labels + assert "a" in tikz_code + assert "b" in tikz_code + assert "c" in tikz_code + + def test_to_tikz_excludes_labels_when_disabled(self): + """Test that labels are excluded when show_labels=False.""" + backend = TikzBackend(self.plot, show_labels=False) + tikz_code = backend.to_tikz() + assert "label=" not in tikz_code + + def test_to_tikz_includes_color_styling(self): + """Test that color information is included.""" + # Node color + plot = NetworkPlot(self.g, node_color="#ff0000") + backend = TikzBackend(plot, show_labels=False) + tikz_code = backend.to_tikz() + assert "color=" in tikz_code or "RGB" in tikz_code + + # Edge color + plot = NetworkPlot(self.g, edge_color="#0000ff") + backend = TikzBackend(plot, show_labels=False) + tikz_code = backend.to_tikz() + assert "color=" in tikz_code or "RGB" in tikz_code + + def test_to_tikz_includes_size_information(self): + """Test that size information is included.""" + tikz_code = self.backend.to_tikz() + assert "size=" in tikz_code + + def test_to_tikz_includes_opacity(self): + """Test that opacity information is included.""" + plot = NetworkPlot(self.g, node_opacity=0.03, edge_opacity=0.05) + backend = TikzBackend(plot, show_labels=False) + tikz_code = backend.to_tikz() + assert "opacity=" in tikz_code + assert "0.03" in tikz_code + assert "0.05" in tikz_code + + def test_to_tikz_includes_position_coordinates(self): + """Test that x,y coordinates are included.""" + tikz_code = self.backend.to_tikz() + assert "x=" in tikz_code + assert "y=" in tikz_code + + def test_to_tikz_directed_graph_includes_bend(self): + """Test that directed graphs have bend/Direct options.""" + g_directed = Graph.from_edge_list([("a", "b"), ("b", "c")]) + plot_directed = NetworkPlot(g_directed, layout="spring") + backend = TikzBackend(plot_directed, show_labels=False) + tikz_code = backend.to_tikz() + assert "bend=" in tikz_code or "Direct" in tikz_code + + def test_to_tex_generates_complete_document(self): + """Test that to_tex generates a complete LaTeX document.""" + tex_code = self.backend.to_tex() + assert "\\documentclass" in tex_code + assert "\\begin{document}" in tex_code + assert "\\end{document}" in tex_code + assert "\\Vertex[" in tex_code + assert "\\Edge[" in tex_code + + def test_to_tex_includes_tikz_network_package(self): + """Test that the template includes tikz-network package.""" + tex_code = self.backend.to_tex() + assert "tikz" in tex_code.lower() + + def test_to_tex_includes_dimensions(self): + """Test that document dimensions are included.""" + tex_code = self.backend.to_tex() + # Should have width/height specifications + assert "width" in tex_code.lower() + assert "height" in tex_code.lower() + + +class TestTikzBackendSaveOperation: + """Test save functionality for different formats.""" + + def setup_method(self): + """Create test graph, plot, and temporary directory.""" + self.g = Graph.from_edge_list([("a", "b"), ("b", "c")]) + self.plot = NetworkPlot(self.g, layout="spring") + self.backend = TikzBackend(self.plot, show_labels=True) + self.temp_dir = tempfile.mkdtemp() + + def teardown_method(self): + """Clean up temporary directory.""" + if os.path.exists(self.temp_dir): + shutil.rmtree(self.temp_dir) + + def test_save_tex_file(self): + """Test saving to .tex format.""" + filepath = os.path.join(self.temp_dir, "test.tex") + self.backend.save(filepath) + assert os.path.exists(filepath) + + # Verify file content + with open(filepath, "r") as f: + content = f.read() + assert "\\documentclass" in content + assert "\\Vertex[" in content + + def test_save_unsupported_format_raises(self): + """Test that unsupported formats raise NotImplementedError.""" + filepath = os.path.join(self.temp_dir, "test.png") + with pytest.raises(NotImplementedError): + self.backend.save(filepath) + + +class TestTikzBackendLatexSymbolReplacement: + """Test LaTeX symbol replacement in node labels.""" + + def setup_method(self): + """Create backend for testing.""" + g = Graph.from_edge_list([("a", "b"), ("b", "c")]) + plot = NetworkPlot(g, layout="spring") + self.backend = TikzBackend(plot, show_labels=True) + + def test_arrow_symbol_replacement(self): + """Test that arrow symbols are replaced with LaTeX commands.""" + self.backend.config["separator"] = "->" + result = self.backend._replace_with_LaTeX_math_symbol("a->b") + assert "\\to" in result + + def test_double_arrow_symbol_replacement(self): + """Test double arrow replacement.""" + self.backend.config["separator"] = "=>" + result = self.backend._replace_with_LaTeX_math_symbol("a=>b") + assert "\\Rightarrow" in result + + def test_bidirectional_arrow_replacement(self): + """Test bidirectional arrow replacement.""" + self.backend.config["separator"] = "<->" + result = self.backend._replace_with_LaTeX_math_symbol("a<->b") + assert "\\leftrightarrow" in result + + def test_inequality_symbol_replacement(self): + """Test inequality symbol replacement.""" + self.backend.config["separator"] = "!=" + result = self.backend._replace_with_LaTeX_math_symbol("a!=b") + assert "\\neq" in result + + def test_no_replacement_for_regular_labels(self): + """Test that regular labels are unchanged.""" + result = self.backend._replace_with_LaTeX_math_symbol("node_123") + assert result == "node_123" + + +class TestTikzBackendEdgeCases: + """Test edge cases and error handling.""" + + def test_empty_graph(self): + """Test backend with an empty graph.""" + g = Graph.from_edge_list([]) + plot = NetworkPlot(g, layout=None) + backend = TikzBackend(plot, show_labels=False) + tikz_code = backend.to_tikz() + # Should generate valid TikZ even if empty + assert isinstance(tikz_code, str) + + def test_higher_order_network_with_separators(self): + """Test higher-order networks with custom separators.""" + from pathpyG.core.index_map import IndexMap + from pathpyG.core.multi_order_model import MultiOrderModel + from pathpyG.core.path_data import PathData + + paths = PathData(IndexMap(["a", "b", "c", "d"])) + paths.append_walks( + [["a", "b", "c"], ["b", "c", "d"], ["a", "b", "d"]], weights=[1, 1, 1] + ) + ho_g = MultiOrderModel.from_path_data(paths, max_order=2).layers[2] + + plot = NetworkPlot(ho_g, layout="spring") + backend = TikzBackend(plot, show_labels=True) + tikz_code = backend.to_tikz() + + # Should generate valid TikZ for higher-order nodes + assert "\\Vertex[" in tikz_code + assert isinstance(tikz_code, str) + assert "\\to" in tikz_code diff --git a/tests/visualisations/test_manim.py b/tests/visualisations/test_manim.py deleted file mode 100644 index e6ff267f0..000000000 --- a/tests/visualisations/test_manim.py +++ /dev/null @@ -1,333 +0,0 @@ -# import unittest -# from unittest.mock import patch, MagicMock -# import tempfile -# import subprocess -# from pathlib import Path -# from manim import Scene, manim_colors, Graph, tempconfig -# import numpy as np -# import pathpyG as pp - -# import pathpyG.visualisations._manim.core as core -# from pathpyG.visualisations._manim.network_plots import NetworkPlot -# from pathpyG.visualisations._manim.network_plots import TemporalNetworkPlot - - -# class ManimTest(unittest.TestCase): -# """Test class for manim visualizations""" -# def setUp(self): -# """setting up patch for manim.config and example input data""" -# patcher = patch("manim.config") -# self.mock_config = patcher.start() -# self.addCleanup(patcher.stop) - -# self.mock_config.media_dir = None # initializing with default values -# self.mock_config.output_file = None -# self.mock_config.pixel_height = 0 -# self.mock_config.pixel_width = 0 -# self.mock_config.frame_rate = 0 -# self.mock_config.quality = "" -# self.mock_config.background_color = None - -# self.data = { # example input data -# "nodes": [0, 1, 2, 3], -# "edges": [{"source": 0, "target": 2, "start": 0}, -# {"source": 1, "target": 2, "start": 1}, -# {"source": 2, "target": 0, "start": 2}, -# {"source": 1, "target": 3, "start": 3}, -# {"source": 3, "target": 2, "start": 4}, -# {"source": 2, "target": 1, "start": 5}] -# } - -# self.data3 = { -# "nodes": [{"uid": "A", "label": "Alpha"}], -# "edges": [{"source": "A", "target": "A", "start": 0, "end": 1}], -# } - -# def test_manim_network_plot(self): -# """Test for initializing the NetworkPlot class""" -# networkplot = NetworkPlot(self.data) - -# self.assertIsInstance(networkplot, NetworkPlot) -# self.assertIsInstance(networkplot.data, dict) -# self.assertIsInstance(networkplot.config, dict) -# self.assertIsInstance(networkplot.raw_data, dict) - -# self.assertEqual(networkplot.data, {}) -# self.assertEqual(networkplot.raw_data, self.data) - -# def test_manim_network_plot_empty(self): -# """Test for initializing the NetworkPlot class with empty input data""" -# data = {} -# networkplot = NetworkPlot(data) - -# self.assertIsInstance(networkplot, NetworkPlot) -# self.assertIsInstance(networkplot.data, dict) -# self.assertIsInstance(networkplot.config, dict) -# self.assertIsInstance(networkplot.raw_data, dict) - -# self.assertEqual(networkplot.data, {}) -# self.assertEqual(networkplot.raw_data, {}) - -# def test_manim_temporal_network_plot(self): -# """Test for initializing the TemporalNetworkPlot class""" -# kwargs = { -# "delta": 2000, -# "start": 100, -# "end": 10000, -# "intervals": 30, -# "dynamic_layout_interval": 10, -# "background_color": "#3A1F8C", -# "font_size": 12, - -# "node_opacity": 0.6, -# "node_size": 5.2, -# "node_label": {0: 'x', 1: "A", 2: ">", 3: "x"}, -# "node_color": (255, 0, 0), -# "node_color_timed": [(0, (1, 0.5)), (1, (2, 0.2))], - -# "edge_opacity": 0.75, -# "edge_size": 4.0, -# "edge_color": ['blue', 'pink'] -# } -# temp_network_plot = TemporalNetworkPlot(self.data, **kwargs) - -# self.assertIsInstance(temp_network_plot, TemporalNetworkPlot) -# self.assertIsInstance(temp_network_plot, NetworkPlot) -# self.assertIsInstance(temp_network_plot, Scene) - -# self.assertIsInstance(temp_network_plot.data, dict) -# self.assertIsInstance(temp_network_plot.config, dict) -# self.assertIsInstance(temp_network_plot.raw_data, dict) - -# self.assertEqual(temp_network_plot.delta, 2000) -# self.assertEqual(temp_network_plot.start, 100) -# self.assertEqual(temp_network_plot.end, 10000) -# self.assertEqual(temp_network_plot.intervals, 30) -# self.assertEqual(temp_network_plot.dynamic_layout_interval, 10) -# self.assertEqual(temp_network_plot.config.get("background_color"), "#3A1F8C") -# self.assertEqual(temp_network_plot.config.get("font_size"), 12) - -# self.assertEqual(temp_network_plot.config.get("node_opacity"), 0.6) -# self.assertEqual(temp_network_plot.config.get("node_size"), 5.2) -# self.assertEqual(temp_network_plot.config.get("node_label"), {0: 'x', 1: "A", 2: ">", 3: "x"}) -# self.assertEqual(temp_network_plot.config.get("node_color"), (255, 0, 0)) -# self.assertEqual(temp_network_plot.config.get("node_color_timed"), [(0, (1, 0.5)), (1, (2, 0.2))]) - -# self.assertEqual(temp_network_plot.config.get("edge_opacity"), 0.75) -# self.assertEqual(temp_network_plot.config.get("edge_size"), 4.0) -# self.assertEqual(temp_network_plot.config.get("edge_color"), ['blue', 'pink']) - -# def test_manim_temporal_network_plot_empty(self): -# """Test for initializing the TemporalNetworkPlot class with empty input data""" -# data = {} -# tempnetworkplot = TemporalNetworkPlot(data) - -# self.assertIsInstance(tempnetworkplot, TemporalNetworkPlot) -# self.assertIsInstance(tempnetworkplot, NetworkPlot) -# self.assertIsInstance(tempnetworkplot, Scene) - -# self.assertIsInstance(tempnetworkplot.data, dict) -# self.assertIsInstance(tempnetworkplot.config, dict) -# self.assertIsInstance(tempnetworkplot.raw_data, dict) - -# self.assertEqual(tempnetworkplot.delta, 1000) -# self.assertEqual(tempnetworkplot.start, 0) -# self.assertEqual(tempnetworkplot.end, None) -# self.assertEqual(tempnetworkplot.intervals, None) -# self.assertEqual(tempnetworkplot.dynamic_layout_interval, None) -# self.assertEqual(tempnetworkplot.font_size, 8) - -# self.assertEqual(tempnetworkplot.node_opacity, 1) -# self.assertEqual(tempnetworkplot.edge_opacity, 1) -# self.assertEqual(tempnetworkplot.node_size, 0.4) -# self.assertEqual(tempnetworkplot.edge_size, 0.4) -# self.assertEqual(tempnetworkplot.node_label, {})# - -# def test_manim_temp_np_mock_config(self): -# """Test for the TemporalNetworkPlot class""" -# with tempfile.TemporaryDirectory() as temp_dir: -# output_dir = Path(temp_dir) -# output_file = "test_output.mp4" - -# _ = TemporalNetworkPlot(self.data, output_dir=output_dir, output_file=output_file) - -# self.assertEqual(Path(self.mock_config.media_dir).resolve(), output_dir.resolve()) -# self.assertEqual(self.mock_config.output_file, output_file) - -# self.assertEqual(self.mock_config.pixel_height, 1080) -# self.assertEqual(self.mock_config.pixel_width, 1920) -# self.assertEqual(self.mock_config.frame_rate, 15) -# self.assertEqual(self.mock_config.quality, "high_quality") -# self.assertEqual(self.mock_config.background_color, manim_colors.WHITE) - -# def test_manim_temp_np_path(self): -# """Test for the TemporalNetworkPlot class""" -# with tempfile.TemporaryDirectory() as temp_dir: -# output_dir = Path(temp_dir) -# output_file = "test_output.mp4" - -# _ = TemporalNetworkPlot(self.data, output_dir=output_dir, output_file=output_file) - -# from manim import config as manim_config - -# self.assertEqual(Path(manim_config.media_dir).resolve(), output_dir.resolve()) -# self.assertEqual(manim_config.output_file, output_file) - -# def test_manim_temp_np_edge_index(self): -# """Test for the method compute_edge_index in the TemporalNetworkPlot class""" -# edgelist = [(0, 2, 0), (1, 2, 1), (2, 0, 2), (1, 3, 3), (3, 2, 4), (2, 1, 5)] -# temp_network_plot = TemporalNetworkPlot(self.data) - -# self.assertEqual(temp_network_plot.compute_edge_index()[0], edgelist) -# self.assertEqual(temp_network_plot.compute_edge_index()[1], 5) - -# def test_manim_temp_np_layout(self): -# """"Test for the method get_layout in the TemporalNetworkPlot class""" -# edgelist = [(0, 2, 0), (1, 2, 1), (2, 0, 2), (1, 3, 3), (3, 2, 4), (2, 1, 5)] -# nodes = {0, 1, 2, 3} -# graph = pp.TemporalGraph.from_edge_list(edgelist) -# temp_network_plot = TemporalNetworkPlot(self.data) -# old_layout = {node: [0.0, 0.0] for node in graph.nodes} - -# layout = temp_network_plot.get_layout(graph, old_layout=old_layout) - -# self.assertIsInstance(layout, dict) -# self.assertEqual(layout.keys(), nodes) - -# for coordinate in layout.values(): -# self.assertIsInstance(coordinate, np.ndarray) -# self.assertEqual(coordinate.shape, (3,)) - -# def test_manim_temp_np_get_color_at_time(self): -# """Test for the method get_color_at_time int the TemporalNetworkPlot class""" -# temp_network_plot = TemporalNetworkPlot(self.data) -# node_data = {"color": manim_colors.RED} -# self.assertEqual(temp_network_plot.get_color_at_time(node_data, 0), manim_colors.RED) -# self.assertEqual(temp_network_plot.get_color_at_time({}, 0), manim_colors.BLUE) - -# node_data_2 = { -# "color": manim_colors.PURPLE, -# "color_change": [ -# {"time": 5, "color": manim_colors.TEAL}, -# {"time": 10, "color": manim_colors.GREEN}, -# ] -# } -# self.assertEqual(temp_network_plot.get_color_at_time(node_data_2, 1), manim_colors.PURPLE) -# self.assertEqual(temp_network_plot.get_color_at_time(node_data_2, 5), manim_colors.TEAL) -# self.assertEqual(temp_network_plot.get_color_at_time(node_data_2, 7), manim_colors.TEAL) -# self.assertEqual(temp_network_plot.get_color_at_time(node_data_2, 10), manim_colors.GREEN) -# self.assertEqual(temp_network_plot.get_color_at_time(node_data_2, 11), manim_colors.GREEN) - -# @patch.object(TemporalNetworkPlot, "compute_edge_index") -# @patch.object(TemporalNetworkPlot, "get_layout") -# @patch.object(TemporalNetworkPlot, "get_color_at_time") -# def test_manim_temp_np_construct(self, mock_color, mock_layout, mock_edge_index): -# """Test for the construct method from the TemporalNetworkPlot class""" -# with tempfile.TemporaryDirectory() as tmp_path: -# with tempconfig({ -# "disable_caching": True, -# "dry_run": True, -# "disable_output": True, -# "media_dir": str(tmp_path), -# }): -# mock_edge_index.return_value = ([("A", "A", 0)], 1) -# mock_layout.return_value = {"A": np.array([0, 0, 0])} -# mock_color.return_value = (0, 0, 1) - -# temp_network_plot = TemporalNetworkPlot(self.data3) - -# temp_network_plot.add = MagicMock() -# temp_network_plot.play = MagicMock() -# temp_network_plot.wait = MagicMock() -# temp_network_plot.remove = MagicMock() - -# temp_network_plot.construct() - -# self.assertTrue(temp_network_plot.add.called) -# self.assertTrue(temp_network_plot.wait.called) -# self.assertTrue(temp_network_plot.remove.called) -# self.assertTrue(any(isinstance(call[0][0], Graph) for call in temp_network_plot.add.call_args_list)) - -# def test_manim_plot_save_mp4(self): -# """Test for saving a mp4 file with the method save from the ManimPlot class""" -# with tempfile.TemporaryDirectory() as tmp_scene_dir, tempfile.TemporaryDirectory() as tmp_save_dir: - -# scene_output = Path(tmp_scene_dir) -# save_output = Path(tmp_save_dir) -# output_file = "TemporalNetworkPlot" - -# manim_plot = TemporalNetworkPlot(self.data, output_dir=scene_output, output_file=output_file) - -# with patch.object(Scene, "render", new=render_side_effect): -# manim_plot.save("testvideo.mp4", save_dir=save_output) - -# target_path = save_output / "testvideo.mp4" -# self.assertTrue(target_path.exists()) -# self.assertEqual(target_path.read_text(), "test video") - -# def test_manim_plot_save_gif(self): -# """Test for saving a gif with the method save from the ManimPlot class""" -# with tempfile.TemporaryDirectory() as tmp_scene_dir, tempfile.TemporaryDirectory() as tmp_save_dir: - -# scene_output = Path(tmp_scene_dir) -# save_output = Path(tmp_save_dir) -# output_file = "TemporalNetworkPlot" - -# manim_plot = TemporalNetworkPlot(self.data, output_dir=scene_output, output_file=output_file) - -# with patch.object(Scene, "render", new=render_side_effect_gif): -# manim_plot.save("testvideo.gif", save_dir=save_output, save_as=format) - -# target_path = save_output / "testvideo.gif" -# self.assertTrue(target_path.exists()) -# #self.assertEqual(target_path.read_text(), "test video") - -# @patch("pathpyG.visualisations._manim.core.display") -# @patch.object(core, "in_jupyter_notebook", return_value=True) -# def test_manim_plot_show(self, mock_jupyter, mock_display): -# """Test for the method show from the ManimPlot class""" -# manim_plot = TemporalNetworkPlot(self.data) - -# with patch.object(Scene, "render", new=render_side_effect): -# manim_plot.show() - -# mock_display.assert_called_once() - - -# def render_side_effect(*args): -# """Mocking Scene.render""" -# from manim import config as manim_config - -# output_dir = Path(manim_config.media_dir) if manim_config.media_dir else Path.cwd() - -# video_dir = output_dir / "videos" / "1080p60" -# video_dir.mkdir(parents=True, exist_ok=True) - -# video_file = video_dir / f"{TemporalNetworkPlot.__name__}.mp4" -# video_file.write_text("test video") - - -# def render_side_effect_gif(*args): -# """Mocking Scene.render""" -# from manim import config as manim_config - -# output_dir = Path(manim_config.media_dir) if manim_config.media_dir else Path.cwd() - -# video_dir = output_dir / "videos" / "1080p60" -# video_dir.mkdir(parents=True, exist_ok=True) -# video_file = video_dir / f"{TemporalNetworkPlot.__name__}.mp4" - -# command = [ -# "ffmpeg", -# "-f", -# "lavfi", -# "-i", -# "color=c=black:s=320x240:d=1", -# "-c:v", -# "libx264", -# "-pix_fmt", -# "yuv420p", -# "-y", -# str(video_file) -# ] -# subprocess.run(command, check=True) From d8fec8f7bd2875b3af250d2b0622ecc82283cb54 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Mon, 27 Oct 2025 15:17:33 +0000 Subject: [PATCH 41/44] fix temporal graph RGB assignment --- .../visualisations/temporal_network_plot.py | 13 +++++++++- tests/visualisations/test_network_plot.py | 2 +- .../test_temporal_network_plot.py | 25 +++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/pathpyG/visualisations/temporal_network_plot.py b/src/pathpyG/visualisations/temporal_network_plot.py index 951d3f037..d279c7737 100644 --- a/src/pathpyG/visualisations/temporal_network_plot.py +++ b/src/pathpyG/visualisations/temporal_network_plot.py @@ -15,6 +15,7 @@ from pathpyG.visualisations.layout import layout as network_layout from pathpyG.visualisations.network_plot import NetworkPlot +from pathpyG.visualisations.utils import rgb_to_hex # pseudo load class for type checking if TYPE_CHECKING: @@ -78,7 +79,11 @@ def _compute_node_data(self) -> None: for key in self.node_args[attribute].keys(): # type: ignore[union-attr] if isinstance(key, tuple): # add node attribute according to node-time keys - new_nodes.loc[key, attribute] = self.node_args[attribute][key] # type: ignore[index] + value = self.node_args[attribute][key] + # convert color tuples to hex strings to avoid pandas sequence assignment + if attribute == "color" and isinstance(value, tuple) and len(value) == 3: + value = rgb_to_hex(value) + new_nodes.loc[key, attribute] = value # type: ignore[index] else: # add node attributes to start nodes according to node keys start_nodes.loc[(key, 0), attribute] = self.node_args[attribute][key] # type: ignore[index] @@ -138,6 +143,12 @@ def _compute_edge_data(self) -> None: edges[attribute] = self.network.data["edge_weight"] # check if attribute is given as argument if attribute in self.edge_args: + if attribute == "color" and isinstance(self.edge_args[attribute], dict): + # convert color tuples to hex strings to avoid pandas sequence assignment + for key in self.edge_args[attribute].keys(): # type: ignore[union-attr] + value = self.edge_args[attribute][key] + if isinstance(value, tuple) and len(value) == 3: + self.edge_args[attribute][key] = rgb_to_hex(value) # type: ignore[index] edges = self._assign_argument(attribute, self.edge_args[attribute], edges) elif attribute == "size" and "weight" in self.edge_args: edges = self._assign_argument("size", self.edge_args["weight"], edges) diff --git a/tests/visualisations/test_network_plot.py b/tests/visualisations/test_network_plot.py index 960cf9f62..67a8c4037 100644 --- a/tests/visualisations/test_network_plot.py +++ b/tests/visualisations/test_network_plot.py @@ -116,7 +116,7 @@ def test_edge_attribute_list_assignment(self): def test_edge_attribute_dict_assignment(self): """Test assigning dictionaries mapping edge tuples to attributes.""" - color_map = {("a", "b"): "#ff0000", ("b", "c"): "#00ff00"} + color_map = {("a", "b"): "#ff0000", ("b", "c"): (0, 255, 0)} size_map = {("a", "b"): 10, ("c", "a"): 20} plot = NetworkPlot(self.g, edge_color=color_map, edge_size=size_map) edges = plot.data["edges"] diff --git a/tests/visualisations/test_temporal_network_plot.py b/tests/visualisations/test_temporal_network_plot.py index e485c6e2e..0d1d9f72c 100644 --- a/tests/visualisations/test_temporal_network_plot.py +++ b/tests/visualisations/test_temporal_network_plot.py @@ -256,3 +256,28 @@ def test_simulation_mode_with_layout(self): TemporalGraph.from_edge_list([("a", "b", 0)]), layout="spring" ) assert plot.config["simulation"] is False + + def test_rgb_color_assignment(self): + """Test that RGB tuple colors are converted to hex strings.""" + tg = TemporalGraph.from_edge_list( + [("a", "b", 0), ("b", "c", 1), ("c", "a", 2), ("a", "d", 3)] + ) + node_colors = { + "a": (255, 0, 0), + ("b", 1): (0, 255, 0), + ("c", 2): (0, 0, 255), + } + edge_colors = { + ("a", "b", 0): (255, 255, 0), + ("b", "c", 1): (0, 255, 255), + } + plot = TemporalNetworkPlot(tg, node_color=node_colors, edge_color=edge_colors) + nodes = plot.data["nodes"] + edges = plot.data["edges"] + # Check that colors are hex strings + for color in nodes["color"].dropna().unique(): + assert isinstance(color, str) + assert color.startswith("#") and len(color) == 7 + for color in edges["color"].dropna().unique(): + assert isinstance(color, str) + assert color.startswith("#") and len(color) == 7 \ No newline at end of file From 173a57722af0ee727a6af70faa0783744aa03ef9 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Mon, 27 Oct 2025 15:47:47 +0000 Subject: [PATCH 42/44] fix higher-order RGB assignment --- src/pathpyG/visualisations/network_plot.py | 6 ++++++ src/pathpyG/visualisations/temporal_network_plot.py | 6 ------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/pathpyG/visualisations/network_plot.py b/src/pathpyG/visualisations/network_plot.py index 7eb91db14..9d8082144 100644 --- a/src/pathpyG/visualisations/network_plot.py +++ b/src/pathpyG/visualisations/network_plot.py @@ -228,6 +228,12 @@ def _assign_argument(self, attr_key: str, attr_value: Any, df: pd.DataFrame) -> """ if isinstance(attr_value, dict): # if dict does not contain values for all edges, only update those that are given + if attr_key == "color": + # convert color tuples to hex strings to avoid pandas sequence assignment + for key in self.edge_args[attr_key].keys(): # type: ignore[union-attr] + value = self.edge_args[attr_key][key] + if isinstance(value, tuple) and len(value) == 3: + self.edge_args[attr_key][key] = rgb_to_hex(value) # type: ignore[index] new_attrs = df.index.map(attr_value) # Check if all values are assigned if (~new_attrs.isna()).sum() == df.shape[0]: diff --git a/src/pathpyG/visualisations/temporal_network_plot.py b/src/pathpyG/visualisations/temporal_network_plot.py index d279c7737..8bb970aae 100644 --- a/src/pathpyG/visualisations/temporal_network_plot.py +++ b/src/pathpyG/visualisations/temporal_network_plot.py @@ -143,12 +143,6 @@ def _compute_edge_data(self) -> None: edges[attribute] = self.network.data["edge_weight"] # check if attribute is given as argument if attribute in self.edge_args: - if attribute == "color" and isinstance(self.edge_args[attribute], dict): - # convert color tuples to hex strings to avoid pandas sequence assignment - for key in self.edge_args[attribute].keys(): # type: ignore[union-attr] - value = self.edge_args[attribute][key] - if isinstance(value, tuple) and len(value) == 3: - self.edge_args[attribute][key] = rgb_to_hex(value) # type: ignore[index] edges = self._assign_argument(attribute, self.edge_args[attribute], edges) elif attribute == "size" and "weight" in self.edge_args: edges = self._assign_argument("size", self.edge_args["weight"], edges) From 4ccfe2b6ec664372aae6994f946e2ffa1f9cc4c8 Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Mon, 27 Oct 2025 15:48:03 +0000 Subject: [PATCH 43/44] fix tikz node border opacity --- src/pathpyG/visualisations/_tikz/backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pathpyG/visualisations/_tikz/backend.py b/src/pathpyG/visualisations/_tikz/backend.py index a96681be9..9342d03a2 100644 --- a/src/pathpyG/visualisations/_tikz/backend.py +++ b/src/pathpyG/visualisations/_tikz/backend.py @@ -380,7 +380,7 @@ def to_tikz(self) -> str: node_strings += "color=" + self.data["nodes"]["color"] + "," # add other options node_strings += "size=" + (self.data["nodes"]["size"] * 0.075).astype(str) + "," - node_strings += "opacity=" + self.data["nodes"]["opacity"].astype(str) + "," + node_strings += "opacity=" + self.data["nodes"]["opacity"].astype(str) + ",style={draw opacity=" + self.data["nodes"]["opacity"].astype(str) + "}," # add position node_strings += ( "x=" + ((self.data["nodes"]["x"] - 0.5) * unit_str_to_float(self.config["width"], "cm")).astype(str) + "," From 9d2c18258de633af860e8bf1a87a0a7d6a05565c Mon Sep 17 00:00:00 2001 From: Moritz Lampert Date: Mon, 27 Oct 2025 15:57:43 +0000 Subject: [PATCH 44/44] fix unit-test fail --- src/pathpyG/visualisations/network_plot.py | 6 +++--- src/pathpyG/visualisations/temporal_network_plot.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pathpyG/visualisations/network_plot.py b/src/pathpyG/visualisations/network_plot.py index 9d8082144..36ff6e5ad 100644 --- a/src/pathpyG/visualisations/network_plot.py +++ b/src/pathpyG/visualisations/network_plot.py @@ -230,10 +230,10 @@ def _assign_argument(self, attr_key: str, attr_value: Any, df: pd.DataFrame) -> # if dict does not contain values for all edges, only update those that are given if attr_key == "color": # convert color tuples to hex strings to avoid pandas sequence assignment - for key in self.edge_args[attr_key].keys(): # type: ignore[union-attr] - value = self.edge_args[attr_key][key] + for key in attr_value.keys(): + value = attr_value[key] if isinstance(value, tuple) and len(value) == 3: - self.edge_args[attr_key][key] = rgb_to_hex(value) # type: ignore[index] + attr_value[key] = rgb_to_hex(value) new_attrs = df.index.map(attr_value) # Check if all values are assigned if (~new_attrs.isna()).sum() == df.shape[0]: diff --git a/src/pathpyG/visualisations/temporal_network_plot.py b/src/pathpyG/visualisations/temporal_network_plot.py index 8bb970aae..7ac843aea 100644 --- a/src/pathpyG/visualisations/temporal_network_plot.py +++ b/src/pathpyG/visualisations/temporal_network_plot.py @@ -79,11 +79,11 @@ def _compute_node_data(self) -> None: for key in self.node_args[attribute].keys(): # type: ignore[union-attr] if isinstance(key, tuple): # add node attribute according to node-time keys - value = self.node_args[attribute][key] + value = self.node_args[attribute][key] # type: ignore[index] # convert color tuples to hex strings to avoid pandas sequence assignment if attribute == "color" and isinstance(value, tuple) and len(value) == 3: value = rgb_to_hex(value) - new_nodes.loc[key, attribute] = value # type: ignore[index] + new_nodes.loc[key, attribute] = value else: # add node attributes to start nodes according to node keys start_nodes.loc[(key, 0), attribute] = self.node_args[attribute][key] # type: ignore[index]