diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 57b726447..08f566d09 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -6,6 +6,9 @@ RUN apt-get -y install git # For signed commits: https://code.visualstudio.com/remote/advancedcontainers/sharing-git-credentials#_sharing-gpg-keys RUN apt install gnupg2 -y +# Install dependencies for .svg support in tikz +RUN apt update && apt install -y ghostscript + # Install dependencies for manim RUN apt-get install -y build-essential python3-dev libcairo2-dev libpango1.0-dev ffmpeg diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index efb4286c2..50ea557fa 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -37,5 +37,12 @@ "all" ], // Install pathpyG as editable python package - "postCreateCommand": "pip install -e '.[dev,test,doc,vis]' && git config --global --add safe.directory /workspaces/pathpyG" + "postCreateCommand": "pip install -e '.[dev,test,doc,vis]' && git config --global --add safe.directory /workspaces/pathpyG", + "features": { + "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" + } + } } diff --git a/src/pathpyG/core/temporal_graph.py b/src/pathpyG/core/temporal_graph.py index 788cb0f2b..59ce7f8d1 100644 --- a/src/pathpyG/core/temporal_graph.py +++ b/src/pathpyG/core/temporal_graph.py @@ -89,8 +89,26 @@ def from_edge_list(edge_list, num_nodes: Optional[int] = None, device: Optional[ ) @property - def temporal_edges(self) -> Generator[Tuple[int, int, int], None, None]: - """Iterator that yields each edge as a tuple of source and destination node as well as the corresponding timestamp.""" + def temporal_edges(self) -> list: + """Return all temporal edges as a list of tuples (source, destination, timestamp). + + Returns: + list: A list of tuples representing temporal edges in the format (source, destination, timestamp). + + Examples: + Get the list of temporal edges: + + >>> g = pp.TemporalGraph.from_edge_list([('a', 'b', 1), ('b', 'c', 2), ('c', 'a', 3)]) + >>> print(g.temporal_edges) + [('a', 'b', 1), ('b', 'c', 2), ('c', 'a', 3)] + + Iterate over temporal edges: + >>> for edge in g.temporal_edges: + >>> print(edge) + ('a', 'b', 1) + ('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)] def to(self, device: torch.device) -> TemporalGraph: diff --git a/src/pathpyG/visualisations/_tikz/core.py b/src/pathpyG/visualisations/_tikz/core.py index e2f2c8873..73c7f7c90 100644 --- a/src/pathpyG/visualisations/_tikz/core.py +++ b/src/pathpyG/visualisations/_tikz/core.py @@ -28,7 +28,7 @@ class TikzPlot(PathPyPlot): - """Base class for plotting d3js objects.""" + """Base class for plotting tikz objects.""" def __init__(self, **kwargs: Any) -> None: """Initialize plot class.""" @@ -52,20 +52,30 @@ def save(self, filename: str, **kwargs: Any) -> None: shutil.copy(temp_file, filename) # remove the temporal directory shutil.rmtree(temp_dir) - + elif filename.endswith("svg"): + # compile temporary svg + temp_file, temp_dir = self.compile_svg() + # Copy a file with new name + shutil.copy(temp_file, filename) + # remove the temporal directory + shutil.rmtree(temp_dir) else: raise NotImplementedError def show(self, **kwargs: Any) -> None: """Show the plot on the device.""" # compile temporary pdf - temp_file, temp_dir = self.compile_pdf() + temp_file, temp_dir = self.compile_svg() if config["environment"]["interactive"]: - from IPython.display import IFrame, display - - # open the file in the notebook - display(IFrame(temp_file, width=600, height=300)) + from IPython.display import SVG, display + + # open the file, read the content and display it + # workaround because it is not possible to embed files in vs code + # https://github.com/microsoft/vscode-jupyter/discussions/13769 + with open(temp_file, "r") as svg_file: + svg = SVG(svg_file.read()) + display(svg) else: # open the file in the webbrowser webbrowser.open(r"file:///" + temp_file) @@ -76,33 +86,44 @@ def show(self, **kwargs: Any) -> None: # remove the temporal directory shutil.rmtree(temp_dir) - def compile_pdf(self) -> tuple: - """Compile pdf from tex.""" - # basename - basename = "default" - # get current directory - current_dir = os.getcwd() - - # template directory - tikz_dir = str( - os.path.join( - os.path.dirname(os.path.dirname(__file__)), - os.path.normpath("templates"), - "tikz-network.sty", - ) - ) - - # get temporal directory - temp_dir = tempfile.mkdtemp() + def compile_svg(self) -> tuple: + """Compile svg from tex.""" + temp_dir, current_dir, basename = self.prepare_compile() - # copy tikz-network to temporal directory - shutil.copy(tikz_dir, temp_dir) + # latex compiler + command = [ + "latexmk", + "--interaction=nonstopmode", + basename + ".tex", + ] + try: + subprocess.check_output(command, stderr=subprocess.STDOUT) + 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", + basename + ".dvi", + "-o", + basename + ".svg", + ] + try: + subprocess.check_output(command, stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as e: + logger.error("dvisvgm command failed with output:\n%s", e.output.decode()) + raise AttributeError from e + finally: + # change back to the current directory + os.chdir(current_dir) - # change to output dir - os.chdir(temp_dir) + # return the name of the folder and temp svg file + return os.path.join(temp_dir, basename + ".svg"), temp_dir - # save the tex file - self.save(basename + ".tex") + def compile_pdf(self) -> tuple: + """Compile pdf from tex.""" + temp_dir, current_dir, basename = self.prepare_compile() # latex compiler command = [ @@ -115,16 +136,32 @@ def compile_pdf(self) -> tuple: try: subprocess.check_output(command, stderr=subprocess.STDOUT) - except Exception: - # If compiler does not exist, try next in the list - logger.error("No latexmk compiler found") - raise AttributeError + except subprocess.CalledProcessError as e: + logger.error("latexmk compiler failed with output:\n%s", e.output.decode()) + raise AttributeError from e finally: # change back to the current directory os.chdir(current_dir) # return the name of the folder and temp pdf file - return (os.path.join(temp_dir, basename + ".pdf"), temp_dir) + 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 def to_tex(self) -> str: """Convert data to tex.""" @@ -144,8 +181,8 @@ 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", "6cm"), - height=self.config.get("height", "6cm"), + width=self.config.get("width", "12cm"), + height=self.config.get("height", "12cm"), tikz=data, ) diff --git a/src/pathpyG/visualisations/_tikz/network_plots.py b/src/pathpyG/visualisations/_tikz/network_plots.py index fb4d3ea65..1aed15856 100644 --- a/src/pathpyG/visualisations/_tikz/network_plots.py +++ b/src/pathpyG/visualisations/_tikz/network_plots.py @@ -32,12 +32,10 @@ def __init__(self, data: dict, **kwargs: Any) -> None: super().__init__() self.data = data self.config = kwargs - self.config["width"] = self.config.pop("width", 6) - self.config["height"] = self.config.pop("height", 6) self.generate() def generate(self) -> None: - """Clen up data.""" + """Clean up data.""" self._compute_node_data() self._compute_edge_data() self._update_layout() diff --git a/src/pathpyG/visualisations/_tikz/templates/network.tex b/src/pathpyG/visualisations/_tikz/templates/network.tex index a6496a3ec..5ce11fd7c 100644 --- a/src/pathpyG/visualisations/_tikz/templates/network.tex +++ b/src/pathpyG/visualisations/_tikz/templates/network.tex @@ -1,10 +1,12 @@ \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,0) rectangle ($width,$height); +\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 a6496a3ec..e926806cf 100644 --- a/src/pathpyG/visualisations/_tikz/templates/static.tex +++ b/src/pathpyG/visualisations/_tikz/templates/static.tex @@ -1,10 +1,13 @@ \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,0) rectangle ($width,$height); +\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); $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 index a6496a3ec..5ce11fd7c 100644 --- a/src/pathpyG/visualisations/_tikz/templates/temporal.tex +++ b/src/pathpyG/visualisations/_tikz/templates/temporal.tex @@ -1,10 +1,12 @@ \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,0) rectangle ($width,$height); +\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_plots.py b/src/pathpyG/visualisations/network_plots.py index ec2bf6bf4..cd89d8f18 100644 --- a/src/pathpyG/visualisations/network_plots.py +++ b/src/pathpyG/visualisations/network_plots.py @@ -412,6 +412,7 @@ 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.""" @@ -419,8 +420,7 @@ def __init__(self, network: TemporalGraph, **kwargs: Any) -> None: def _get_edge_data(self, edges: dict, attributes: set, attr: defaultdict, categories: set) -> None: """Extract edge data from temporal network.""" - # TODO: Fix typing issue with temporal graphs - for u, v, t in self.network.temporal_edges: # type: ignore + for u, v, t in self.network.temporal_edges: uid = f"{u}-{v}-{t}" edges[uid] = { "uid": uid, diff --git a/src/pathpyG/visualisations/plot.py b/src/pathpyG/visualisations/plot.py index b2c04cfdc..7e85b20d5 100644 --- a/src/pathpyG/visualisations/plot.py +++ b/src/pathpyG/visualisations/plot.py @@ -28,6 +28,7 @@ ".html": "d3js", ".tex": "tikz", ".pdf": "tikz", + ".svg": "tikz", ".png": "matplotlib", ".mp4": "manim", ".gif": "manim",