bsplot 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. bsplot/__init__.py +23 -0
  2. bsplot/anat.py +55 -0
  3. bsplot/animate.py +194 -0
  4. bsplot/bioicons.py +777 -0
  5. bsplot/brain.py +188 -0
  6. bsplot/colors.py +1935 -0
  7. bsplot/data/HCP_avg-SC.txt +379 -0
  8. bsplot/data/MNI152.rh.pial +0 -0
  9. bsplot/data/STN_lh.nii.gz +0 -0
  10. bsplot/data/STN_rh.nii.gz +0 -0
  11. bsplot/data/__init__.py +22 -0
  12. bsplot/data/left_electrode.ply +257270 -0
  13. bsplot/data/parcellations/HCP-MMP1.L.label.gii +442 -0
  14. bsplot/data/parcellations/HCP-MMP1.R.label.gii +442 -0
  15. bsplot/data/parcellations/code/split_hcpmmp1.sh +4 -0
  16. bsplot/data/right_electrode.ply +257270 -0
  17. bsplot/data/surface.py +112 -0
  18. bsplot/data/tpl-MNI152NLin2009b_atlas-hcpmmp1_desc-ordered_dseg.nii.gz +0 -0
  19. bsplot/figure.py +177 -0
  20. bsplot/graph/__init__.py +31 -0
  21. bsplot/graph/edges.py +312 -0
  22. bsplot/graph/flowchart.py +762 -0
  23. bsplot/graph/layout.py +201 -0
  24. bsplot/graph/network.py +974 -0
  25. bsplot/graph/nodes.py +452 -0
  26. bsplot/panels.py +110 -0
  27. bsplot/scientific_color_maps/__init__.py +0 -0
  28. bsplot/scientific_color_maps/_sync_from_zenodo_download.sh +1 -0
  29. bsplot/scientific_color_maps/batlowK.txt +256 -0
  30. bsplot/scientific_color_maps/batlowW.txt +256 -0
  31. bsplot/scientific_color_maps/cyclic/bamO.txt +256 -0
  32. bsplot/scientific_color_maps/cyclic/brocO.txt +256 -0
  33. bsplot/scientific_color_maps/cyclic/corkO.txt +256 -0
  34. bsplot/scientific_color_maps/cyclic/romaO.txt +256 -0
  35. bsplot/scientific_color_maps/cyclic/vikO.txt +256 -0
  36. bsplot/scientific_color_maps/diverging/bam.txt +256 -0
  37. bsplot/scientific_color_maps/diverging/berlin.txt +256 -0
  38. bsplot/scientific_color_maps/diverging/broc.txt +256 -0
  39. bsplot/scientific_color_maps/diverging/cork.txt +256 -0
  40. bsplot/scientific_color_maps/diverging/lisbon.txt +256 -0
  41. bsplot/scientific_color_maps/diverging/managua.txt +256 -0
  42. bsplot/scientific_color_maps/diverging/roma.txt +256 -0
  43. bsplot/scientific_color_maps/diverging/tofino.txt +256 -0
  44. bsplot/scientific_color_maps/diverging/vanimo.txt +256 -0
  45. bsplot/scientific_color_maps/diverging/vik.txt +256 -0
  46. bsplot/scientific_color_maps/multisequential/bukavu.txt +256 -0
  47. bsplot/scientific_color_maps/multisequential/fes.txt +256 -0
  48. bsplot/scientific_color_maps/multisequential/oleron.txt +256 -0
  49. bsplot/scientific_color_maps/naviaW.txt +256 -0
  50. bsplot/scientific_color_maps/sequential/acton.txt +256 -0
  51. bsplot/scientific_color_maps/sequential/bamako.txt +256 -0
  52. bsplot/scientific_color_maps/sequential/batlow.txt +256 -0
  53. bsplot/scientific_color_maps/sequential/bilbao.txt +256 -0
  54. bsplot/scientific_color_maps/sequential/buda.txt +256 -0
  55. bsplot/scientific_color_maps/sequential/davos.txt +256 -0
  56. bsplot/scientific_color_maps/sequential/devon.txt +256 -0
  57. bsplot/scientific_color_maps/sequential/glasgow.txt +256 -0
  58. bsplot/scientific_color_maps/sequential/grayC.txt +256 -0
  59. bsplot/scientific_color_maps/sequential/hawaii.txt +256 -0
  60. bsplot/scientific_color_maps/sequential/imola.txt +256 -0
  61. bsplot/scientific_color_maps/sequential/lajolla.txt +256 -0
  62. bsplot/scientific_color_maps/sequential/lapaz.txt +256 -0
  63. bsplot/scientific_color_maps/sequential/lipari.txt +256 -0
  64. bsplot/scientific_color_maps/sequential/navia.txt +256 -0
  65. bsplot/scientific_color_maps/sequential/nuuk.txt +256 -0
  66. bsplot/scientific_color_maps/sequential/oslo.txt +256 -0
  67. bsplot/scientific_color_maps/sequential/tokyo.txt +256 -0
  68. bsplot/scientific_color_maps/sequential/turku.txt +256 -0
  69. bsplot/streamlines.py +314 -0
  70. bsplot/style.py +791 -0
  71. bsplot/styles/black.mplstyle +78 -0
  72. bsplot/styles/bss.mplstyle +50 -0
  73. bsplot/styles/bwcomp.mplstyle +30 -0
  74. bsplot/styles/nature.mplstyle +57 -0
  75. bsplot/styles/transparent.mplstyle +16 -0
  76. bsplot/styles/tvbo.mplstyle +57 -0
  77. bsplot/surface.py +2370 -0
  78. bsplot/templates.py +270 -0
  79. bsplot/text.py +199 -0
  80. bsplot/text2obj.py +59 -0
  81. bsplot/timeseries.py +10 -0
  82. bsplot/utils.py +13 -0
  83. bsplot/volume.py +958 -0
  84. bsplot-0.0.2.dist-info/METADATA +60 -0
  85. bsplot-0.0.2.dist-info/RECORD +89 -0
  86. bsplot-0.0.2.dist-info/WHEEL +5 -0
  87. bsplot-0.0.2.dist-info/entry_points.txt +2 -0
  88. bsplot-0.0.2.dist-info/licenses/LICENSE +193 -0
  89. bsplot-0.0.2.dist-info/top_level.txt +1 -0
bsplot/data/surface.py ADDED
@@ -0,0 +1,112 @@
1
+ from os.path import abspath, dirname, join
2
+
3
+ import nibabel as nib
4
+ import templateflow
5
+
6
+ root = dirname(abspath(__file__))
7
+
8
+
9
+ def get_flat_surface_geometry(template="fsaverage", hemi="lh"):
10
+ """Load a cortical flatmap surface (cut and unfolded mesh).
11
+
12
+ Currently available for:
13
+ - ``fsaverage`` (164k vertices) via nilearn
14
+
15
+ Parameters
16
+ ----------
17
+ template : str
18
+ Template name. Only ``"fsaverage"`` is supported.
19
+ hemi : str
20
+ Hemisphere: ``"lh"`` / ``"left"`` / ``"L"`` or
21
+ ``"rh"`` / ``"right"`` / ``"R"``.
22
+
23
+ Returns
24
+ -------
25
+ vertices : ndarray, shape (n_vertices, 3)
26
+ faces : ndarray, shape (n_faces, 3)
27
+ """
28
+ from nilearn import datasets
29
+
30
+ if hemi.lower() in ("lh", "left", "l"):
31
+ side = "left"
32
+ elif hemi.lower() in ("rh", "right", "r"):
33
+ side = "right"
34
+ else:
35
+ raise ValueError(f"Unknown hemisphere '{hemi}'")
36
+
37
+ tpl = template.lower()
38
+ import contextlib, io
39
+ with contextlib.redirect_stdout(io.StringIO()):
40
+ if tpl == "fsaverage":
41
+ fsavg = datasets.fetch_surf_fsaverage(mesh="fsaverage")
42
+ elif tpl in ("fsaverage5", "fsaverage_5"):
43
+ fsavg = datasets.fetch_surf_fsaverage(mesh="fsaverage5")
44
+ else:
45
+ raise ValueError(
46
+ f"Flat surface not available for template '{template}'. "
47
+ "Supported: 'fsaverage', 'fsaverage5'."
48
+ )
49
+
50
+ flat_path = fsavg[f"flat_{side}"]
51
+ gii = nib.load(flat_path)
52
+ vertices, faces = gii.agg_data()
53
+ return vertices, faces
54
+
55
+
56
+ def get_surface_geometry(
57
+ template="fsLR",
58
+ suffix="midthickness",
59
+ hemi="lh",
60
+ density="32k",
61
+ desc=None,
62
+ extension="surf.gii",
63
+ ):
64
+
65
+ if template == "fsLR":
66
+ if suffix not in [
67
+ "inflated",
68
+ "midthickness",
69
+ "veryinflated",
70
+ "sphere",
71
+ ]:
72
+ raise ValueError(
73
+ f"Invalid suffix '{suffix}' for template 'fsLR'. Must be one of 'inflated', 'midthickness', 'veryinflated', or 'sphere'."
74
+ )
75
+
76
+ elif template == "fsaverage":
77
+ if suffix not in [
78
+ "inflated",
79
+ "midthickness",
80
+ "pial",
81
+ "smoothwm",
82
+ "sphere",
83
+ "white",
84
+ ]:
85
+ raise ValueError(
86
+ f"Invalid suffix '{suffix}' for template 'fsaverage'. Must be one of 'inflated', 'midthickness', 'pial', 'smoothwm', 'sphere', or 'white'."
87
+ )
88
+ if density not in ["164k", "41k", "10k", "3k"]:
89
+ raise ValueError(
90
+ f"Invalid density '{density}' for template 'fsaverage'. Must be one of '164k', '41k', '10k', or '3k'."
91
+ )
92
+ if hemi in ["lh", "left"]:
93
+ hemi = "L"
94
+ elif hemi in ["rh", "right"]:
95
+ hemi = "R"
96
+
97
+ fsLR = templateflow.api.get(
98
+ template=template,
99
+ density=density,
100
+ suffix=suffix,
101
+ hemi=hemi,
102
+ desc=desc,
103
+ extension=extension,
104
+ )
105
+ # print(f"Loading surface geometry from {fsLR}")
106
+ if isinstance(fsLR, list):
107
+ raise ValueError(f"Expected a single file path, but got a list: {fsLR}")
108
+ gii = nib.load(fsLR)
109
+
110
+ vertices, triangles = gii.agg_data()
111
+
112
+ return vertices, triangles
bsplot/figure.py ADDED
@@ -0,0 +1,177 @@
1
+ """
2
+ Figure layout wizard for bsplot.
3
+
4
+ Provides a convenient API for creating matplotlib figures with labeled axes
5
+ using ``subplot_mosaic`` under the hood.
6
+
7
+ Examples
8
+ --------
9
+ Simple single-panel figure:
10
+
11
+ >>> fig, ax = bsplot.figure.subplots(layout="a")
12
+
13
+ Row of three panels:
14
+
15
+ >>> fig, ax = bsplot.figure.subplots(layout="abc")
16
+
17
+ 2×3 grid with mosaic layout:
18
+
19
+ >>> fig, ax = bsplot.figure.subplots(layout="abc\\ndef")
20
+
21
+ Spanning cells (matplotlib mosaic syntax):
22
+
23
+ >>> fig, ax = bsplot.figure.subplots(layout="aab\\nccd")
24
+
25
+ Access axes by label:
26
+
27
+ >>> ax["a"].plot([1, 2, 3])
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import string
33
+ from typing import Any
34
+
35
+ import matplotlib.pyplot as plt
36
+ import matplotlib.axes
37
+ import matplotlib.figure
38
+ import numpy as np
39
+
40
+
41
+ def _parse_layout(layout):
42
+ """Convert a compact layout string into the list-of-lists expected by
43
+ ``subplot_mosaic``.
44
+
45
+ Parameters
46
+ ----------
47
+ layout : str or list[list[str]]
48
+ If a string, each character becomes an axes label. Rows are
49
+ separated by ``\\n``. Spaces and ``|`` are treated as column
50
+ separators for readability but are otherwise ignored.
51
+ A ``.`` marks an empty cell.
52
+ If already a list-of-lists, it is returned as-is.
53
+
54
+ Returns
55
+ -------
56
+ list[list[str]]
57
+ Mosaic specification suitable for ``plt.subplot_mosaic``.
58
+ """
59
+ if isinstance(layout, list):
60
+ return layout
61
+
62
+ rows = layout.split("\n")
63
+ mosaic = []
64
+ for row in rows:
65
+ # strip leading/trailing whitespace per row
66
+ row = row.strip()
67
+ if not row:
68
+ continue
69
+ # allow pipe separators for readability (e.g. "a|b|c")
70
+ row = row.replace("|", "")
71
+ # each remaining character is a cell
72
+ mosaic.append(list(row))
73
+ return mosaic
74
+
75
+
76
+ def subplots(
77
+ layout: str | list[list[str]] = "a",
78
+ *,
79
+ figsize: tuple[float, float] | None = None,
80
+ dpi: float | None = None,
81
+ width_ratios: list[float] | None = None,
82
+ height_ratios: list[float] | None = None,
83
+ gridspec_kw: dict | None = None,
84
+ subplot_kw: dict | None = None,
85
+ per_subplot_kw: dict[str, dict[str, Any]] | None = None,
86
+ constrained_layout: bool = True,
87
+ **fig_kw,
88
+ ) -> tuple[matplotlib.figure.Figure, dict[str, matplotlib.axes.Axes]]:
89
+ """Create a figure with labeled subplots using mosaic layout.
90
+
91
+ This is bsplot's main entry-point for building multi-panel figures.
92
+ Under the hood it delegates to :func:`matplotlib.pyplot.subplot_mosaic`,
93
+ so every feature of the mosaic API is available.
94
+
95
+ Parameters
96
+ ----------
97
+ layout : str or list[list[str]], default ``"a"``
98
+ Compact layout specification.
99
+
100
+ * A plain string like ``"abc"`` creates a single row of panels
101
+ labeled *a*, *b*, *c*.
102
+ * Use ``\\n`` to separate rows: ``"ab\\ncd"`` gives a 2×2 grid.
103
+ * A ``.`` marks an empty cell.
104
+ * Repeating a letter spans that panel across cells:
105
+ ``"aab\\nccd"`` makes *a* span two columns in the first row.
106
+ * You can also pass a nested list directly for full control.
107
+
108
+ figsize : tuple of float, optional
109
+ Figure size ``(width, height)`` in inches. When *None* a
110
+ sensible default is chosen based on the number of rows and
111
+ columns.
112
+ dpi : float, optional
113
+ Resolution. Defaults to matplotlib's ``figure.dpi`` rcParam.
114
+ width_ratios : list of float, optional
115
+ Relative widths of each column.
116
+ height_ratios : list of float, optional
117
+ Relative heights of each row.
118
+ gridspec_kw : dict, optional
119
+ Extra keyword arguments forwarded to the underlying
120
+ :class:`~matplotlib.gridspec.GridSpec`.
121
+ subplot_kw : dict, optional
122
+ Keyword arguments applied to **every** subplot
123
+ (e.g. ``{"projection": "polar"}``).
124
+ per_subplot_kw : dict, optional
125
+ Per-label keyword arguments, e.g.
126
+ ``{"a": {"projection": "polar"}}``.
127
+ constrained_layout : bool, default True
128
+ Use matplotlib's constrained layout engine for automatic spacing.
129
+ **fig_kw
130
+ Any remaining keyword arguments are forwarded to
131
+ :func:`matplotlib.pyplot.figure`.
132
+
133
+ Returns
134
+ -------
135
+ fig : matplotlib.figure.Figure
136
+ ax : dict[str, matplotlib.axes.Axes]
137
+ A dict keyed by the panel label strings.
138
+
139
+ Examples
140
+ --------
141
+ >>> fig, ax = bsplot.figure.subplots() # single panel
142
+ >>> fig, ax = bsplot.figure.subplots("abc") # 1×3 row
143
+ >>> fig, ax = bsplot.figure.subplots("ab\\ncd") # 2×2 grid
144
+ >>> fig, ax = bsplot.figure.subplots("aab\\n.cd") # mosaic
145
+ >>> ax["a"].plot([0, 1], [0, 1])
146
+ """
147
+ mosaic = _parse_layout(layout)
148
+
149
+ # --- derive a default figsize from the grid dimensions ---------------
150
+ nrows = len(mosaic)
151
+ ncols = max(len(row) for row in mosaic) if mosaic else 1
152
+ if figsize is None:
153
+ col_width = 4.0
154
+ row_height = 3.5
155
+ figsize = (ncols * col_width, nrows * row_height)
156
+
157
+ # --- assemble gridspec_kw --------------------------------------------
158
+ if gridspec_kw is None:
159
+ gridspec_kw = {}
160
+ if width_ratios is not None:
161
+ gridspec_kw["width_ratios"] = width_ratios
162
+ if height_ratios is not None:
163
+ gridspec_kw["height_ratios"] = height_ratios
164
+
165
+ # --- create the figure -----------------------------------------------
166
+ fig, ax_dict = plt.subplot_mosaic(
167
+ mosaic, # type: ignore[arg-type]
168
+ figsize=figsize,
169
+ dpi=dpi,
170
+ gridspec_kw=gridspec_kw or None,
171
+ subplot_kw=subplot_kw,
172
+ per_subplot_kw=per_subplot_kw, # type: ignore[arg-type]
173
+ layout="constrained" if constrained_layout else None,
174
+ **fig_kw,
175
+ )
176
+
177
+ return fig, ax_dict
@@ -0,0 +1,31 @@
1
+ from . import edges, layout, nodes
2
+ from .flowchart import (
3
+ build_dot,
4
+ flowchart_html,
5
+ prune_nodes_to_edges,
6
+ render_html,
7
+ save_html,
8
+ show_matplotlib,
9
+ show_matplotlib_bioicons,
10
+ )
11
+ from .network import (
12
+ get_centers_from_surface_parc,
13
+ create_network,
14
+ create_node_mesh,
15
+ create_edge_mesh,
16
+ plot_network_on_surface,
17
+ )
18
+ from .nodes import (
19
+ draw_custom_nodes,
20
+ draw_nodes_with_icons,
21
+ draw_icon_nodes,
22
+ )
23
+
24
+ # Re-export from parent for convenience
25
+ from ..bioicons import (
26
+ BioIcons,
27
+ plot_icon,
28
+ plot_icons_grid,
29
+ IconAnnotation,
30
+ search_icons,
31
+ )
bsplot/graph/edges.py ADDED
@@ -0,0 +1,312 @@
1
+ import numpy as np
2
+ from matplotlib.colors import Normalize
3
+ from bsplot.colors import Colormap as _resolve_cmap
4
+
5
+ import numpy as np
6
+ from matplotlib.colors import Normalize
7
+ from matplotlib.patches import FancyArrowPatch
8
+
9
+
10
+ def get_edge_color_mapping(G, colormap="viridis", color_by="type"):
11
+ """
12
+ Generate a color mapping for edges in graph G based on their specified attribute.
13
+
14
+ Parameters:
15
+ ----------
16
+ G : networkx.Graph
17
+ The graph containing the edges with the specified attribute.
18
+ colormap : str, optional
19
+ The name of the colormap to use (default is 'viridis').
20
+ color_by : str, optional
21
+ The edge attribute to color by (default is 'type').
22
+
23
+ Returns:
24
+ -------
25
+ edge_colors : dict
26
+ A dictionary mapping edge attributes to colors.
27
+ """
28
+ # Extract all edge attributes for the specified attribute
29
+ edge_attributes = [
30
+ data[color_by] if color_by in data.keys() else "n.a."
31
+ for u, v, data in G.edges(data=True)
32
+ ]
33
+ unique_edge_attributes = list(set(edge_attributes))
34
+
35
+ # Ensure "n.a." is first in the list
36
+ if "n.a." in unique_edge_attributes:
37
+ unique_edge_attributes.remove("n.a.")
38
+ unique_edge_attributes.insert(0, "n.a.")
39
+
40
+ # Create a colormap
41
+ cmap = _resolve_cmap(colormap)
42
+ norm = Normalize(vmin=0, vmax=len(unique_edge_attributes) - 1)
43
+
44
+ # Map edge attributes to colors
45
+ edge_colors = {
46
+ attr: cmap(norm(i)) if attr != "n.a." else (0.6, 0.6, 0.6, 1)
47
+ for i, attr in enumerate(unique_edge_attributes)
48
+ }
49
+
50
+ return edge_colors
51
+
52
+
53
+ def get_unique_node_pairs(G):
54
+ unique_pairs = set() # Use a set to store unique node pairs
55
+ for n1, n2 in G.edges():
56
+ # Sort using the 'name' attribute of the nodes (assuming each node has a 'name' attribute)
57
+ pair = tuple(
58
+ sorted(
59
+ [n1, n2], key=lambda node: node if isinstance(node, str) else node.name
60
+ )
61
+ )
62
+ unique_pairs.add(pair) # Add the sorted pair to the set
63
+ return unique_pairs
64
+
65
+
66
+ def compute_midpoint(start, end, rad):
67
+ """
68
+ Compute the midpoint of a quadratic Bézier curve.
69
+
70
+ Parameters:
71
+ - start: tuple of (x, y) coordinates of the start point.
72
+ - end: tuple of (x, y) coordinates of the end point.
73
+ - rad: curvature radius as a percentage.
74
+
75
+ Returns:
76
+ - tuple of (x, y) coordinates of the midpoint.
77
+ """
78
+ x1, y1 = start
79
+ x2, y2 = end
80
+ y12 = (y1 + y2) / 2
81
+ dy = y2 - y1
82
+ cy = y12 + rad * dy
83
+
84
+ # Ensure cy is a single value
85
+ if isinstance(cy, np.ndarray):
86
+ cy = cy.item()
87
+
88
+ # Midpoint of the quadratic Bézier curve
89
+ t = 0.5
90
+ midpoint_x = (1 - t) ** 2 * x1 + 2 * (1 - t) * t * (x1 + (x2 - x1) / 2) + t**2 * x2
91
+ midpoint_y = (1 - t) ** 2 * y1 + 2 * (1 - t) * t * cy + t**2 * y2
92
+ return (midpoint_x, midpoint_y)
93
+
94
+
95
+ def _bezier_point(x1, y1, x2, y2, cy, t):
96
+ """Quadratic Bézier point at parameter t. Control point is ((x1+x2)/2, cy)."""
97
+ cx = (x1 + x2) / 2.0
98
+ bx = (1 - t)**2 * x1 + 2 * (1 - t) * t * cx + t**2 * x2
99
+ by = (1 - t)**2 * y1 + 2 * (1 - t) * t * cy + t**2 * y2
100
+ return float(bx), float(by)
101
+
102
+
103
+ def _bezier_tangent(x1, y1, x2, y2, cy, t):
104
+ """Tangent vector of quadratic Bézier at t (not normalised)."""
105
+ cx = (x1 + x2) / 2.0
106
+ tx = 2 * (1 - t) * (cx - x1) + 2 * t * (x2 - cx)
107
+ ty = 2 * (1 - t) * (cy - y1) + 2 * t * (y2 - cy)
108
+ return float(tx), float(ty)
109
+
110
+
111
+ def plot_curve(
112
+ ax,
113
+ start,
114
+ end,
115
+ rad,
116
+ color="black",
117
+ shrinkA=0,
118
+ shrinkB=0.03,
119
+ arrow_size=0.1,
120
+ arrow_style="->",
121
+ annotate=None,
122
+ label_t=0.5,
123
+ **kwargs,
124
+ ):
125
+ font_size = kwargs.pop("font_size") if "font_size" in kwargs else 12
126
+
127
+ x1, y1 = start
128
+ x2, y2 = end
129
+ y12 = (y1 + y2) / 2
130
+ dy = y2 - y1
131
+ cy = y12 + rad * dy
132
+ midpoint = compute_midpoint(start, end, rad)
133
+
134
+ tau = np.linspace(0, 1, 100)
135
+ xsupport = np.linspace(x1, x2, 100)
136
+ ysupport = [(1 - i) ** 2 * y1 + 2 * (1 - i) * i * cy + i**2 * y2 for i in tau]
137
+
138
+ # Apply shrink to the start and end
139
+ shrink_index_start = int(len(xsupport) * shrinkA)
140
+ shrink_index_end = int(len(xsupport) * (1 - shrinkB))
141
+
142
+ line = ax.plot(
143
+ xsupport[shrink_index_start:shrink_index_end],
144
+ ysupport[shrink_index_start:shrink_index_end],
145
+ color=color,
146
+ **kwargs,
147
+ )
148
+ line = line[0]
149
+ if isinstance(annotate, str):
150
+ # Label position: Bézier point at label_t
151
+ lp_x, lp_y = _bezier_point(x1, y1, x2, y2, cy, label_t)
152
+
153
+ # True tangent at label_t — varies with both rad and t
154
+ tx, ty = _bezier_tangent(x1, y1, x2, y2, cy, label_t)
155
+ length = np.sqrt(tx**2 + ty**2)
156
+ if length > 0:
157
+ tx /= length
158
+ ty /= length
159
+ else:
160
+ tx, ty = 1.0, 0.0
161
+
162
+ # Right-perpendicular of tangent (rotate 90° clockwise)
163
+ right_x = ty
164
+ right_y = -tx
165
+
166
+ chord_len = np.sqrt((x2 - x1)**2 + (y2 - y1)**2)
167
+ offset = max(0.08 * chord_len, 0.04)
168
+ ax.annotate(
169
+ annotate,
170
+ xy=(lp_x, lp_y),
171
+ xytext=(lp_x + offset * right_x, lp_y + offset * right_y),
172
+ xycoords="data",
173
+ ha="left",
174
+ va="center",
175
+ color="k",
176
+ fontsize=font_size,
177
+ )
178
+
179
+
180
+ def draw_custom_edges(
181
+ G,
182
+ pos,
183
+ ax=None,
184
+ edge_labels=False,
185
+ color_by="type",
186
+ edge_colors="#606060",
187
+ edge_radius=0,
188
+ **kwargs,
189
+ ):
190
+ # if "shrinkA" not in kwargs.keys():
191
+ # kwargs["shrinkA"] = .1
192
+ # if "shrinkB" not in kwargs.keys():
193
+ # kwargs["shrinkB"] = .3
194
+ # if "arrowstyle" not in kwargs.keys():
195
+ # kwargs["arrowstyle"] = "-|>,head_length=0.4,head_width=0.2"
196
+ # if "mutation_scale" not in kwargs.keys():
197
+ # kwargs["mutation_scale"] = 10.0
198
+
199
+ if edge_colors in colormaps.keys():
200
+ cmap = (
201
+ edge_colors
202
+ if isinstance(edge_colors, str) and edge_colors in colormaps.keys()
203
+ else "viridis"
204
+ )
205
+ colmap = get_edge_color_mapping(G, colormap=cmap, color_by=color_by)
206
+ edge_colors = [
207
+ colmap[data[color_by] if color_by in data else "n.a."]
208
+ for (u, v, data) in G.edges(data=True)
209
+ ]
210
+ elif isinstance(edge_colors, str):
211
+ edge_colors = np.repeat(edge_colors, len(G.edges()))
212
+
213
+ for n1, n2 in get_unique_node_pairs(G):
214
+ point1 = pos[n1]
215
+ point2 = pos[n2]
216
+ types12 = {e["type"] for e in G[n1][n2].values()} if n2 in G[n1] else set()
217
+ types21 = {e["type"] for e in G[n2][n1].values()} if n1 in G[n2] else set()
218
+
219
+ num_edges = len(types12) + len(types21)
220
+
221
+ if num_edges == 1:
222
+ rad = [edge_radius]
223
+ else:
224
+ rad = np.linspace(-0.5, 0.5, num_edges)
225
+
226
+ # Spread label positions along the curve so labels don't stack
227
+ if num_edges == 1:
228
+ label_ts = [0.5]
229
+ else:
230
+ label_ts = np.linspace(0.3, 0.7, num_edges).tolist()
231
+
232
+ i = 0
233
+ for t in types12:
234
+ plot_curve(
235
+ ax,
236
+ point1,
237
+ point2,
238
+ rad[i],
239
+ color=edge_colors[i],
240
+ annotate=t if edge_labels else None,
241
+ label_t=label_ts[i],
242
+ **kwargs,
243
+ )
244
+ i += 1
245
+ for t in types21:
246
+ plot_curve(
247
+ ax,
248
+ point2,
249
+ point1,
250
+ rad[i],
251
+ color=edge_colors[i],
252
+ annotate=t if edge_labels else None,
253
+ label_t=label_ts[i],
254
+ **kwargs,
255
+ )
256
+ i += 1
257
+
258
+
259
+ def adjust_arrow_end(start, end, bbox_end):
260
+ x_start, y_start = start
261
+ x_end, y_end = end
262
+
263
+ x_min_end, y_min_end, width_end, height_end = bbox_end.bounds
264
+ x_max_end, y_max_end = x_min_end + width_end, y_min_end + height_end
265
+
266
+ # x_min_end, y_min_end = bbox_end[0]
267
+ # x_max_end, y_max_end = bbox_end[1]
268
+
269
+ # Determine which edge of the bounding box to intersect with
270
+ if y_start > y_end: # Arrow goes downward; intersect with top edge
271
+ y_bbox = y_max_end
272
+ else: # Arrow goes upward; intersect with bottom edge
273
+ y_bbox = y_min_end
274
+
275
+ # Calculate the intersection point
276
+ if x_start == x_end: # Vertical line case
277
+ x_intersect = x_start
278
+ else:
279
+ slope = (y_end - y_start) / (x_end - x_start)
280
+ x_intersect = (y_bbox - y_start) / slope + x_start
281
+
282
+ # Clip x_intersect to within the bounding box's horizontal bounds
283
+ x_intersect = max(x_min_end, min(x_intersect, x_max_end))
284
+
285
+ return (x_intersect, y_bbox)
286
+
287
+
288
+ def plot_edge(edge, pos, bbox_positions_data, ax, **kwargs):
289
+ start, end = pos[edge[0]], pos[edge[1]]
290
+ xstart, ystart = start
291
+ xend, yend = end
292
+
293
+ # bbox_start = bbox_positions_data[edge[0]].bounds
294
+ bbox_end = bbox_positions_data[edge[1]]
295
+
296
+ # x_min_start, y_min_start = bbox_start[0]
297
+ # x_max_start, y_max_start = bbox_start[1]
298
+
299
+ # x_min_end, y_min_end = bbox_end[0]
300
+ # x_max_end, y_max_end = bbox_end[1]
301
+
302
+ start = pos[edge[0]]
303
+ end = pos[edge[1]]
304
+ adjusted_end = adjust_arrow_end(start, end, bbox_end)
305
+
306
+ # Plot the edge with adjusted endpoint
307
+ arrow = FancyArrowPatch(
308
+ start,
309
+ adjusted_end,
310
+ **kwargs,
311
+ )
312
+ ax.add_patch(arrow)