diff --git a/docs/reference/pathpyG/visualisations/index.md b/docs/reference/pathpyG/visualisations/index.md index 5aeaf7cd..f62bf558 100644 --- a/docs/reference/pathpyG/visualisations/index.md +++ b/docs/reference/pathpyG/visualisations/index.md @@ -62,12 +62,12 @@ The default backend is `d3.js`, which is suitable for both static and temporal n 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`, `jpg` | -| **tikz** | ✔️ | ❌ | `svg`, `pdf`, `tex`| +| Backend | Static Networks | Temporal Networks | Time-Unfolded Networks | Available File Formats| +|---------------|------------|-------------|--------------|-------------| +| **d3.js** | ✔️ | ✔️ | ✔️ | `html` | +| **manim** | ❌ | ✔️ | ❌ | `mp4`, `gif` | +| **matplotlib**| ✔️ | ❌ | ✔️ | `png`, `jpg` | +| **tikz** | ✔️ | ❌ | ✔️ | `svg`, `pdf`, `tex`| #### Details @@ -427,6 +427,44 @@ The layout algorithm can be any of the supported static layout algorithms descri Manim Custom Properties Animation +### Time-Unfolded Networks + +For temporal networks, you can use the time-unfolded visualisation to show a static representation of the temporal network. +In this representation, each node is duplicated for each timestep, and edges are drawn between nodes at different timesteps to represent temporal interactions. +You can enable this visualisation by setting the "kind" argument to `"unfolded"` in the `pp.plot()` function call. +This visualisation is supported by all backends that support static networks, i.e. D3.js, Matplotlib, and TikZ. + +!!! example "Time-Unfolded Visualisation of Temporal Networks" + + In the example below, we create a time-unfolded visualisation of a temporal network using the `tikz` backend. + ```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 + node_color = {"a": "red", ("a", 2): "darkred"} + edge_color = {("a", "b", 2): "blue"} + pp.plot(t, backend="tikz", kind="unfolded", node_size=12, node_color=node_color, edge_color=edge_color) + ``` + Example TikZ Time-Unfolded Layout + + !!! tip "Customising Time-Unfolded Visualisations" + In the time-unfolded visualisation, you can still customise node and edge properties as described in the [Node and Edge Customisation](#node-and-edge-customisation) section. + ## Customisation Options Below is full list of supported keyword arguments for each backend and their descriptions. @@ -445,6 +483,7 @@ Below is full list of supported keyword arguments for each backend and their des | `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 | +| `orientation` | ✔️ | ✔️ | ❌ | ✔️ | Orientation of the time-unfolded network plot (`"up"`, `"down"`, `"left"`, or `"right"`) | | **Nodes** | | | | | | | `size` | ✔️ | ✔️ | ✔️ | ✔️ | Radius of nodes (uniform or per-node) | | `color` | ✔️ | ✔️ | ✔️ | ✔️ | Node fill color | diff --git a/docs/reference/pathpyG/visualisations/plot/documentation_plots.ipynb b/docs/reference/pathpyG/visualisations/plot/documentation_plots.ipynb index b8a813d0..ede69250 100644 --- a/docs/reference/pathpyG/visualisations/plot/documentation_plots.ipynb +++ b/docs/reference/pathpyG/visualisations/plot/documentation_plots.ipynb @@ -1396,11 +1396,183 @@ ")" ] }, + { + "cell_type": "markdown", + "id": "d7223c25", + "metadata": {}, + "source": [ + "### Time-Unfolded Layout\n" + ] + }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "id": "89628069", "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pathpyG as pp\n", + "\n", + "# Example temporal network data\n", + "tedges = [\n", + " (\"a\", \"b\", 1),\n", + " (\"a\", \"b\", 2),\n", + " (\"b\", \"a\", 3),\n", + " (\"b\", \"c\", 3),\n", + " (\"d\", \"c\", 4),\n", + " (\"a\", \"b\", 4),\n", + " (\"c\", \"b\", 4),\n", + " (\"c\", \"d\", 5),\n", + " (\"b\", \"a\", 5),\n", + " (\"c\", \"b\", 6),\n", + "]\n", + "t = pp.TemporalGraph.from_edge_list(tedges)\n", + "\n", + "# Create temporal plot and display inline\n", + "node_color = {\"a\": \"red\", (\"a\", 2): \"darkred\"}\n", + "edge_color = {(\"a\", \"b\", 2): \"blue\"}\n", + "pp.plot(t, backend=\"tikz\", kind=\"unfolded\", node_size=12, node_color=node_color, edge_color=edge_color, filename=\"unfolded_graph.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "53c35a96", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pathpyG as pp\n", + "\n", + "# Example temporal network data\n", + "tedges = [\n", + " (\"a\", \"b\", 1),\n", + " (\"a\", \"b\", 2),\n", + " (\"b\", \"a\", 3),\n", + " (\"a\", \"b\", 4),\n", + " (\"c\", \"b\", 4),\n", + " (\"c\", \"d\", 5),\n", + " (\"b\", \"a\", 5),\n", + " (\"c\", \"b\", 6),\n", + "]\n", + "t = pp.TemporalGraph.from_edge_list(tedges)\n", + "\n", + "# Create temporal plot and display inline\n", + "node_color = {\"a\": \"red\", (\"a\", 2): \"darkred\"}\n", + "edge_color = {(\"a\", \"b\", 2): \"blue\"}\n", + "pp.plot(t, backend=\"tikz\", kind=\"unfolded\", node_size=12, node_color=node_color, edge_color=edge_color, orientation=\"right\", filename=\"unfolded_graph_tikz.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "7df27ecd", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import pathpyG as pp\n", + "\n", + "# Example temporal network data\n", + "tedges = [\n", + " (\"a\", \"b\", 1),\n", + " (\"a\", \"b\", 2),\n", + " (\"b\", \"a\", 3),\n", + " (\"b\", \"c\", 3),\n", + " (\"d\", \"c\", 4),\n", + " (\"a\", \"b\", 4),\n", + " (\"c\", \"b\", 4),\n", + "]\n", + "t = pp.TemporalGraph.from_edge_list(tedges)\n", + "\n", + "# Create temporal plot and display inline\n", + "node_opacity = {(node_id, time): 0.1 for node_id in t.nodes for time in range(t.data.time.max().item() + 2)}\n", + "node_opacity.update({(source_id, time): 1.0 for source_id, target_id, time in t.temporal_edges})\n", + "node_opacity.update({(target_id, time+1): 1.0 for source_id, target_id, time in t.temporal_edges})\n", + "pp.plot(t, backend=\"matplotlib\", kind=\"unfolded\", node_size=12, node_opacity=node_opacity, filename=\"unfolded_graph_matplotlib.png\")" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "034d667a", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pathpyG as pp\n", + "\n", + "# Example temporal network data\n", + "tedges = [\n", + " (\"a\", \"d\", 1),\n", + " (\"b\", \"c\", 2),\n", + " (\"b\", \"c\", 3),\n", + " (\"b\", \"a\", 3),\n", + " (\"d\", \"b\", 4),\n", + "\n", + "]\n", + "t = pp.TemporalGraph.from_edge_list(tedges)\n", + "\n", + "# Create temporal plot and display inline\n", + "pp.plot(t, kind=\"unfolded\", show_labels=False, filename=\"unfolded_graph_d3js.html\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b9f0f45b", + "metadata": {}, "outputs": [], "source": [] } diff --git a/docs/reference/pathpyG/visualisations/plot/unfolded_graph.svg b/docs/reference/pathpyG/visualisations/plot/unfolded_graph.svg new file mode 100644 index 00000000..53a914a1 --- /dev/null +++ b/docs/reference/pathpyG/visualisations/plot/unfolded_graph.svg @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +a + +b + +c + +d + +0 + +1 + +2 + +3 + +4 + +5 + +6 + + \ No newline at end of file diff --git a/docs/reference/pathpyG/visualisations/plot/unfolded_graph_d3js.html b/docs/reference/pathpyG/visualisations/plot/unfolded_graph_d3js.html new file mode 100644 index 00000000..cacf4ff7 --- /dev/null +++ b/docs/reference/pathpyG/visualisations/plot/unfolded_graph_d3js.html @@ -0,0 +1,506 @@ + + +
+ + \ No newline at end of file diff --git a/docs/reference/pathpyG/visualisations/plot/unfolded_graph_matplotlib.png b/docs/reference/pathpyG/visualisations/plot/unfolded_graph_matplotlib.png new file mode 100644 index 00000000..0a8b0bfa Binary files /dev/null and b/docs/reference/pathpyG/visualisations/plot/unfolded_graph_matplotlib.png differ diff --git a/docs/reference/pathpyG/visualisations/plot/unfolded_graph_tikz.svg b/docs/reference/pathpyG/visualisations/plot/unfolded_graph_tikz.svg new file mode 100644 index 00000000..47122d94 --- /dev/null +++ b/docs/reference/pathpyG/visualisations/plot/unfolded_graph_tikz.svg @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +a + +b + +c + +d + +0 + +1 + +2 + +3 + +4 + +5 + +6 + + \ No newline at end of file diff --git a/src/pathpyG/pathpyG.toml b/src/pathpyG/pathpyG.toml index 3eff438d..d6c8ddc2 100644 --- a/src/pathpyG/pathpyG.toml +++ b/src/pathpyG/pathpyG.toml @@ -32,6 +32,7 @@ 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. delta = 1000 # Time between frames in milliseconds separator = "->" # Separator for higher-order node labels +orientation = "down" # Orientation of the time-unfolded plots. Options are "down", "up", "left", "right" [visualisation.node] color = [36, 74, 92] # Node color in RGB from the pathpyG logo diff --git a/src/pathpyG/visualisations/_d3js/__init__.py b/src/pathpyG/visualisations/_d3js/__init__.py index 00fbfaf9..ac3fb40b 100644 --- a/src/pathpyG/visualisations/_d3js/__init__.py +++ b/src/pathpyG/visualisations/_d3js/__init__.py @@ -51,6 +51,8 @@ ## Network Visualization with custom Images +With D3.js, you can easily use custom images for nodes by providing URLs or local paths. + ```python import torch import pathpyG as pp @@ -83,6 +85,29 @@ - **Jupyter**: Direct display in notebook cells - **Web Apps**: Easy integration into existing websites +## Time-Unfolded Network + +Below is an example of a time-unfolded network visualization using the D3.js backend. + +```python +import pathpyG as pp + +# Example temporal network data +tedges = [ + ("a", "d", 1), + ("b", "c", 2), + ("b", "c", 3), + ("b", "a", 3), + ("d", "b", 4), + +] +t = pp.TemporalGraph.from_edge_list(tedges) + +# Create temporal plot and display inline +pp.plot(t, kind="unfolded", show_labels=False) +``` + + ## 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. diff --git a/src/pathpyG/visualisations/_d3js/backend.py b/src/pathpyG/visualisations/_d3js/backend.py index 904e0144..685d8376 100644 --- a/src/pathpyG/visualisations/_d3js/backend.py +++ b/src/pathpyG/visualisations/_d3js/backend.py @@ -10,6 +10,7 @@ - Both static and temporal network support - Jupyter notebook integration with inline display """ + from __future__ import annotations import json @@ -26,7 +27,8 @@ 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 +from pathpyG.visualisations.unfolded_network_plot import TimeUnfoldedNetworkPlot +from pathpyG.visualisations.utils import in_jupyter_notebook, rgb_to_hex, unit_str_to_float # create logger logger = logging.getLogger("root") @@ -34,6 +36,7 @@ SUPPORTED_KINDS: dict[type, str] = { NetworkPlot: "static", TemporalNetworkPlot: "temporal", + TimeUnfoldedNetworkPlot: "unfolded", } @@ -62,7 +65,7 @@ class D3jsBackend(PlotBackend): !!! 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 @@ -105,12 +108,14 @@ def save(self, filename: str) -> None: !!! 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 """ + # Default to the CDN version of d3js since browsers may block local scripts + self.config["d3js_local"] = self.config.get("d3js_local", False) with open(filename, "w+") as new: new.write(self.to_html()) @@ -125,6 +130,8 @@ def show(self) -> None: Uses pathpyG config to detect interactive environment and choose appropriate display method automatically. """ + # Default to local d3js in Jupyter notebooks for offline use + self.config["d3js_local"] = self.config.get("d3js_local", False or in_jupyter_notebook()) if config["environment"]["interactive"]: from IPython.display import display_html, HTML # noqa I001 @@ -149,16 +156,18 @@ def _prepare_data(self) -> dict: !!! 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["uid"] = self.data["nodes"].index.map(lambda x: f"({x[0]},{x[1]})" if isinstance(x, tuple) else str(x)) node_data = node_data.rename(columns={"x": "xpos", "y": "ypos"}) + if self._kind == "unfolded": + node_data["ypos"] = 1 - node_data["ypos"] # Invert y-axis for unfolded layout 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") + edge_data["source"] = edge_data.index.to_frame()["source"].map(lambda x: f"({x[0]},{x[1]})" if isinstance(x, tuple) else str(x)) + edge_data["target"] = edge_data.index.to_frame()["target"].map(lambda x: f"({x[0]},{x[1]})" if isinstance(x, tuple) else str(x)) data_dict = { "nodes": node_data.to_dict(orient="records"), "edges": edge_data.to_dict(orient="records"), @@ -235,9 +244,8 @@ def to_html(self) -> str: os.path.normpath("_d3js/templates"), ) - # get d3js version - local = self.config.get("d3js_local", True) - if local: + # get d3js library path + if self.config.get("d3js_local", False): d3js = os.path.join(template_dir, "d3.v7.min.js") else: d3js = "https://d3js.org/d3.v7.min.js" @@ -305,9 +313,9 @@ def get_template(self, template_dir: str) -> str: !!! 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 @@ -319,7 +327,9 @@ def get_template(self, template_dir: str) -> str: 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: + with open( + os.path.join(template_dir, "static.js" if self._kind == "unfolded" else f"{self._kind}.js") + ) as template: js_template += template.read() return js_template diff --git a/src/pathpyG/visualisations/_matplotlib/__init__.py b/src/pathpyG/visualisations/_matplotlib/__init__.py index 33016053..0de0b8e8 100644 --- a/src/pathpyG/visualisations/_matplotlib/__init__.py +++ b/src/pathpyG/visualisations/_matplotlib/__init__.py @@ -17,4 +17,32 @@ pp.plot(g, backend="matplotlib") ``` Example Matplotlib Backend Output + +## Time-Unfolded Network + +We also support time-unfolded static visualizations of temporal networks using the matplotlib backend. +The example uses the `node_opacity` parameter to highlight active nodes and edges at each time step. + +```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), +] +t = pp.TemporalGraph.from_edge_list(tedges) + +# Create temporal plot and display inline +node_opacity = {(node_id, time): 0.1 for node_id in t.nodes for time in range(t.data.time.max().item() + 2)} +node_opacity.update({(source_id, time): 1.0 for source_id, target_id, time in t.temporal_edges}) +node_opacity.update({(target_id, time+1): 1.0 for source_id, target_id, time in t.temporal_edges}) +pp.plot(t, backend="matplotlib", kind="unfolded", node_size=12, node_opacity=node_opacity) +``` +Example Matplotlib Backend Time-Unfolded Output """ diff --git a/src/pathpyG/visualisations/_matplotlib/backend.py b/src/pathpyG/visualisations/_matplotlib/backend.py index 26ce5be5..e5f208dc 100644 --- a/src/pathpyG/visualisations/_matplotlib/backend.py +++ b/src/pathpyG/visualisations/_matplotlib/backend.py @@ -16,12 +16,14 @@ from pathpyG.visualisations.network_plot import NetworkPlot from pathpyG.visualisations.pathpy_plot import PathPyPlot from pathpyG.visualisations.plot_backend import PlotBackend +from pathpyG.visualisations.unfolded_network_plot import TimeUnfoldedNetworkPlot from pathpyG.visualisations.utils import unit_str_to_float logger = logging.getLogger("root") SUPPORTED_KINDS = { NetworkPlot: "static", + TimeUnfoldedNetworkPlot: "unfolded", } @@ -139,16 +141,44 @@ def to_fig(self) -> tuple[plt.Figure, plt.Axes]: # 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 in the center of the node - ax.annotate( - label, - (x, y), - fontsize=0.4 * self.data["nodes"]["size"].mean(), - ha="center", - va="center", - ) + if self._kind == "static": + for label in self.data["nodes"].index: + x, y = self.data["nodes"].loc[[label], ["x", "y"]].values.flatten() + # Annotate the node label with text in the center of the node + ax.annotate( + label, + (x, y), + fontsize=0.4 * self.data["nodes"]["size"].mean(), + ha="center", + va="center", + ) + elif self._kind == "unfolded": + # add labels at the starting nodes only + min_time = self.data["nodes"]["start"].min() + offset = 0.005 * self.data["nodes"]["size"].mean() + sign = 1 if self.config["orientation"] in ["down", "left"] else -1 + label_df = self.data["nodes"][self.data["nodes"]["start"] == min_time] + for label in label_df.index: + x, y = label_df.loc[[label], ["x", "y"]].values.flatten() + ax.annotate( + label[0], + (x, y + offset * sign) if self.config["orientation"] in ["down", "up"] else (x + offset * sign, y), + fontsize=0.5 * self.data["nodes"]["size"].mean(), + ha="center", + va="center", + ) + + # add timestamps at the border + times = self.data["nodes"]["start"].unique() + for time in times[:-1]: # skip last time as it would be outside the plot + x, y = self.data["nodes"].iloc[time:time+2, :][["x", "y"]].values.mean(axis=0) + ax.annotate( + str(time), + (x - offset, y) if self.config["orientation"] in ["down", "up"] else (x, y - offset), + fontsize=0.5 * self.data["nodes"]["size"].mean(), + ha="center", + va="center", + ) # set limits ax.set_xlim(-1 * self.config["margin"], 1 + (1*self.config["margin"])) @@ -308,7 +338,7 @@ def get_bezier_curve( 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): + if (not self.config["curved"]) or 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 @@ -326,7 +356,7 @@ def get_bezier_curve( ] return vertices, codes - def get_arrowhead(self, vertices, head_length=0.01, head_width=0.02): + def get_arrowhead(self, vertices, head_length, head_width=0.02): """Generate triangular arrowhead paths for directed edges. Creates proportional arrowheads at curve endpoints using tangent vectors diff --git a/src/pathpyG/visualisations/_tikz/__init__.py b/src/pathpyG/visualisations/_tikz/__init__.py index 73edc2ed..19475e04 100644 --- a/src/pathpyG/visualisations/_tikz/__init__.py +++ b/src/pathpyG/visualisations/_tikz/__init__.py @@ -49,6 +49,34 @@ ``` Example TikZ Custom Properties +## Time-Unfolded Network Example + +You can also create time-unfolded visualizations of temporal networks using the TikZ backend with all customization options from the temporal animations. +With the `orientation` parameter, you can control the layout direction of the time-unfolded graph. + +```python +import pathpyG as pp + +# Example temporal network data +tedges = [ + ("a", "b", 1), + ("a", "b", 2), + ("b", "a", 3), + ("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 +node_color = {"a": "red", ("a", 2): "darkred"} +edge_color = {("a", "b", 2): "blue"} +pp.plot(t, backend="tikz", kind="unfolded", node_size=12, node_color=node_color, edge_color=edge_color, orientation="right") +``` +Example TikZ Custom Properties + ## Templates PathpyG uses LaTeX templates to generate TikZ visualizations. Templates define standalone LaTeX documents with placeholders for dynamic content. diff --git a/src/pathpyG/visualisations/_tikz/backend.py b/src/pathpyG/visualisations/_tikz/backend.py index 9342d03a..2bebc19a 100644 --- a/src/pathpyG/visualisations/_tikz/backend.py +++ b/src/pathpyG/visualisations/_tikz/backend.py @@ -49,6 +49,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.unfolded_network_plot import TimeUnfoldedNetworkPlot from pathpyG.visualisations.utils import hex_to_rgb, prepare_tempfile, unit_str_to_float # create logger @@ -56,37 +57,39 @@ SUPPORTED_KINDS = { NetworkPlot: "static", + TimeUnfoldedNetworkPlot: "unfolded", } class TikzBackend(PlotBackend): """TikZ/LaTeX Backend for Publication-Quality Network Graphics. - - Generates high-quality vector graphics using LaTeX's TikZ package. + + 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") ``` @@ -95,17 +98,17 @@ class TikzBackend(PlotBackend): def __init__(self, plot: PathPyPlot, show_labels: bool): """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. @@ -118,17 +121,17 @@ def __init__(self, plot: PathPyPlot, show_labels: bool): def save(self, filename: str) -> None: """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. @@ -155,18 +158,18 @@ def save(self, filename: str) -> None: def show(self) -> None: """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. @@ -195,23 +198,23 @@ def show(self) -> None: def compile_svg(self) -> tuple: """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. """ @@ -252,14 +255,14 @@ def compile_svg(self) -> tuple: def compile_pdf(self) -> tuple: """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 @@ -293,26 +296,26 @@ def compile_pdf(self) -> tuple: def to_tex(self) -> str: """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 + 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. @@ -324,7 +327,7 @@ def to_tex(self) -> str: ) # get template files - with open(os.path.join(template_dir, f"{self._kind}.tex")) as template: + with open(os.path.join(template_dir, f"static.tex")) as template: tex_template = template.read() # generate data @@ -343,14 +346,14 @@ def to_tex(self) -> str: def to_tikz(self) -> str: 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 @@ -365,7 +368,7 @@ def to_tikz(self) -> str: if not self.data["nodes"].empty: node_strings: pd.Series = "\\Vertex[" # show labels if specified - if self.show_labels: + if self.show_labels and self._kind == "static": node_strings += ( "label=$" + self.data["nodes"].index.astype(str).map(self._replace_with_LaTeX_math_symbol) + "$," ) @@ -383,20 +386,86 @@ def to_tikz(self) -> 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) + "," + "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) + "]" + "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" + node_strings += ( + "{" + + self.data["nodes"].index.map(lambda x: f"{x[0]}{x[1]}" if isinstance(x, tuple) else str(x)) + + "};\n" + ) tikz += node_strings.str.cat() + if self.show_labels and self._kind == "unfolded": + # add labels at the starting nodes only + min_time = self.data["nodes"]["start"].min() + offset = 0.06 * self.data["nodes"]["size"].mean() + sign = 1 if self.config["orientation"] in ["down", "left"] else -1 + label_df = self.data["nodes"][self.data["nodes"]["start"] == min_time] + label_strings: pd.Series = "\\Vertex[" + label_strings += "label=$" + label_df.index.map(lambda x: str(x[0])) + "$," + label_strings += "fontsize=\\fontsize{" + str(int(label_df["size"].mean())) + "}{10}\\selectfont," + label_strings += "opacity=0.0,style={draw=none}," + label_strings += ( + "x=" + + ( + (label_df["x"] - 0.5) * unit_str_to_float(self.config["width"], "cm") + + (sign * offset if self.config["orientation"] in ["left", "right"] else 0) + ).astype(str) + + "," + ) + label_strings += ( + "y=" + + ( + (label_df["y"] - 0.5) * unit_str_to_float(self.config["height"], "cm") + + (sign * offset if self.config["orientation"] in ["down", "up"] else 0) + ).astype(str) + + "]" + ) + label_strings += "{" + label_df.index.map(lambda x: "label_" + str(x[0])) + "};\n" + tikz += label_strings.str.cat() + + # add timestamps at the border + time_df = self.data["nodes"].iloc[: self.data["nodes"]["end"].max()] + time_df.loc[:, ["x", "y"]] = (time_df[["x", "y"]] + time_df[["x", "y"]].shift(-1)) / 2 + time_df = time_df.iloc[:-1] + time_strings: pd.Series = "\\Vertex[" + time_strings += "label=$" + time_df["start"].astype(str) + "$," + time_strings += "fontsize=\\fontsize{" + str(int(time_df["size"].mean())) + "}{10}\\selectfont," + time_strings += "opacity=0.0,style={draw=none}," + time_strings += ( + "x=" + + ( + (time_df["x"] - 0.5) * unit_str_to_float(self.config["width"], "cm") + - (offset if self.config["orientation"] in ["up", "down"] else 0) + ).astype(str) + + "," + ) + time_strings += ( + "y=" + + ( + (time_df["y"] - 0.5) * unit_str_to_float(self.config["height"], "cm") + - (offset if self.config["orientation"] in ["left", "right"] else 0) + ).astype(str) + + "]" + ) + time_strings += "{" + time_df.index.map(lambda x: "time_" + str(x[0])) + "};\n" + tikz += time_strings.str.cat() + # generate edge strings if not self.data["edges"].empty: edge_strings: pd.Series = "\\Edge[" + if self.config["curved"]: + edge_strings += "bend=15," if self.config["directed"]: - edge_strings += "bend=15,Direct," + edge_strings += "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("()") + "}," @@ -405,7 +474,15 @@ 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"].index.get_level_values("source").astype(str) + ")(" + self.data["edges"].index.get_level_values("target").astype(str) + ")\n" + "(" + + self.data["edges"] + .index.to_frame()["source"] + .map(lambda x: f"{x[0]}{x[1]}" if isinstance(x, tuple) else str(x)) + + ")(" + + self.data["edges"] + .index.to_frame()["target"] + .map(lambda x: f"{x[0]}{x[1]}" if isinstance(x, tuple) else str(x)) + + ");\n" ) tikz += edge_strings.str.cat() diff --git a/src/pathpyG/visualisations/plot_function.py b/src/pathpyG/visualisations/plot_function.py index 06f55e44..9f73dc03 100644 --- a/src/pathpyG/visualisations/plot_function.py +++ b/src/pathpyG/visualisations/plot_function.py @@ -61,6 +61,7 @@ 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.unfolded_network_plot import TimeUnfoldedNetworkPlot # create logger logger = logging.getLogger("root") @@ -106,6 +107,7 @@ def is_backend(backend: str) -> bool: PLOT_CLASSES: dict = { "static": NetworkPlot, "temporal": TemporalNetworkPlot, + "unfolded": TimeUnfoldedNetworkPlot, } def _get_plot_backend(backend: Optional[str], filename: Optional[str], default: str) -> type[PlotBackend]: diff --git a/src/pathpyG/visualisations/temporal_network_plot.py b/src/pathpyG/visualisations/temporal_network_plot.py index 7ac843ae..b86d4ecf 100644 --- a/src/pathpyG/visualisations/temporal_network_plot.py +++ b/src/pathpyG/visualisations/temporal_network_plot.py @@ -93,7 +93,7 @@ def _compute_node_data(self) -> None: # save node data and combine start nodes with new nodes by making sure start nodes are overwritten self.data["nodes"] = new_nodes.combine_first(start_nodes) - def _post_process_node_data(self) -> pd.DataFrame: + def _post_process_node_data(self) -> None: """Add node lifetime information and forward-fill attributes. Computes start/end times for each node appearance and fills @@ -115,7 +115,7 @@ def _post_process_node_data(self) -> pd.DataFrame: max_node_time = nodes["start"].max() + 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) + nodes["end"] = nodes["end"].fillna(max_node_time).astype(int) self.data["nodes"] = nodes def _compute_edge_data(self) -> None: diff --git a/src/pathpyG/visualisations/unfolded_network_plot.py b/src/pathpyG/visualisations/unfolded_network_plot.py new file mode 100644 index 00000000..feb8e7a2 --- /dev/null +++ b/src/pathpyG/visualisations/unfolded_network_plot.py @@ -0,0 +1,108 @@ +"""Time-unfolded temporal network visualisation module. + +Prepares temporal graphs for visualization as time-unfolded networks +by assigning the node positions in a grid. +""" + +import numpy as np +import pandas as pd + +from pathpyG.core.temporal_graph import TemporalGraph +from pathpyG.visualisations.temporal_network_plot import TemporalNetworkPlot + + +class TimeUnfoldedNetworkPlot(TemporalNetworkPlot): + """Time-unfolded temporal network visualisation class. + + Prepares temporal graphs for visualization as time-unfolded networks + by assigning the node positions in a grid. + + Inherits from TemporalNetworkPlot. + """ + + _kind = "unfolded" + network: TemporalGraph + + def _compute_edge_data(self): + super()._compute_edge_data() + self.data["edges"].index = pd.MultiIndex.from_arrays( + [ + list(zip(self.data["edges"].index.get_level_values("source"), self.data["edges"]["start"])), + list(zip(self.data["edges"].index.get_level_values("target"), self.data["edges"]["end"])), + ], + names=["source", "target"], + ) + + def _post_process_node_data(self): + super()._post_process_node_data() + + self.data["nodes"].index = pd.Index( + list(zip(self.data["nodes"].index, self.data["nodes"]["start"])), + name="uid", + tupleize_cols=False + ) + + def _compute_layout(self) -> None: + """Compute time-unfolded node layout. + + For each node, assign positions in a grid based on time steps. + Depending on orientation, x (left/right) or y (up/down) coordinates represent time steps + and the other coordinate represents node identity. + """ + num_nodes = self.network.n + max_time = int( + max(self.data["nodes"].index.get_level_values("time").max() + 1, self.data["edges"]["end"].max() + 1) + ) + orientation = self.config.get("orientation") + + # Determine coordinate assignment based on orientation + if orientation in ["left", "right"]: + time_coord = "x" + node_coord = "y" + if orientation == "left": + sign = -1 + else: + sign = 1 + elif orientation in ["up", "down"]: + time_coord = "y" + node_coord = "x" + if orientation == "down": + sign = -1 + else: + sign = 1 + else: + raise ValueError("Invalid orientation option. Choose from 'left', 'right', 'up', or 'down'.") + + # Create a DataFrame for the grid layout + node_ids = np.repeat(self.data["nodes"].index.get_level_values("uid").unique(), max_time) + node_values = np.repeat(np.arange(num_nodes), max_time) + time_values = np.tile(np.arange(max_time), num_nodes) + layout_df = pd.DataFrame( + { + "uid": node_ids, + "time": time_values, + time_coord: (sign * time_values).astype(float), + node_coord: node_values.astype(float), + } + ).set_index(["uid", "time"]) + + # Scale coordinates between 0 and 1 + layout_df[time_coord] = (layout_df[time_coord] - layout_df[time_coord].min()) / ( + layout_df[time_coord].max() - layout_df[time_coord].min() + ) + layout_df[node_coord] = (layout_df[node_coord] - layout_df[node_coord].min()) / ( + layout_df[node_coord].max() - layout_df[node_coord].min() + ) + + # Join the layout DataFrame with the existing node data + self.data["nodes"] = self.data["nodes"].join(layout_df, how="outer") + + def _compute_config(self) -> None: + """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"] = False diff --git a/src/pathpyG/visualisations/utils.py b/src/pathpyG/visualisations/utils.py index 6a67f844..57ac3940 100644 --- a/src/pathpyG/visualisations/utils.py +++ b/src/pathpyG/visualisations/utils.py @@ -51,6 +51,22 @@ from pathlib import Path from typing import Callable +from IPython.core.getipython import get_ipython + + +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 + def prepare_tempfile() -> tuple[str, str]: """Prepare temporary directory for backend compilation processes. diff --git a/tests/visualisations/_d3js/test_backend.py b/tests/visualisations/_d3js/test_backend.py index 1d4cd3de..ca6436ef 100644 --- a/tests/visualisations/_d3js/test_backend.py +++ b/tests/visualisations/_d3js/test_backend.py @@ -13,6 +13,21 @@ from pathpyG.visualisations._d3js.backend import D3jsBackend from pathpyG.visualisations.network_plot import NetworkPlot from pathpyG.visualisations.temporal_network_plot import TemporalNetworkPlot +from pathpyG.visualisations.unfolded_network_plot import TimeUnfoldedNetworkPlot + + +def test_supports_unfolded_network_plot(): + """Test that D3js backend supports TimeUnfoldedNetworkPlot.""" + tg = TemporalGraph.from_edge_list( + [("a", "b", 1), ("b", "c", 2), ("c", "a", 3)] + ) + unfolded_plot = TimeUnfoldedNetworkPlot(tg) + + backend = D3jsBackend(unfolded_plot, show_labels=True) + assert backend.data is unfolded_plot.data + assert backend.config is unfolded_plot.config + assert backend.show_labels is True + assert backend._kind == "unfolded" class TestD3jsBackendInitialization: diff --git a/tests/visualisations/_matplotlib/test_backend.py b/tests/visualisations/_matplotlib/test_backend.py index 5e17019b..fe0b221d 100644 --- a/tests/visualisations/_matplotlib/test_backend.py +++ b/tests/visualisations/_matplotlib/test_backend.py @@ -13,6 +13,21 @@ from pathpyG.visualisations._matplotlib.backend import MatplotlibBackend from pathpyG.visualisations.network_plot import NetworkPlot from pathpyG.visualisations.temporal_network_plot import TemporalNetworkPlot +from pathpyG.visualisations.unfolded_network_plot import TimeUnfoldedNetworkPlot + + +def test_supports_unfolded_network_plot(): + """Test that Matplotlib backend supports TimeUnfoldedNetworkPlot.""" + tg = TemporalGraph.from_edge_list( + [("a", "b", 1), ("b", "c", 2), ("c", "a", 3)] + ) + unfolded_plot = TimeUnfoldedNetworkPlot(tg) + + backend = MatplotlibBackend(unfolded_plot, show_labels=True) + assert backend.data is unfolded_plot.data + assert backend.config is unfolded_plot.config + assert backend.show_labels is True + assert backend._kind == "unfolded" class TestMatplotlibBackendInitialization: @@ -226,7 +241,7 @@ def test_get_arrowhead_returns_vertices_and_codes(self): P2 = np.array([[1, 0], [1, 0]], dtype=float) vertices = [P0, P1, P2] - arrow_vertices, arrow_codes = self.backend.get_arrowhead(vertices) + arrow_vertices, arrow_codes = self.backend.get_arrowhead(vertices, head_length=0.02) assert len(arrow_vertices) == 4 assert len(arrow_codes) == 4 @@ -241,7 +256,7 @@ def test_get_arrowhead_scales_with_edge_size(self): # Set different edge sizes self.backend.data["edges"]["size"] = np.array([1.0, 5.0]) - arrow_vertices, arrow_codes = self.backend.get_arrowhead(vertices) + arrow_vertices, arrow_codes = self.backend.get_arrowhead(vertices, head_length=0.02) # First arrowhead vertices arrow1_vertices = [v[0] for v in arrow_vertices] diff --git a/tests/visualisations/_tikz/test_backend.py b/tests/visualisations/_tikz/test_backend.py index 87ba8b11..0f060f7c 100644 --- a/tests/visualisations/_tikz/test_backend.py +++ b/tests/visualisations/_tikz/test_backend.py @@ -5,12 +5,27 @@ import tempfile import pytest -import torch from pathpyG.core.graph import Graph +from pathpyG.core.temporal_graph import TemporalGraph from pathpyG.visualisations._tikz.backend import TikzBackend from pathpyG.visualisations.network_plot import NetworkPlot from pathpyG.visualisations.temporal_network_plot import TemporalNetworkPlot +from pathpyG.visualisations.unfolded_network_plot import TimeUnfoldedNetworkPlot + + +def test_supports_unfolded_network_plot(): + """Test that TikZ backend supports TimeUnfoldedNetworkPlot.""" + tg = TemporalGraph.from_edge_list( + [("a", "b", 1), ("b", "c", 2), ("c", "a", 3)] + ) + unfolded_plot = TimeUnfoldedNetworkPlot(tg) + + backend = TikzBackend(unfolded_plot, show_labels=True) + assert backend.data is unfolded_plot.data + assert backend.config is unfolded_plot.config + assert backend.show_labels is True + assert backend._kind == "unfolded" class TestTikzBackendInitialization: diff --git a/tests/visualisations/test_unfolded_network_plot.py b/tests/visualisations/test_unfolded_network_plot.py new file mode 100644 index 00000000..a807c76f --- /dev/null +++ b/tests/visualisations/test_unfolded_network_plot.py @@ -0,0 +1,254 @@ +"""Unit tests for TimeUnfoldedNetworkPlot class in pathpyG.visualisations.""" + +import pandas as pd +import pytest + +from pathpyG.core.temporal_graph import TemporalGraph +from pathpyG.visualisations.unfolded_network_plot import TimeUnfoldedNetworkPlot + + +class TestTimeUnfoldedNetworkPlotInitialization: + """Test TimeUnfoldedNetworkPlot 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 TimeUnfoldedNetworkPlot initializes correctly.""" + plot = TimeUnfoldedNetworkPlot(self.tg) + assert plot.network is self.tg + assert plot._kind == "unfolded" + assert isinstance(plot.data, dict) + + def test_initialization_with_config_options(self): + """Test initialization with various configuration options.""" + plot = TimeUnfoldedNetworkPlot(self.tg, orientation="right", node_color="#ff0000", edge_color="#0000ff") + assert plot.config.get("orientation") == "right" + assert plot.config["directed"] is True + assert plot.config["curved"] is False + + +class TestTimeUnfoldedNetworkPlotNodeData: + """Test node data structure and indexing.""" + + def setup_method(self): + """Create a temporal graph for testing.""" + self.tg = TemporalGraph.from_edge_list([("a", "b", 0), ("b", "c", 1), ("c", "a", 2), ("a", "d", 3)]) + + def test_node_data_has_correct_index(self): + """Test that node data index includes uid and time information.""" + plot = TimeUnfoldedNetworkPlot(self.tg) + nodes = plot.data["nodes"] + + # Index should be a Index with uid and time as tuples + assert isinstance(nodes.index, pd.Index) + # Should have (node_id, time) tuples + assert len(nodes) > 0 + # Each row should have uid and time information in the tuple index + assert all(isinstance(idx, tuple) and len(idx) == 2 for idx in nodes.index) + + def test_node_data_has_position_columns(self): + """Test that node data includes x and y position columns.""" + plot = TimeUnfoldedNetworkPlot(self.tg) + nodes = plot.data["nodes"] + + assert "x" in nodes.columns + assert "y" in nodes.columns + + def test_node_positions_are_normalized(self): + """Test that node positions are normalized to [0, 1].""" + plot = TimeUnfoldedNetworkPlot(self.tg) + nodes = plot.data["nodes"] + + x_coords = nodes["x"] + y_coords = nodes["y"] + + # Coordinates should be between 0 and 1 + assert (x_coords >= 0).all() and (x_coords <= 1).all() + assert (y_coords >= 0).all() and (y_coords <= 1).all() + + def test_node_data_includes_start_time(self): + """Test that node data includes start time information.""" + plot = TimeUnfoldedNetworkPlot(self.tg) + nodes = plot.data["nodes"] + + assert "start" in nodes.columns + assert (nodes["start"] >= 0).all() + + def test_node_data_multiple_instances_per_node(self): + """Test that each node appears at different time steps.""" + plot = TimeUnfoldedNetworkPlot(self.tg) + nodes = plot.data["nodes"] + + # Get unique node IDs from index + node_ids = set(idx[0] for idx in nodes.index) + + # Should have at least nodes a, b, c + assert len(node_ids) >= 3 + + +class TestTimeUnfoldedNetworkPlotEdgeData: + """Test edge data structure and indexing.""" + + def setup_method(self): + """Create a temporal graph for testing.""" + self.tg = TemporalGraph.from_edge_list([("a", "b", 0), ("b", "c", 1), ("c", "a", 2), ("a", "d", 3)]) + + def test_edge_data_has_correct_index(self): + """Test that edge data index includes source-time and target-time tuples.""" + plot = TimeUnfoldedNetworkPlot(self.tg) + edges = plot.data["edges"] + + # Index should be a MultiIndex with source and target + assert isinstance(edges.index, pd.MultiIndex) + assert edges.index.names == ["source", "target"] + + def test_edge_index_includes_temporal_information(self): + """Test that edge index preserves temporal information in tuples.""" + plot = TimeUnfoldedNetworkPlot(self.tg) + edges = plot.data["edges"] + + # Index values should be tuples of (node, time) + for source, target in edges.index: + assert isinstance(source, tuple) and len(source) == 2 + assert isinstance(target, tuple) and len(target) == 2 + + def test_edge_data_has_temporal_columns(self): + """Test that edge data includes temporal columns.""" + plot = TimeUnfoldedNetworkPlot(self.tg) + edges = plot.data["edges"] + + assert "start" in edges.columns + assert "end" in edges.columns + + def test_edge_count_matches_temporal_graph(self): + """Test that number of edges matches temporal graph edges.""" + plot = TimeUnfoldedNetworkPlot(self.tg) + edges = plot.data["edges"] + + # Should have same number of edges as temporal graph + assert len(edges) == len(self.tg.temporal_edges) + + +class TestTimeUnfoldedNetworkPlotLayout: + """Test layout computation for time-unfolded networks.""" + + 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_orientation_right(self): + """Test layout with right orientation.""" + plot = TimeUnfoldedNetworkPlot(self.tg, orientation="right") + nodes = plot.data["nodes"] + x_values = nodes["x"].unique() + y_values = nodes["y"].unique() + + # Should have multiple time steps (x values) and nodes (y values) + for i in range(len(x_values) - 1): + assert x_values[i] < x_values[i + 1] # Increasing x for time steps + assert len(y_values) > 1 # Multiple nodes + + def test_layout_orientation_left(self): + """Test layout with left orientation.""" + plot = TimeUnfoldedNetworkPlot(self.tg, orientation="left") + nodes = plot.data["nodes"] + x_values = nodes["x"].unique() + y_values = nodes["y"].unique() + + # X should decrease for time steps when orientation is left + for i in range(len(x_values) - 1): + assert x_values[i] > x_values[i + 1] # Decreasing x for time steps + assert len(y_values) > 1 # Multiple nodes + + def test_layout_orientation_up(self): + """Test layout with up orientation.""" + plot = TimeUnfoldedNetworkPlot(self.tg, orientation="up") + nodes = plot.data["nodes"] + x_values = nodes["x"].unique() + y_values = nodes["y"].unique() + + # Y should increase for time steps when orientation is up + for i in range(len(y_values) - 1): + assert y_values[i] < y_values[i + 1] # Increasing y for time steps + assert len(x_values) > 1 # Multiple nodes + + def test_layout_orientation_down(self): + """Test layout with down orientation.""" + plot = TimeUnfoldedNetworkPlot(self.tg, orientation="down") + nodes = plot.data["nodes"] + x_values = nodes["x"].unique() + y_values = nodes["y"].unique() + + # Y should decrease for time steps when orientation is down + for i in range(len(y_values) - 1): + assert y_values[i] > y_values[i + 1] # Decreasing y for time steps + assert len(x_values) > 1 # Multiple nodes + + def test_layout_invalid_orientation_raises(self): + """Test that invalid orientation raises ValueError.""" + with pytest.raises(ValueError, match="Invalid orientation"): + TimeUnfoldedNetworkPlot(self.tg, orientation="invalid") + + def test_layout_nodes_at_each_time_step(self): + """Test that nodes appear at each time step in the unfolding.""" + plot = TimeUnfoldedNetworkPlot(self.tg, orientation="right") + nodes = plot.data["nodes"] + assert len(nodes) == self.tg.n * (self.tg.data.time.max() + 2) + + +class TestTimeUnfoldedNetworkPlotConfig: + """Test configuration settings for time-unfolded plots.""" + + def setup_method(self): + """Create a temporal graph for testing.""" + self.tg = TemporalGraph.from_edge_list([("a", "b", 0), ("b", "c", 1), ("c", "a", 2)]) + + def test_orientation_default_is_set(self): + """Test that orientation has a default value.""" + plot = TimeUnfoldedNetworkPlot(self.tg) + assert plot.config.get("orientation") is not None + + def test_orientation_can_be_customized(self): + """Test that orientation can be set in config.""" + plot = TimeUnfoldedNetworkPlot(self.tg, orientation="up") + assert plot.config.get("orientation") == "up" + + +class TestTimeUnfoldedNetworkPlotAttributes: + """Test 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 = TimeUnfoldedNetworkPlot(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 = TimeUnfoldedNetworkPlot(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_edge_constant_attributes(self): + """Test assigning constant attributes to all edges.""" + plot = TimeUnfoldedNetworkPlot(self.tg, edge_color="#0000ff", edge_size=5) + edges = plot.data["edges"] + + assert (edges["color"] == "#0000ff").all() + assert (edges["size"] == 5).all() diff --git a/tests/visualisations/test_utils.py b/tests/visualisations/test_utils.py index a10dfabb..d7808333 100644 --- a/tests/visualisations/test_utils.py +++ b/tests/visualisations/test_utils.py @@ -2,6 +2,7 @@ import base64 import os +from unittest.mock import MagicMock import pytest @@ -9,6 +10,7 @@ cm_to_inch, hex_to_rgb, image_to_base64, + in_jupyter_notebook, inch_to_cm, inch_to_px, prepare_tempfile, @@ -18,6 +20,53 @@ ) +class TestJupyterDetection: + """Test Jupyter notebook detection utilities.""" + + def test_in_jupyter_notebook_true(self, monkeypatch): + """Test that in_jupyter_notebook returns True when running in Jupyter.""" + mock_ipython = MagicMock() + mock_ipython.config = {"IPKernelApp": {}} + + def mock_get_ipython(): + return mock_ipython + + monkeypatch.setattr("pathpyG.visualisations.utils.get_ipython", mock_get_ipython) + assert in_jupyter_notebook() is True + + def test_in_jupyter_notebook_false_no_ipkernelapp(self, monkeypatch): + """Test that in_jupyter_notebook returns False when IPKernelApp not in config.""" + mock_ipython = MagicMock() + mock_ipython.config = {"SomeOtherApp": {}} + + def mock_get_ipython(): + return mock_ipython + + monkeypatch.setattr("pathpyG.visualisations.utils.get_ipython", mock_get_ipython) + assert in_jupyter_notebook() is False + + def test_in_jupyter_notebook_name_error(self, monkeypatch): + """Test that in_jupyter_notebook returns False when get_ipython raises NameError.""" + def mock_get_ipython_raises_name_error(): + raise NameError("name 'get_ipython' is not defined") + + monkeypatch.setattr("pathpyG.visualisations.utils.get_ipython", mock_get_ipython_raises_name_error) + + assert in_jupyter_notebook() is False + + def test_in_jupyter_notebook_attribute_error(self, monkeypatch): + """Test that in_jupyter_notebook returns False when get_ipython().config raises AttributeError.""" + mock_ipython = MagicMock() + del mock_ipython.config # Remove config attribute to trigger AttributeError + + def mock_get_ipython(): + return mock_ipython + + monkeypatch.setattr("pathpyG.visualisations.utils.get_ipython", mock_get_ipython) + + assert in_jupyter_notebook() is False + + class TestColorConversion: """Test RGB/Hex color conversion utilities."""