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/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 6d5a1becb..ad9992bd7 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,12 @@ runs: python-version: ${{ inputs.python-version }} activate-environment: true + - name: Cache uv virtual environment + uses: actions/cache@v4 + with: + path: .venv + 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 +44,48 @@ runs: uv run python -c "import torch; print('CUDA:', torch.version.cuda)" shell: bash + - name: Set up TeX Live + if: ${{ inputs.full_install == 'true' }} + uses: TeX-Live/setup-texlive-action@v3 + with: + cache: true + packages: | + scheme-basic + latexmk + 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' }} - run: | # ToDo: Add LaTeX installation - sudo apt-get update && sudo apt-get install -y build-essential python3-dev libcairo2-dev libpango1.0-dev ffmpeg + uses: awalsh128/cache-apt-pkgs-action@v1 + with: + execute_install_scripts: true + packages: | + build-essential + python3-dev + libcairo2-dev + libpango1.0-dev + + - name: Install ffmpeg + if: ${{ inputs.full_install == 'true' }} + uses: FedericoCarboni/setup-ffmpeg@v3 + + - name: Install optional Python dependencies + if: ${{ inputs.full_install == 'true' }} + run: | uv sync --frozen --extra vis --extra ${{ inputs.cuda-version }} shell: bash diff --git a/docs/gen_ref_pages.py b/docs/gen_ref_pages.py index f2059e22c..8055deda8 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("") @@ -28,7 +28,14 @@ elif parts[-1] == "__main__": continue - nav[(part.split("_")[-1] for part in parts)] = doc_path.as_posix() + parts_list = [] + for part in parts: + if part.startswith("_"): + parts_list.append(part.split("_")[-1]) + else: + parts_list.append(part) + + nav[tuple(parts_list)] = doc_path.as_posix() print(f"Checking {full_doc_path}") if not (Path("docs") / full_doc_path).exists(): diff --git a/docs/getting_started.md b/docs/getting_started.md index 912a99037..b4a8ebdc3 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -47,10 +47,22 @@ pip install git+https://github.com/pathpy/pathpyG.git ### Optional Visualisation Backends -We provide multiple visualisation backends for PathpyG. The default backend [D3.js](https://d3js.org/) does not require any additional dependencies. We further provide a [Matplotlib](https://matplotlib.org/) backend that is installed by default. Additionally, we implemented a [Manim](https://www.manim.community/) backend that is not installed by default due to its dependencies that are required for installation. Please refer to the [Manim installation instructions](https://docs.manim.community/en/stable/installation/uv.html) for more information. Once installed, you can use the Manim backend for visualisation by setting the `backend` in the `PathpyG.plot` function to `manim`: -```python -import pathpyg as pp +We provide multiple visualisation backends for PathpyG. The default backend [D3.js](https://d3js.org/) does not require any additional dependencies. We further provide a [Matplotlib](https://matplotlib.org/) backend that is installed by default. Additionally, we implemented a [tikz](https://tikz.dev/) and a [Manim](https://www.manim.community/) backend that are not installed by default due to their dependencies that are required for installation. You can use the tikz backend if you have a LaTeX distribution installed on your system. -t_graph = TemporalGraph.from_edge_list([('a', 'b', 1),('b', 'a', 3), ('b', 'c', 3)]) -pp.plot(t_graph, backend='manim') -``` \ No newline at end of file +To use the Manim backend, please refer to the [Manim installation instructions](https://docs.manim.community/en/stable/installation/uv.html) for more information. Once installed, you can use the backends for visualisation by setting the `backend` in the `PathpyG.plot` function to `tikz` or `manim`: + +??? example "Using the TikZ Backend" + ```python + import pathpyg as pp + + g = pp.Graph.from_edge_list([('a', 'b'),('b', 'c'),('c', 'a')]) + pp.plot(g, backend='tikz') + ``` + +??? example "Using the Manim Backend" + ```python + import pathpyg as pp + + t_graph = TemporalGraph.from_edge_list([('a', 'b', 1),('b', 'a', 3), ('b', 'c', 3)]) + pp.plot(t_graph, backend='manim') + ``` \ No newline at end of file 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. 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/_manim/index.md b/docs/reference/pathpyG/visualisations/_manim/index.md deleted file mode 100644 index 7f9c6d813..000000000 --- a/docs/reference/pathpyG/visualisations/_manim/index.md +++ /dev/null @@ -1,122 +0,0 @@ -# Visualisation using Manim - -The `_manim` submodule provides plotting tools for creating Manim-based visualisations of temporal networks. -Designed for use in Jupyter notebooks or standalone rendering, it allows animated plots of temporal graphs -while offering a wide range of customizable styling options. - ---- - -## Classes - -### `ManimPlot` - -Base class for Manim visualisations integrated with Jupyter notebooks. Defines the interface for rendering and exporting animations from data. - -**Methods** - -- `show(**kwargs)`: Render and display inline in Jupyter Notebook -- `save(filename: str, **kwargs)`: Save animation to disk - -**kwargs** for saving Manim plots: - -- `filename` (`str`): Name the rendered file should be given. This keyword is necessary for saving. -- `save_as` {`gif`,`mp4`}: Saving format options. Default is `mp4` -- `save_dir` (`str`): Directory path to save the Output to. Default is current working directory. - -For rendering and inline display use the `show()` method instead of `save()`. - - -### `TemporalNetworkPlot` - -Animation class for temporal graphs. Supports dynamic layout, time-based color changes, and further customized styling options. - -#### Keyword Arguments Overview - -| Argument | Type | Default | Short Description | -|------------------------|------------------|----------|-------------------------------------------------| -| **General** | | | | -| `delta` | int | 1000 | Duration of timestep (ms) | -| `start` | int | 0 | Animation start timestep | -| `end` | int / None | None | Animation end timestep (last edge by default) | -| `intervals` | int | None | Number of animation intervals | -| `dynamic_layout_interval` | int | None | Steps between layout recalculations | -| `background_color` | str | WHITE | Background color (name, hex, RGB, or Manim) | -| **Nodes** | | | | -| `node_size` | float / dict | 0.4 | Radius of nodes (uniform or per-node) | -| `node_color` | str / list / dict / float / tuple | BLUE | Node fill color or list of colors | -| `node_cmap` | Colormap | None | Colormap for scalar node values | -| `node_opacity` | float / dict | 1 | Node fill opacity (0 transparent, 1 solid) | -| `node_color_timed` | list | None | Color Changes for Nodes at timestep -| **Edges** | | | | -| `edge_size` | float / dict | 0.4 | Edge width (uniform or per-edge) | -| `edge_color` | str / list / dict / float / tuple | GRAY | Edge line color or list of colors | -| `edge_cmap` | Colormap | None | Colormap for scalar edge values | -| `edge_opacity` | float / dict | 1 | Edge line opacity (0 transparent, 1 solid) | - ---- -#### Detailed Descriptions - -##### General - -- `delta`: Duration (in milliseconds) of each animation timestep. -- `start`: Starting timestep of the animation sequence. -- `end`: Ending timestep; defaults to the last timestamp of the input data. -- `intervals`: Number of discrete animation steps. -- `dynamic_layout_interval`: How often (in timesteps) the layout recomputes. -- `background_color`: Background color of the plot, accepts color names, hex codes, RGB tuples, or Manim color constants. - - -##### 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 -- `node_cmap`: Colormap used when node colors are numeric. -- `node_opacity`: Opacity level for nodes, either uniform or per node. -- `node_color_timed`: List containing color changes at certain time steps for a certain node. Tuples in the list follow `('node_id',(t, color))` format to indicate for a node with node_id a change in color at time t. Color can be a single color string referred to by name, HEX, RGB or float. - -##### 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. - ---- - - -#### Notable Methods - -- `construct`: Core method for generating the Manim animation -- `get_layout()`: Computed node 3D positions based on temporal windows -- `get_color_at_time()`: Determines a node´s color at a given timestep -- `compute_edge_index()`: converts input data into `(source, target, time)` tuples - -## Usage Example -```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)] -t = pp.TemporalGraph.from_edge_list(tedges) - -# Create temporal plot with custom settings and display inline -pp.plot( - t, - backend= 'manim', - delta = 5, - start= 1, - end = 10, - background_color = '#f0f0f0', - node_size = {"a": 0.6, "b": 0.3}, - node_color = ["red", "blue"], - edge_color = {'a-b-1.0':0.6, 'd-c-4.0':'green'}, - edge_opacity = 0.7, - node_color_timed = [('a', (1, 'yellow')), ('b', (2, 'blue')), ('c', (4, 0.1)), ('b', 4, (255,0,0))] -) -``` - -## Notes - -- The Manim config is adjusted internally for resolution, framerate and format - - diff --git a/docs/reference/pathpyG/visualisations/index.md b/docs/reference/pathpyG/visualisations/index.md new file mode 100644 index 000000000..5aeaf7cd1 --- /dev/null +++ b/docs/reference/pathpyG/visualisations/index.md @@ -0,0 +1,480 @@ +# 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) + ``` + + +### 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`, `jpg` | +| **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). + +### 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 + +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 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) +- 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`). +
+ +1. If both the graph attribute and the function argument are provided, the function argument takes precedence. + +!!! 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. + +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. + + ```python + import torch + import numpy as np + import pathpyG as pp + + # 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 + +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. + +### Static Networks +You can change the layout algorithm for static networks using the `layout` argument in the `pp.plot()` function call. + +**networkx layouts:** + +We currently support most layouts via the `networkx` library. +See the examples below for usage. + +=== "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 + +=== "Circular" + + 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) + + g = pp.algorithms.generative_models.watts_strogatz(30, 2, 0.25) + pp.plot(g, backend="tikz", layout="circular") + ``` + Example TikZ Circular Layout + +=== "Shell" + + 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 + + ```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="grid", filename="tikz_grid_layout.svg") + ``` + Example TikZ Grid Layout + +- 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.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..575799af2 --- /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_node_opacity.html b/docs/reference/pathpyG/visualisations/plot/d3js_node_opacity.html new file mode 100644 index 000000000..b615a7ac2 --- /dev/null +++ b/docs/reference/pathpyG/visualisations/plot/d3js_node_opacity.html @@ -0,0 +1,506 @@ + + +
+ + \ 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..0ca847239 --- /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..13794f4b9 --- /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 aadd2e700..000000000 Binary files a/docs/reference/pathpyG/visualisations/plot/demo_manim.mp4 and /dev/null differ diff --git a/docs/reference/pathpyG/visualisations/plot/demo_matplotlib.png b/docs/reference/pathpyG/visualisations/plot/demo_matplotlib.png deleted file mode 100644 index b4966f934..000000000 Binary files a/docs/reference/pathpyG/visualisations/plot/demo_matplotlib.png and /dev/null differ diff --git a/docs/reference/pathpyG/visualisations/plot/documentation_plots.ipynb b/docs/reference/pathpyG/visualisations/plot/documentation_plots.ipynb new file mode 100644 index 000000000..b8a813d0c --- /dev/null +++ b/docs/reference/pathpyG/visualisations/plot/documentation_plots.ipynb @@ -0,0 +1,1429 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "9d7f85a4", + "metadata": {}, + "outputs": [], + "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", + "pp.plot(t, filename=\"d3js_temporal.html\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "011e3cc1", + "metadata": {}, + "outputs": [], + "source": [ + "import pathpyG as pp\n", + "\n", + "# Example network data\n", + "edges = [\n", + " (\"a\", \"b\"),\n", + " (\"a\", \"c\"),\n", + " (\"b\", \"c\"),\n", + " (\"c\", \"d\"),\n", + " (\"d\", \"e\"),\n", + " (\"e\", \"a\"),\n", + "]\n", + "g = pp.Graph.from_edge_list(edges)\n", + "pp.plot(g, filename=\"d3js_static.html\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "639acb38", + "metadata": {}, + "outputs": [], + "source": [ + "import torch\n", + "import pathpyG as pp\n", + "\n", + "# Example undirected network data\n", + "edge_index = torch.tensor([[0, 1, 3, 3], [1, 2, 1, 0]])\n", + "g = pp.Graph.from_edge_index(edge_index).to_undirected()\n", + "\n", + "# Create static plot and display inline\n", + "pp.plot(g, backend=\"matplotlib\", filename=\"matplotlib_undirected.png\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e9b55019", + "metadata": {}, + "outputs": [], + "source": [ + "import torch\n", + "import pathpyG as pp\n", + "\n", + "# Example network data\n", + "edges = [\n", + " (\"a\", \"b\"),\n", + " (\"a\", \"c\"),\n", + " (\"b\", \"d\"),\n", + " (\"c\", \"d\"),\n", + " (\"d\", \"a\"),\n", + "]\n", + "g = pp.Graph.from_edge_list(edges)\n", + "\n", + "# Add properties as attributes to the graph\n", + "g.data[\"node_size\"] = torch.tensor([10, 15, 20, 15])\n", + "g.data[\"edge_color\"] = torch.tensor([0, 1, 2, 1, 0])\n", + "g.data[\"node_opacity\"] = torch.zeros(g.n)\n", + "\n", + "# Create static plot with custom settings and display inline\n", + "pp.plot(\n", + " g,\n", + " backend=\"tikz\",\n", + " node_color={\"a\": \"red\", \"b\": \"#00FF00\"},\n", + " edge_opacity={(\"a\", \"b\"): 0.1, (\"a\", \"c\"): 0.5, (\"b\", \"d\"): 1.0},\n", + " node_opacity=1.0, # override graph attribute\n", + " edge_size=torch.tensor([1, 2, 3, 2, 1]),\n", + " filename=\"tikz_custom_properties.svg\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bd65a329", + "metadata": {}, + "outputs": [], + "source": [ + "import torch\n", + "import pathpyG as pp\n", + "\n", + "# Example network data\n", + "edges = [\n", + " (\"b\", \"a\"),\n", + " (\"c\", \"a\"),\n", + "]\n", + "mapping = pp.IndexMap([\"a\", \"b\", \"c\", \"d\"])\n", + "g = pp.Graph.from_edge_list(edges, mapping=mapping)\n", + "g.data[\"node_size\"] = torch.tensor([25]*4)\n", + "pp.plot(\n", + " g,\n", + " node_size={\"d\": 50},\n", + " edge_size=5,\n", + " node_image={\n", + " \"a\": \"https://avatars.githubusercontent.com/u/52822508?s=48&v=4\",\n", + " \"b\": \"https://raw.githubusercontent.com/pyg-team/pyg_sphinx_theme/master/pyg_sphinx_theme/static/img/pyg_logo.png\",\n", + " \"c\": \"https://pytorch-geometric.readthedocs.io/en/latest/_static/img/pytorch_logo.svg\",\n", + " \"d\": \"/workspaces/pathpyG/docs/img/pathpy_logo_new.png\",\n", + " },\n", + " show_labels=False,\n", + " filename=\"d3js_custom_node_images.html\",\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b31fb5a7", + "metadata": {}, + "outputs": [], + "source": [ + "import torch\n", + "import numpy as np\n", + "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", + "]\n", + "t = pp.TemporalGraph.from_edge_list(tedges)\n", + "t.data[\"node_size\"] = torch.tensor([15, 8, 19])\n", + "t.data[\"node_color\"] = np.array([\"blue\", \"green\", \"orange\"])\n", + "t.data[\"edge_color\"] = torch.tensor([0, 1, 2, 1])\n", + "\n", + "# Create temporal plot and display inline\n", + "pp.plot(\n", + " t,\n", + " backend=\"manim\",\n", + " node_opacity=0.5,\n", + " edge_size={(\"a\", \"b\", 1): 10, (\"a\", \"b\", 2): 1},\n", + " node_color={(\"b\", 2): \"red\", \"a\": \"purple\"}, # node_color for node 'a' is set to 'purple' from the start\n", + " filename=\"manim_custom_properties.gif\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "324fc97c", + "metadata": {}, + "source": [ + "## Layouts" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "90d4b5cf", + "metadata": {}, + "outputs": [], + "source": [ + "import pathpyG as pp\n", + "from torch_geometric import seed_everything\n", + "seed_everything(42)\n", + "\n", + "g = pp.algorithms.generative_models.watts_strogatz(30, 2, 0.25)\n", + "pp.plot(g, backend=\"tikz\", layout=\"random\", filename=\"tikz_random_layout.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11b1d2fd", + "metadata": {}, + "outputs": [], + "source": [ + "import pathpyG as pp\n", + "from torch_geometric import seed_everything\n", + "seed_everything(42)\n", + "\n", + "g = pp.algorithms.generative_models.watts_strogatz(30, 2, 0.25)\n", + "pp.plot(g, backend=\"tikz\", layout=\"circle\", filename=\"tikz_circle_layout.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fa396cc5", + "metadata": {}, + "outputs": [], + "source": [ + "import pathpyG as pp\n", + "from torch_geometric import seed_everything\n", + "seed_everything(42)\n", + "\n", + "g = pp.algorithms.generative_models.watts_strogatz(30, 2, 0.25)\n", + "pp.plot(g, backend=\"tikz\", layout=\"shell\", filename=\"tikz_shell_layout.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f8031bb", + "metadata": {}, + "outputs": [], + "source": [ + "import pathpyG as pp\n", + "from torch_geometric import seed_everything\n", + "seed_everything(42)\n", + "\n", + "g = pp.algorithms.generative_models.watts_strogatz(30, 2, 0.25)\n", + "pp.plot(g, backend=\"tikz\", layout=\"spectral\", filename=\"tikz_spectral_layout.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "49f57f41", + "metadata": {}, + "outputs": [], + "source": [ + "import pathpyG as pp\n", + "from torch_geometric import seed_everything\n", + "seed_everything(42)\n", + "\n", + "g = pp.algorithms.generative_models.watts_strogatz(30, 2, 0.25)\n", + "pp.plot(g, backend=\"tikz\", layout=\"kk\", filename=\"tikz_kk_layout.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2ab50ca7", + "metadata": {}, + "outputs": [], + "source": [ + "import pathpyG as pp\n", + "from torch_geometric import seed_everything\n", + "seed_everything(42)\n", + "\n", + "g = pp.algorithms.generative_models.watts_strogatz(30, 2, 0.25)\n", + "pp.plot(g, backend=\"tikz\", layout=\"spring\", filename=\"tikz_spring_layout.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "07bbd250", + "metadata": {}, + "outputs": [], + "source": [ + "import pathpyG as pp\n", + "from torch_geometric import seed_everything\n", + "seed_everything(42)\n", + "\n", + "g = pp.algorithms.generative_models.watts_strogatz(30, 2, 0.25)\n", + "pp.plot(g, backend=\"tikz\", layout=\"fa2\", filename=\"tikz_fa2_layout.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e3a120f7", + "metadata": {}, + "outputs": [], + "source": [ + "import pathpyG as pp\n", + "from torch_geometric import seed_everything\n", + "seed_everything(42)\n", + "\n", + "g = pp.algorithms.generative_models.watts_strogatz(30, 2, 0.25)\n", + "pp.plot(g, backend=\"tikz\", layout=\"grid\", filename=\"tikz_grid_layout.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1d774139", + "metadata": {}, + "outputs": [], + "source": [ + "import pathpyG as pp\n", + "\n", + "g = pp.Graph.from_edge_list([(\"a\", \"b\"), (\"a\", \"c\"), (\"b\", \"d\"), (\"c\", \"d\"), (\"d\", \"a\")])\n", + "# Provide custom x and y coordinates for a layout\n", + "layout = {\n", + " \"a\": (0, 0),\n", + " \"b\": (1, 0),\n", + " \"c\": (0, 1),\n", + " \"d\": (1, 1)\n", + "}\n", + "pp.plot(g, backend=\"tikz\", layout=layout, filename=\"tikz_layout.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0780f52c", + "metadata": {}, + "outputs": [], + "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", + "pp.plot(t, backend=\"manim\", layout_window_size=2, layout=\"fa2\", filename=\"manim_temporal_fa2.gif\")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "69983370", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pathpyG as pp\n", + "\n", + "# Example network data\n", + "g = pp.Graph.from_edge_list([(\"a\", \"b\"), (\"a\", \"c\")])\n", + "\n", + "# Create network plot and display inline\n", + "pp.plot(g, node={\"opacity\": 0.2}, filename=\"d3js_node_opacity.html\")" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "e262e2ad", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 2, + "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, backend=\"tikz\", filename=\"tikz_init_basic.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "23ddcdfe", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pathpyG as pp\n", + "import torch\n", + "\n", + "# Graph with custom styling\n", + "edges = [(\"A\", \"B\"), (\"B\", \"C\"), (\"C\", \"D\"), (\"D\", \"A\")]\n", + "g = pp.Graph.from_edge_list(edges)\n", + "g.data[\"node_size\"] = torch.tensor([15, 20, 25, 20])\n", + "\n", + "pp.plot(\n", + " g,\n", + " backend=\"tikz\",\n", + " node_color={\"A\": \"red\", \"B\": \"#00FF00\"},\n", + " edge_opacity=0.7,\n", + " curvature=0.2,\n", + " width=\"8cm\",\n", + " height=\"6cm\",\n", + " filename=\"tikz_init_advanced.svg\",\n", + " margin=0.25\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "22165c89", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# The backend is typically used via pp.plot()\n", + "import pathpyG as pp\n", + "g = pp.Graph.from_edge_list([(\"A\", \"B\"), (\"B\", \"C\")])\n", + "pp.plot(g, backend=\"tikz\", filename=\"tikz_backend_example.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "aaa69bed", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAkMAAAI/CAYAAAB02d0hAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAXEgAAFxIBZ5/SUgAAQc1JREFUeJzt3XmYXPld3/vPqa2ret/3fW+11FpGs3tmPLa5GAx27LEnYXkguSQhEJJLHgjJQ4AA3iCOE0juTbgQuEASAvbYMUmIgcTM4lk8mtHSLbXU+77va1XXeu4fmtFMqVtLt6rrVNV5v/6yfqe6zldikN5P1TnnZ5imaQoAAMCmHFYPAAAAYCViCAAA2BoxBAAAbI0YAgAAtkYMAQAAWyOGAACArRFDAADA1oghAABga8QQAACwNWIIAADYGjEEAABsjRgCAAC2RgwBAABbI4YAAICtEUMAAMDWiCEAAGBrxBAAALA1YggAANgaMQQAAGyNGAIAALZGDAEAAFsjhgAAgK0RQwAAwNaIIQAAYGvEEAAAsDViCAAA2BoxBAAAbI0YAgAAtkYMAQAAWyOGAACArRFDAADA1oghAABga8QQAACwNWIIAADYGjEEAABsjRgCAAC2RgwBAABbI4YAAICtEUMAAMDWiCEAAGBrxBAAALA1l9UDHKdQKKTR0VENDQ2p7+pVTU7PKBgMyulwKjcvVz3dJ9TZ2amOjg6Vl5fLMAyrRwYAAElmmKZpWj1Eoo2Pj+urL7ygr/3XP1XUcMidnS9ndq6yc/PlcDplmqYioZD8W+syg34FttbV0daqH/3hH9KHP/xheb1eq38LAAAgSTIqhqanp/W5L3xRb759Udll1apsbFd2fsE9fy4Wi2p5ZkobcxNyRYL6yb/3d/WDP/ADcjqdSZgaAABYKSNiKBaL6U/+5E/05d/8t/JV1Km2rVtO19G+AdzeWNPMtYvqbKrT5371V9TU1JTgaQEAQCpJ+xjy+/36Rz/zs3r76nXVnXpYuQVFD/yephnTzNAN7cyP69c/96v6yEc+koBJAQBAKkrrGNrd3dWP/+RPanh2WS3nHpfDkdivtTZXlzXd+x19/p//gj72sY8l9L0BAEBqSNsYikQi+vGf+Eldm5hV67knju1OsO2NNU1eek3/5l/+up5++uljOQcAALBO2j5n6Pd+//d1ZXBULWcfP9Zb4vMKi1Vz8mH9k3/2C1pZWTm28wAAAGukZQwNDQ3pt377d9XY84gcjuP/LRSVV8qRX6pf+eznlKYfpAEAgDtIuxgyTVP/7Bd/SQX1rfLl5iXtvPVdZ/TqhYv61re+lbRzAgCA45d2MXTx4kWNTs2quqUzqed1ulwqbe7Ub//u7/HpEAAAGSTtYug//dF/UW5lrSVbZ5TV1GtodFzXr19P+rkBAMDxSKsYWl5e1kvfflVVje2WnN/hcCq3sk5f+eoLlpwfAAAkXlrFUF9fn7z5RXJnZVk2Q0lVnV594w3Lzg8AABIrrWLo+o0bcnhzLJ0hp6BQy8sr2tjYsHQOAACQGEfbwMsiFy9fUW5RyZF/Phzc05VX/lIzI4MK7QWUX1yqpz/5g8rJL7zv93A4nPLlF2poaEiPPPLIkWcBAACpIa1iaGZmVrlN3Uf++Tf+59cUCYf13T/84/Ll5mp9aUFOl/vQ7+PM8ml+fv7IcwAAgNSRVl+TBUNBOZxH67fA7o6mh67r0Y/+NWXn5cswHCquqJY3+whfuxkOhUKhI80BAABSS1rFkNPhlI74jJ/dzXU5nK5DfSV2J6ZpyulM7KawAADAGmkVQ9k5OQqHj/aJTE5BkWLRiHa3Nh58kFhUPp/vwd8HAABYLq1iqKujXdvrq0f6WV9OrmrbunThL/5UgZ0tmWZMa4tzCgb8h36v0O6WmpubjzQHAABILWkVQw+dPaO97Y0j//zjH/u0svML9M0/+Hf6ym98Thf+4k8VDYcP9R7hYFDR0B4xBABAhkiru8na29sV9W8f+ec9WV49+t1/Tfruo8+wubqkttZWud2HvwsNAACknrT6ZKinp0dep3Hkr8oSYX1uUh//2Pdadn4AAJBYaRVDHo9HP/g3ntfi+JAl5w8G/Apvren7v//7LTk/AABIvLSKIUn61Cc/qb31JYX2Akk/9+zogL73o9+twsLCpJ8bAAAcj7SLoYqKCv3g85/WeN9bMo/4zKGj2NlcV2hlTn/3b/9Y0s4JAACOX9rFkCT9g5/6KZX4XFqcGk/K+WKxqKb6Luhnf/ofqr6+PinnBAAAyZGWMeTz+fTFz31Wq6P9iXmI4l2YpqmJa5fV09Gq559//ljPBQAAki8tY0iSzp49q3/2cz+j8be/Lf/25rGcwzRNTd3oVZErqi9/6V/I4UjbPy4AAHAHafWcodt9+tOfVjgS0a//q99UXc+jKigtT9h7x2JRjV+9qBKP9Lu//dsqKSlJ2HsDAIDUYZjJvAr5mPzlX/6lfulXPydnQZnqT5yW84g7279ra21FM9fe1sM93friFz6v4uLiBE0KAABSTUbEkCQtLS3pVz77Ob1x8bKK69tVXtcoxyF3lvdvb2p+bFCxrRX9/M/9Y3384x+XYRjHNDEAAEgFGRND0s1rfF566SX93h/8oa5eH1BOWbUKK2qUV1Qsl9tz4OsDO9vaWl3W1uK0jJBfz/21T+hHf+RHVFFRYcHvAAAAJFtGxdD7jY6O6mtf/7pe/84FTUxMyOX1ye3LkQyHJFNmJKLA9oZysn3qPnFCH//Y9+rDH/6wsrOzrR4dAAAkUcbG0PsFg0GNjo5qaWlJoVBITqdTPp9Pzc3NqqiokGmaeuXyVXncLj3S3SnXIb9eAwAA6csWMXQva5vb+m+vvK5AMKSOhlo9fa6HIAIAwCbS+tb6RNn2+xUMhTW1sCjpZhsSRAAA2AMxJGnHH1AwHJYvy6PZpRVJNy+ufuah0wQRAAAZjhiStOX3KxQOKz8nWzk+nybnF28de/pcj9wu/pgAAMhU/Cuvm58MhcJhZXtzlePzqqGqQpPzizLNm1+aPUMQAQCQsfgXXtL2rl/BcEQet1uS4oJIkvTOV2YEEQAAmcf2/7qHIxH5g0GFwmFlud/748jxedVYXaGJufe+MiOIAADIPLb/l317N6BwOCKX0ynnbRdLZ3vfH0SmTEkfJIgAAMgotv9Xffudi6c97oP/KG4GUaUm5hZurRFEAABkDtv/i77tv3m9UNY71wsdJNubdSuITN287f7Z82cIIgAAMoDt/zXf3g2888nQnWNIig+id57LSBABAJABbP8v+c1PhsLK8Xnv+dp3g2jynYuqTUnPPnT6niEFAABSl+1jaMcfUCh052uGbpftzVJjTYUmZt+7y4wgAgAgfdk6hkLh8M3b6iORQ8WML+u9IDJlSqapDz18lq/MAABIQw6rB7DStj+g0Lu31Tvu748iFotpa9ev1Y0txWRqdXNLC6vrt/Y0AwAA6cXWH2Vs7/rv6+LpWCymncCeNnd2tb3rl9vtUkFOjlprq1WYl6v6ynLVlJcmaWoAAJBI9o6hdz4ZyvLs/2OIxWLa8Qe0uevX1o5fTqdDxQV5Ki+6GUB1leWqryhTWVGhDMOwYHoAAJAIto6hHX9AwdB7nwzFYjFt+wPa2tnV1m5ADochwzBkypTT6dCTp0+qoapCZYUFBBAAABnC1jG09c7TpyVTUwtL2vb7leX2qCA3R8UF+ZpeXFJ+TrYKcnOU7c1SVWmxyosKrR4bAAAkkK1jaOedBy5GzZgKcnJUWVJ86xqg+spyXRoY1srG5q3XT84tqra8zMKJAQBAotk2hkzTlGFIzbVVKszLU11Fmeory1VSkH/rK7DG6sq4GJpZXlE4EuEWegAAMohhmqZp9RBW2Qnc3LG+MC/3wGuA9kIh/dcXX9X7/4ge7+lWU3VlMscEAADHyNbPGcr1+VSUn3fHi6G9Ho+qSovj1ibnFw58LQAASE+2jqH70VAV/ynQ/Mqa9oIhi6YBAACJRgzdQ215qZxO561fm+bNO88AAEBmIIbuwe1yqbYs/unS43PzFk0DAAASjRi6D423XTC9urmltc1ti6YBAACJRAzdh6rSYmV7vXFrIzOzFk0DAAASiRi6Dw6HQy21VXFrE3MLCkciFk0EAAAShRi6Ty211TL03i34kWhUk/OLFk4EAAASgRi6T9ler6rLS+LWRqbnLJoGAAAkCjF0CK21NXG/Xtva0urmlkXTAACARCCGDuGgC6lH+XQIAIC0Rgwdws0Lqavj1ibmuZAaAIB0RgwdUkttFRdSAwCQQYihQzroQurhaZ45BABAuiKGjqCtLv5C6vWtbS6kBgAgTRFDR1BZwoXUAABkCmLoCLiQGgCAzEEMHVFLbbUMI/5C6ok5LqQGACDdEENHlO3NUk1ZadzayPSsTNO0aCIAAHAUxNADaK2L/6psfXtby+sb1gwDAACOhBh6AJUlxcrx+eLWro9PWjQNAAA4CmLoATgcDnU21sWtzS2van1r26KJAADAYRFDD6iltlpejydu7foYnw4BAJAuiKEH5HI61d5QG7c2tbCk7V2/RRMBAIDDIIYSoL2+Vm6X69avTZm6MT5l4UQAAOB+EUMJ4HG71XrbFh1jc/Py7wUtmggAANwvYihBOhvr5HS898cZi8U0MMGnQwAApDpiKEF8WVlqqqmKWxuZnlUwFLZoIgAAcD+IoQTqaqyXofgtOoamZiycCAAA3AsxlEB5Odmqr6qIWxuanGEDVwAAUhgxlGAnmhvifh0MhzQ6M2/RNAAA4F6IoQQrystV9W0buA5MTCkai1k0EQAAuBti6Bjc/umQf29PE3MLFk0DAADuhhg6BuVFhSovKoxbuz42qRifDgEAkHKIoWNy+6dD236/pheXLZoGAADcCTF0TKpKS1SUlxe31js8xrVDAACkGGLomBiGoe6Wxri1Hb9fw1Oz1gwEAAAORAwdo7qKMpUWFsSt9Y9OKBTmqdQAAKQKYugYGYahMx2tcWvBcEjXxyYtmggAANyOGDpm5UWFqqsoj1sbnJzWbmDPookAAMD7EUNJcLq9RYbx3p5l0VhMfcNjFk4EAADeRQwlQX5OttrqauLWJuYWtL61bdFEAADgXcRQkpxsaZLb5br1a1OmLg+OyDRNC6cCAADEUJJ4szzqaqqPW1tYXdP8yppFEwEAAIkYSqrOxnple71xa1eGRtimAwAACxFDSeRyOnWqtSlubWN7R+Ns4goAgGWIoSRrqq5UYV5u3Frf8Jgi0ahFEwEAYG/EUJI5HA6daY9/EGMgGNTgxLRFEwEAYG/EkAWqSotVWVIct3Z9fFJ7wZBFEwEAYF/EkAUMw9CZ9lYZeu9BjOFIRL3DoxZOBQCAPRFDFikuyFNjdWXc2ujMnBbX1i2aCAAAeyKGLNTT1iyX0xm3duHagKJcTA0AQNIQQxbK8Xl1ur0lbm3b71c/u9oDAJA0xJDF2upqVFKQH7d2fXxSG9s7Fk0EAIC9EEMWczgceuRkV9yu9rFYTBf6B9i3DACAJCCGUkBRXq66GuP3LVvZ2NTw9KxFEwEAYB/EUIo42dqk3OzsuLXeoVH59/YsmggAAHsghlKEy+nUIyc64tbCkYgu3hi2aCIAAOyBGEohlaXFaqqpilubXlzS9OKSRRMBAJD5iKEUc7ajVVluT9za29eHFAqHLZoIAIDMRgylGK/Ho3NdbXFrgWBQvUNjFk0EAEBmI4ZSUGNVhapKS+LWRqZntby+Yc1AAABkMGIoBRmGoYdPdMj5vq06TJm60D+gaCxm4WQAAGQeYihF5Wb71NPaHLe2ubOra6PjFk0EAEBmIoZSWEdDrYry8+LWro9OsrM9AAAJRAylMIfDoUe747fqMGXqjb7r2guFLJwMAIDMQQyluOKCPPW0xX9d5t/b05vXbrB3GQAACUAMpYETTQ2qLCmOW5tdWtHwFHuXAQDwoIihNGAYhh47dUJeT/zDGC8PDmt9e8eiqQAAyAzEUJrI9mbpsVNdcWvRWEyv915TOBKxaCoAANIfMZRGqstK1dlYH7e2ubOrywMjFk0EAED6I4bSzOm25n2324/MzGpyftGiiQAASG/EUJpxOp168vRJud73dGpJutA/oJ1AwKKpAABIX8RQGsrPydb5Ex1xa+FIRK/39ivGdh0AABwKMZSmmqor1VBVGbe2srGpq2zXAQDAoRBDacowDD3S3aFcny9une06AAA4HGIojbldLj15+uS+7Tpe7+3XXpDtOgAAuB/EUJorKczX6baWuLVAMKhXr1xVlOuHAAC4J2IoA3Q11e/brmNpfUNvXx9k/zIAAO6BGMoAhmHo8Z4TyvZ649ZHZ+Y0ODlj0VQAAKQHYihD+LKy9PTZHjlve/7Q5YFhzS2vWjQVAACpjxjKIMUFeXr81Im4NVOmXuu9ps2dXYumAgAgtRFDGaa+slynWpvj1sKRiF6+1KdgKGzRVAAApC5iKAOdbGlUfWVF3NqO36/Xeq9xhxkAALchhjKQYRh67FSXivPz49YXVtd0eWDYoqkAAEhNxFCGcjmdevrcKfmysuLWh6ZmNDTFHWYAALyLGMpg2V7vzTvMHPH/Z754Y0gLq2sWTQUAQGohhjJcSWG+Hj3ZFbdmmqZevXJN27t+i6YCACB1EEM20Fhdqe6Wxri1UDisly/1KRTmDjMAgL0RQzbR09qs2vKyuLWt3V29eoU7zAAA9kYM2cS7W3YU5eXFrS+srumNvuuKEUQAAJsihmzE7XLpqXOn5PV44tanFhZ1oZ9NXQEA9kQM2Uyuz6enz/XIddseZmOzc7o0MEwQAQBshxiyodLCggNvuR+cnNa10XGLpgIAwBrEkE1VlhbryTMnZRhG3PrVkXENTExZNBUAAMlHDNlYbXmZHj91Qobig+jSwLBGpmctmgoAgOQihmyusbpSD3d37Ft/q39Qk/OLFkwEAEByEUNQa12Nzna0xa2ZMvV6X79ml1YsmgoAgOQghiBJ6mqq3/eU6pvbdlxlHzMAQEYjhnBLT2uz2hvq4taisZheudSnlY1Ni6YCAOB4EUO4xTAMPdTZpqaaqrj1SDSqly72an17x6LJAAA4PsQQ4hiGoUe7O1VXUR63HgqH9eLbV7S5s2vRZAAAHA9iCPs4HA49cbpbVaUlcet7waC+deGS1ja3LZoMAIDEM0z2X8AdRKJRvfT2FS2tb8Stu10uPXOuR+XFRdYMBgBAAvHJEO7I5XTqmYdOq6QgP249HInoxYu9mltetWgyAAASh0+GcE/hSESvXOrT4tp63LrD4dDjp06ooarCoskAAHhwxBDuSzQa1au91/Y9hNGQoYe7O9RaV2PRZAAAPBhiCPctGovpzWs3NDG3sO/Y2Y42dTXVWzAVAAAPhhjCoZimqYs3hjQ0NbPvWHdzo3rammUYxgE/CQBAaiKGcGimaapvZEz9oxP7jrXV1er8iXaCCACQNoghHNmN8SldHhzet95YXalHT3bJ6eBmRQBA6iOG8EBGpuf0Vv+ATMX/Z1RTXqoPnD4pp9Np0WQAANwfYggPbHJ+UW9cva5YLBa3Xl5cpGfO9cjtclk0GQAA90YMISHmllf17StXFY1G49aL8vL09Lke5fi8Fk0GAMDdEUNImKX1Db18sVfhSCRu3evx6Kmzp1RWVGjNYAAA3AUxhIRa29zWSxevaC8Uilt3OBx6pLtTzTVVFk0GAMDBiCEk3NauXy9f7NW237/vWGdjvc60t8jBnWYAgBRBDOFYBENhvdZ7TQura/uOVZeV6ImebnncbgsmAwAgHjGEYxOLxXR5cESDk9P7jhXk5ujpsz3Ky8m2YDIAAN5DDOHYjUzP6e0bg/tuvfe43frAmZOqLCm2aDIAAIghJMnS2rq+ffmaguH4C6sNw9BDXe1qr6+1aDIAgN0RQ0ianUBAr1zq08b2zr5jbXW1OtfVxhYeAICkI4aQVOFIRG/0XdfM0vK+YxXFRXryzEl5PR4LJgMA2BUxhKQzTVN9w2PqH5vYdyw3O1vPnOtRQW5O8gcDANgSMQTLTMwt6M1rNxS97cJql9Op8yc61FRdKcMwLJoOAGAXxBAstbqxpVcu9ykQDO471lBVqUe6O9joFQBwrIghWM6/F9S3L/dpdXNr37Hc7Gw92dOtksJ8CyYDANgBMYSUEIlGdenGsEZmZvcdMwxDZ9pb1dlYx9dmAICEI4aQUibnF3Whf0DhSGTfsarSEj12qku+rCwLJgMAZCpiCClnJxDQ6739WtnY3HfMm5Wlx091qaq0xILJAACZiBhCSorGYro2Mq7rY5Mytf8/0a6mBvW0NfOQRgDAAyOGkNIWVtb0xtXrB95tVlKQrydPn1Ruts+CyQAAmYIYQsrbC4b0nWvXNbe8uu+Y2+XSI92daqiqsGAyAEAmIIaQFkzT1ODktK4MjSp220MaJam5ploPdbXxTCIAwKERQ0grq5tbeq23Xzt+/75jednZevRkp8qLiyyYDACQroghpJ1wJKK3rw9pfG7+wOOtdTU6094ij9ud5MkAAOmIGELaGp+d11vXBxWJRvcdy/Z6df5Eu2rLyyyYDACQToghpLWtXb/euHpdqwc8k0iS6isr9FBXGw9qBADcETGEtBeLxTQ0Nave4VFFD/iUyON266HONjVWV7KdBwBgH2IIGWPHH9CF/gEtrK4deLyypFiPnOxUro/nEgEA3kMMIaOYpqnxuQVdGhhWKBzed9zldKqnrUXt9TVy8PRqAICIIWSoQDCoizeGNbWweODxksICPdrdqcK83CRPBgBINcQQMtrM0rLe6h88cDsPh8Oh7uYGnWhqkNPptGA6AEAqIIaQ8ULhsK4MjWpkevbA4wW5OXq4u1PlRYXJHQwAkBKIIdjG4tq6Llwb0PYBT6+WpIbKCp3uaOECawCwGWIIthKJRnVtZFw3JqZ00H/6TodDnY31OtHcwD5nAGATxBBsaW1zW2/239D61vaBx71ZWTrd1qzmmiqeTQQAGY4Ygm29+7DGqyNjCkciB76mKD9P5zrbVMHmrwCQsYgh2N5eKKRroxManpo58KszSaqrKNfZjlblZnM9EQBkGmIIeMfmzq4uDw5rbnn1wOMOh0MdDXXqbm6Qx+1O8nQAgONCDAG3mVte1eXBYW3u7B543Ovx6FRrs1pqq3iKNQBkAGIIOEAsFtPIzJyuDo8rGA4d+JrCvFyd62hTZWlxkqcDACQSMQTcRTAUVv/YhIamZhSLxQ58TU15qU63tbC1BwCkKWIIuA9bu35dGRzRzNLygccNGaqrLNfJlkaiCADSDDEEHMLC6pouDQxrY3vnwONEEQCkH2IIOKRYLKax2Xn1DY9pL3Tw9UREEQCkD2IIOKJwJKKBiWkNTEzd8aGNRBEApD5iCHhAoXBYg5PTGpiYJooAIA0RQ0CCEEUAkJ6IISDBiCIASC/EEHBMDhNF3S2NKiKKAMASxBBwzO4niiSpsqRYnY11qiotkWEYSZwQAOyNGAKS5H6jKC87Wx2NdWqqrpTb5UrihABgT8QQkGTBUFhDU/eOIrfLpda6GrXX1yrH503ihABgL8QQYJF3o2hocvaOm8FKkmEYqqsoV2djnUoLC5I4IQDYAzEEWCwSjWpiblGDk9Pa3Dl4m493lRQWqLOhTrUVZXI6HEmaEAAyGzEEpAjTNLW4uq6ByWnNLa/c9bXZXq/a6mvUWlujLI87SRMCQGYihoAUtLXr19DktEZn5xWNRu/4OqfTqabqSnU01KkgNyeJEwJA5iCGgBQWDIU1Njuvwclp+ff27vraqtIStdZVq7qslK/QAOAQiCEgDcRiMU0vLmtwclorG5t3fa3X41FjdaWaa6pS6unWfr9fV65c0eDgoC5d6dXQyIj2AnsyTVNZ3iw11Nfr3JnTOtHVpVOnTqmkpMTqkQHYBDEEpJnVjS0NTE5ramFR9/p/35KCfDXXVKuhqlwetzXXFo2MjOgrX31B3/hv/11Glk8Ob66yC4qUU1Akl8stGVI0GpV/a0M7G2sy93YV3N7QRz70Qf3AX//rOnv2LA+hBHCsiCEgTfn39jQ8Navh6VmFwuG7vtbpcKiuslzNNVWqKC5KSlwsLy/rc5//gl5+/Q3llNWosqlNvtz8+/rZUHBP8+ND2l2cUVtjgz73q7+stra24x0YgG0RQ0Cai0Sjmpxf1NjsvJbXN+75+hyfT801VWqqqVSuz5fweUzT1De/+U199gu/JkdBqeq7zsh5xCdpm6ap2dEBbU2P6Cf+zo/pb/3NvykXT+UGkGDEEJBBtnb9GpuZ0/jcggLB4F1fa8hQRUmRmmuqVFdRJqfT+cDnj0aj+sIXf01f/7Nvqrb7vApKyx/4PSUpsLOlyb63dLqjRf/mN/61cnK4cw5A4hBDQAaKxWKaX1nT2Oy8ZpdXFIvF7vp6t8ulxqpKNddWqTg/70hfo8ViMf3CL/6S/vzl19T2yNNye7KOOv4d33/08htqqSjW7/y/v0UQAUgYYgjIcHvBkCbmFzU6M3fPJ1xLUmFerhqrKlVfVX6or9H+xZf+pf74T/9M7Y9+UK5juljbNE2NXnpDp5pr9Vv/7v9JyKdZAEAMATZhmqbWtrY1NjOvifmFu24S+67SwgI1VFWovrJcvqw7f9Lz5ptv6sf/wU+r/cnvkifreDeVjcViGnzjW/pHf+9v64d/6IeO9VwA7IEYAmwoEo1qZnFZY7PzWlhdu+frDRkqLy5UQ1WFaivK5PV4bh3b3t7WJ577tNzljSqrbTjOsW/Z3drQ1KVX9bU//iM1NjYm5ZwAMhcxBNjcTiCg8dl5jc0uaDcQuOfrDcNQVWmxzp/oUK7Ppy99+ct64c//Sm0PPZmEad8zNXBNraU5+r3/8DtJPS+AzEMMAZD0zkaxa+uanF/U9OLyXZ9d5HQ69alnP6BQMKhnP/J/qO6hp+77GUKJEotGdf3l/6mv/uc/5BlEAB4IGxgBkHTzE5/KkmI9erJLn3z2A3rm3Gk1VlfKdcBFyrVlpXK7XPrzP/9zyZub9BCSJIfTqZzyGn3lqy8k/dwAMgufDAG4q0g0qrnlVU3OL2pueUXRWExPne1RXUWZPvWZ5+X3Fqu0pv5Q7/mNf/8lPfThj6mu/cQDzba3u6PJt17St1/6K3m9x3vhNoDMxaNcAdyVy+lUfWW56ivLFY5ENLO0ourSYu3u7mpkdEzdHzpp2WzenFzJ5dHw8LBOnTpl2RwA0htfkwG4b26XS03VlXI6nRoeHlZWbt6Rt9pIFFdOnoaGhiydAUB645MhAEcyODgopzf3yD+/ubKoq6//lbbX11RWXafHvvc5Zecd/tojd3a+rvT26bnnnjvyLADsjU+GABzJ4tKSHJ6jX6cz0ndRT37/X9dzP/VP5c3N0+v/46tHeh9vTq5m5+aPPAcAEEMAjmRvb08Ox9G3w2g7+4gKSsrkcnt09oMf1eLUmPxbm4d+H6fTqWDo7pvSAsDdEEMAjsTtcsk0774B7N3k5Bfe+t++nFw5nC75d7YO/T6xWEyeY9oLDYA9EEMAjqSgoEDRSOjIP7+7tXHrf+/t7igWjSj7CM8rCgeDys9P/nOOAGQOYgjAkbS1tUl7/iP//MiVt7S1uqxIOKzLL/2FyusalZ1fcOj3CWyt60wPt9UDODruJgNwJB0dHQpsb8g0TRmGceifbzl1Tq/+9z/R9vqaSqvr9OT3PX+kOWJ7O+rs7DzSzwKAxBOoARyRaZp65sPfpaK208orKrFkhmgkrP4X/4de+l9/oeLiYktmAJD++JoMwJEYhqFPfeL7tTg5YtkM8xOjevT8Q4QQgAdCDAE4sk8/95z2VhYUCR/9QuqjMk1T2wtT+pEf/qGknxtAZiGGABxZbW2tHn/0Yc2NDib93GuLcyrMztITTzyR9HMDyCzEEIAH8jP/6Ke1Mzcu//bhH5h4VJFwWAsDV/Tz/+Tn5HQe/cGPACARQwAeUGtrq37qJ35ck1ffUrLux5jsv6SPPP0BfehDH0rK+QBkNu4mA/DAIpGIfuRv/i1NrO2quef8sZ5rYXJE4cVJ/enXv6bCwsJjPRcAe+CTIQAPzOVy6d/+5m+o0BnRxPUrx/YJ0eLUmHamR/Q7v/XvCSEACUMMAUiIkpIS/f7v/gcVOyIaufwdRSORhL23acY0NXBVgdlR/d5v/5ba29sT9t4AQAwBSJjy8nL94e//np481aEbr/6F1pcWHvg9/VubGnj9r1Sd49R//sPf14kTJxIwKQC8h2uGACScaZr61re+pV/+7OcVdmerrKFVBaXlh9q2Y3drQ4sTwwquLer/+vs/oR/8gR+Qy8UOQgASjxgCcGw2Njb0X7/xDf2nP/ov2vQH5S2uVH5JqfKKSuT2ZMW9NhqJaHt9VdvrK/KvLckVCeozn/qknn/+M6qtrbXodwDADoghAMcuGo3qjTfe0Esvv6LLvb0aHRuTw+WR0+2WIUOxaFShPb/q6mp1+tQpfeCJx/Xss8/K5/NZPToAGyCGACRdKBTS3Nyc9vb2FIvFlJWVpaqqKmVnZ0uSLg0Ma2x2Xk+e7lZVqTWbwAKwD76AB5B0Ho9HjY2Ndzw+tbCkibkFhSMRffCh0wQRgGNFDAFIKZFoVLuBPa1tbSsYCkuSnjl3WtVlBBGA48Gt9QBSyo4/oHAkIodhyOEwNDozp5cv9WpuedXq0QBkKGIIQErZ9vsVCoflcbtVV1kup8Oh0Zk5vfj2Fc0tr1g9HoAMRAwBSCnbuwEFwxFluV1yGMatIBqfm9eLb/dqdokgApBYxBCAlLLjD9z6ZEjSrSByOZ0an5vXSxcJIgCJRQwBSClbfr+C4bA8HvetNYdhqLai7GYQzd4MopmlZQunBJBJiCEAKeXdT4ay3PE3u94KItfNIHr5Yp+mFwkiAA+OGAKQMm7eVh9QKBy59TXZ+zkMQ3XvBNHY7LxeudRLEAF4YMQQgJSx7Q8oHInKYRhyOZ0HvsZ4J4g8cUG0lORJAWQSYghAyth593qhAz4Vej/jna/MPC7XO0HUp6kFggjA0RBDAFLG1u7NZwxlee4eQ9L7gsjt1ug7QTQ5v5iEKQFkGrbjAJAybl48HZHHfX9/NRmGodryUs0srWhsbv7WekNVxXGNCCADEUMAUsb2rl/BUFgFeTn3/TNxQTR7M4icTodqy8uOa0wAGYavyQCkjO1bt9Xf+2sySYrGYtrc2dX04rK2dnZlGNJuIKDx2YVjnhRAJuGTIQApIRyJyL+3p1Dk7l+TRWMxbe/6tbXr1/auX96sLBXk5qiypFhF+XmqryxXR0NdEicHkO6IIQApYefd2+od+2+rfzeANnd2te0PyDAMlRcVqqq0WEV5NwOorrJcxfl5MgzDot8BgHRFDAFICdv+gILv+4osGotpa9evrZ1d7fgDcjgct0LH5XSqq6le57s6VJSfSwABeCDEEICUsL3rVygUVixmamJ+Ubv+gHzem1+BVZeVamVjU4YhFeTmyOvxyJeVpeKCPKvHBpABiCEAKWHbH1AoEpHT6VBetk81ZaUqKchXXWWZ6isrtLK+qbeuD9x6/ezyStzu9gBwVMQQgJTgy/KooqRIRXm5qqsoV31luQpyc259BZadlaWLA0OKxWKSpFgspunFZbXUVls5NoAMYJimaVo9BACYpqm9UEi+rKw7vuaVS32aWXpvY9bKkmJ96OGzyRgPQAbjOUMAUoJhGHcNIWn/k6UXV9fl3wse51gAbIAYApA2aspL5Xa99+2+KZMNWgE8MGIIQNpwOZ2qrYjfZmNijqdNA3gwxBCAtNJ421dla1tb2tjesWgaAJmAGAKQViqKi/ZdWzQyPWvRNAAyATEEIK04HA4111bFrY3PLSgSjVo0EYB0RwwBSDsttdUy9N4WHOFIhAupARwZMQQg7eT6fKosLY5bG+arMgBHRAwBSEutdTVxv17d2NQ6F1IDOAJiCEBaqikr2Xch9SifDgE4AmIIQFq604XU4UjEookApCtiCEDa4kJqAIlADAFIW7k+n6rK4i+kHpmZs2gaAOmKGAKQ1lpqD7iQemvbomkApCNiCEBaqykrUbbXG7c2Ms2nQwDuHzEEIK05HA4118RfSD0xz4XUAO4fMQQg7R10IfXkPBdSA7g/xBCAtJfj86qqrCRubXSGZw4BuD/EEICM0FpXHffr1c0trW1yITWAeyOGAGSE6tL9F1IPTk1bNA2AdEIMAcgIDodDLbc9kXpibkG7gT2LJgKQLoghABmjra5WTqfz1q9N09SNiSkLJwKQDoghABnDm+VRS238tUOjM3PaC4YsmghAOiCGAGSUrsZ6GcZ7t9lHo1GuHQJwV8QQgIyS4/Oqsboybm1ocoaHMAK4I2IIQMY50dQQ9+twJKLhKZ47BOBgxBCAjFOQm6Pa8rK4tYHJaUWjUYsmApDKiCEAGam7uTHu13vBoMZmF6wZBkBKI4YAZKSSwnxVFBfFrd0Yn1QsFrNoIgCpihgCkLFu/3RoJxDQ1AIbuAKIRwwByFgVJUUqzs+PW7s+PinTNC2aCEAqIoYAZCzDMNTdEn9n2cb2juZXVi2aCEAqIoYAZLTa8jLl5+TErfWPTVo0DYBURAwByGiGYairqT5ubXl9Q0vrG9YMBCDlEEMAMl5jdaWyvd64tf7RCWuGAZByiCEAGc/pcKizMf7TofmVVS2srlk0EYBUQgwBsIWW2ip5s7Li1i4PjnBnGQBiCIA9uF0unWppiltb39rWxPyiRRMBSBXEEADbaKmt2ndnWd/wGHuWATZHDAGwDYfDoTMdLXFru4GAhtjRHrA1YgiArdSUlaq8qDBu7drouIKhsDUDAbAcMQTAVgzD0NmOtri1cCSi/rEJawYCYDliCIDtlBTmq6GyIm5taGpGO/6ARRMBsBIxBMCWTre3yOF476/AWCym3qFRCycCYBViCIAt5Wb71F5fG7c2ubCo1Y0tiyYCYBViCIBtdTc3yu1yxa1dHuJBjIDdEEMAbCvL41Z3c2Pc2tLaumaXV6wZCIAliCEAttbRUKscny9urXdoVLFYzKKJACQbMQTA1pxOp3ramuPWNnd2NTY7b9FEAJKNGAJge41VFSrKz4tb6xsZVyjMgxgBOyCGANjezQcxtsat7QWDusKt9oAtEEMAIKmypFg15aVxayPTs1paW7doIgDJQgwBwDvOd3XI5XTGrV3oH2RXeyDDEUMA8I4cn1c9bfG72m/t7ur6+KRFEwFIBmIIAN6nvb5GJQX5cWv9Y5Pa3Nm1aCIAx40YAoD3cTgceqS7U4Zh3FqLxWK60D/Ak6mBDEUMAcBtivLz1NVYH7e2vL6hkek5iyYCcJyIIQA4wMnWJuXe9mTqK0Mj8u8FLZoIwHEhhgDgAC6nU490d8athSMRXbwxZNFEAI4LMQQAd1BZWqym6qq4tenFJU0vLls0EYDjQAwBwF2c7WxVltsTt3bxxpDCkYhFEwFINGIIAO7C6/HoXFdb3Jp/b0+9bNUBZAxiCADuobGqQpUlxXFrw1OzWtnYtGgiAIlEDAHAPRiGoUe6O+V831Ydpky9eW2ArTqADEAMAcB9yM326VRLU9za5s6OLvN1GZD2iCEAuE+djXUqysuLWxuanNbMEneXAemMGAKA++RwOPR4zwk5HfF/db55dUD+vT2LpgLwoIghADiEwrxcnetsj1sLhkN6o++6YrGYRVMBeBDEEAAcUmtdteoqyuPWFtfWdWN8yqKJADwIYggADundu8uyvd649b6RMS2vb1gzFIAjI4YA4AiyPG49cbpbhoxba6Zp6vW+6wqFwxZOBuCwiCEAOKLyokKdbI2/3X43ENCF/kGZpmnRVAAOixgCgAfQ3dyg8qLCuLWphUWNzsxbMxCAQyOGAOAB3Lzdvlsetztu/eLAkDZ3di2aCsBhEEMA8IByfF49erIzbi0ajer13n626wDSADEEAAlQV1GutrrauLX17W226wDSADEEAAlytrNVBbm5cWts1wGkPmIIABLE5XTqydPd+7breKPvurZ2/RZNBeBeiCEASKCDtusIRyJ65VIfzx8CUhQxBAAJdtB2HVu7u3qtt5/9y4AURAwBQIIZhqHHTnWpKC8vbn1+ZVWXB0csmgrAnRBDAHAM3C6Xnj7XI6/HE7c+ODmtkelZi6YCcBBiCACOSY7Pq6fOnpLjtguq37o+qMW1dYumAnA7YggAjlFZUaEe6Y5/IKNpmnr18jXt+AMWTQXg/YghADhmzTVV6mpqiFsLhkN6+VKfwpGIRVMBeBcxBABJcLqtWdVlpXFrmzs73GEGpABiCACSwOFw6MnT3fueUD23vKLe4TGLpgIgEUMAkDRul0vPnOtRljv+DrMb45Mam523aCoAxBAAJFFutk9PnT0pwzDi1i/0D2h5fcOaoQCbI4YAIMnKi4v08In4O8xisZi+ffmqdgLcYQYkGzEEABZoratWR0Nd3NpeKKQX37qivWDIoqkAeyKGAMAiZztaVVVaEre27ffrxbevsKkrkETEEABY5L07zHLi1te3t/XSxV6eQQQkCTEEABbyuN169vwZ5fh8cesrG5v69uWrikajFk0G2AcxBAAWy/Z69aHzZ+TLyopbX1hd46GMQBIQQwCQAvJysvXs+TP7nkE0s7Ss71y7IdM0LZoMyHzEEACkiMK8XH3w/Gm5Xa649Ym5Bb19fYggAo4JMQQAKaSkIF/PnOuR0xH/1/Pw9Ix6h0YtmgrIbMQQAKSY8uIiPXX2lBy3BdH18Un1j01YMxSQwYghAEhB1WWlevzUCRmK37ajd2hUQ1MzFk0FZCZiCABSVENVhR452blv/e3rgxpnY1cgYYghAEhhLbXVOtfZvm/9O9duaHpxyYKJgMxDDAFAiutsrNOp1ua4NdM09Vpvv6YWCCLgQRFDAJAGTrY0qrOxPm4tFovptSvXNDozZ9FUQGYghgAgDRiGobMdrWqtrYlbN2XqzWs3NDAxbdFkQPozTJ7iBQBpIxaL6e0bQxqZnt137FRrk062NMkwjAN+EsCdEEMAkGZM01Tv0Kiuj0/uO9beUKeHOtsIIuAQiCEASFP9YxMHPpW6qaZKj3Z37ntoI4CDEUMAkMaGp2b19vVBmYr/q7yuolxP9JyQ0+m0aDIgfRBDAJDmJuYW9MbV6/s2cq0sKdZTZ0/t2/gVQDxiCAAywOzSil69clXRWCxuvaSwQB88d1pZHrdFkwGpjxgCgAyxuLauVy71KRyJxK0X5uXq2fNn5MvKsmgyILURQwCQQVY3t/TS270KhkNx67nZ2frQw2eU6/NZNBmQuoghAMgwmzu7+qu3LisQDMatZ3u9+uBDp1WYl2vRZEBqIoYAIAPt+AP6q7evaMfvj1t3u1x6oqdbNeWlFk0GpB5iCAAylH8vqBffvqLNnZ24dUOGTre3qKupnoczAiKGACCjBUNhvXSpV6sbm/uONVVX6ZHuDp5FBNsjhgAgw0WiUV3oH9DE3MK+YyWFBXr67CnuNIOtEUMAYAOmaer6+KT6hsb2Pa062+vV02d7VFyQZ9F0gLWIIQCwkZmlZb3e269INBq37nQ69djJLjVUVVg0GWAdYggAbGZje0cvX+rTbiCw79jJliadam3iwmrYCjEEADa0Fwzp1StXtbS+se9YXUW5HjvVxZ5msA1iCABsKhqL6eL1IY3MzO47VpSXp6fP9SjH57VgMiC5iCEAsDHTNDU0NaNLA8P7dr33ejx66uwplRUVWjMckCTEEABA8yurevXKtX2bvDocDp3v6lBLbRXXESFjEUMAAEnS1q5fr1zq09bu7r5jDZUVeri7Qx6324LJgONFDAEAbgmFw3qtt1/zK6v7juX6fHridLdKCwssmAw4PsQQACBOLBbTlaFRDUxM7TtmGIZ62pp1oqmBr82QMYghAMCBpheX9J2rN/ZdRyRJlSXFerznBNt4ICMQQwCAO9oN7On1vn4tH/A8Iq/Ho8dOnVB1WUnyBwMSiBgCANxVLBbTtdEJ9Y9O7NvXTJI6G+t1ur1FTofDgumAB0cMAQDuy+Laul7v7VcgGNx3rKQgX0/0dCsvJ9uCyYAHQwwBAO7bXiik71y9obnllX3H3C6XHj7RocbqSgsmA46OGAIAHMq7T62+PDiiWCy273hTTZXOd7WztxnSBjEEADiStc1tvdZ7Tdt+/75j+Tk5eqKnW8UFeRZMBhwOMQQAOLJwJKK3bwxpfHZ+3zHDMHSiqUEnWxrldDotmA64P8QQAOCBTcwt6K3rgwc+kygvO1uPnuxUeXGRBZMB90YMAQASYnvXr9d6+7W2tXXg8da6Gp3taOVaIqQcYggAkDDRWEz9oxO6Pj554MXV2V6vzp9oV215mQXTAQcjhgAACbexvaM3r93Q6ubBnxI1VFbooa52ebM8SZ4M2I8YAgAci1gspqGpWfUOjyoaje477nG79VBnmxqrK9n0FZYihgAAx2rHH9CF/gEtrK4deLyqtEQPd3co1+dL8mTATcQQAODYmaap8bkFXRoYVigc3nfc5XTqdHuL2upq5GCPMyQZMQQASJpAMKiLN4Y1tbB44PGSwgI9drJLBbk5SZ4MdkYMAQCSbnpxWW9fHzxw01eHw6GupnqdaGrgNnwkBTEEALBEKBzWlcFRjczMHnjcl5Wl0+0tauICaxwzYggAYKnFtXW9eW1AOwfscSZJxfn5OtfZyhOscWyIIQCA5SLRqK6NjOvGxJTu9M9SXUW5zna0Kjebu86QWMQQACBlbO7s6vLgsOaWVw887nA41NlQp+6WRq4nQsIQQwCAlDO3vKLLgyPa3Nk98LjX41FPW7Oaa6q4FR8PjBgCAKSkaCym0ek59Y2MHfhsIkkqzMvVuc42VZYUJ3k6ZBJiCACQ0oKhsPrHJjQ4OX3H64lqy8t0pqNV+TnZSZ4OmYAYAgCkha1dvy4PDmt2aeXA4w6HQ+31tTrZ0iiP253k6ZDOiCEAQFpZWFnTxYFhbe7sHHg8y+3RieYGtdXXyOV0Jnk6pCNiCACQdmKxmEZn5tU3PKZgOHTga7yem1HUWkcU4e6IIQBA2gqFw+ofm9Tg5LRisdiBryGKcC/EEAAg7W3v+nVlaFTTi0t3fA1RhDshhgAAGWNlY1NXR8Y1v3LwQxslogj7EUMAgIxDFOEwiCEAQMa6ryjKytKJpnqiyMaIIQBAxiOKcDfEEADANu43iroa69VSW8XDG22CGAIA2M79RJHb5VJzTZXa62uVxzYfGY0YAgDY1vL6hq6NTtw1igwZqikvVUdjncqLCmUYRhInRDIQQwAA27ufKJKkorw8dTTWqaGyXE6uK8oYxBAAAO9Y2djUjfEpzSwuy9Sd/3n0ZmWpra5GbXU18mZ5kjghjgMxBADAbXb8AQ1NzWh0Zk7hSOSOr3M4HGqsqlRHY52K8nKTOCESiRgCAOAOwpGIxmbnNTg5ox2//66vrSguUkdjnWrKSrmuKM0QQwAA3EMsFtPcyqoGJ6a1uLZ+19fmZmers6FOTTWVcrtcSZoQD4IYAgDgENa3tjUwOa3J+UXFYrE7vs7tcqmpulLNNdUqys9Ni0+LIpGIIpGI3G63rS4QJ4YAADiCQDCokelZDU/Nai8Uuutri/Ly1FxbpYaqCnk9qXHBdSAQ0Ouvv65r/f26dLlXA4OD2vX7JdOUYRjKy8/Tia4unTtzWqdOntRjjz0md4Y+hJIYAgDgAUSjUU0uLGlwYlrr29t3fa3D4VBteZmaa6pUWVIkh8ORpCnfMz4+rhde+Jpe+MY3JLdPztwC5RaWKL+oRB6vT4bDoVgsqlDAr621Fe1srCmys6ksR0x/4/nP6LlPflJVVVVJn/s4EUMAACSAaZpaWt/Q4MS0ZpdW7nprviRle73vfI1WlZQnXO/u7uo3fvPf6IVv/Kl8pVWqbGxXdn7Bff/89vqqliZGFFxf1I/9rR/V3/mxH5MnRT7lelDEEAAACbbjD2h8bl6jM/Py7+3d8/XlRYVqrq1WXUXZsVx0/dZbb+nnf/GXtBVxqOHUQ/JkeY/8Xnu7O5rou6Da0kJ98XOfVVdXVwIntQYxBADAMTFNU4ur6xqdndfM4pKid7ngWpJcTqcaqirUXFOl0sKChFx0/cILL+jzX/qyytpOqby28YHfT7r5+5ofH9LW1Ii+/Otf0DPPPJOQ97UKMQQAQBIEQ2FNLixqbGZea1tb93x9fk6Ommuq1FhdqWxv1pHO+Sdf+Yq++OXfUNNDH1BOfuGR3uNuNpYXNXv1gv7Vr39Bzz77bMLfP1mIIQAAkmx9e0djM3OamFtUMHz3O9EMw1BVabEaKitUe4iv0V566SX99M/9UzWff+ZQ1wYd1ubKkuauXdD/99u/pZ6enmM7z3EihgAAsEg0GtXs8qrGZuc0v7x2z4uunQ6HqstKVV9ZrpryUrnu8CygtbU1ffyTz6mg5aSKyo//zq+FiVF5dpf19a9+RV7v0a9HsgoxBABACvDv7Wl8dkGjs/P33PpDunl9UU15mRqqylVVUnzrIYmmaepn//HP6fX+YbWcfuS4x751zuG3vq3nP/Zd+tmf+ZmknDORiCEAAFKIaZpaXt/Q6Oy8pheWFIlG7/kzbpdLZzta1VpXozfffFM/8dM/o44nv0tOV/IekhjcC2jk9f+lr/3xH6m5uTlp502E5D/tCQAA3JFhGCovLtLjp07ok89+QI/3dKu6rPSuD2gMRyLyZt185s9//M9/pLyqhqSGkCRleX3ylVbrT77y1aSeNxH4ZAgAgDQQDIU1vbisqYVFLa6ux11f5Ha59KlnP6DFxUV97yc+qc6nv0cud/IfiBjY2dL0xW/rxf/9l8rJyUn6+Y+K7XQBAEgDWR63Wuuq1VpXrUAwqOmFZU0uLGp5fUN1FeVyOp36n9/8prKKyi0JIUny5ebL8OXqxRdf1Pd93/dZMsNREEMAAKQZX1aW2htq1d5Qq93Ant79kufCWxeVV1Jh6WzuvCJd6e1NqxjimiEAANJYjs+r3GyfTNPUtev9yi8usXSe/OJSXbrSZ+kMh8UnQwAAZIDFxUXt+gPy5eYf6edvXHhVw1cuKLC7LW92rjrPP6GOhx4/9PvkF5Vq4OqbCoVCabORKzEEAEAG2NjYkMebfeT9zHIKCvXhv/F/KjuvQItT43rphT9QUUW1ymsbDvU+Lo9HMVPa2dlRcXHxkWZJNr4mAwAgA4TDYRl3uf3+Xuo7Tionv1CGYaiyoVlVTW1amho70ns5HA6Fw+Ejz5JsfDIEAEAGcLvdkhk78s+P91/Rjbde1e7mhkzTVDQcVm5B0ZHeyzTNm/OkCWIIAIAMUFBQoNBeQKZpHvqrst2tDb3xZ1/Ts8//qCrqm+RwOPXy1/+TjvIkwkg4JMViafWcIb4mAwAgA1RWVsqXlaW93e1D/2wkFJJkypudK8MwNDs6qPnxkSPNsb2+pqamRmVlZR3p563AJ0MAAGQAwzDUfeKEJtdWD31HWUFpubof/6C+9ce/KzMWU01bl2pbO480x9basj5y7vSRftYqxBAAABni4fPndO3rf6aK+qZD/+zppz6i00995IFnCG2v68zpngd+n2TiazIAADLE93z0owqsLCgaseZOrj3/rqK7m/rQhz5kyfmPihgCACBD1NfX69Hz5zQ/MWrJ+efHBvWJ7/uY8vLyLDn/URFDAABkkB/54R/S5uy4otFIUs8bDu7JvzSr5z/zmaSeNxGIIQAAMsiTTz6pxx86o6nrvUk978TVi3ruE9+vjo6OpJ43EQzTPMpTBAAAQKpaWlrSxz/1aZV1nlNBafnxn296QubqjL7xta+m1fOF3sUnQwAAZJjy8nL98i/8vKb73lRgd+dYz7W9vqrl4av64ud+NS1DSCKGAADISB/96Ef1D//e39XohZcV2Nk6lnNsr69q8tJr+vwv/6LOnz9/LOdIBr4mAwAgQ5mmqT/8j/9R//r//veqPnFOxZU1CXvvxakxrYxc0xd+5Z/re77nexL2vlYghgAAyHAvv/yyfvGXf1VhT64aTp6Vy+058nsF9wKa7HtLRV6Xfu3zn9XZs2cTOKk1iCEAAGxgY2NDv/6lL+nP//eLyi6vUVVTu7zZ93+Nj397UwvjwwqszOkHPvOcfurv/31lZ2cf48TJQwwBAGAj/f39+spXX9D/+OY35cotlDunUPklpcorKpHb897mqqG9gLbWVrS9vqrIzobMoF+f/uQn9OnnnlNzc7OFv4PEI4YAALChzc1NvfLKK+rt69Pl3j6NjI4pFovJcDgUi0blcbvV3t6mc2dO63RPj5566qmM+STodsQQAABQJBJRIBBQOByW2+1Wdna2nE6n1WMlBTEEAABsjecMAQAAWyOGAACArRFDAADA1oghAABga8QQAACwNWIIAADYGjEEAABsjRgCAAC2RgwBAABbI4YAAICtEUMAAMDWiCEAAGBrxBAAALA1YggAANgaMQQAAGyNGAIAALZGDAEAAFsjhgAAgK0RQwAAwNaIIQAAYGvEEAAAsDViCAAA2BoxBAAAbI0YAgAAtkYMAQAAWyOGAACArRFDAADA1oghAABga8QQAACwNWIIAADYGjEEAABsjRgCAAC2RgwBAABbI4YAAICtEUMAAMDWiCEAAGBrxBAAALA1YggAANgaMQQAAGyNGAIAALZGDAEAAFv7/wFYeLxucpdrhgAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import pathpyG as pp\n", + "\n", + "# Static network\n", + "g = pp.Graph.from_edge_list([('a', 'b'), ('b', 'c')])\n", + "pp.plot(g, filename='network.png')" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "be239917", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pathpyG as pp\n", + "tg = pp.TemporalGraph.from_edge_list([('a', 'b', 1), ('b', 'c', 2), ('a', 'c', 3)])\n", + "pp.plot(tg, filename='temporal_network.html')" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "5432f763", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAkMAAAI/CAYAAAB02d0hAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAXEgAAFxIBZ5/SUgAAYbBJREFUeJzt3Xd4Y+l9H/rvQe8ACRAk2HuZ4fS+u9pdraot2bIdWZFkyVauk8hO7PhJnihxLNnXvnFu4iS35N7k5jqWda1YtizHslUsWcWStszuzuz04cywd7AXEL2fc//gDHcOQM6wgDgAzvfzPPvM4AVI/EaaIb98y+8VJEmSQERERKRSGqULICIiIlISwxARERGpGsMQERERqRrDEBEREakawxARERGpGsMQERERqRrDEBEREakawxARERGpGsMQERERqRrDEBEREakawxARERGpGsMQERERqRrDEBEREakawxARERGpGsMQERERqRrDEBEREakawxARERGpGsMQERERqRrDEBEREakawxARERGpGsMQERERqRrDEBEREakawxARERGpGsMQERERqRrDEBEREakawxARERGpGsMQERERqRrDEBEREakawxARERGpGsMQERERqRrDEBEREakawxARERGpGsMQERERqRrDEBEREakawxARERGpGsMQERERqRrDEBEREakawxARERGpGsMQERERqRrDEBEREakawxARERGpmk7pAoiIqHgkSYIoikhnsshksxAlCQIAQRAe/gcAAjSCAAiAAAEajQA8/prHfk/lLRgMYnh4GMPDw7h9dwDrgQ2k0ykY9AbU1tbgxLFj6OnpQVdXF2w2m9LlHhpBkiRJ6SKIiGhn2WwW6WwWmcyjXzNbYSadybz969bY289tfdxjryvUl30BDwPTNiFJwGawejw0aTUaGAx6GPU6GPT6zf90m79/e+zRYz30eh20mspewBBFET+8dgsSgAv9fXBYLUV5zytXruBLf/plvHHlKgxWO7RmG0x2JwxGMwSNBqIoIhmPIhkOQkxEkY5H8e6XXsTHP/pRnDx5suKCMMMQEVGRSZKEVDqDWDKJeCKBaDyJWDKBWCKJWDyBRCq9FV4y2SxEUVS6ZMXotNqtcPR4UDI8Hqj0Ohj0uofjm491Wm1ZfMMOhCP4+suvYy0YQoPXg4v9fWitrzu093vllVfw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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import pathpyG as pp\n", + "graph = pp.Graph.from_edge_list([[\"a\", \"b\"], [\"b\", \"c\"], [\"a\", \"c\"]])\n", + "pp.plot(graph, kind=\"static\", filename=\"graph.png\")" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "6e5cd0a6", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'a': array([ 0.61899711, -1. ]), 'b': array([-0.00132282, 0.00213747]), 'c': array([-0.61767429, 0.99786253])}\n" + ] + } + ], + "source": [ + "from pathpyG import Graph\n", + "from pathpyG.visualisations import layout\n", + " \n", + "g = Graph.from_edge_list([('a', 'b'), ('b', 'c')])\n", + "positions = layout(g, layout='spring', k=0.5)\n", + "print(positions)" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "295a1d53", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAkMAAAI/CAYAAAB02d0hAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAXEgAAFxIBZ5/SUgAAYghJREFUeJzt3Xd4XPl9Hvr3TO8FM+i9gwRBsC+Xq61atVVxkSyXOC7X8c29cs117DixpbjI9nVynRsnjuPrxE6z5chayZZsR8Vabd/lshMASRBE78Bgem/n3D9AgjwzAIgyfd7Po33I+Z2Zc74gKeLlrwqSJEkgIiIiqlKKYhdAREREVEwMQ0RERFTVGIaIiIioqjEMERERUVVjGCIiIqKqxjBEREREVY1hiIiIiKoawxARERFVNYYhIiIiqmoMQ0RERFTVGIaIiIioqjEMERERUVVjGCIiIqKqxjBEREREVY1hiIiIiKoawxARERFVNYYhIiIiqmoMQ0RERFTVGIaIiIioqjEMERERUVVjGCIiIqKqxjBEREREVY1hiIiIiKoawxARERFVNYYhIiIiqmoMQ0RERFTVGIaIiIioqjEMERERUVVjGCIiIqKqxjBEREREVY1hiIiIiKoawxARERFVNYYhIiIiqmoMQ0RERFTVGIaIiIioqjEMERERUVVjGCIiIqKqxjBEREREVY1hiIiIiKoawxARERFVNYYhIiIiqmqqYheQT4lEAlNTU5iYmMDI6CjmFhYRj8ehVChhMptwfPAoBgYG0N/fj7q6OgiCUOySiYiIqMAESZKkYheRazMzM/jSyy/jy3/1VaQFBdQGC5QGEwwmCxRKJSRJQiqRQCTghRSPIBrwor+3Bz/6w/8A73//+6HT6Yr9JRAREVGBVFQYWlhYwOd/+3fw3pWrMNQ2oaGjDwaL9bGfE8U0XIvz8C3PQpWK4zP/x/+OH/rBH4RSqSxA1URERFRMFRGGRFHEF7/4Rfze7/976Otb0dI7CKXqYCOAQZ8Hi2NXMdDZis//xq+js7Mzx9USERFRKSn7MBSJRPBPfuGf4srobbQOnYXJaj/0PSVJxOLEHYRWZvC7n/8NvPjiizmolIiIiEpRWYehcDiMf/yZz+Dekgvdp56EQpHbYS2/24WFmxfxW//yV/HRj340p/cmIiKi0lC2YSiVSuEf/5+fwdjsEnpOXcjbSrCgz4O5a2/j3/0/v4tnnnkmL88gIiKi4inbfYb+9L/+V9y4O4Xuk0/mdUm82VaD5mNn8c9+5VexsbGRt+cQERFRcZRlGJqYmMAf/fGfoOP4OSgU+f8S7HUNUFic+PXf/DzKtCONiIiIdlB2YUiSJPzKZz8Ha1sP9CZzwZ7bduQE3rp0Fa+88krBnklERET5V3Zh6OrVq5iaX0JT90BBn6tUqeDsGsAf/8mfsneIiIiogpRdGPqzL/wFTA0tRTk6o7a5DRNTM7h9+3bBn01ERET5UVZhyOVy4bU330JjR19Rnq9QKGFqaMVffunlojyfiIiIcq+swtDIyAh0FjvUWu2B7/Hu//oy/vx3fwX+jfUDfd7R2Iq33n33wM8nIiKi0lJWYej2nTtQ6IwH/nwyHsf8+Bg0Oj2mRq4e6B5Gqw0u1wZ8Pt+B6yAiIqLSUVZh6Or1GzDZHQf+/Nz4CFRqNU4+9yHM3LoOMZ3e9z0UCiX0FhsmJiYOXAcRERGVjrIKQ4uLSzCYLAf+/NTIVXQcPYH2I8eRSiaxODl+oPsotXqsrKwcuA4iIiIqHWUVhuKJOBTKg51G799Yx8byArqGTkKt0aKl7yimRq4crBBBgUQicbDPEhERUUkpqzCkVCiBA+7xMzlyBfa6BtjrGgEAXcdOYmVmEpGgf9/3kiQJSmVuD4UlIiKi4jhYN0uRGIxGJJP775ER02nM3LqBVCKBL//B72w2ShIkScT06DUcu/D8fm8IvV6/7zqIiIio9JRVGDrS34erM6uwOmr39bnFyTtIxuN46cd/Ghqtbqt94vp7mBq9hsEnn9vXJo6JcABdXV37qoGIiIhKU1kNk50+eQKxoG/fn5sauYqOI8dhddRCbzJv/dd/+klEQwGszU/v+V7JeBzpRIxhiIiIqEKUVc9QX18f0pHgvj/3/Pf96LbtOoMRP/ALv76ve/nd6+jt6YFard53HURERFR6yqpn6Pjx49ApBQS97qLV4F2ewyc++lLRnk9ERES5VVZhSKPR4Id+4NNYmynOhofxaATJgAcf//jHi/J8IiIiyr2yCkMA8L3f8z2IedeRiEUL/uylqXG89OEPwWazFfzZRERElB9lF4bq6+vxQ5/+FGZGLkM64J5DBxHye5HYWMb//o9+omDPJCIiovwruzAEAD/z0z8Nh16FtfmZgjxPFNOYH7mEf/rzP4u2traCPJOIiIgKoyzDkF6vx+98/jfhnrqFcMCX12dJkoTZses43t+DT3/603l9FhERERVeWYYhADh58iR+5Zd+ATNX3jzQkRp7IUkS5u/chF2Vxu/9638FhaJsf7mIiIhoB2W1z1CmT33qU0imUvjdf/P7aD3+BKzOupzdWxTTmBm9CocG+JM//mM4HI6c3ZuIiIhKhyAVchZynnzrW9/C537j81Baa9F2dBjKA55s/0DAs4HFsSs4e3wQv/ZrvwZ7jR06jSZH1RIREVEpqYgwBADr6+v49d/8PN69eh01bX2oa+2AYp8ny0eCfqxM34UY2MC/+KVfxMc//nF87Y13EYnG0N7UgCMdbaixmvP0FRAREVExVEwYAjbn+Lz22mv40//23zF6exzG2ibY6pthttdApc7u2ZEkCdFQEAG3C4G1BQiJCD753d+FH/2RH0F9fT0isRi+8urbmFlahsmgh8NiQXOdE0c629DodOzrcFciIiIqTRUVhh41NTWFL3/lK3jn4iXMzs5CpdNDrTcCggKABCmVQjTog9Ggx+DRo/jER1/C+9//fhgMBqTTaYzPLeD29ByWXG7E4nFEYnGk02nYzCY4bFbU19jQ396GzqZ6KPfZA0VERESlo2LD0KPi8Timpqawvr6ORCIBpVIJvV6Prq4u1NfXy3p4QpEoXrl8HeHo5g7X614fwpEYlEoFGhw1cPsD8ASCMOp1qLVZ4bBa0Nfegp7WZs4rIiIiKkNVEYb2QxRFfOPdy/AFQwCAUDSK6cUVqFRKHO1sBwCk02l4AiG4/QGolEo4bVbUWM3oaWlCf3srzEZDMb8EIiIi2geGoW2sbnjwnSvXAQBpUcTt6TmEozGcPtILterhSjVRkhAIhbHh8yOVFjdDkcWM9sZ6DHS0otZuK9JXQERERHtV1vsM5UuDswbNdU4srW9AqVBAp9XAFwzBHwzDabduvU8hCLCZTbCajAhHY9jw+bHm8WLd48X00jIanQ4c6WxDc62TGzYSERGVKIahHZzo68Gyyw1JkmDQaaFSKrHkcsvC0AOCIMBk0MNk0COWSGDDF8DE/BJW3V4srLlQa7dhoKMVXc2NUHGyNRERUUnhMNkurtyZwMTcAryBEKYWlxGOxXDmSB+Met1jP5tMpeD2B+HxB6DXauG0W+GwmHFheBBNtc4CVE9ERER7wbGbXRzr7oBapYJBp4VapUQqlcbyhht7iY9qlQoNDjv6O1oBAZhbWYMnEMSq25v/womIiGjPGIZ2odNoMNTTCY1aBbVaBaVCgWA4An8ovKfPi5KENbcX8UQS3c1NGOhow1BPZ56rJiIiov1gGHqM3rYWmI1GGLQ6qFRKJFMprLo9EMXdu4dSqTRml1cRSyTQ09KEc8cG8PTJIdlqNCIiIio+hqHHUCoUONnfA4NeC7VKhWQqjWQqhQ2/f8fPRGJxTC4uQ6fRoLetBS+cO4mhnk4e30FERFSCGIb2oKXOidb6Oqjv9wwBgMvrRzKVznqvNxDC9NIKHFYL+ttb8eEnz6KlrrbQJRMREdEeMQztgSAIeHp4c4hLlCSkRRGiKGLd83AytCRJWNlwY9m1sdUD9MLZE7CajMUqm4iIiPaAYWiP6hw2tNTXQq182DvkCQSRSKaQSqcxs7wKXzAMCYDDaoHTZsHVO/fAnQuIiIhKG8PQPhzv6YJGrZYNjy2srWNyYRlatRoatRqt9bVocNghCAIW1124OTFVxIqJiIjocRiG9qGlzolau3WrZygWT2BuZR02kxH9HW34sY9/KOs8stszc5heWilCtURERLQXDEP74LBZ0VzrRDqdRjASRTAShdVkhFGvx4fOn0FrfS3ed+JY1qqxS7fGse71FadoIiIi2hXD0D6YDXpYTSZYjEYkUynUWM1ornPAZNAhEosBABocNTh7tF/2OVEU8ea1UYSi0WKUTURERLtgGNoHQRDgsFnQ3dKIWpsVHY31aHDUQBAEjExOb72vp7UZ/e2tss/Gkwm8fnVka4iNiIiISgMPat0nty+Au/MLEADMLK/Krn3w/Bk4bZun2ouiiDeuj2DZ5Za9p6nWgWdOHodCwRxKRERUCvgdeZ8cNgsuHB/EmaP90Ko1smsj9x72DikUClw4Ppi1z9Cyy40bXGFGRERUMhiGDkitUmGwu13Wtur2YO2RjRg1ajWePTWcFZrGZ+cxubBUkDqJiIhodwxDh9DT2gy9VitrG7k3Ldto0WTQ4+mTx7KGxS7fvisLTkRERFQcDEOHoFIqMdjdIWtzeX1Y2fDI2upq7Dg3OCBrkyQJb14fhS8YyneZREREtAuGoUPqbmmCUa+XtWX2DgFAV3MjjnTKh9USySReu3oT4Wgs73USERHR9hiGDkmpUGCop1PW5gkEsLjuynrvcG9X1gn2kVgMr165gVgikdc6iYiIaHsMQznQ0VgPi1G+amzk3gxEUZS1KRQKPHn8KBz3l98/EAiH8fo17kFERERUDAxDOaDYpnfIHwphfnU9671qlQrPnjqeteTe7fPjrRtjSGcEKCIiIsovhqEcaWuog81skrWNTGb3DgGATqPBc6dPwKDTydpXNtx4b+xO1nwjIiIiyh+GoRwRBAHHe7tkbaFIBNNLq9u+36jX4fkzJ6BRq2Xts8uruH53koGIiIioQBiGcqi51gmH1SJrG5uaQTqd3vb9VpMRz50ehlKplLWPz87j9sxc3uokIiKihxiGcmi73qFILIapxZUdP+O0WfH0iWMQBEHWfnNiClOLy3mpk4iIiB5iGMqxBkcN6uw2WdvY9CxSO/QOAUBTrRPnh45mtV8aG992iT4RERHlDsNQjm3XOxSLxzExt7jr5zqbGnBqoFfWJkHC2zfGsM5jO4iIiPKGYSgP6mrsaHQ6ZG13ZuYfu4/QQEcbjmbsUp0WRbx+bQReHttBRESUFwxDeZLZOxRPJjA+u/DYzw33daOruUnWlkyl8NqVGwhFozmtkYiIiBiG8sZhtWQdvTE+O494Irnr5wRBwLnBfjTXOWXt0Xgcr16+gVicx3YQERHlEsNQHg31dkHAw1ViyVQKd/awZF6hUOCp4WOozZiIHYxE8NrVmzy2g4iIKIcYhvLIbjahrbFe1nZvYQmJ5O69QwCgUirxzMnjsJrku1p7AgG8eX2Ux3YQERHlCMNQng31dGb1Dk0u7G3/IK1GjefPDMOo18vaV90eXBy5zV2qiYiIcoBhKM8sRgNaG+pkbRPzi3vu2THodHju9DC0ao2sfW51DVfH7zEQERERHRLDUAEc6WiTvY7EYphbWdvz5x8c26HKOLZjYm4Bt6Znc1EiERFR1WIYKgCHzZK1K/Wdmfl99eo4bBY8fXIICoX8t2zk3jQmF5ZyUSYREVFVYhgqkIFOee+QPxTCyoZnX/dodDrw5NBR2RwkALh86y4W1tYPXSMREVE1YhgqkOZaJyxGo6xtfHZ+3/dpb6zHqSPZx3a8c/MW1nhsBxER0b4xDBWIIAgYyJg7tOr2wOMP7vte/e2tGOzukLWlRRGvX72Jda/vEFUSERFVH4ahAupsqodOq5W13TlA7xAAHO/pQk9Ls6wtlU7jtSs32ENERES0DwxDBaRUKtHf1iJrm19dO9CZY4Ig4MzRPrTWy5ftp9JpvHb1Jlb3OR+JiIioWjEMFVhPa7NsibwkSbi7hwNct6NQKHDh+NGsc8zS6TRev3YTyy73oWolIiKqBgxDBabVqNHVIj+Vfmpx+bEHuO5EqVTifSeGsnqI0qKIN66PYGl948C1EhERVQOGoSIYaG+VLY9PpdOYXDz4XkFKhQIXhgfR1iA/B00URbx5YxQLa64D35uIiKjSMQwVgcmgR1vGER135xaRTqcPfE/l/SGzjqYGWbsoinjrxijmV7kPERER0XYYhookcxPGWDyO2X0c0bEdhUKB88eOoLO5UdYuSRLevjm2ryNAiIiIqgXDUJE4rBbU1dhlbeOz+zuiYzsPAlF3xrwkSdrcmHFmefVQ9yciIqo0DENFdDTriI4wVjYOvwJMEAScGxxAb6t8Gb8ECRdHbmN6aeXQzyAiIqoUDENF1Oh0wGqSH9Fxe+ZgmzBmerAPUV97q6xdgoSLo7d5uCsREdF9DENFtN0RHeseL9z+QM7uf3qgN+sZAHDp1jgm5hdz8hwiIqJyxjBUZB2N9dBnHNExnqPeIWAzEJ3s78HRzvasa1du38X4ATd8JCIiqhQMQ0WmVCrR1555RMc6QpH9H9GxE0EQMNzXjWPdnVnXro1P4E4OwxcREVG5YRgqAT0tGUd0QML4XG57bARBwPHeLgz1dGVdu373Hm5Nzeb0eUREROWCYagEaDXqrKXwhzmiYzdDPZ0Y7uvOar95bwqjkzM5fx4REVGpYxgqEf0drRCEh0d0pNNp3FvIzwTnwa4OnBrozWofnZzGzXtTh97riIiIqJwwDJUIk16fdbbYxPzSoY7o2M1ARxtOH+nLar81NYubEwxERERUPRiGSsiRjtwf0bGb/vZWnD06kNV+e2YO1+9OMhAREVFVYBgqITVWM+ozjuiYWlzO6zN725pxbvAIBAiy9vHZeVwdv8dAREREFY9hqMRk7hi94fPDFwzl9Zk9rU14Yig7EE3MLeDSrXGIopjX5xMRERUTw1CJaap1QJexCWO+e4cAoKu5EU8eP5oViKYWl/H6tREkU6m810BERFQMDEMlRqlQoKupQdY2s7yat4nUj+poasCF4UHZqjYAWNlw49vvXUMkFst7DURERIXGMFSCMvccSiSTWFhzFeTZ7Y31eN+JISgV8j8a3mAQ37p4Fd48D9kREREVGsNQCTIbDQWfSP2o1vpavP/cKWjVGll7JBbDt9+7ipUNd8FqISIiyjeGoRKV2Tu05vEiGI4U7PlOmxUfPH8aZoNB1p5MpfDa1ZsFDWdERET5xDBUolrra6FRq2VthQ4gZqMBHzh/GrV2m6xdkiS8N3aHu1UTEVFFYBgqUUqlEp0ZE6mnl1eRLvAyd51GgxfOnEB7xu7YwOZu1e+O3i7I5G4iIqJ8YRgqYZlDZbF4HMuuws/XUSqVuDA8iKOd7VnXZpdX8erVm3k5VJaIiKgQGIZKmM1sgtNmlbVNLS4VpRZBEHCivwdnjw5k7UW07vHi79+7ilAkWpTaiIiIDoNhqMRl9g6tuDwIR4u3309vWzOePX0cKqVS1h4Ih/Gti1fg9gWKVBkREdHBMAyVuLaGOqhVqq3XEiRML60UsSKgqdaJF8+dhj5jp+xYIoFvX76GxfXC7IlERESUCwxDJU6tUqG9UT55eWpxuejnhdVYzfjg+TOwmkyy9nQ6jTevjeLu3EKRKiMiItofhqEykDlUFonFsOr2Fqmah4x6HT7wxCk0OGpk7RIkXL0zgWs89Z6IiMoAw1AZqLGYYTebZW2lsumhRq3Gs6eH0dncmHVtfHYeb90YQ4pL74mIqIQxDJUBQRCyeocW112IxuNFqkhOqVDg/LEjGOrpyrq2sLaOVy5fRyyeKEJlREREj8cwVCY6muqhfGQFlyRJmFlaLWJFcoIgYKinE08OHYUi45BXt8+Pb128gkABjxMhIiLaK4ahMqFRq9FWXydrm1pcLrk5OZ3NjXju9LBsBRwAhKJRfOviFax7fcUpjIiIaAcMQ2Wku0U+LycYiZRkuGhw1OADT5yGQaeTtSeSSXzn8nXMrawVqTIiIqJsDENlpNZug8VolLWVykTqTDazCR88fwZ2i3zityiKePvmGG5OTBV9ewAiIiKAYaisbDeRemF1vWTPBTPotHjx3Ck01Tqzrt2ansWrV25wYjURERUdw1CZ6WxqkE1QTosiZldKZyJ1JrVKhWdODqG3tSXr2prHi6+/c6kkh/qIiKh6MAyVGZ1Wg5a6WllbKU6kfpRCocCZo304faQPgiA/5DUaj+OVS9cwPjtf0l8DERFVLoahMpQ5kdoXDMHjDxapmr0RBAH97a148dyprInVkiTh2vg9vHVjDMlUqkgVEhFRtWIYKkMNjhoY9XpZWykPlT2q1m7Dh588m3WEB7C5QeM33rkMbzBUhMqIiKhaMQyVIUEQ0NXcIGtbWHOVzTCTTqvBc6eHMdjdkXUtGIngWxevYGZppfCFERFRVWIYKlOtGRswRmIxbPj8Rapm/xQKBYZ7u/Hc6WFo1GrZtXQ6jXdHb+PSrXGkea4ZERHlGcNQmbKZTbCa5HsOLay5ilTNwTXVOvHhC2dRY7FkXZtcWMLfv3cNoUi0CJUREVG1YBgqY5m9Q/Or62UzVPYok16PDzxxatvl955AAN949zKW1jeKUBkREVUDhqEy1tqQPVTm9geKVM3hKJVKnB3sx5PHB2UH0gKbx3i8fu0md60mIqK8YBgqYzaTMet4jvnV9SJVkxudTQ340PkzWV8X8HDX6mg8XoTKiIioUjEMlTFBENDaIN+AsZxWle3EZjbhQ0+eQVtDfda1NY8X33jnMnetJiKinGEYKnNtGfOGwtFoyW/AuBdqlQpPDQ/i1AB3rSYiovxiGCpzNrMJZoNB1ja/Vt5DZQ8IgoCBDu5aTURE+cUwVOYEQUBbQ2WsKtsJd60mIqJ8YhiqAJlL7MPRKDyB8h8qe9SDXauPdXdmXXuwa/U0d60mIqIDYBiqAHaLCaaMobKFMl9Vth2FQoHjvV077lp9cfQ2Lo7e4bAZERHtC8NQBRAEAW318lVllTZU9qgHu1Y7rNm7Vk8vLePv3noPqxueIlRGRETliGGoQmRuwBiKRuENVO48GpNejxfPbb9rdSQWw3euXMflW3fZS0RERI/FMFQhaixmmPR6WdtChawq28mDXasvHB+EKmPXagC4t7CIr799CesebxGqIyKicsEwVCE2N2CU9w7NVfBQ2aM6mhrw0vueQH2NPetaKBrFK5eu4+qdCaTS6SJUR0REpY5hqIJkLrEPRSLwVcmSc5NejxfOnsSZI/1ZZ5tJkHB3bgFff/sSNnz+IlVIRESlimGogtRYzDBmDJVVygaMeyEIAvraW/DShXOotduyrgcjEfz9xau4fncSafYSERHRfQxDFUQQBLRmripbqY6hskeZjQa8/+xJnBrohVIh/yMuQcKdmTl8493LcPsDRaqQiIhKCcNQhWnPONw0GInAFwoXqZriUSgUGOhow4cvnIPDZs267g+F8a2LVzBybxppUSxChUREVCoYhipMjdWcdY5XJW7AuFdWkxEfOHcKw33dUGT2EkkSxqZm8K13r8BbYTt2ExHR3jEMVZhqOKtsvxQKBQa7OvDhJ8/CbjFnXfcGg/jmxSsYm5qByF4iIqKqwzBUgTLDUCAchr8Kh8oy2cwmfPD8GQz1dEEQBNk1URQxcm8a37p4lb9WRERVhmGoAjmsluyhsipaVbYbpUKBoZ5OfOj8WdjMpqzrnkAA33jnEm5Pz7GXiIioSjAMVaDtVpUtrLmKVE1pqrGa8aHzZzDY1QEB8l6itCjixsQkvn3pGgLhSJEqJCKiQmEYqlCZu1H7giFE4/EiVVOalEolhvu68cHzZ2AxGrOub/j8+Po7lzA+u1DVc66IiCodw1CFclotUKtUsrZVN8/o2o7DZsFHLpzFQEdbdi9ROo1r4xN45fJ1hCLRIlVIRET5xDBUoRQKBepqbLK2VbenOMWUAaVSiVMDvXj/E6dgMhiyrq97vPi7t9/D2NQMd68mIqowDEMVrNHhkL1e3fBwuOcx6uw2fOTCWfS1t2ZdS6fTGLk3jb976z0srrv4a0lEVCEYhipYvUN+ins0HueE4D1Qq1Q4c6QP7z93KuusNwAIRaN449oIXrt6k8vwiYgqAMNQBbMYDVlL7DlUtnf1NXa89NQ59La2ZM0lAoCVDTf+19vv4dr4PSRTqSJUSEREucAwVMEEQUCDo0bWtrLBMLQfapUKZwf78aEnz6LWbsu6LkkSxmfn8TdvXsTM0gqHzoiIyhDDUIVryBgqW/d4eTDpAdRYzXjx3ClcOD4IvVabdT0Wj+Pd0dv4+/euwu0PFKFCIiI6KIahCpfZM5RKp/nN+oAEQUBHUwM+9vR5HO1szzr4Fdjcm+hb717BpbFxxOKJIlRJRET7xTBU4XRaDexm+eGkqxwqOxS1SoUT/T146akn0FTrzLouQcLk4hL+5s13cXdugcd6EBGVOIahKpC5qoyTqHPDYjTgudPDePbU8LZ7EyVTKVy9M4Gvv3OZv+ZERCWMYagKZA6Vuf0BJJLJIlVTeZrrnPjoU+cw3NcNlVKZdd0fCuE7l6/jrRtjCEdjRaiQiIh2wzBUBepqbLL5LZIkYc3jK15BFUipVGKwqwMfe/o8Opoatn3P/Ooa/vatixidnEGKu1gTEZUMhqEqoFIqUWuzytrWOGyTFwadDheOD+LFJ05nzdUCNnexHp3c3MV6YW2dS/GJiEoAw1CVyBwq46Gt+VVnt+FDT57B2aMD0KjVWdfD0SjevD6KV6/c4C7WRERFxjBUJRqc8jAUCIc5fyXPFAoFetua8fGnn9xxF+tVt2drF2vO4yIiKg6GoSphN5uyeii4wqkwtBo1zg7248MXzqJul12s//bNixifXeB8IiKiAhMkTlqoGm9eH8XC2vrW6/bGBjw1PFjEiqqPJEmYX13H9buTiMS275nTa7U42tWOnpYmKLdZnUZERLnFMFRF7s0v4fLt8a3XOo0G3/P8+yAI2cM3lF/JVAq3p+dwZ3Z+x00ZDTodjna1o7u5kaGIiCiPGIaqSCgSxdfeeEfW9pEL52C3ZK96osIIhiO4dvceltY3dnyPQafDse4OdDY3QrnNESBERHQ4DENV5muvv4NQNLr1+mR/L450thWxIgIAl9eH0cmZXedxGfV6DHZ1oLO5gaGIiCiHGIaqzKWxcUwuLm29bnDU4IWzJ4tYET1q3ePF6OQM1jw7b31g0utxrKcTHY312x4WS0RE+8MwVGXmV9fx1o3RrddKhQKfev8znJNSYtY8Xozem8a617fje0wGA4Z6OtHeUMdQRER0CAxDVSaeSOIr33kTEh7+tr9w9mTWpoxUfJIkYc3txejUDFy7hCKL0Yhj3R1oYygiIjoQhqEq9I13LsMTCGy9PtrZjhP9PUWsiHYjSRJW3R6MTM7A7fPv+D6ryYhj3Z1oa6jjCkEion1gGKpCN+5O4vbM3Nbruho7Xjx3qogV0V5IkoSVDTdGJ2fg9gd2fJ/VZMJQTyda62sZioiI9kBV7AKo8Jx2KzDz8LXHH4AkSfzGWeIEQUBTrRONTgeWXW6MTE7DGwhmvc8fCuGtG6Owm8041tOJljonf2+JiHbBnqEqFInF8devvSVr++j7zsNqMhapIjoISZKwuL6BsckZeIPZoegBu8WMoZ5ONNcyFBERbYdhqEr91atvIRqPb71+cugoOpsbi1gRHdRmKHJh5N4M/KHQju+rsVhwvLcTjU4HQxER0SMYhqrUG9dGsLju2nrd196KM0f6ilgRHdaDc8/GpmbgD4V3fJ/VZERvWws6mxqgVnGknIiIYahKjU3NYOTe9NZrp82KD54/U8SKKFdEUcTCmgujkzMIhHcORWqVCt0tTehra4HJoC9ghUREpYVhqEotu9x47eqNrddKhQKfevFZHvNQQURRxNzqOsYmZxCMRHZ8nwABTXUO9Le3or7GziE0Iqo67COvUjVW+eGsaVFEIBTmoa0VRKFQoLOpAe0NdZhdWcOdmblth88kSFha38DS+gasJhP621vQ0dQAFXclJ6IqwZ6hKpZ5aOu5wSPoaW0qYkWUTw92tL47v4DldbdsF/JMGrUa3S1N6G1rhknPITQiqmwMQ1XsrRtjmF9d23rd09KMc8cGilgRFUowHMG9hSVMLS4jmUrt+D4BAlrqa9HX3oI6u41DaERUkRiGqtidmXlcv3tv67XdYsZHLpwrYkVUaMlUCjPLq5iYW9x1sjUA2Mwm9LW1oqOpnkNoRFRRGIaq2JrHi1cuXdt6LQgCPv3iszzBvgptHvXhwcT8ApZd7l3fq1Gr0dPShN62Fhj1ugJVSESUPwxDVSyZSuHlb78hmzvywfNn4LRZi1gVFVsgHMHE/CJmllZ2H0ITBLTU1aK/vQW1HEIjojLGMFTl/vbNi7LhkTNH+tHX3lLEiqhUJFMpTC+tYGJucdel+QBgN5vR196CjsZ69iwSUdlhGKpy747cxszyytbrzuZGPDl0tIgVUanZHEJz4+7cIlY2dh9C06o16GlrQm9rMww6DqERUXlgGKpyd+cWcPXOxNZrq8mEj77viSJWRKXMHwrj3vwippdWkEqnd3yfAAH1Djs6mhrQWl/LYz+IqKQxDFU5l9eHv3/v6tZrAQI+9eIz/OZFu0okk5heWsXE3IJsr6rtKJVKtNQ60dHUgAZnDXc5J6KSwzBU5VLpNL707dfx6B+DF8+dQl2NvYhVUbkQRRErGx7cnVvAqtvz2Pdr1Rq0N9aho6kBDquFk66JqCQwDBG+/vYleIPBrdenBnox0NFWxIqoHPlDYdydW8Ds8uquQ2gPmAwGdDbVo72xARajoQAVEhFtj2GI8N7YHUwtLm+9bm+ox1MnjhWxIipnqXQaS+sbmFlexcqGG3v5K8Zhs6KjsR7tDfXQaTUFqJKI6CGGIcLkwhIu3Rrfem0yGPCJZ54sYkVUKWLxBOZX1zG7sooNn/+x7xcEAY3OGnQ0NqClvpY7XRNRQTAMETz+IL7x7iVZ2ydfeAZajbpIFVElCoYjmF1Zw+zy6mP3LQIAlVKJ1vo6dDTVo77GDgUnXhNRnjAMEdKiiJe//TrSorjV9sKZk2hw1hSxKqpUkiTB4w9iZmUV8ytriCUSj/2MTqtFR2M9OhobYLeYOPGaiHKKYYgAAN+8eAXuR4Yxhnu7MdjdUbyCqCqkRRFrbg9mltewuO5Ceg8Tr60mIzoaG9DeVA+TXl+AKomo0jEMEQDgyu27mJhf3HrdWl+Hp08OFbEiqjbJVAqLay7MLK9ize2VnZm3k1q7DW0NdWhyOmDmijQiOiDurEcAgBqrRfbaGwju8E6i/FCrVOhsbkRncyMisTjmV9cwu7wGTyCw42dcXh9cXh+uArAYjWiqdaCp1oFau42bOxLRnrFniAAAbn8A33z38tZrAQI+/cHn+A2Fis4fCmN2ZRWzy2sIP2a36wfUKhUaHDVoqnWg0emAQafNc5VEVM4YhgjA5vEKL7/yhqztY08/yc3wqGRIkgSXz4/Z5VXMr64jkUzu+bN2ixnNtU401Tq48zURZWEYoi1ffuVNxJMPV/Y8d3oYTbXOIlZEtL10Oo3lDQ8W111Ycbn3tCLtAZ1Gg0an436vUQ00am4hQVTtGIZoS+aKslMDfRjoaC1iRUSP92Cp/vLGBpbW3bvOMcokCAJqbVY01jrQXOuE1WRkrxFRFWIYoi3vjNzC7PLq1uu+thacOdpfxIqI9i8aj2Nlw4Ol9Q2suj1IplJ7/qxBp9saTqt32LkDNlGVYBiiLaOTMxidnN563eh04PkzJ4pXENEhpUURLq8Pyy43Vjbc8IfCe/6sUqFAvcOOJqcTTXUO7mlEVMG4tJ62mA3yv+yDkb2t3CEqVUqFAg2OGjQ4agD0IhSJYtnlxpJrA+ser2zX9UxpUcSyy41llxu4s7nZY6Nzc9l+rc3KA2WpIqVSKaRSKajVaiirqGeUPUO0xe0L4JsXH1leLwj49Ae4vJ4qUyqdxprbiyXXBpZdbkRisX193mwwwGm3otZmQ63dCovRwPlGVFai0SjeeecdjN26hWvXb2L87l2EIxFAkiAIAswWM44eOYJTJ4YxdOwYzp8/D3WFLjhgGKIt8UQSX/4Ol9dT9ZEkCf5QGEuuDay43HD5/NjvX40atRpOmxW1NiucdiscVgvnHFFJmp2dxcsvfxlf+qu/AtR6KE1WmGwOWOwOaHR6CAoFRDGNRDSCgGcDIZ8HqZAfWoWIH/z+T+N7v/u70djYWOwvI6cYhkiGy+uJNv9hsOr2bA2TPfr/ib0SBAE1FvNmQLLb4LRZufkjFVU4HMa//f1/h5f/+qvQOxvR0NEHg8W6588HvW6sz04i7l3DT/z4j+Inf+InoNFUxnAxwxDJZC6vP32kD/3tXF5P1UsURXgCQSy73HB5fdjwB/Z0oOx2jHo9au3WrR4kq8kIBYehqQAuX76Mf/HZzyGQUqB96DQ0Wt2B7xULhzA7cgktTht+5/O/iSNHjuSw0uJgGCIZLq8n2p0oivAGQ9jw+bHh9cPl8+97vtEDapUKDqtlKyA5bVaoVVzXQrn18ssv47f+9e+htncIdS0dObmnJElYmZlAYH4Sv/e7v41nn302J/ctFoYhkhmdnMbo5MzWay6vJ3q8cDSGDZ9/8+BYnx++QAgS9v9XqwABNrPp/tDa5twjo07Hidl0YF/8y7/E7/zev0Xn6ffBaLHl/P4+1xqWRi/h3/zub+P555/P+f0LhWGIZGaXV/HOyK2t1yaDAZ945skiVkRUfpKpFNy+AFw+/2YPks+/r80fH6VRq2EzGWE1mWA1GWE1G2E1GaGrkLkalD+vvfYafv6XfhldZ57d19yg/fJvrGN57BL+yx//EY4fP5635+QTwxDJcHk9Ue6Jogh/OIINr3+rBykUPdw+XjqN5n44MsFmMsJiMsJmMvKsNQIAeDwefOJ7Pglr9zHY6/K/8mt1dgqasAtf+dJfQqc7+HykYuHgNMmYMjZelCQJ4WiMy+uJDkGhUMBuNsFuNqG3rRnA5rEhm8Foc96RNxCEuMsmkJliiQRingTWPF5Zu0Gng8VogM282ZNkM5lgMRk4F6mKSJKE3/rt30HaYC1IEAKA+vYu3Lu8hD/4D/8B//QXfqEgz8wl/r+DZLQaNTRqNRLJ5FZbKBJhGCLKMb1Wi9b6OrTW1wHY3ATSEwhurljz+bHhDRxoSX8kFkMkFsOq2yNrN+r1mz1J93uQrPdDEvdCKo54IolILAab2ZTzOWGXLl3Cq2+/i/6nPpDT++5GEAS0Hz+LP//il/C93/M96OrqKtizc4FhiLKYDXq4/Q/DEI/lIMo/lVKJOrsNdXYbgIe9sr5QCP5QePO/YBiBcHjXY0R2Eo5GEY5Gseza2GoTIMBo0G+GI7MRVuPmsJvFoK+qoxiK4a0bo1hyueGwWtDX1oKOpvqc9d79jz//AsyN7VCqCjtkqtXpoXc24Yt/+SX881/+ZwV99mExDFEWs9EAtz+w9ZphiKjwBEGAyaCHyaBHS13tVrsoighFY/CHQvAFwwiEwvCFNkPSfqeASpAQikQQikSwuO6SXdOqNTDotTDqdDDotDA8+FG/+aNeq+VcwkNIpFKYX1nD4roLi+su1FjM6GpuRF9bC8yH6IlfXl7GW+9exMAzH8lhtXvX2NWHv/rq1/CzP/PTMBqNRanhIBiGKEvmga2hSKRIlRBRJoVCAYvRAIvRgNb6h+1pUUQwHHnYixQKwxcMIRSJHmiZfzyZQDyZgDcQ3Pa6AAF6nRZ6XXZgMup0MOi10Gk03BZgBxqVCgqFAgKAZZcbKy43VjY8uD0zh9b6WvS1taDR6dj3r9//+vrXobXXQaUuzmpDvckCQW/Cq6++io997GNFqeEgGIYoi8kg/1dJIMyeIaJSp1QoYDObYDObZO3pdBqBcGSz9ygU3hx2C4YPvZpNgrQ1P8kN/7bvUSgUMGi1D3uUtJs/PhqeNGpVVQYmjVoNpVIBg06LtoY6hCJRuP0BrHm8WHN7Mb20CqfNir62ZnQ2NUKr2duQ16XLV2F21D/+jTtYX5zF2Duvwb28AAmA0WJDx+AwBs5cgFK5t8igNttx4+ZNhiEqb5k9Q+FoFGlRZJc4URlSKpWwW8ywW8yy9mQqhcD9niRf8OG8pIPupr2dzSG96Gbw8m7/HqVSuRWO9Fot1Col1GoVNCo11CoVNGoV1Crl5mu1ChrV5utyn9OkVqmgVCggiuLmCfFGA8xGA+KJJDyBAKYXl7HicmNpfQM3LFPoampEb3sL7Blh91GSJGHs9i00DJ0/UE2Lk+N4+2++iOH3vYgLH/s+6AxG+N0u3L74OqKhIExW+57uY6lx4tqNkQPVUCwMQ5TFnNEzJEkSItHYocaxiai0PDgKxGG1yNqTqRTC0RgisTgisRjCsfs/f6TtIBO4d7LZc7U552k/lAoF1Gr1ZjjaCkmqrNdbP3/0PWoVVEplUXukNOrNYbLMX0utRo1GpwN1NXb4g2Gse7xY2XBj1e3B+Nw8mpwO9La1oKW+NusfqGtrawhHotCb5L+neyFJEq5++29x9IlnMHD2qa12q6MWT370U/u6l8XuxPjoe0gkEmVzkCvDEGXZbnl9MBJlGCKqAmqVatvhtgckSUI8mUQkGkckHkMkGn8YmB75Md/7+aZFEel4HLF4/ECfFyDIApJSqYAgCJv/AQ9/Ltz/OQTZdQgCFIIACIDi/vUHP0fmPQAIikfvASytbyAYjiCZSkOlVEKSNn9tt/4DIIoSdFoNYokEFtdcuDe/BJ1Wgzevj8Ko18Fpt8Fps0CtUuEjF87B5/NBozMcKOQFvW6E/F50HDn8DtIqjQaiBIRCIdTU1Bz6foXAMETbylxeH+KKMiLC5jd4nUYDnUaDGpi3fY8oiojd30cnHI0hGo/LepsisThi8cSBJnbnigQJiWRS9o++QhAlCem0uLWfVFoUkRbTj/2c+v6E62g8jqVgGEqlAssbHui1GtjNJpwfOopkMgnhgNMZYpHNnjmDef+9SttRKBRIFvjX9jAYhmhbBp1Otrw+ltj/5m9EVJ0UCsX9CdJaOG3bn4mVFkVE45vDb+H7ISmeSCKZSiGZSiOR3Px5IplCMr35ej87dBeCJElIptJIp0WkxM0f0+k0UuL9H9ObQefR9gdfQzQeRyyR2Neml0qFAia9HkadDvFEEuFYDKFIFOFoDN989zKGWxsA6WC/Rjr9Zs9/JBiA2e440D0eJUkS1GV0NAzDEG0rc+VCPFE+CZ+ISt+Db+wmvf7xb74vnU4jcT8gpVJpJFLJzbC0FZpSSCZTj7zn4c83Q9beD8uVpM3A9uBzyQfPSaUfth3w8F1gczhNkjZ7ig4ifT9YaTUa6LUadDY1wGq1IhGLQpKkfQ+VmWucMFrtmLszgmMXDnf6fCqZAESR+wxR+csMQ+wZIqJiUyqV0CuV0Gu1B/q8KIpI3Q9UsVgcgXAEgUgEwXAUkVgM0VgCkXgcsUQc0VgcqbQIPBjMkwCFQoBWvTkBG5DwIMc8Oj/qwc8lSLj/P0B6OCD44HpUpUI8kYTifijE/blJiq15Ro/MV7r/H7DZo+QNhGDQadHeWI/6GjuOdXdgqLcLCkGAXqtFLBzc9yRqQRBw5sWP4e2/+SLUWh06jg5Dqzcg4NnA7Ytv4NhTz+95NVnQ60FnZwe0B/x9KgaGIdqWNmPDrngZjf0SUXWTJAmxRCJrcvfmvKXNYbndJl4/mBOVT9F4HKIkQoCAzuaGx74/Fk9gZcODeDKJ7pZG1NfYcaK/B51NDbJeoMGjRzHncR9oRVlLzwCe/74fxdg7r+Hmm98GABgtVnQOnoTetP38sO0EPC68eGp4388vJoYh2haHyYio1CWSSfjun9nmv3+GWzgWRzTHy/8fRxAEaNWbq3C1GrXs5xq1Clq1BlqNClq1GlqNBhq1ColUCl997W2Mzy7seu9UOo11jw++YAhOmxVdLY042tmOo13t255ldvbMKYx95e9Q39Z5oK+lrqUDL3z6xw702QcSQS9ODB9+VVohMQzRtrQZE9/YM0RExfLoLtqbG0RunsuWyw0id6PfZudso/7+jzodtBr1vufoKBUKKBQKSJIEURShyFgFJkoSPP4A1j0+mI0G9La1oKu5ESf7e2Ay7DzP6iMf/jD+8I//BOlUsuAHtQKbq9LSYT9eeOGFgj/7MBiGaFvZPUOJA03KIyLaK0mSEIpEHwk9mz0+gXAkb/sWqVUqWbh5eLbaZtDR6/JzIK1apYJS2NzbKJ0RhoLhCFbcHigVCnQ0NaCp1oHTR/pQX/P4OTttbW144swpTMxOoaVnIOd1P87K9F1818c+CrN578NqpYBhiLaVOV4uSRISydSez8chItrJgzk9DwKPL3g//ITDSKcfv+fOXgmCcL/3RisLNw/DjhaaIi3/FgQBKpVy60gOYHM6wsqGB7FEAg0OO+pq7Bju7UZ3S2NWz9FufuSH/wF+5hf/ORo7e/Z8nlguJOMxRNaX8Onv+62CPTNXGIZoW9uFnngyyTBERPuSTKUehp4HPT7BMOLJ3KxQFQQBFqMRNpMRVrMRZoNhs3dHr4NOo9lXiCi0B4e1JlJpeAIeeAJBOK0WtDfW4UhnO451dxworD311FN48vQJ3Lx9E51Dp/NQ+fZmR6/ik9/1cfT39xfsmbnCMETbUik3D0J89F9p8UQC4JEcRLSDtCjCGwjC5fXD5fXBGwwhHM3d7vVGvR42sxE2kwlWkxFWswkWo6FsD5HW3D+sdW5lDRajAb2tzehoasDJ/h5YTQffo0cQBPza5z6LT3zvp+DfWIfVWZfDqre3vjALi0rCP/n5n8v7s/KBYYh2pFWrEXk0DHESNRE9IplKbQYfnw8urx9ufyAnw1xatQY282bYsZmMsJk3w892q6fKmVqtglGvR4OjBo1OB04N9KKp9vC7PwNAXV0dfu1X/wV++XO/Ds0Tz0Nv3Pm0+8MKet1w3RvFf/rDf19WGy0+qrL+ZFFOaTVq2WoNLq8nqm6RWBwurw8u32bPjy8QOtT5YkqlcnN4y7R5MOyDoS6dRlMVizXaGuoQicbQ39GKntbmnPdwffjDH8by8gr+3f/3n9F99pl97RW0V0GvG3PX3sZv//rncObMmZzfv1AYhmhHmZOoGYaIqockSQiEI1tDXi6vD6EDDnltzusx3A8998OPyQiTQV8VoWcnfW0t6GtryeszfvzHfwxKlRL/7x/8RzQdPYWahuac3XttfhruqVv47V//HF566aWc3bcYGIZoRzySg6h6ZM73cXn9B57kbDWZUGu3otZmhc1ihsWgh3IfB5JS7giCgB/9kR9BR3s7PvtrvwHvyiLaj52ESn3wHbYTsShmR6/ArlXiT/7oD3Hy5MkcVlwcDEO0I268SFS5kqkUNnz+rfCzccD5PgqFAjUWM2rtNtTZbXDarFx1WoKeffZZfO2vvoz/+1/9K3zzlW/BUNeMxs4+6Ax7n+MTCfqxOnMP0Y1l/OD3fRI//VM/BYOhMhbVCFK+drKisjc2NYORe9Nbr5tqnXjudHmdN0NEm2LxBNY83kPP91GrVPd7fWyotVtRY7VAxV6fsnLr1i385Zdext9+/etQmWxQG22wOJww2x1Qax4erpqIRRHwbCDodSMV8kGKR/Cp7/kufOqTn0RXV1cRv4LcYxiiHd2bX8Ll2+Nbrx02Kz50vnwnyBFVE0mS4A+FsbS+gSXXBty+wIHCj0GnQ63Nilr7ZvixmowlvXcP7Z3f78cbb7yBmyMjuH5zBJNT0xBFEYJCATGdhkatRl9fL06dGMbw8eN4+umnK6YnKBPDEO1oYW0db14f3XptMhjwiWeeLGJFRLSbdDqNda/vfgByH2iPH6vJeD/4bIYfo05X1ZOcq0kqlUI0GkUymYRarYbBYKiauV6cM0Q7ypozxAnURCUnFk9gecONpfUNrGy4kdrHvJ9H5/vU2q1w2qxZq0ipeqhUqrI7UyxXGIZoR9qMvxSTqRTSoli2u70SVYLDDH+plMqt4FNnt3G+D9F9DEO0o8yeIWBzryGDTrvNu4koX7aGv1ybPUD7Gf4y6HRoqXOiqdaJ+hpb1Qx7EO0HwxDtSKNWQYAg+1dnPMkwRFQIBx3+EiCgxmZBc60DzXW1sJmMnPND9BgMQ7QjhUIBtVqFxCP7C3HeEFF+HHb4q9HpQFPt5n96Lf/BQrQfDEO0K51GkxGGuPEiUa6kRRHrHi+Hv4iKjGGIdqXVqIHww9cMQ0SHI0kS3P4AZpZWMbe6JvvHxm44/EWUPwxDtCu1Sv6vzf0s2yWih8LRGGaXVzGzvIpAOPz4D4DDX0SFwjBEu8r8lyf36CTau2QqhYU1F2aWVrDu8e1pDpBBp0NzrRPNdRz+IioUhiHalUKQ7ykkMgwR7UoURax7fJhZXsX82vqeDj91WC1ornOiudYJm9nE4S+iAmMYol2xZ4hob/yhMGaWVjC7soZILPbY95sMBnQ2NaCzqQEmg74AFRLRThiGaFcMQ0Q7iyUSmF9Zx8zyCtz+wGPfr1ap0N5Qj87mBjhtVvYAEZUIhiHalSLjL2sOk1G1S4sill0bmFlaxfKGG6Io7vp+QRDQ6HSgs6kBLXVOzgEiKkEMQ7Qr9gwRbf659/iDmFneHAbby3J4u9mMzuYGtDfWcxUYUYljGKJdsWeIqtl+l8PrtFp0NNajs7kRdrOpABUSUS4wDNGusnqGRIYhqmzpdBrz95fDr7m9j10Or1Qo0FJXi87mBjQ4aqBQKHZ9PxGVHoYh2pWg4DAZVYdYPIF7C0u4t7CEWDz+2PfX2W3oaGpEW0MtNGp1ASokonxhGKJdcZiMKp0vGMLduQXMLq8i/ZjJ0Ca9Hp3NDehobIDZaChQhUSUbwxDtCtOoKZKJEkSVjY8GJ+dx6rbs+t7HyyH72huQC2XwxNVJIYh2hV7hqiSpNJpzCytYmJ+Af7Q7hOi62rs6G1tRnOdEyouhyeqaAxDtCsF5wxRBYjE4rg3v4jJhWXEk4kd36dQKNDeUI/+9lbUWM0FrJCIiolhiHaVOSTwuA3miEqJxx/E+Nw85lfXd/2zq1Vr0NPWhN7WFhh03BOIqNowDNGussIQe4aoxImiiCWXG3dn57Hu9e36XqvJiP72VnQ0NXAojKiKMQzRrjJPrecwGZWqZCqF6aUV3J1bRCgS2fW9jU4H+ttb0eis4YRoImIYot1xNRmVulA0iom5RUwtLiOZSu34PqVCgc6mRvR3tMJqMhawQiIqdQxDtCuuJqNS5fL6cHduEQtr67uGdJ1Wi762ZvS0NkOn0RSwQiIqFwxDtCv2DFEpEUURC2sujM8twO3z7/peu8WMgfZWtDXU8aR4ItoVwxDtihOoqRRIkoS5lTWMTs4guMt8IAECmuuc6O9oRZ3dxvlARLQnDEO0q6x9hnhQKxWQJElYXN/AyL1p+EOhHd+nUirR1dKE/rYWHpNBRPvGMES74jAZFcOD4zJG7k3DEwjs+D6DTof+9lZ0tzTysFQiOjCGIdoVJ1BToa17vBi5N73rHkE1FguOdLahtb4WCoVix/cREe0FwxDtij1DVChuXwA3703tenCqzWzCUE8XWuqcnA9ERDnDMES7YvihfPMGQxi9N43FddeO7zEbDDje24W2hjqGICLKOYYh2lUqnZa95pEFlCuBcASjkzOYX1mDhO1Dt0Gnw1BPJzqbGjgcRkR5wzBEu8oKQyqGITqcUDSKsclZzCyv7NjzqNNqcayrA90tjdwjiIjyjmGIdpVKsWeIciMSi+P29CwmF5d3PEFeq9bgaFc7etua+WeNiAqGYYh2ldkzpOY3KNqnWCKBOzPzmJhfRDrjz9MDapUKAx1tGOhohVrFv5aIqLD4tw7tKslhMjqgRDKJ8dkF3J1b2PEAVaVSif62FhzpbIdWw32CiKg4GIZoVxwmo/1KplKYmF/EnZl5JJLJbd+jUCjQ29qMo13t0Gu1Ba6QiEiOYYh2xQnUtFdpUcTkwhJuTc8hFo9v+x5BENDd3ITB7g4Y9boCV0hEtD2GIdpVKi0f3mDPEG1ndcODK3cmEAiHt70uQEB7Uz2Gujt5dhgRlRyGIdpV5jCZWsk/MvRQJBbDtfFJzK+u7fie1vo6DPV0wmY2FbAyIqK943c22lXmBGo1h8kIm0Nid2cXMDY1kzWU+kBTrQNDPV1wWC0Fro6IaH8YhmhXWROoGYaq3uOGxGosFpw60os6u62whRERHRDDEO2Kx3HQA5FYDNfHJzG3w5CYRq3Gib5udDU38ugMIiorDEO0K4YhetyQmAAB3S1NON7XBZ1GU4QKiYgOh2GIdiRJ0jbDZPwjU01W3R5cvTMBf2jnIbGzR/vhsHFeEBGVL35nox2lRTHrNHEex1Ed9jIkNtzbje4WDokRUfljGKIdZfYKARwmq3RpUcTE3CJGJ6c5JEZEVYNhiHa03TdDriarXHsZEjtztA9Om7XAlRER5RfDEO0oc48hAQKUHBKpOBwSI6JqxzBEO0plnDSuUikhCEKRqqFce9yQGAB0tzRhuK+bQ2JEVNEYhmhHXFZfuTgkRkT0EMMQ7YhhqPJwSIyIKBvDEO2IR3FUDkmSMLW4jGvj9zgkRkSUgWGIdpR1SCt7hspSJBbHpVt3sOxyb3vdbjHj7NF+DokRUdViGKIdsWeo/M2trOHy7btIJJNZ1zgkRkS0iWGIdpT5DVSt5B+XchFPJHHlzgTmVla3vd7V3IQTfd3QaTkkRkTE7260o0g8Lnut12mLVAntx7LLjffG7iCa8fsHADqtFuePDaCp1lmEyoiIShPDEO0oEo3JXhsYhkpaMpXCjbtTuLewuO31toZ6nDnaxwnSREQZGIZoR5GYvGfBoNMVqRJ6HJfXh3dH7yAUiWRdU6tUOHu0H+2N9dw0k4hoGwxDtC1JkrKGydgzVHrSooixyRncnp6DBCnreoOjBueHjjDIEhHtgmGIthVPJCGKoqyNYai0eIMhXBy5DW8wmHVNqVTiZH8Pelub2RtERPQYDEO0rcwhMkEQONekRIiiiPHZBYxMTmcFVgBw2Kx4cugoLEZDEaojIio/DEO0rXBMPnlar9VyL5oSEAxHcHHsDlxeX9Y1hUKBoe5OHOls4+8VEdE+MAzRtqJZk6c5RFZMjztOw2oy4cLxo7BbzEWojoiovDEM0bYiscxl9ZyAWyy7HachQMBAZxuO93RCyeNSiIgOhGGItsWVZKVht+M0THo9zg8dQV2NvQiVERFVDoYh2lb2HkMMQ4X0uOM0elqacXKgB2oV/y9MRHRY/JuUtpW5+7ReyzBUKCsbblwc3fk4jScGB9Bcx+M0iIhyhWGIsmy34aJRzzlD+SaKIm5MTGF8dn7b6zxOg4goPxiGKAs3XCy8aDyOt2/ewrrHm3WNx2kQEeUXwxBlydpwEdxwMZ/WvT68fWNs22ExHqdBRJR/DEOUJRLPmC+k44aL+SBJEibmF3Ft/B4kSX6umEKhwKn+XvS28TgNIqJ8YxiiLJk9Q3oOkeVcMpXCpVt3t10tZtDp8PSJIThsliJURkRUfRiGKEvmSjIjh2hyKhCO4M3ro/CHQlnXGhw1uHB8EDothyWJiAqFYYiycMPF/FlYc+Hi6G0kU6msa4NdHRjq6eSQJBFRgTEMURZuuJh7oihidHIGt6Zns66pVSqcHzqK1vrawhdGREQMQ5Qta84QN1w8lFgigXdu3sKq25N1zWoy4emTQ7AYDUWojIiIAIYhyiBJEqJZh7QyDB2U2xfAmzdGsw6+BYD2xgacG+znkRpEREXGv4VJJp5IIp2x4SJ3n94/SZIwtbiMK3cmsjawFAQBpwZ60dfWwmXzREQlgGGIZLjh4uGl0mlcuT2B6aXlrGt6rRZPnTiGOrut8IUREdG2GIZIJhSNyl5zw8X9CUWjePP6KLyBYNa1OrsNT504xjlYREQlhmGIZDL3vuHE3r1bdrnxzsgtJJLJrGsDHW0Y7uuGksGSiKjkMAyRjC8Ylr22mU1FqqR8SJKEsalZjE3OQIL8WA2VUoknjh1Be2N9kaojIqLHYRgiGV9Q3jNkNRmLVEl5iCeSeHf0NpZdG1nXLEYjnj45xF9DIqISxzBEW1LpNEIR+Zwhm4k9QzvxBoJ48/po1jwrAGitr8P5oSNcNk9EVAb4NzVt8YfCsmEeAQIsJs4Z2s7M8ioujd3J2oZAgIDhvm4c6WzjsnkiojLBMERb/CH5fCGjQc+ejQyb84NmMDo5k3VNp9HgwvAgGhw1RaiMiIgOit/paEvmfCEb57rIpEURl2/d3Xb/IIfNiqdPHINBxw0qiYjKDcMQbckKQ1xJtiWZSuGtG2NY2XBnXettbcGpgR4olcoiVEZERIfFMERbMofJuApqUyQWx+vXbmZtpChAwOkjfehrbylSZURElAsMQwRg82T1aFx+FAd7hjYD4mtXbyKcsWJMqVDgwvAxtNbXFqkyIiLKFYYhApDdK6RQKGA26ItUTWlY93jxxvXRrB2ltWoNnjk1hFqeL0ZEVBEYhggA4M/YedpqNFb1mWRzK2u4OHo7a+m8yWDAc6eHeUwJEVEFYRgiANvsPG2u3vlC47PzuDZ+L6vdYbXg2VPD0Gk1RaiKiIjyhWGIAAC+UOay+uqbLySKIq7fncTduYWsa811Tlw4Psh9l4iIKhD/ZidIkpQ1Z8hWZT1DqXQa747cxsLaeta13tYWnD7SW9XDhkRElYxhiBCOxZBMpWRt1irqGYolEnjj2gg2fP6sa8N93Tja2c6jNYiIKhjDEGVNnlarVDDotEWqprBCkShevXIDwUhE1q5QKPDEsSPobGooUmVERFQoDEOUPV/IbKqKnhC3P4DXr95ELJGQtatVKjx9cohnjBERVQmGIYIvc1l9Few8vbS+gbdujiGdTsvaDTodnj09DDs3nCQiqhoMQwT/Nj1DlWxyYQmXb92FBEnWbjOb8NzpYR62SkRUZRiGqlxaFBEIy+fLVOpp9ZIkYWRyGremZrOuNThq8L4Tx6BRqwtfGBERFRXDUJULhiMQM3ZZrsSVZGlRxKWxccwsr2Rd62xqxLljA1By6TwRUVViGKpymfsLGXQ6aDWV1TuSSCbx1o0xrLo9WdcGuztwvKerKiaMExHR9hiGqlzWMRwVNkQWicXw2tWbWV+nAAFnB/vR09pcpMqIiKhUMAxVOU8gKHtdSWEoHI3hlcvXEcrYQ0ipVOJ9w8fQXOcsUmVERFRKGIaqmCiKWbsuO6yWIlWTW6FoFN+5dB2haFTWrtNo8OypYThslfF1EhHR4TEMVTFfMJx1DEet3VacYnIoFI3ilUvXEc4IQmaDAc+fOQGTQV+kyoiIqBQxDFWxda9P9tpkMJT9MRyhSBTfvnQNkVhM1m41GfHC2ZPQa8v76yMiotxjGKpirowwVFfmvULBcASvXL6+TRAy4f1nT0Kn1RSpMiIiKmUMQ1VKkqSsMFRrtxanmBwIhCP4zjZByGY24YUzDEJERLQzhqEqFYxEsw4oLdeeIX8ojO9cvo5oPC5rt5vNeP7sCeg0DEJERLQzhqEqte7xyV7rtNqynFjsD4XxyuXriGUGIYsZL5w5WXEbSBIRUe4xDFUpl88ne11nt5XdLsw7BaEaiwXPnznBIERERHvCMFSlXBk9Q+U2X8gXDOE7l69nDfU5rJtBiAeuEhHRXjEMVaFILJ61GWE5zRfyBkP4zqXriCczgpDNiudPDzMIERHRvjAMVaHMVWRqlapsjuHwBoL4zuUbWUHIabPi+TMnoFbxjzQREe0Pv3NUoczJ07V2KxQKRXGK2QePP4jvXLmORDIpa6+12/Dc6WEGISIiOhB+96hCmZOny+EIDrc/gO9cvp51fEid3YZnGYSIiOgQ+B2kysQTSfiDYVlbqYchty+A71zZJgjV2PHsqeMMQkREdCj8LlJlNnx+SJC2XisVCjgs5iJWtLsNnx+vXrmRFYTqa+x4hkGIiIhygN9Jqkzm4awOqwVKpbI4xTyGy+vDa1dvZgWhBkcNnjl1HKoSrZuIiMoLw1CVyTqPrMZWlDoeZ93rw2tXbiCVTsvaGYSIiCjXGIaqSCqdhicQlLXV2mzFKWYX6x4vXrt6MysINTodeObkUMn2ZBERUXliGKoibn8AoihuvRYglNzO02seL17fJgg11Trw9AkGISIiyj2GoSqSub+Q3WIuqQnID3qE0hlBqLnOifcNH2MQIiKivCid74SUd1nzhUqoV8gbCOL1ayNZQailrhZPnTgGZRlsCklEROWJYahKiKKIDZ9f1lZXIpOnQ5EoXt1m1VhrfR0uDA8yCBERUV7xu0yV8AZDWfNwSmHydCyewKtXbiAWj8vaW+pqGYSIiKgg+J2mSmQOkVmMRui0muIUc18ylcJrV28iGInI2uvsNgYhIiIqGH63qRKrbo/sdbHnC6VFEW9eH4UnEJC128wm7iNEREQFxTBUBZKpFNbcXllbg6OmSNUAkiThvbE7WQHNqNfjudMnoFGri1QZERFVI4ahKrDq9iD96P5CgoBGZ3HCkCRJuH53ErPLq7J2rVqD504Pw6DTFqUuIiKqXgxDVWBp3S17XV9jL1rvy52ZeYzPzsvalEolnjs9DKvJWJSaiIioujEMVThRFLHs2pC1NdU6i1LL9NIKbkxMytoEQcDTJ4bgsFmKUhMRERHDUIVz+wOIJRKytpa6woehpfUNvDd2J6v9/NBRNNU6Cl4PERHRAwxDFW5pXd4rZDWZYDLoC1rDhs+Pt26OQZIkWfvJ/l50NjUUtBYiIqJMDEMVbsklny/UXOBeGH8ovO15YwMdbTjS2VbQWoiIiLbDMFTBQpEo/KGQrK25gENkkVgMr165gUQyKWvvbGrEyf6egtVBRES0G4ahCraYMUSm02jgsBZmonI8kcSrV24gEovJ2ptqHTh3bACCIBSkDiIiosdhGKpgS+su2eumWicUBTjiIpVO443rI/CHwrJ2h9WCp4Z5Aj0REZUWfleqUIlkEusZ55E11+V/vpAoinj75ti2Z6E9e3oYapUq7zUQERHtB8NQhVrZ8MhWbykVirwfwSFJEi7fvpu1gk2v1eK5M8PQaYp7MCwREdF2GIYqVOZ8oXqHPe+9MiOT05haXJa1qVUqPH/mBEz6wi7nJyIi2iuGoQqU3mbX6eba2rw+8+7cAm5NzcralAoFnj11HDazKa/PJiIiOgyGoQq04fMjmUrJ2vI5X2huZQ3X7tyTtQkQcGH4GOpq7Hl7LhERUS4wDFWgzDk7dosZBp0uL89a3fDg3dHbkCDfXfrs4ABa6/PbG0VERJQLDEMVRpKkrPlC+TqLzOMP4o3rIxBFUdZ+vLcLPa1NeXkmERFRrjEMVZhAOIJQJCJry8d8oWg8jjeujyCVccxGX1sLBrs6cv48IiKifGEYqjCZQ2QGnQ52S24nMKfTabx5fTRrd+m2hnqcGujl7tJERFRWGIYqzFLGKrKmWkdOw4kkSbhyZwIbPr+svc5uw5NDRwqywzUREVEu8TtXBYklEtjwykNKrucL3ZtfytpLyKjX430nhqBUKnP6LCIiokJgGKogKy63bFWXUqlEfQ6Xtq+6Pbg6PiFrUyqVeObkEHRa7i5NRETliWGogmSuImt01OSstyYUieLtG7dkR3wAwPljR2C3mHPyDCIiomJgGKoQ6XQaKxtuWVtzjobIkqkU3rg+gngyIWsf7O5Ae2N9Tp5BRERULAxDFWJ5wyNb5i5AQFPt4XedliQJF0fvwBcMydqb65w43tN16PsTEREVG8NQhZhdXpW9rrVboddqD33fsalZLKyty9qsJiMuHB/kEnoiIqoIDEMVIJZIZC2p72hqPPR9F9ZcGJ2clrVp1Go8c/I41CrVoe9PRERUChiGKsD86rrsSAylQoG2hsPtOu0LhvDuyC1ZmwABTw0Pwmw0HOreREREpYRhqALMZAyRNdfVQqNWH/h+sUQCb1zLPmrj5EAPGp2Hn4dERERUShiGypw/FIY7YzfozqaGA99PFEW8c/MWQtFoxj0b0d/eeuD7EhERlSqGoTI3uyLvFdJpNGhw1hz4ftfvTmLV7ZG1OawWnBvs54RpIiKqSAxDZUySJMwsycNQe2M9lAc8H2xqcRl35xZkbXqtFk+fPM6jNoiIqGIxDJWxda8v6+T4zgOuInN5fbh8+66sTaFQ4OmTQzDoDr9En4iIqFQxDJWxzF4hq8kEu8W07/tEYjG8eWNMtiINAM4NDsBpsx6qRiIiolLHMFSmUuk05lfXZG2dTQ37nteTTqfx5vVRxOJxWXt/eyu6mg+/VxEREVGpYxgqU4trrqzjNzr2uYpMkiRcunUXbn9A1t7gqMHJ/p6c1ElERFTqGIbKVObeQvUO+77n9tydW8DM8oqszWQw4KnhY1AccBI2ERFRueF3vDIUicWxuiFf/t65zyGtlQ03ro9PytpUSiWeOTkErebgGzYSERGVG4ahMjS3sgYJ0tZrlVKJljrnnj8fCEfw1o0x2T0A4Mnjg7CZ9z8Bm4iIqJwxDJWhzKGt1oa6PR+cmkyl8Ob1ESRTKVn7UE8XWusPd54ZERFROWIYKjPeQBC+YEjWttfjNyRJwjsjt+APhWXtrfV1ONbdkasSiYiIygrDUJnJnDht0OlQX2Pf02fvzi1gaX1D1mYzm3B+6AiP2iAioqrFMFRGRFHE7MrB9hZy+wO4MTEla9OqNXjm5PE9D7ERERFVIoahMrLq9mRtjriXvYWSqRTevnkra4fpC8NHYTLoc1ojERFRuWEYKiOZx284rBZYTcZdP/NgY8VQJCJrP9rZjkanI+c1EhERlRuGoTKRSCaxuO6Ste3lUNaZ5VXMrWSEKJsVQ71dOa2PiIioXDEMlYn5VRfSjwxzKRQKtDXW7foZfyicdRK9WqXCU8ODUHKHaSIiIgAMQ2VjNmNvoSanAzqNZsf3p9NpvH1zDOlHzi8DgCeOHYFJz3lCREREDzAMlYFQJIp1r0/W1tm8+8Tp63cns/Yj6mltRlvD7r1JRERE1YZhqAxMLS7LXmvUajTtMvl5Yc2FiflFWZvVZMKpgd681EdERFTOGIZKXDqdzgpDHY31UCqV274/HI3hvbE7sjalUomnhgeh2uEzRERE1YxhqMQtrLkQSyRkbb1tLdu+VxRFvDNyC4lkUtZ+eqCXB7ASERHtgGGoxGUOd9XV2HfcW2hsahaujLlFbQ316G5pyld5REREZY9hqIR5A0Fs+Pyytr4deoVW3R7cmpqVtRn1epwb7Oe5Y0RERLtgGCph9+aXZK/1Wi2a65xZ74vFE3h35DYkSFttgiDgqeFBaNTqvNdJRERUzhiGSlQimcRMxs7RPa1NWZslSpKEi2O3Ec04s2y4txtOmzXvdRIREZU7hqESNbO8KtswURCEbef+3J1bwLLLLWtrcNTgSGdb3mskIiKqBAxDRbDs2sDt6TlEYvFtr0uSlDVE1lJXC4NOJ2tz+wO4MTEla9NptXjy+FHOEyIiItojVbELqEaXbt3FqtuDG3cn0d5Yj972FtTarFsBZs3jRSAcln2mt61Z9jqZSuHtm7cgPnJemQABF4aOQq/V5v+LICIiqhAMQ0UgCAJW3V4srm1g1ePF5OIS6mrs6GtrQUdjfVavkNVkRH2Nfeu1JEm4dOsuQpGI7H1HOtvQ4KwpyNdARERUKRiGikCjUkGpEGC3WxBPJjExv4hllwcLay6YDXqsbnhgt5ihUW/+9vS2tsiGvWaWVzGXMbnaYbNiqLeroF8HERFRJWAYKgK1SgmlQgGNWo1auw0Njhp4A0EsrrkgSZvL411eH8xGA+pr7GhvfHi4qj8UxuXbdzPup8JTw4NZK82IiIjo8fjdswjUahUUCgXS6c35PiqlErV2G/rbW9BU64BWowYEAdF4Am5/AK9cvo7JhSXEEwm8fXNMtsoMAJ44dgQmvb4YXwoREVHZY89QEahVKigVCqRFeagRBAFWkxFWkxGx+0HIFwzj5sQUFtdciMTiSIsiHFbz1maKPa3NaGuo2+4xREREtAcMQ0WgUd3vGRKlHd+j02rQXOdEvcMObyCEycVl+IIh6LVauLw+WExG9LQ049RAbwErJyIiqjwcJisCtXqzZ+jRZfE7USmVsJqMUCuVMOn1iCeT2PD5se7xIpVOQaVUFqBiIiKiysUwVARqpQpKpQLpPYQhSQIW110QJWlzrpEgQKlQoqOxAcO93QWoloiIqLJxmKwINA8mUO8hDLn9AYSjMaRFEf5gGEqlAp3NDfjEM0+ia5vjOYiIiGh/GIaK4MEE6scNk8UTSay6PUimUvCHwtBpNHDarPiHL31g29PriYiIaP8Yhorg4WqyncOQJElYWHchGo8jGI7CZNDDbjbhhz/yIoMQERFRDjEMFYFapYTiMT1D614fNrx+RGJxWE1GOKxmvHD2JLpbOTRGRESUSwxDRaBRqzd7htLbh6FwNI7JhWUkUynUWEyoq7Gjt60Fp4/0FbhSIiKiyscwVAQPjuMQRRGSJMnOHUskU7h5bwqiKKLGYkZznRNOqxUXjh+FWsXfLiIiolzjd9ciUKtUUCgESABESYLyfhiKxRMYm5oBJAk1VjPaG+phMugx0NmGWrutqDUTERFVKoahIlApN+cMPZg3pFQoEAxHML20ClGS4LBZ0dFYB61GA6vJhOM9ncUumYiIqGIxDBWBIAiyFWV+nx9rHh8UCgFOmxVtDXVQKZUQBAHnh45AyV2miYiI8oZhqEgenE+27HIjmUrDpNdDqVSgqdYBxf1hs2PdHXBYLUWulIiIqLIxDBXJg54hAGh01iCRTMFhNW9NprZbzDja1VHEComIiKoDw1CeiKKIZDIJpVIJ5f0hr0dp1Co4rGbYzCakUmlYjIatawqFAk8OHd0KS0RERJQ/DEM5kE6nceXKFYyMjODajRGM3b4Fn9cHSZIAQYBOq0VfXy9OnRjG0LFjuHDhAvraW6FUKtHb2ozRyRl4AoGt+x3v6YLNbCriV0RERFQ9BEmSpGIXUa7cbje++rWv4c++8D8RiMahtthhtNTAXOOAzmCCQqmEJIlIxuMIet0IejaQigSQjgTxXR97Cd/3qU9hYGAAaVHE7ek5jE3NwGG14MVzp6BgrxAREVFBMAwdQDqdxhf+4i/w+3/wh1CZa+Bs64bVWZc1FLaTWDiElZkJRNaX8KEXn8cv/9IvwWazweMPQq1SwvzIkBkRERHlF8PQPs3OzuJXPvs5jM8uoG3oLIwW24HvlUomMHfrOlTxIH7jc5/F888/n7tCiYiIaE8Yhvbh2rVr+MzP/jy0tS1o6TsKQcjNUJZnbRnLt6/hMz/5v+Enf+In9tzDRERERIfHCdR7dPXqVfzjn/oZOPuG4Wxqzem9a+qbYDBZ8B//5L8imUjgpz7zmZzen4iIiHbGWbp7MDs7i5/62Z/PSxB6QGc0oefcc/hP/+3P8ZWvfCUvzyAiIqJsHCZ7jFQqhR/5sR/HUlhEa/9g3p8X9HmwfONd/NXLX0RLS0ven0dERFTt2DP0GH/253+Ou3OLaOk7UpDnmW010NW34Fc++y8himJBnklERFTNGIZ2sbGxgX//h3+E9qFzOZssvRet/UMYnZjEN7/5zYI9k4iIqFoxDO3ir7/6VaitDhgs1oI+V6FQwNHeh//y3/8HOIpJRESUXwxDO0ilUvizL/xP1Lb3FuX5tS1tuDc9izt37hTl+URERNWCYWgHly5dQjiRgtVRW5TnKxRKGOqa8ddf/VpRnk9ERFQtuM/QDkbHxqAy2w/02b//wn/GxvI8FAolIABGsw1D73sB7QND+7qPrbYBV65dO1ANREREtDcMQzu4dv0GjNaDhSEAOPnshzBw9ilIkoTl6bt44ytfgKOxBaZ93NNsd2D85kXE43FotdoD10JEREQ74zDZDsZu34bF7jz0fQRBQHP3ANQ6HYKejX19VqXWQKXTY3p6+tB1EBER0fYYhrYhSRIC/gC0hsOfHi9JIhbu3UY6lYS9rnHfn1fr9PD5fIeug4iIiLbHYbJtpFIpSJAgKJQHvseNN76Fkbe/AzGdgphOY/iZD0JnNB3gTgKSyeSB6yAiIqLdMQxtQ6VSQYAASRQBxcE6z04880EMnH0KABD0uvHal/8HNDodek+cO1A9RERElB8cJtuGIAgwW8xIxCI5uZ/Z7kBzVz+WJsf3/dlUPAartbCbPhIREVUThqEdHD1yBIF9TnjeScjvxdL0XdhqG/b1uVQyiUQ0jJ6enpzUQURERNk4/rKD0ydP4NbXvoX6tq4Dff7669/EzTe/DQBQa3Vo6x/E0FPP7+seQZ8bHR3tXFZPRESURwxDOzg2OIjUF750oM9+4If+UU5q8K+v4oPnTuTkXkRERLQ9DpPt4IknnoBWISLodRfl+ZIkIry+hO/+rk8U5flERETVgmFoBxqNBj/0A9+P9dl7RXn+xtICOtqaMTS0vyM8iIiIaH8Yhnbxvd/93Yh71xENhwr6XEmSsDE3gR//hz8MQRAK+mwiIqJqwzC0i4aGBvzkT/w45kYuQZKkgj136d5t9LW34KWXXirYM4mIiKqVIBXyu3wZSiaT+IF/8MNwixq09BzJ+/MiAT/mrr6Bl//nF9DZ2Zn35xEREVU79gw9hlqtxu98/jcRXpqGd30lr89KxGOYuf4O/q+f+xkGISIiogJhGNqDvr4+/P7v/Wus3LqSt0CUiEVx773X8UOf/C780A/+YF6eQURERNk4TLYP77zzDn7uF34RppZuNHX152xys9/twuLoZfzD7/8U/snP/xwnTRMRERUQw9A+jY+P45//6mex4PKh/fhZ6A90Ev2mdDqF+ds3kfa78Cv/7BfxsY99jEGIiIiowBiGDiCZTOI//+mf4j/9yX+BtqYe9R29MNlq9vz5RDyGlZkJhNcW8dTZ0/jsr/4K6urq8lgxERER7YRh6BCWlpbwla98BX/xpZeREtRQmW0w252w1Dih1Ru23pdKJhD0ehD0bCAR8iHud+PFF57DD37/9+PkyZPsDSIiIioihqEciMfjePvtt3Hz5k1cuzGCO+PjiMXjUCiVkEQRgiCgs6Mdw8eHcOL4cTz77LNwOBzFLpuIiIjAMJQXoigiEokgkUhArVZDp9NBrVYXuywiIiLaBsMQERERVTXuM0RERERVjWGIiIiIqhrDEBEREVU1hiEiIiKqagxDREREVNUYhoiIiKiqMQwRERFRVWMYIiIioqrGMERERERVjWGIiIiIqhrDEBEREVU1hiEiIiKqagxDREREVNUYhoiIiKiqMQwRERFRVWMYIiIioqrGMERERERVjWGIiIiIqhrDEBEREVU1hiEiIiKqagxDREREVNUYhoiIiKiqMQwRERFRVWMYIiIioqrGMERERERVjWGIiIiIqhrDEBEREVU1hiEiIiKqagxDREREVNUYhoiIiKiqMQwRERFRVWMYIiIioqrGMERERERVjWGIiIiIqhrDEBEREVU1hiEiIiKqagxDREREVNUYhoiIiKiqMQwRERFRVWMYIiIioqrGMERERERV7f8HnwFrsucWR04AAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "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, backend=\"matplotlib\", filename=\"network.png\")" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "e32343c6", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + " \r" + ] + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pathpyG as pp\n", + "\n", + "# 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.gif\")" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "00e8f652", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + " \r" + ] + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "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", + "\n", + "pp.plot(\n", + " tg,\n", + " backend=\"manim\",\n", + " delta=2000, # 2 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", + " edge_color={(\"a\", \"b\", 1): \"purple\", (\"c\", \"d\", 2): \"orange\"},\n", + " filename=\"dynamic_network.mp4\"\n", + ")" + ] + }, + { + "cell_type": "code", + "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": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "pathpyg", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.18" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} 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/dynamic_network.mp4 b/docs/reference/pathpyG/visualisations/plot/dynamic_network.mp4 new file mode 100644 index 000000000..4784a5920 Binary files /dev/null and b/docs/reference/pathpyG/visualisations/plot/dynamic_network.mp4 differ diff --git a/docs/reference/pathpyG/visualisations/plot/graph.png b/docs/reference/pathpyG/visualisations/plot/graph.png new file mode 100644 index 000000000..bc105ffd9 Binary files /dev/null and b/docs/reference/pathpyG/visualisations/plot/graph.png differ diff --git a/docs/reference/pathpyG/visualisations/plot/manim_custom_properties.gif b/docs/reference/pathpyG/visualisations/plot/manim_custom_properties.gif new file mode 100644 index 000000000..d770bb52c Binary files /dev/null and b/docs/reference/pathpyG/visualisations/plot/manim_custom_properties.gif differ diff --git a/docs/reference/pathpyG/visualisations/plot/manim_temporal_fa2.gif b/docs/reference/pathpyG/visualisations/plot/manim_temporal_fa2.gif new file mode 100644 index 000000000..4ca9edca8 Binary files /dev/null and b/docs/reference/pathpyG/visualisations/plot/manim_temporal_fa2.gif differ 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 000000000..f68aec539 Binary files /dev/null and b/docs/reference/pathpyG/visualisations/plot/matplotlib_undirected.png differ diff --git a/docs/reference/pathpyG/visualisations/plot/network.png b/docs/reference/pathpyG/visualisations/plot/network.png new file mode 100644 index 000000000..2caa0d7fe Binary files /dev/null and b/docs/reference/pathpyG/visualisations/plot/network.png differ 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 000000000..1b430d928 Binary files /dev/null and b/docs/reference/pathpyG/visualisations/plot/temporal_network.gif differ diff --git a/docs/reference/pathpyG/visualisations/plot/temporal_network.html b/docs/reference/pathpyG/visualisations/plot/temporal_network.html new file mode 100644 index 000000000..c4d06c2e9 --- /dev/null +++ b/docs/reference/pathpyG/visualisations/plot/temporal_network.html @@ -0,0 +1,628 @@ + + +
+ + \ 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 000000000..70391de9e Binary files /dev/null and b/docs/reference/pathpyG/visualisations/plot/temporal_network.mp4 differ 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_circle_layout.svg b/docs/reference/pathpyG/visualisations/plot/tikz_circle_layout.svg new file mode 100644 index 000000000..391d9f748 --- /dev/null +++ b/docs/reference/pathpyG/visualisations/plot/tikz_circle_layout.svg @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/reference/pathpyG/visualisations/plot/tikz_custom_properties.svg b/docs/reference/pathpyG/visualisations/plot/tikz_custom_properties.svg new file mode 100644 index 000000000..0278ac7a7 --- /dev/null +++ b/docs/reference/pathpyG/visualisations/plot/tikz_custom_properties.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +a + + +b + + +c + + +d + + \ No newline at end of file diff --git a/docs/reference/pathpyG/visualisations/plot/tikz_fa2_layout.svg b/docs/reference/pathpyG/visualisations/plot/tikz_fa2_layout.svg new file mode 100644 index 000000000..b2107dbbc --- /dev/null +++ b/docs/reference/pathpyG/visualisations/plot/tikz_fa2_layout.svg @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/reference/pathpyG/visualisations/plot/tikz_grid_layout.svg b/docs/reference/pathpyG/visualisations/plot/tikz_grid_layout.svg new file mode 100644 index 000000000..9d4bc101d --- /dev/null +++ b/docs/reference/pathpyG/visualisations/plot/tikz_grid_layout.svg @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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/docs/reference/pathpyG/visualisations/plot/tikz_kk_layout.svg b/docs/reference/pathpyG/visualisations/plot/tikz_kk_layout.svg new file mode 100644 index 000000000..9727d75cc --- /dev/null +++ b/docs/reference/pathpyG/visualisations/plot/tikz_kk_layout.svg @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/reference/pathpyG/visualisations/plot/tikz_layout.svg b/docs/reference/pathpyG/visualisations/plot/tikz_layout.svg new file mode 100644 index 000000000..7fad67811 --- /dev/null +++ b/docs/reference/pathpyG/visualisations/plot/tikz_layout.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +a + + +b + + +c + + +d + + \ No newline at end of file diff --git a/docs/reference/pathpyG/visualisations/plot/tikz_random_layout.svg b/docs/reference/pathpyG/visualisations/plot/tikz_random_layout.svg new file mode 100644 index 000000000..b333f9280 --- /dev/null +++ b/docs/reference/pathpyG/visualisations/plot/tikz_random_layout.svg @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/reference/pathpyG/visualisations/plot/tikz_shell_layout.svg b/docs/reference/pathpyG/visualisations/plot/tikz_shell_layout.svg new file mode 100644 index 000000000..db6f8b959 --- /dev/null +++ b/docs/reference/pathpyG/visualisations/plot/tikz_shell_layout.svg @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/reference/pathpyG/visualisations/plot/tikz_spectral_layout.svg b/docs/reference/pathpyG/visualisations/plot/tikz_spectral_layout.svg new file mode 100644 index 000000000..ab945aac5 --- /dev/null +++ b/docs/reference/pathpyG/visualisations/plot/tikz_spectral_layout.svg @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/reference/pathpyG/visualisations/plot/tikz_spring_layout.svg b/docs/reference/pathpyG/visualisations/plot/tikz_spring_layout.svg new file mode 100644 index 000000000..15170e6e9 --- /dev/null +++ b/docs/reference/pathpyG/visualisations/plot/tikz_spring_layout.svg @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/tutorial/manim_tutorial.ipynb b/docs/tutorial/manim_tutorial.ipynb index c231df3d1..9330377b2 100644 --- a/docs/tutorial/manim_tutorial.ipynb +++ b/docs/tutorial/manim_tutorial.ipynb @@ -16,15 +16,15 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "id": "26f1e825", "metadata": {}, "outputs": [], "source": [ - "%%capture\n", - "# !pip install torch\n", - "!pip install torch_geometric\n", - "!pip install git+https://github.com/pathpy/pathpyG.git" + "# %%capture\n", + "# # !pip install torch\n", + "# !pip install torch_geometric\n", + "# !pip install git+https://github.com/pathpy/pathpyG.git" ] }, { @@ -52,7 +52,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "id": "3771679e", "metadata": {}, "outputs": [], @@ -111,18 +111,18 @@ "name": "stderr", "output_type": "stream", "text": [ - "100%|██████████| 21/21 [00:29<00:00, 1.39s/it]\n" + " \r" ] }, { "data": { "text/html": [ "\n", - " \n", - " " + " \n", + " " ], "text/plain": [ "" @@ -134,7 +134,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 3, @@ -143,7 +143,7 @@ } ], "source": [ - "pp.plot(t, backend=\"manim\", node_size=0.3, edge_size=4, edge_color=[\"red\", \"blue\"], node_color=0.89, node_label=\"Node\")" + "pp.plot(t, backend=\"manim\", node_size=20, edge_size=10, edge_color=\"red\", node_color=\"gray\")" ] }, { @@ -156,37 +156,18 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "8e80f87a", "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "100%|██████████| 21/21 [00:31<00:00, 1.48s/it]\n" - ] - }, - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "pp.plot(\n", " t,\n", " backend=\"manim\",\n", - " node_size=0.3,\n", + " node_size=20,\n", " edge_size=4,\n", - " edge_color=[\"red\", \"blue\"],\n", - " node_color=0.89,\n", - " node_label=\"Node\",\n", + " edge_color=\"red\",\n", + " node_color=\"gray\",\n", " filename=\"tutorial_plot.gif\",\n", ")" ] @@ -196,26 +177,19 @@ "id": "7016626b", "metadata": {}, "source": [ - "## Dynamically Changing Customisations\n", + "## Dynamic Customisations and Layout\n", "\n", - "So far, we only used customisations that you were already familiar with from other plotting backends.\n", - "Manim offers four additional customisations:\n", - "1. Both node and edge colors can be specified for single time stamps with the `node_color` and `edge_color` keyword arguments.\n", + "Since we use `manim` to animate temporal networks, we can also use dynamic customisations that change over time. For example, we can change the `node_size` or `edge_size` over time.\n", + "To change those properties dynamically, we can provide a dictionary that maps node/source-target id and the time step to the desired property value.\n", "\n", - " e.g. : `node_color = {'a-1.0': 'yellow', 'a': 'green', 'b-3.0':'black'}`, where the keys `node_id-time_stamp` specify the node and the time stamp and the values the colors. If no time stamp is specified the first time stamp of the temporal graph is used.\n", - "\n", - " e.g. : `edge_color = {'a-b-1.0':'purple', 'd-c-4.0':'green'}`, where the keys `node_id_1-node_id_2-time_stamp` specify the nodes and the time stamp and the values the colors\n", - "\n", - "2. Additionally, the user can specify after how many time steps the layout is recalculated with the Fruchtermann Rheingold algorithm based on the edges that existed in the last interval with the `dynamic_layout_interval` keyword argument. Additionally the user can specify based on how many timesteps backwards and forwards the new layout is calculated with the keyword arguments `look_forward` and `look_behind`\n", - "3. The background color can be changed with the `background_color` keyword argument\n", - "4. The font size of the node labels is adjustable with the `font_size` keyword argument.\n", + "Additionally, we can use dynamic layouts that are based on sliding windows. For example, we can use a `ForceAtlas2` layout that is computed based on a sliding window of the last 3 time steps. This allows us to have a layout that changes over time but is still stable enough to be visually appealing.\n", "\n", "The following shows an example where all of the customisations described above are used:" ] }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "26a31185", "metadata": {}, "outputs": [ @@ -223,18 +197,18 @@ "name": "stderr", "output_type": "stream", "text": [ - "100%|██████████| 21/21 [00:28<00:00, 1.36s/it]\n" + " \r" ] }, { "data": { "text/html": [ "\n", - " \n", - " " + " \n", + " " ], "text/plain": [ "" @@ -246,10 +220,10 @@ { "data": { "text/plain": [ - "" + "" ] }, - "execution_count": 5, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } @@ -258,13 +232,11 @@ "pp.plot(\n", " t,\n", " backend=\"manim\",\n", - " node_size=0.1,\n", - " edge_size=4,\n", - " edge_color={\"a-b-1.0\": \"purple\", \"b-c-10.0\": \"green\"},\n", - " node_color={\"a-1.0\": \"yellow\", \"a\": \"green\", \"b-3.0\": \"black\"},\n", - " node_label=t.mapping.node_ids.tolist(),\n", - " font_size=40,\n", - " dynamic_layout_interval=5,\n", + " layout_window_size=[3, 1],\n", + " layout=\"fa2\",\n", + " node_size=12,\n", + " edge_color={(\"b\", \"c\", 10): \"green\"},\n", + " node_color={(\"a\", 3): \"yellow\", (\"a\", 0): \"green\", (\"b\", 3): \"black\"},\n", ")" ] }, @@ -280,7 +252,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 7, "id": "2475ae88", "metadata": {}, "outputs": [], @@ -288,24 +260,54 @@ "g = pp.io.read_netzschleuder_graph('sp_baboons', 'observational', time_attr='time')" ] }, + { + "cell_type": "markdown", + "id": "7c59349b", + "metadata": {}, + "source": [ + "The timestamps are provided in seconds since epoch (converted from unix time). We first convert them to a more human-readable format (hours since the first interaction): " + ] + }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 8, + "id": "73dd5b14", + "metadata": {}, + "outputs": [], + "source": [ + "g.data.time -= g.data.time.min()\n", + "g.data.time = g.data.time // (60 * 60 * 12) # convert to 12 hours intervals" + ] + }, + { + "cell_type": "markdown", + "id": "dcdda88b", + "metadata": {}, + "source": [ + "The dataset contains different types of interactions between the baboons. We can use this information to color the edges based on the interaction type:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, "id": "42520b99", "metadata": {}, "outputs": [], "source": [ "colors = []\n", - "for category in g.data['edge_category']:\n", + "for category in g.data[\"edge_category\"]:\n", " match category:\n", - " case 'Affiliative': colors.append('red')\n", - " case 'Agonistic': colors.append('green')\n", - " case 'Other': colors.append('grey')" + " case \"Affiliative\":\n", + " colors.append(\"red\")\n", + " case \"Agonistic\":\n", + " colors.append(\"green\")\n", + " case \"Other\":\n", + " colors.append(\"grey\")" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 11, "id": "8bc45755", "metadata": {}, "outputs": [ @@ -313,18 +315,18 @@ "name": "stderr", "output_type": "stream", "text": [ - "100%|██████████| 21/21 [00:38<00:00, 1.83s/it]\n" + " \r" ] }, { "data": { "text/html": [ "\n", - " \n", - " " + " \n", + " " ], "text/plain": [ "" @@ -336,10 +338,10 @@ { "data": { "text/plain": [ - "" + "" ] }, - "execution_count": 8, + "execution_count": 11, "metadata": {}, "output_type": "execute_result" } @@ -348,21 +350,26 @@ "pp.plot(\n", " g,\n", " backend=\"manim\",\n", - " start=1560412200,\n", - " end=1560431041,\n", - " intervals=20,\n", - " dynamic_layout_interval=50,\n", + " layout_window_size=12, # three days\n", + " layout=\"kk\",\n", " edge_color=colors,\n", - " node_size=0.06,\n", - " edge_size=5,\n", - " node_label_size=20,\n", + " edge_size=2.5,\n", + " delta=500, # Each time step is 500 ms\n", ")" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "39d425ad", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { "kernelspec": { - "display_name": "base", + "display_name": "pathpyg", "language": "python", "name": "python3" }, @@ -376,7 +383,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.9" + "version": "3.10.18" } }, "nbformat": 4, diff --git a/docs/tutorial/visualisation.ipynb b/docs/tutorial/visualisation.ipynb index aea0e274a..ab6a437af 100644 --- a/docs/tutorial/visualisation.ipynb +++ b/docs/tutorial/visualisation.ipynb @@ -15,7 +15,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -54,17 +54,9 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Running on cpu\n" - ] - } - ], + "outputs": [], "source": [ "import pathpyG as pp\n", "import torch" @@ -79,7 +71,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": { "scrolled": true }, @@ -88,42 +80,22 @@ "data": { "text/html": [ "\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", "\n", + "
\n", + "\n", "\n' + + # start JavaScript + html += '" + + return html + + def get_template(self, template_dir: str) -> str: + """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() + + with open(os.path.join(template_dir, f"{self._kind}.js")) as template: + js_template += template.read() + + return js_template 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 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 = config.curvature || 0.25; // Factor to control curvature of edges (higher = more curved) + + // 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"); + + 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 = config.directed ? (edgeStrokeWidth * arrowheadMultiplier) : 0; + + 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; + + const dx = x2 - x1, dy = y2 - y1; + const distance = Math.hypot(dx, dy); + + // Don't draw if nodes are overlapping + if (distance < effectiveSourceRadius + effectiveTargetRadius) return ""; + + // --- 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; + + return `M${sourceX},${sourceY}L${targetX},${targetY}`; + } else { + // --- 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}`; + } + }); + }; + + 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); + // 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; + } + return 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(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(0, 0, width, height)); + + // 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(); + } }); -}; - -/*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/_manim/__init__.py b/src/pathpyG/visualisations/_manim/__init__.py index ca6a9daa1..623aa2474 100644 --- a/src/pathpyG/visualisations/_manim/__init__.py +++ b/src/pathpyG/visualisations/_manim/__init__.py @@ -1,33 +1,64 @@ -""" -This is Manim Base Plot Class -""" +"""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 -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 +!!! warning "Requirements" + - Manim Community Edition (`pip install manim`) + - FFmpeg for video rendering + - LaTeX distribution for mathematical text -PLOT_CLASSES: dict = { - "network": NetworkPlot, - "static": StaticNetworkPlot, - "temporal": TemporalNetworkPlot, -} +## Basic Usage +```python +import pathpyG as pp -def plot(data: dict, kind: str = "network", **kwargs: Any) -> Any: - """ - Function to create and return a Manim-based network plot. +# 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") +``` - 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. +## Advanced Example - Returns: - Any: An instance of selected plot class. - """ - return PLOT_CLASSES[kind](data, **kwargs) +```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/_manim/backend.py b/src/pathpyG/visualisations/_manim/backend.py new file mode 100644 index 000000000..8e28dc4f6 --- /dev/null +++ b/src/pathpyG/visualisations/_manim/backend.py @@ -0,0 +1,222 @@ +"""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 + +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): + """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): + """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: + 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")) # type: ignore[arg-type] + manim_config.pixel_width = int(unit_str_to_float(self.config.get("width"), "px")) # type: ignore[arg-type] + manim_config.quality = "high_quality" + 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. + + Returns: + tuple: (video_file_path, temp_directory_path) for post-processing + + !!! 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 + 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: + """Render and save temporal network animation to specified file. + + 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: Output file path with extension (.mp4 or .gif) + + !!! 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() + 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: Path) -> None: + """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( + [ + "ffmpeg", + "-i", + filename, + "-vf", + "fps=30,scale=1080:-1:flags=lanczos", + "-y", + "-hide_banner", + "-loglevel", + "error", + filename.with_suffix(".gif"), + ], + check=True, + ) + except Exception as e: + logger.error(f"GIF conversion failed: {e}") + + def show(self) -> None: + """Display temporal network animation in interactive environment. + + 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() + + 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.as_posix()) + 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..b14f66417 --- /dev/null +++ b/src/pathpyG/visualisations/_manim/temporal_graph_scene.py @@ -0,0 +1,181 @@ +"""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 +from copy import deepcopy + +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) +# set root logger +logger = logging.getLogger("root") + + +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 = 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"} + ) + 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): + """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"]: + 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=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()]) + + # 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): + # 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)] + # 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 = { + (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["delta"]/(4*1000)) + else: + self.wait(self.config["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=str(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=(nodes | new_nodes)[source].radius/2, + ), + end=self.get_boundary_point( + center=layout[target], + direction=layout[source] - layout[target], + radius=(nodes | 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["delta"]/(2*1000) - 0.02) # 0.02 for time text update + else: + self.wait(self.config["delta"]/(2*1000) - 0.02) # 0.02 for time text update + + # Gather all old edges to be removed + 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["delta"]/(4*1000) + ) + else: + self.wait(self.config["delta"]/(4*1000)) + + self.play(Uncreate(node) for node in nodes.values()) + + def get_boundary_point(self, center, direction, radius): + """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 + direction = direction / distance + return center + direction * radius diff --git a/src/pathpyG/visualisations/_matplotlib/__init__.py b/src/pathpyG/visualisations/_matplotlib/__init__.py index 9d2023d47..330160531 100644 --- a/src/pathpyG/visualisations/_matplotlib/__init__.py +++ b/src/pathpyG/visualisations/_matplotlib/__init__.py @@ -1,39 +1,20 @@ -"""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 -# ============================================================================= -# 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 +Raster graphics backend using matplotlib for static network images. -PLOT_CLASSES: dict = { - "network": NetworkPlot, - "static": StaticNetworkPlot, - "temporal": TemporalNetworkPlot, -} +!!! info "Output Formats" + - **PNG**: High-quality raster images for presentations + - **JPG**: Compressed raster images for web usage +## Basic Usage -def plot(data: dict, kind: str = "network", **kwargs: Any) -> Any: - """Plot function.""" - return PLOT_CLASSES[kind](data, **kwargs) +```python +import pathpyG as pp - -# ============================================================================= -# eof -# -# Local Variables: -# mode: python -# mode: linum -# mode: auto-fill -# fill-column: 79 -# End: +# 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 new file mode 100644 index 000000000..26ce5be57 --- /dev/null +++ b/src/pathpyG/visualisations/_matplotlib/backend.py @@ -0,0 +1,374 @@ +"""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 + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.collections import EllipseCollection, LineCollection, PathCollection +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 + +logger = logging.getLogger("root") + +SUPPORTED_KINDS = { + NetworkPlot: "static", +} + + +class MatplotlibBackend(PlotBackend): + """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 + + 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. + """ + + 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) # 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 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: + """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]: + """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")), + dpi=150, + ) + ax.set_axis_off() + + # get source and target coordinates for edges + 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) + else: + self.add_undirected_edges(source_coords, target_coords, ax, size_factor) + + # plot nodes + # 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: + 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", + ) + + # set limits + 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): + """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) + direction = vec / dist + 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( + 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, size_factor): + """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( + source_coords, + target_coords, + 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"].index.get_level_values("target"), ["size"]].values + * (size_factor / 2), + head_length=head_length, + ) + 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=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=1, + ) + ) + + def get_bezier_curve( + self, + source_coords, + target_coords, + source_node_size, + target_node_size, + head_length, + shorten=0.005, + ): + """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: 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: (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 + 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) + # 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 * self.config["curvature"] + + # Shorten the curve to avoid overlap with nodes + 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 = [ + Path.MOVETO, + Path.CURVE3, + Path.MOVETO, + ] + return vertices, codes + + def get_arrowhead(self, vertices, head_length=0.01, head_width=0.02): + """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: 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: (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] + # 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 * 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 = [ + 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/_tikz/__init__.py b/src/pathpyG/visualisations/_tikz/__init__.py index 0ec92cb9b..73edc2ed5 100644 --- a/src/pathpyG/visualisations/_tikz/__init__.py +++ b/src/pathpyG/visualisations/_tikz/__init__.py @@ -1,39 +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 -# ============================================================================= -# 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) - - -# ============================================================================= -# 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 new file mode 100644 index 000000000..9342d03a2 --- /dev/null +++ b/src/pathpyG/visualisations/_tikz/backend.py @@ -0,0 +1,430 @@ +"""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 +import os +import shutil +import subprocess +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 hex_to_rgb, prepare_tempfile, unit_str_to_float + +# create logger +logger = logging.getLogger("root") + +SUPPORTED_KINDS = { + NetworkPlot: "static", +} + + +class TikzBackend(PlotBackend): + """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 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) # 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 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()) + elif filename.endswith("pdf"): + # compile temporary pdf + temp_file, temp_dir = self.compile_pdf() + # Copy a file with new name + 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) -> 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. + """ + # compile temporary pdf + temp_file, temp_dir = self.compile_svg() + + if config["environment"]["interactive"]: + 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) + + # Wait for .1 second before temp file is deleted + time.sleep(0.1) + + # remove the temporal directory + shutil.rmtree(temp_dir) + + 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. + """ + temp_dir, current_dir = prepare_tempfile() + # save the tex file + self.save("default.tex") + + # latex compiler + command = [ + "latexmk", + "--interaction=nonstopmode", + "default.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", + "default.dvi", + "-o", + "default.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) + + # return the name of the folder and temp svg file + return os.path.join(temp_dir, "default.svg"), temp_dir + + 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 + + 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") + + # latex compiler + command = [ + "latexmk", + "--pdf", + "-shell-escape", + "--interaction=nonstopmode", + "default.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 + 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, "default.pdf"), temp_dir + + 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 + 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__)), + os.path.normpath("_tikz/templates"), + ) + + # get template files + with open(os.path.join(template_dir, f"{self._kind}.tex")) as template: + tex_template = template.read() + + # generate data + data = self.to_tikz() + + # 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"), # 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, + ) + + return tex + + 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 + + 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 + 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) + ",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) + "," + ) + 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 + 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 + + 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["separator"].strip() in replacements: + node_label = node_label.replace( + self.config["separator"], + replacements[self.config["separator"].strip()], + ) + return node_label diff --git a/src/pathpyG/visualisations/_tikz/core.py b/src/pathpyG/visualisations/_tikz/core.py deleted file mode 100644 index 73c7f7c90..000000000 --- a/src/pathpyG/visualisations/_tikz/core.py +++ /dev/null @@ -1,204 +0,0 @@ -#!/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 os -import time -import shutil -import logging -import tempfile -import subprocess -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 TikzPlot(PathPyPlot): - """Base class for plotting tikz objects.""" - - def __init__(self, **kwargs: Any) -> None: - """Initialize plot class.""" - super().__init__() - if kwargs: - self.config = kwargs - - def generate(self) -> None: - """Generate the plot.""" - raise NotImplementedError - - def save(self, filename: str, **kwargs: Any) -> None: - """Save the plot to the hard drive.""" - if filename.endswith("tex"): - with open(filename, "w+") as new: - new.write(self.to_tex()) - elif filename.endswith("pdf"): - # compile temporary pdf - temp_file, temp_dir = self.compile_pdf() - # Copy a file with new name - 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_svg() - - if config["environment"]["interactive"]: - 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) - - # Wait for .1 second before temp file is deleted - time.sleep(0.1) - - # remove the temporal directory - shutil.rmtree(temp_dir) - - def compile_svg(self) -> tuple: - """Compile svg from tex.""" - temp_dir, current_dir, basename = self.prepare_compile() - - # 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) - - # return the name of the folder and temp svg file - return os.path.join(temp_dir, basename + ".svg"), temp_dir - - def compile_pdf(self) -> tuple: - """Compile pdf from tex.""" - temp_dir, current_dir, basename = self.prepare_compile() - - # latex compiler - command = [ - "latexmk", - "--pdf", - "-shell-escape", - "--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 - 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 - - 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.""" - # get path to the pathpy templates - template_dir = os.path.join( - os.path.dirname(os.path.dirname(__file__)), - os.path.normpath("_tikz/templates"), - ) - - # get template files - with open(os.path.join(template_dir, f"{self._kind}.tex")) as template: - tex_template = template.read() - - # generate data - data = self.to_tikz() - - # 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"), - 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: 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..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.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 + \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/_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/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 28f3e71db..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 -*- # ============================================================================= @@ -21,610 +55,215 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # ============================================================================= -import numpy as np -from pathpyG import tqdm - +from typing import Iterable, Optional -def layout(network, **kwds): - """Function to generate a layout for the network. +import numpy as np +import torch +from torch import Tensor +from torch_geometric import EdgeIndex +from torch_geometric.utils import to_scipy_sparse_matrix - 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. +from pathpyG.core.graph import Graph - The layout function supports different network types and layout algorithm. - Currently supported networks are: - - `cnet`, - - `networkx`, - - `igraph`, - - `pathpyG` - - node/edge list +def layout(network: Graph, layout: str = "random", weight: None | str | Iterable = None, **kwargs): + """Generate node positions using specified layout algorithm. - Currently supported algorithms are: + 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. - - Fruchterman-Reingold force-directed algorithm - - Uniformly at random node positions + 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 - The appearance of the layout can be modified by keyword arguments which will - be explained in more detail below. + Returns: + dict: Node positions as {node_id: (x, y)} coordinate mapping - 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` | + Raises: + ValueError: If weight attribute not found or weight length mismatch + ValueError: If layout algorithm not recognized 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])} + ```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 - _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: + 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: # type: ignore[arg-type] + weight = torch.tensor(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("Length of weight iterable does not match number of edges in the network.") # create layout class - layout = Layout(nodes, adjacency_matrix, **kwds) + 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): - """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. - **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] + 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, 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. - + def __init__(self, nodes: list, edge_index: Optional[Tensor] = None, layout_type: str = "random", weight: Optional[Tensor] = None, **kwargs): + """Initialize layout computation with network data and parameters. + 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] + 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 - 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} + 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 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_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() + else: + self.layout = self.generate_nx_layout() - # 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. - + def generate_nx_layout(self): + """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: - 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.) - + 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. """ - 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 - - # 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 + import networkx as nx - def _sparse_fruchterman_reingold(self): - """Fruchterman-Reingold algorithm for sparse matrices. + 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)}) - 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.) + 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"] + names_forceatlas2 = ["forceatlas2", "fa2", "forceatlas", "force-atlas", "force-atlas2", "fa 2"] - """ - 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() - - 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) + 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: - # 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 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 # 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)} diff --git a/src/pathpyG/visualisations/network_plot.py b/src/pathpyG/visualisations/network_plot.py new file mode 100644 index 000000000..36ff6e5ad --- /dev/null +++ b/src/pathpyG/visualisations/network_plot.py @@ -0,0 +1,394 @@ +"""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 -*- +# ============================================================================= +# File : network_plots.py -- Network plots +# Author : Jürgen Hackl +# Time-stamp: +# +# Copyright (c) 2016-2023 Pathpy Developers +# ============================================================================= +from __future__ import annotations + +import logging +import os +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 image_to_base64, 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): + """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 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 = {} + self.edge_args = {} + self.attributes = ["color", "size", "opacity", "image"] + # 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}") + if "node" in kwargs: + self.config["node"].update(kwargs["node"]) + kwargs.pop("node") + if "edge" in kwargs: + self.config["edge"].update(kwargs["edge"]) + kwargs.pop("edge") + self.config.update(kwargs) + # generate plot data + self.generate() + + def generate(self) -> None: + """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() + self._post_process_node_data() + self._compute_config() + + def _compute_node_data(self) -> None: + """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["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] + 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 node attribute + if f"node_{attribute}" in self.network.node_attrs(): + 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) + + # save node data + self.data["nodes"] = nodes + + def _post_process_node_data(self) -> None: + """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) + + # 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: + """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["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] + 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 edge attribute + 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" 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"]) + edges["color"] = edges["color"].map(self._convert_color) + + # 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() + 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 + + def _assign_argument(self, attr_key: str, attr_value: Any, df: pd.DataFrame) -> pd.DataFrame: + """Assign user arguments to node/edge attributes flexibly. + + 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: 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 + if attr_key == "color": + # convert color tuples to hex strings to avoid pandas sequence assignment + for key in attr_value.keys(): + value = attr_value[key] + if isinstance(value, tuple) and len(value) == 3: + 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]: + # If all values are assigned, directly set the column to make sure that dtype is correct + df[attr_key] = new_attrs + else: + # Otherwise, only update the values that are not NaN + df.loc[~new_attrs.isna(), attr_key] = new_attrs[~new_attrs.isna()] + elif isinstance(attr_value, Sized) and not isinstance(attr_value, str): + # check if attr_key="color" and given values is an RGB tuple + if attr_key == "color": + if isinstance(attr_value, tuple) and len(attr_value) == 3: + df[attr_key] = [attr_value] * len(df) + else: + df[attr_key] = attr_value + elif 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 + else: + df[attr_key] = attr_value + else: + df[attr_key] = attr_value + return df + + def _convert_to_rgb_tuple(self, colors: pd.Series) -> dict: + """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 + 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: + """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): + 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 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!") + raise AttributeError + + def _load_image(self, image_path: str) -> str: + """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: + # 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: + """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") + + # 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"]) + if self.network.order > 1 and not isinstance(layout_df.index[0], str): + 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()) + # join layout with node data + self.data["nodes"] = self.data["nodes"].join(layout_df, how="left") + + def _compute_config(self) -> None: + """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 + + +# ============================================================================= +# 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..4bbbf7134 --- /dev/null +++ b/src/pathpyG/visualisations/pathpy_plot.py @@ -0,0 +1,45 @@ +"""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 + +logger = logging.getLogger("root") + + +class PathPyPlot: + """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: Dictionary containing processed plot data + config: Visualization configuration from pathpyG settings + """ + + def __init__(self) -> None: + """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): + 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: + """Generate plot data structures. + + Raises: + NotImplementedError: Must be implemented by subclasses + """ + 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..b0147eaed --- /dev/null +++ b/src/pathpyG/visualisations/plot_backend.py @@ -0,0 +1,57 @@ +"""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: + """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 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 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: + """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 new file mode 100644 index 000000000..06f55e448 --- /dev/null +++ b/src/pathpyG/visualisations/plot_function.py @@ -0,0 +1,246 @@ +"""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 -*- +# ============================================================================= +# 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._d3js.backend import D3jsBackend +from pathpyG.visualisations.network_plot import NetworkPlot +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): + """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" + manim = "manim" + + @staticmethod + def is_backend(backend: str) -> bool: + """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 +FORMATS: dict = { + ".html": Backends.d3js, + ".tex": Backends.tikz, + ".pdf": Backends.tikz, + ".svg": Backends.tikz, + ".png": Backends.matplotlib, + ".jpg": Backends.matplotlib, + ".jpeg": 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) -> type[PlotBackend]: + """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.") + 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) 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 + _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") + + +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 + 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: A `pathpyG` object representing the network data. This can + be a `Graph` or `TemporalGraph` object, or other compatible types. + 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: + Configured backend instance ready for display or saving + + Raises: + 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 = 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): + 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) + + 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 set layout to None if not specifically given as argument + 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) + 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..7ac843aea --- /dev/null +++ b/src/pathpyG/visualisations/temporal_network_plot.py @@ -0,0 +1,242 @@ +"""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 +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 +from pathpyG.visualisations.utils import rgb_to_hex + +# 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): + """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 temporal network plot. + + Args: + network: TemporalGraph instance to visualize + **kwargs: Additional plotting parameters + """ + super().__init__(network, **kwargs) + + def _compute_node_data(self) -> None: + """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"]) + ) + new_nodes: pd.DataFrame = pd.DataFrame(index=pd.MultiIndex.from_tuples([], names=["uid", "time"])) + # 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 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): + # check if entry is tuple or string + 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] # 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 + 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] + else: + start_nodes[attribute] = self.node_args[attribute] + + # 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: + """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() + + # 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") + # 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 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 + + def _compute_edge_data(self) -> None: + """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"]) + ) + 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 edge attribute + 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" 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"]) + edges["color"] = edges["color"].map(self._convert_color) + 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.droplevel("time") + + # save edge data + self.data["edges"] = edges + + def _compute_layout(self) -> None: + """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") + + # 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()) + ) + window_size = self.config.get("layout_window_size") + if isinstance(window_size, int): + # if uneven window size, add one to the future time steps since the end time step is exclusive + window_size = [window_size // 2, ceil(window_size / 2)] + elif isinstance(window_size, list | tuple): + if window_size[0] < 0: + # use all previous time steps + window_size[0] = max_time # type: ignore[index] + if window_size[1] < 0: + # 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 + + pos = network_layout(self.network, layout="random") # initial layout + 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]) + end_time = step + window_size[1] + 1 + # only compute layout if there are edges in the current window, otherwise use the previous layout + if ((start_time <= self.network.data.time) & (self.network.data.time <= end_time)).sum() > 0: + # get subgraph for the current time step + sub_graph = self.network.get_window(start_time=start_time, end_time=end_time) + + # 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["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: + """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 9f5252f2d..6a67f8443 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 @@ -7,41 +44,323 @@ # # Copyright (c) 2016-2023 Pathpy Developers # ============================================================================= -from typing import Optional + +import base64 +import os +import tempfile +from pathlib import Path +from typing import Callable + + +def prepare_tempfile() -> tuple[str, str]: + """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() + + # 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.""" + """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: 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) + 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 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)) - - -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) + 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: + """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 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 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 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 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, + "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: inch_to_cm(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(f"The provided conversion '{conversion_key}' is not supported.") + + +def image_to_base64(image_path): + """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", + } + 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}" # ============================================================================= 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): 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_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_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_manim.py b/tests/visualisations/test_manim.py deleted file mode 100644 index d20f34183..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) diff --git a/tests/visualisations/test_network_plot.py b/tests/visualisations/test_network_plot.py new file mode 100644 index 000000000..67a8c4037 --- /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"): (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"] + # 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 new file mode 100644 index 000000000..ee02e1049 --- /dev/null +++ b/tests/visualisations/test_pathpy_plot.py @@ -0,0 +1,146 @@ +"""Unit tests for PathPyPlot base class.""" + +import logging + +import pytest + +from pathpyG import config +from pathpyG.visualisations.pathpy_plot import 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" + + 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.py b/tests/visualisations/test_plot.py deleted file mode 100644 index ac42c5fa3..000000000 --- a/tests/visualisations/test_plot.py +++ /dev/null @@ -1,129 +0,0 @@ -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) - - -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") - - # load matplotlib backend - plt = _get_plot_backend(backend="matplotlib") - assert isinstance(plt, ModuleType) - - # test .png file - png = _get_plot_backend(filename="test.png") - assert isinstance(png, ModuleType) - - assert png == plt - - # load d3js backend - d3js = _get_plot_backend(backend="d3js") - assert isinstance(d3js, ModuleType) - - # test .html file - html = _get_plot_backend(filename="test.html") - assert isinstance(html, ModuleType) - - assert d3js == html - - # load tikz backend - tikz = _get_plot_backend(backend="tikz") - assert isinstance(tikz, ModuleType) - - # test .tex file - tex = _get_plot_backend(filename="test.tex") - assert isinstance(tex, ModuleType) - - assert tikz == tex - - -# 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.""" - 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]]) - - plot = network_plot(net, edge_color="green", layout="fr") - plot.save(tmp_path / "test.png") - assert (tmp_path / "test.png").exists() - - -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") - 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() - - -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") - assert (tmp_path / "test.tex").exists() - - -def test_temporal_plot(tmp_path) -> None: - """Test to plot a temporal network.""" - net = TemporalGraph.from_edge_list( - [ - ("a", "b", 1), - ("b", "c", 5), - ("c", "d", 9), - ("d", "a", 9), - ("a", "b", 10), - ("b", "c", 10), - ] - ) - net.data["edge_size"] = torch.tensor([[3], [4], [5], [1], [2], [3]]) - - color = {"a": "blue", "b": "red", "c": "green", "d": "yellow"} - plot = temporal_plot( - net, - node_color=color, - start=3, - end=25, - delta=1000, - layout="fr", - d3js_local=False, - ) - plot.save(tmp_path / "temp.html") - assert (tmp_path / "temp.html").exists() 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 new file mode 100644 index 000000000..d5bae36d0 --- /dev/null +++ b/tests/visualisations/test_plot_function.py @@ -0,0 +1,128 @@ +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.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) + + # load matplotlib backend + plt = _get_plot_backend(backend="matplotlib", default=None, filename=None) + assert plt == MatplotlibBackend + + # test .png file + png = _get_plot_backend(filename="test.png", default=None, backend=None) + assert png == MatplotlibBackend + + # load d3js backend + d3js = _get_plot_backend(backend="d3js", default=None, filename=None) + assert d3js == D3jsBackend + + # test .html file + html = _get_plot_backend(filename="test.html", default=None, backend=None) + assert html == D3jsBackend + + # load tikz backend + tikz = _get_plot_backend(backend="tikz", default=None, filename=None) + assert tikz == TikzBackend + + # test .tex file + 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 + + # 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 +# 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"]]) + + 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_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"]]) + + 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 + + +@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), + ] + ) + + 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_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", 2), + ("c", "d", 4), + ] + ) + + 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..0d1d9f72c --- /dev/null +++ b/tests/visualisations/test_temporal_network_plot.py @@ -0,0 +1,283 @@ +"""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 + + 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 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 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" } },