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.
- bsplot/__init__.py +23 -0
- bsplot/anat.py +55 -0
- bsplot/animate.py +194 -0
- bsplot/bioicons.py +777 -0
- bsplot/brain.py +188 -0
- bsplot/colors.py +1935 -0
- bsplot/data/HCP_avg-SC.txt +379 -0
- bsplot/data/MNI152.rh.pial +0 -0
- bsplot/data/STN_lh.nii.gz +0 -0
- bsplot/data/STN_rh.nii.gz +0 -0
- bsplot/data/__init__.py +22 -0
- bsplot/data/left_electrode.ply +257270 -0
- bsplot/data/parcellations/HCP-MMP1.L.label.gii +442 -0
- bsplot/data/parcellations/HCP-MMP1.R.label.gii +442 -0
- bsplot/data/parcellations/code/split_hcpmmp1.sh +4 -0
- bsplot/data/right_electrode.ply +257270 -0
- bsplot/data/surface.py +112 -0
- bsplot/data/tpl-MNI152NLin2009b_atlas-hcpmmp1_desc-ordered_dseg.nii.gz +0 -0
- bsplot/figure.py +177 -0
- bsplot/graph/__init__.py +31 -0
- bsplot/graph/edges.py +312 -0
- bsplot/graph/flowchart.py +762 -0
- bsplot/graph/layout.py +201 -0
- bsplot/graph/network.py +974 -0
- bsplot/graph/nodes.py +452 -0
- bsplot/panels.py +110 -0
- bsplot/scientific_color_maps/__init__.py +0 -0
- bsplot/scientific_color_maps/_sync_from_zenodo_download.sh +1 -0
- bsplot/scientific_color_maps/batlowK.txt +256 -0
- bsplot/scientific_color_maps/batlowW.txt +256 -0
- bsplot/scientific_color_maps/cyclic/bamO.txt +256 -0
- bsplot/scientific_color_maps/cyclic/brocO.txt +256 -0
- bsplot/scientific_color_maps/cyclic/corkO.txt +256 -0
- bsplot/scientific_color_maps/cyclic/romaO.txt +256 -0
- bsplot/scientific_color_maps/cyclic/vikO.txt +256 -0
- bsplot/scientific_color_maps/diverging/bam.txt +256 -0
- bsplot/scientific_color_maps/diverging/berlin.txt +256 -0
- bsplot/scientific_color_maps/diverging/broc.txt +256 -0
- bsplot/scientific_color_maps/diverging/cork.txt +256 -0
- bsplot/scientific_color_maps/diverging/lisbon.txt +256 -0
- bsplot/scientific_color_maps/diverging/managua.txt +256 -0
- bsplot/scientific_color_maps/diverging/roma.txt +256 -0
- bsplot/scientific_color_maps/diverging/tofino.txt +256 -0
- bsplot/scientific_color_maps/diverging/vanimo.txt +256 -0
- bsplot/scientific_color_maps/diverging/vik.txt +256 -0
- bsplot/scientific_color_maps/multisequential/bukavu.txt +256 -0
- bsplot/scientific_color_maps/multisequential/fes.txt +256 -0
- bsplot/scientific_color_maps/multisequential/oleron.txt +256 -0
- bsplot/scientific_color_maps/naviaW.txt +256 -0
- bsplot/scientific_color_maps/sequential/acton.txt +256 -0
- bsplot/scientific_color_maps/sequential/bamako.txt +256 -0
- bsplot/scientific_color_maps/sequential/batlow.txt +256 -0
- bsplot/scientific_color_maps/sequential/bilbao.txt +256 -0
- bsplot/scientific_color_maps/sequential/buda.txt +256 -0
- bsplot/scientific_color_maps/sequential/davos.txt +256 -0
- bsplot/scientific_color_maps/sequential/devon.txt +256 -0
- bsplot/scientific_color_maps/sequential/glasgow.txt +256 -0
- bsplot/scientific_color_maps/sequential/grayC.txt +256 -0
- bsplot/scientific_color_maps/sequential/hawaii.txt +256 -0
- bsplot/scientific_color_maps/sequential/imola.txt +256 -0
- bsplot/scientific_color_maps/sequential/lajolla.txt +256 -0
- bsplot/scientific_color_maps/sequential/lapaz.txt +256 -0
- bsplot/scientific_color_maps/sequential/lipari.txt +256 -0
- bsplot/scientific_color_maps/sequential/navia.txt +256 -0
- bsplot/scientific_color_maps/sequential/nuuk.txt +256 -0
- bsplot/scientific_color_maps/sequential/oslo.txt +256 -0
- bsplot/scientific_color_maps/sequential/tokyo.txt +256 -0
- bsplot/scientific_color_maps/sequential/turku.txt +256 -0
- bsplot/streamlines.py +314 -0
- bsplot/style.py +791 -0
- bsplot/styles/black.mplstyle +78 -0
- bsplot/styles/bss.mplstyle +50 -0
- bsplot/styles/bwcomp.mplstyle +30 -0
- bsplot/styles/nature.mplstyle +57 -0
- bsplot/styles/transparent.mplstyle +16 -0
- bsplot/styles/tvbo.mplstyle +57 -0
- bsplot/surface.py +2370 -0
- bsplot/templates.py +270 -0
- bsplot/text.py +199 -0
- bsplot/text2obj.py +59 -0
- bsplot/timeseries.py +10 -0
- bsplot/utils.py +13 -0
- bsplot/volume.py +958 -0
- bsplot-0.0.2.dist-info/METADATA +60 -0
- bsplot-0.0.2.dist-info/RECORD +89 -0
- bsplot-0.0.2.dist-info/WHEEL +5 -0
- bsplot-0.0.2.dist-info/entry_points.txt +2 -0
- bsplot-0.0.2.dist-info/licenses/LICENSE +193 -0
- bsplot-0.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from graphviz import Source
|
|
4
|
+
import html
|
|
5
|
+
import math
|
|
6
|
+
import json
|
|
7
|
+
from io import BytesIO
|
|
8
|
+
from typing import Iterable, List, Sequence, Tuple, Optional, Dict
|
|
9
|
+
|
|
10
|
+
# Optional at call sites only (so this module imports even outside notebooks)
|
|
11
|
+
try:
|
|
12
|
+
from IPython.display import display, HTML # noqa: F401
|
|
13
|
+
except Exception: # pragma: no cover
|
|
14
|
+
display = None
|
|
15
|
+
HTML = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
Node = Tuple[str, str, str, str] # (nid, icon, title, sub)
|
|
19
|
+
Edge = Tuple[str, str, Optional[str]] # (u, v, label)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_dot(
|
|
23
|
+
nodes: Sequence[Node],
|
|
24
|
+
edges: Sequence[Edge],
|
|
25
|
+
same_rank_groups: Optional[Sequence[Sequence[str]]] = None,
|
|
26
|
+
node_width: float | str = "auto",
|
|
27
|
+
node_height: float | str = "auto",
|
|
28
|
+
nodesep: float = 1,
|
|
29
|
+
ranksep: float = 1,
|
|
30
|
+
fontsize: int = 10,
|
|
31
|
+
char_w: float = 0.08,
|
|
32
|
+
line_h: float = 0.28,
|
|
33
|
+
min_w: float = 0.5,
|
|
34
|
+
max_w: float = 2.0,
|
|
35
|
+
min_h: float = 0.5,
|
|
36
|
+
max_h: float = 0.8,
|
|
37
|
+
rankdir: str = "LR",
|
|
38
|
+
dpi: int = 144,
|
|
39
|
+
include_icon_row: bool = True,
|
|
40
|
+
icon_fontname: Optional[str] = None, # use local Material Symbols ligature font if available
|
|
41
|
+
splines: str = "true", # control Graphviz splines mode
|
|
42
|
+
edge_label_mode: str = "auto", # 'auto' | 'label' | 'xlabel'
|
|
43
|
+
icon_color: str = "#4a90e2", # icon color (match HTML)
|
|
44
|
+
icon_scale: float = 2.4, # icon size scale vs fontsize
|
|
45
|
+
icon_fontpath: Optional[str] = None, # directory for the font, passed to Graphviz
|
|
46
|
+
# spacing controls
|
|
47
|
+
label_cellpadding: int = 0,
|
|
48
|
+
node_margin: float = 0.06,
|
|
49
|
+
compact_label: bool = False,
|
|
50
|
+
# NEW: tighten line spacing between title and subheading
|
|
51
|
+
label_lineheight: float = 2.0,
|
|
52
|
+
label_leading: float = 2.0,
|
|
53
|
+
# NEW: edge styling and label control
|
|
54
|
+
edge_color: str = "#999999",
|
|
55
|
+
edge_penwidth: float = 1.6,
|
|
56
|
+
edge_font_scale: float = 0.8,
|
|
57
|
+
show_edge_labels: bool = True,
|
|
58
|
+
# NEW: edge label background + padding
|
|
59
|
+
edge_label_bgcolor: str = "#ffffff",
|
|
60
|
+
edge_label_cellpadding: int = 2,
|
|
61
|
+
) -> str:
|
|
62
|
+
"""
|
|
63
|
+
Return a DOT string to compute node positions with Graphviz.
|
|
64
|
+
|
|
65
|
+
The label uses an HTML-like table with: optional icon row, title, sub.
|
|
66
|
+
Width/height are estimated if set to "auto", otherwise treated as fixed hints.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def n(nid: str, icon: str, title: str, sub: str) -> str:
|
|
70
|
+
w = (
|
|
71
|
+
max(min_w, min(max_w, (char_w * max(len(title), len(sub)) + min_w)))
|
|
72
|
+
if node_width == "auto" else float(node_width)
|
|
73
|
+
)
|
|
74
|
+
lines_title = max(1, math.ceil(len(title) / max(1, int((w - 0.6) / char_w))))
|
|
75
|
+
lines_sub = max(1, math.ceil(len(sub) / max(1, int((w - 0.6) / char_w))))
|
|
76
|
+
h_est = min_h + line_h * (lines_title - 1 + lines_sub - 1)
|
|
77
|
+
h = max(min_h, min(max_h, h_est)) if node_height == "auto" else float(node_height)
|
|
78
|
+
|
|
79
|
+
sub_fs = max(8, int(fontsize * 0.9))
|
|
80
|
+
icon_fs = max(fontsize, int(fontsize * icon_scale))
|
|
81
|
+
|
|
82
|
+
# Ensure no empty cells (Graphviz HTML labels reject empty FONT tags)
|
|
83
|
+
def _safe_cell_text(s: str) -> str:
|
|
84
|
+
return s if s and s.strip() else "\u00A0"
|
|
85
|
+
|
|
86
|
+
title_txt = _safe_cell_text(title)
|
|
87
|
+
sub_txt = _safe_cell_text(sub)
|
|
88
|
+
|
|
89
|
+
# Compact: single TD with <BR/> lines (minimizes inter-row spacing)
|
|
90
|
+
if compact_label:
|
|
91
|
+
icon_html = ""
|
|
92
|
+
if include_icon_row and icon:
|
|
93
|
+
if icon_fontname:
|
|
94
|
+
icon_html = (
|
|
95
|
+
f'<FONT FACE="{html.escape(icon_fontname)}" '
|
|
96
|
+
f'POINT-SIZE="{icon_fs}" COLOR="{html.escape(icon_color)}">{html.escape(icon)}</FONT>'
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
icon_html = (
|
|
100
|
+
f'<FONT POINT-SIZE="{icon_fs}" COLOR="{html.escape(icon_color)}">{html.escape(icon)}</FONT>'
|
|
101
|
+
)
|
|
102
|
+
parts = []
|
|
103
|
+
if icon_html:
|
|
104
|
+
parts.append(icon_html)
|
|
105
|
+
parts.append(f'<FONT POINT-SIZE="{fontsize}">{html.escape(title_txt)}</FONT>')
|
|
106
|
+
parts.append(f'<FONT POINT-SIZE="{sub_fs}" COLOR="#6b7280">{html.escape(sub_txt)}</FONT>')
|
|
107
|
+
return (
|
|
108
|
+
f'{nid}[label=<<TABLE BORDER="0" CELLBORDER="0" CELLPADDING="{label_cellpadding}" CELLSPACING="0">'
|
|
109
|
+
f'<TR><TD ALIGN="CENTER">' + "<BR/>".join(parts) + "</TD></TR></TABLE>>,"
|
|
110
|
+
f'width={w:.3f},height={h:.3f},fixedsize=false];'
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Default: separate rows
|
|
114
|
+
rows = []
|
|
115
|
+
if include_icon_row and icon:
|
|
116
|
+
if icon_fontname:
|
|
117
|
+
rows.append(
|
|
118
|
+
f'<TR><TD ALIGN="CENTER"><FONT FACE="{html.escape(icon_fontname)}" '
|
|
119
|
+
f'POINT-SIZE="{icon_fs}" COLOR="{html.escape(icon_color)}">{html.escape(icon)}</FONT></TD></TR>'
|
|
120
|
+
)
|
|
121
|
+
else:
|
|
122
|
+
rows.append(
|
|
123
|
+
f'<TR><TD ALIGN="CENTER"><FONT POINT-SIZE="{icon_fs}" COLOR="{html.escape(icon_color)}">'
|
|
124
|
+
f'{html.escape(icon)}</FONT></TD></TR>'
|
|
125
|
+
)
|
|
126
|
+
rows.append(f'<TR><TD ALIGN="CENTER"><FONT POINT-SIZE="{fontsize}">{html.escape(title_txt)}</FONT></TD></TR>')
|
|
127
|
+
rows.append(f'<TR><TD ALIGN="CENTER"><FONT POINT-SIZE="{sub_fs}" COLOR="#6b7280">{html.escape(sub_txt)}</FONT></TD></TR>')
|
|
128
|
+
return (
|
|
129
|
+
f'{nid}[label=<<TABLE BORDER="0" CELLBORDER="0" CELLPADDING="{label_cellpadding}" CELLSPACING="0">'
|
|
130
|
+
+ "".join(rows)
|
|
131
|
+
+ "</TABLE>>,"
|
|
132
|
+
f'width={w:.3f},height={h:.3f},fixedsize=false];'
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
rank_blocks = ""
|
|
136
|
+
if same_rank_groups:
|
|
137
|
+
rank_blocks = "".join("{rank=same; " + " ".join(group) + ";}" for group in same_rank_groups)
|
|
138
|
+
|
|
139
|
+
# Decide which edge label attribute to use
|
|
140
|
+
use_xlabel = (edge_label_mode == "xlabel") or (edge_label_mode == "auto" and splines.lower() == "curved")
|
|
141
|
+
label_attr = "xlabel" if use_xlabel else "label"
|
|
142
|
+
|
|
143
|
+
# Compute edge label font size once (used in HTML label)
|
|
144
|
+
edge_fontsize = max(7, int(fontsize * edge_font_scale))
|
|
145
|
+
|
|
146
|
+
def e(u: str, v: str, lbl: Optional[str]) -> str:
|
|
147
|
+
# Add a label only if enabled and provided
|
|
148
|
+
if show_edge_labels and lbl:
|
|
149
|
+
# HTML-like label with white background and small padding
|
|
150
|
+
html_lbl = (
|
|
151
|
+
f'<<TABLE BORDER="0" CELLBORDER="0" CELLPADDING="{edge_label_cellpadding}" '
|
|
152
|
+
f'BGCOLOR="{html.escape(edge_label_bgcolor)}"><TR><TD>'
|
|
153
|
+
f'<FONT POINT-SIZE="{edge_fontsize}" COLOR="#666666">{html.escape(lbl)}</FONT>'
|
|
154
|
+
f"</TD></TR></TABLE>>"
|
|
155
|
+
)
|
|
156
|
+
return f"{u}->{v} [{label_attr}={html_lbl}];"
|
|
157
|
+
return f"{u}->{v};"
|
|
158
|
+
|
|
159
|
+
# Build edges string robustly for (u,v) and (u,v,label)
|
|
160
|
+
edges_str_parts = []
|
|
161
|
+
for t in edges:
|
|
162
|
+
u, v = t[0], t[1]
|
|
163
|
+
lbl = t[2] if len(t) > 2 else None
|
|
164
|
+
if lbl is not None and not isinstance(lbl, str):
|
|
165
|
+
lbl = str(lbl)
|
|
166
|
+
edges_str_parts.append(e(u, v, lbl))
|
|
167
|
+
edges_str = "".join(edges_str_parts)
|
|
168
|
+
|
|
169
|
+
# Global defaults to better match HTML styling
|
|
170
|
+
node_font = "Segoe UI"
|
|
171
|
+
edge_font = "Segoe UI"
|
|
172
|
+
# edge_fontsize already computed above
|
|
173
|
+
|
|
174
|
+
# Graph attributes (allow passing a fontpath so dot can find local fonts)
|
|
175
|
+
graph_attrs = f'dpi={dpi},splines={splines}'
|
|
176
|
+
if icon_fontpath:
|
|
177
|
+
graph_attrs += f',fontpath="{icon_fontpath}"'
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
"digraph G{"
|
|
181
|
+
f'graph[{graph_attrs}];'
|
|
182
|
+
f'rankdir={rankdir};nodesep={nodesep};ranksep={ranksep};'
|
|
183
|
+
f'node[fontname="{node_font}",fontsize={fontsize},shape=box,style="rounded,filled",'
|
|
184
|
+
f'fillcolor="#ffffff:#eef2f7",gradientangle=90,color="transparent",penwidth=0,margin="{node_margin}",'
|
|
185
|
+
f'lineheight={label_lineheight},leading={label_leading}];'
|
|
186
|
+
f'edge[fontname="{edge_font}",fontsize={edge_fontsize},color="{edge_color}",fontcolor="#666666",'
|
|
187
|
+
f'penwidth={edge_penwidth},arrowsize=0.7];'
|
|
188
|
+
+ "".join(n(*x) for x in nodes)
|
|
189
|
+
+ edges_str
|
|
190
|
+
+ ("" if not same_rank_groups else "".join("{rank=same; " + " ".join(group) + ";}" for group in same_rank_groups))
|
|
191
|
+
+ "}"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def layout(dot: str) -> Tuple[float, float, Dict[str, Dict[str, float]]]:
|
|
196
|
+
"""Run Graphviz 'plain' and return canvas size (W,H) in px and node box dict."""
|
|
197
|
+
S = 72.0 # pt->px
|
|
198
|
+
W = H = 0.0
|
|
199
|
+
pos: Dict[str, Dict[str, float]] = {}
|
|
200
|
+
for line in Source(dot).pipe(format="plain").decode().splitlines():
|
|
201
|
+
p = line.split()
|
|
202
|
+
if not p:
|
|
203
|
+
continue
|
|
204
|
+
if p[0] == "graph":
|
|
205
|
+
W, H = float(p[2]) * S, float(p[3]) * S
|
|
206
|
+
elif p[0] == "node":
|
|
207
|
+
_, name, x, y, w, h = p[:6]
|
|
208
|
+
pos[name] = {
|
|
209
|
+
"x": float(x) * S,
|
|
210
|
+
"y": float(y) * S,
|
|
211
|
+
"w": float(w) * S,
|
|
212
|
+
"h": float(h) * S,
|
|
213
|
+
}
|
|
214
|
+
return W, H, pos
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _card_div(pos: Dict[str, Dict[str, float]], nid: str, icon: str, title: str, sub: str, H: float) -> str:
|
|
218
|
+
d = pos[nid]
|
|
219
|
+
left = d["x"] - d["w"] / 2
|
|
220
|
+
top = H - (d["y"] + d["h"] / 2)
|
|
221
|
+
return (
|
|
222
|
+
f'<div class="step" data-nid="{nid}" style="position:absolute;left:{left:.1f}px;top:{top:.1f}px;'
|
|
223
|
+
f'width:{d["w"]:.1f}px;"><div class="icon"><span class="material-symbols-outlined">'
|
|
224
|
+
f"{html.escape(icon)}</span></div><h4>{html.escape(title)}</h4><small>{html.escape(sub)}</small></div>"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def render_html(
|
|
229
|
+
W: float,
|
|
230
|
+
H: float,
|
|
231
|
+
pos: Dict[str, Dict[str, float]],
|
|
232
|
+
nodes: Sequence[Node],
|
|
233
|
+
edges: Sequence[Edge],
|
|
234
|
+
ui_fontsize: int = 12,
|
|
235
|
+
rankdir: str = "LR",
|
|
236
|
+
) -> str:
|
|
237
|
+
"""Return standalone HTML for the diagram with cards that expand downward to fit text."""
|
|
238
|
+
cards = "".join(_card_div(pos, *n, H) for n in nodes)
|
|
239
|
+
|
|
240
|
+
# Scale-dependent sizes (px)
|
|
241
|
+
icon_px = int(ui_fontsize * 2.2)
|
|
242
|
+
title_px = int(ui_fontsize * 1.0)
|
|
243
|
+
small_px = int(ui_fontsize * 0.85)
|
|
244
|
+
pad_px = int(ui_fontsize * 0.6)
|
|
245
|
+
|
|
246
|
+
edges_pairs = [[u, v] for (u, v, *_lbl) in edges]
|
|
247
|
+
edges_js = json.dumps(edges_pairs)
|
|
248
|
+
|
|
249
|
+
css = f"""
|
|
250
|
+
.jp-RenderedHTMLCommon {{
|
|
251
|
+
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;
|
|
252
|
+
line-height:1.5;
|
|
253
|
+
}}
|
|
254
|
+
.jp-RenderedHTMLCommon h4 {{
|
|
255
|
+
font-weight:600;
|
|
256
|
+
color:rgba(0,0,0,.6);
|
|
257
|
+
}}
|
|
258
|
+
.jp-RenderedHTMLCommon small {{
|
|
259
|
+
color:rgba(0,0,0,.55);
|
|
260
|
+
}}
|
|
261
|
+
.workflow {{
|
|
262
|
+
position:relative;
|
|
263
|
+
width:{W:.1f}px;
|
|
264
|
+
height:{H:.1f}px;
|
|
265
|
+
margin:24px auto;
|
|
266
|
+
background:#ffffff;
|
|
267
|
+
}}
|
|
268
|
+
.step {{
|
|
269
|
+
background:#fff;
|
|
270
|
+
border-radius:1rem;
|
|
271
|
+
box-shadow:0 2px 6px rgba(0,0,0,.1);
|
|
272
|
+
box-sizing:border-box;
|
|
273
|
+
padding:{pad_px}px 10px;
|
|
274
|
+
display:grid;
|
|
275
|
+
grid-template-rows:{icon_px}px auto auto;
|
|
276
|
+
align-items:start;
|
|
277
|
+
justify-items:center;
|
|
278
|
+
gap:2px;
|
|
279
|
+
text-align:center;
|
|
280
|
+
overflow:visible;
|
|
281
|
+
}}
|
|
282
|
+
.icon {{
|
|
283
|
+
height:{icon_px}px;
|
|
284
|
+
width:100%;
|
|
285
|
+
display:flex;
|
|
286
|
+
align-items:center;
|
|
287
|
+
justify-content:center;
|
|
288
|
+
}}
|
|
289
|
+
.material-symbols-outlined {{
|
|
290
|
+
font-size:{icon_px}px;
|
|
291
|
+
color:#4a90e2;
|
|
292
|
+
line-height:1;
|
|
293
|
+
}}
|
|
294
|
+
h4 {{
|
|
295
|
+
margin:0;
|
|
296
|
+
font-family:sans-serif;
|
|
297
|
+
font-size:{title_px}px;
|
|
298
|
+
line-height:1.15;
|
|
299
|
+
max-width:100%;
|
|
300
|
+
overflow-wrap:anywhere;
|
|
301
|
+
word-break:normal;
|
|
302
|
+
}}
|
|
303
|
+
small {{
|
|
304
|
+
color:#555;
|
|
305
|
+
font-size:{small_px}px;
|
|
306
|
+
display:block;
|
|
307
|
+
max-width:100%;
|
|
308
|
+
overflow-wrap:anywhere;
|
|
309
|
+
word-break:normal;
|
|
310
|
+
}}
|
|
311
|
+
svg {{
|
|
312
|
+
position:absolute;
|
|
313
|
+
left:0;
|
|
314
|
+
top:0;
|
|
315
|
+
width:100%;
|
|
316
|
+
height:100%;
|
|
317
|
+
pointer-events:none;
|
|
318
|
+
}}
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
js = f"""
|
|
322
|
+
(function(){{
|
|
323
|
+
const rankdir = "{rankdir}";
|
|
324
|
+
const pad = 8;
|
|
325
|
+
const edges = {edges_js};
|
|
326
|
+
const container = document.querySelector('.workflow');
|
|
327
|
+
const svg = container.querySelector('svg');
|
|
328
|
+
function draw(){{
|
|
329
|
+
const g = svg.querySelector('#edges');
|
|
330
|
+
g.innerHTML = '';
|
|
331
|
+
let maxBottom = 0;
|
|
332
|
+
const rects = {{}};
|
|
333
|
+
Array.from(container.querySelectorAll('.step')).forEach(el => {{
|
|
334
|
+
rects[el.dataset.nid] = {{
|
|
335
|
+
left: el.offsetLeft,
|
|
336
|
+
top: el.offsetTop,
|
|
337
|
+
width: el.offsetWidth,
|
|
338
|
+
height: el.offsetHeight
|
|
339
|
+
}};
|
|
340
|
+
maxBottom = Math.max(maxBottom, el.offsetTop + el.offsetHeight);
|
|
341
|
+
}});
|
|
342
|
+
const Wpx = {W:.1f};
|
|
343
|
+
const Hpx = Math.ceil(maxBottom);
|
|
344
|
+
container.style.height = Hpx + 'px';
|
|
345
|
+
svg.setAttribute('viewBox', `0 0 ${'{'}Wpx{'}'} ${'{'}Hpx{'}'}`);
|
|
346
|
+
svg.setAttribute('height', Hpx);
|
|
347
|
+
function pathTD(u,v){{
|
|
348
|
+
const a = rects[u], b = rects[v];
|
|
349
|
+
if(!a||!b) return '';
|
|
350
|
+
const x1 = a.left + a.width/2;
|
|
351
|
+
const y1 = a.top + a.height + pad;
|
|
352
|
+
const x2 = b.left + b.width/2;
|
|
353
|
+
const y2 = b.top - pad;
|
|
354
|
+
const cy = (y1 + y2)/2;
|
|
355
|
+
return `M${'{'}x1{'}'},${'{'}y1{'}'} C${'{'}x1{'}'},${'{'}cy{'}'} ${'{'}x2{'}'},${'{'}cy{'}'} ${'{'}x2{'}'},${'{'}y2{'}'}`;
|
|
356
|
+
}}
|
|
357
|
+
function pathLR(u,v){{
|
|
358
|
+
const a = rects[u], b = rects[v];
|
|
359
|
+
if(!a||!b) return '';
|
|
360
|
+
const x1 = a.left + a.width + pad;
|
|
361
|
+
const y1 = a.top + a.height/2;
|
|
362
|
+
const x2 = b.left - pad;
|
|
363
|
+
const y2 = b.top + b.height/2;
|
|
364
|
+
const cx = (x1 + x2)/2;
|
|
365
|
+
return `M${'{'}x1{'}'},${'{'}y1{'}'} C${'{'}cx{'}'},${'{'}y1{'}'} ${'{'}cx{'}'},${'{'}y2{'}'} ${'{'}x2{'}'},${'{'}y2{'}'}`;
|
|
366
|
+
}}
|
|
367
|
+
edges.forEach(([u,v]) => {{
|
|
368
|
+
const d = (rankdir === 'TD' || rankdir === 'BT') ? pathTD(u,v) : pathLR(u,v);
|
|
369
|
+
if(!d) return;
|
|
370
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg','path');
|
|
371
|
+
path.setAttribute('d', d);
|
|
372
|
+
path.setAttribute('fill','none');
|
|
373
|
+
path.setAttribute('stroke','#aaa');
|
|
374
|
+
path.setAttribute('stroke-width','1');
|
|
375
|
+
path.setAttribute('marker-end','url(#arrow)');
|
|
376
|
+
g.appendChild(path);
|
|
377
|
+
}});
|
|
378
|
+
}}
|
|
379
|
+
function layout(){{ requestAnimationFrame(draw); }}
|
|
380
|
+
window.addEventListener('load', layout);
|
|
381
|
+
window.addEventListener('resize', layout);
|
|
382
|
+
layout();
|
|
383
|
+
}})();
|
|
384
|
+
"""
|
|
385
|
+
|
|
386
|
+
return (
|
|
387
|
+
f'<!doctype html><meta charset="utf-8" />'
|
|
388
|
+
f'<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" rel="stylesheet">'
|
|
389
|
+
f'<style>{css}</style>'
|
|
390
|
+
f'<div class="jp-RenderedHTMLCommon workflow">'
|
|
391
|
+
f'<svg viewBox="0 0 {W:.1f} {H:.1f}"><defs>'
|
|
392
|
+
f'<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">'
|
|
393
|
+
f'<path d="M0,0 L10,5 L0,10 Z" fill="#aaa"/></marker></defs>'
|
|
394
|
+
f'<g id="edges"></g></svg>{cards}'
|
|
395
|
+
f'<script>{js}</script>'
|
|
396
|
+
f'</div>'
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def flowchart_html(
|
|
401
|
+
nodes: Sequence[Node],
|
|
402
|
+
edges: Sequence[Edge],
|
|
403
|
+
fontsize: int = 14,
|
|
404
|
+
rankdir: str = "TD",
|
|
405
|
+
nodesep: float = 1,
|
|
406
|
+
ranksep: float = 1,
|
|
407
|
+
same_rank_groups: Optional[Sequence[Sequence[str]]] = None,
|
|
408
|
+
) -> str:
|
|
409
|
+
"""Build DOT, compute layout, and return interactive HTML string."""
|
|
410
|
+
# Important: exclude the icon row from Graphviz labels.
|
|
411
|
+
# In HTML mode we render icons via the web font in CSS, so if Graphviz
|
|
412
|
+
# tries to render the icon ligature text (e.g., "radio_button_unchecked")
|
|
413
|
+
# it will greatly expand node width because the font is typically missing.
|
|
414
|
+
# By disabling the icon row for layout we keep widths driven by title/sub only.
|
|
415
|
+
dot = build_dot( # type: ignore[arg-type]
|
|
416
|
+
nodes,
|
|
417
|
+
edges,
|
|
418
|
+
fontsize=fontsize,
|
|
419
|
+
ranksep=ranksep,
|
|
420
|
+
nodesep=nodesep,
|
|
421
|
+
rankdir=rankdir,
|
|
422
|
+
same_rank_groups=same_rank_groups,
|
|
423
|
+
include_icon_row=False,
|
|
424
|
+
)
|
|
425
|
+
W, H, pos = layout(dot)
|
|
426
|
+
return render_html(W, H, pos, nodes, edges, ui_fontsize=fontsize, rankdir=rankdir)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def save_html(
|
|
430
|
+
path: str,
|
|
431
|
+
nodes: Sequence[Node],
|
|
432
|
+
edges: Sequence[Edge],
|
|
433
|
+
**kwargs,
|
|
434
|
+
) -> None:
|
|
435
|
+
"""Write interactive HTML to disk."""
|
|
436
|
+
html_out = flowchart_html(nodes, edges, **kwargs)
|
|
437
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
438
|
+
f.write(html_out)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _dot_png(dot: str) -> bytes:
|
|
442
|
+
"""Render DOT to PNG bytes via Graphviz."""
|
|
443
|
+
return Source(dot).pipe(format="png")
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def show_matplotlib(
|
|
447
|
+
nodes: Sequence[Node],
|
|
448
|
+
edges: Sequence[Edge],
|
|
449
|
+
fontsize: int = 12,
|
|
450
|
+
rankdir: str = "TD",
|
|
451
|
+
nodesep: float = 0.6,
|
|
452
|
+
ranksep: float = 0.6,
|
|
453
|
+
same_rank_groups: Optional[Sequence[Sequence[str]]] = None,
|
|
454
|
+
ax=None,
|
|
455
|
+
*,
|
|
456
|
+
icon_fontname: Optional[str] = "Material Symbols Outlined",
|
|
457
|
+
icon_fontpath: Optional[str] = None,
|
|
458
|
+
icon_color: str = "#4a90e2",
|
|
459
|
+
icon_scale: float = 2.4,
|
|
460
|
+
label_cellpadding: Optional[int] = None,
|
|
461
|
+
node_margin: Optional[float] = None,
|
|
462
|
+
# NEW: edge styling and label control
|
|
463
|
+
edge_color: str = "#999999",
|
|
464
|
+
edge_penwidth: float = 1.6,
|
|
465
|
+
edge_font_scale: float = 0.8,
|
|
466
|
+
show_edge_labels: bool = True,
|
|
467
|
+
):
|
|
468
|
+
"""
|
|
469
|
+
Draw a static PNG from Graphviz into a Matplotlib Axes.
|
|
470
|
+
To use local Material Symbols, install the font and optionally pass icon_fontpath
|
|
471
|
+
pointing to the directory containing the .ttf file.
|
|
472
|
+
"""
|
|
473
|
+
# Prepare kwargs and forward optional spacing only when explicitly provided
|
|
474
|
+
_kwargs = dict(
|
|
475
|
+
fontsize=fontsize,
|
|
476
|
+
ranksep=ranksep,
|
|
477
|
+
nodesep=nodesep,
|
|
478
|
+
rankdir=rankdir,
|
|
479
|
+
include_icon_row=True,
|
|
480
|
+
icon_fontname=icon_fontname,
|
|
481
|
+
icon_fontpath=icon_fontpath,
|
|
482
|
+
splines="curved", # wavy edges
|
|
483
|
+
edge_label_mode="auto", # switch to xlabel for curved splines
|
|
484
|
+
icon_color=icon_color, # blue icons
|
|
485
|
+
icon_scale=icon_scale, # larger icons
|
|
486
|
+
compact_label=True, # tighter vertical spacing using <BR/>
|
|
487
|
+
label_lineheight=0.85,
|
|
488
|
+
label_leading=0.0,
|
|
489
|
+
# edge styling
|
|
490
|
+
edge_color=edge_color,
|
|
491
|
+
edge_penwidth=edge_penwidth,
|
|
492
|
+
edge_font_scale=edge_font_scale,
|
|
493
|
+
show_edge_labels=show_edge_labels,
|
|
494
|
+
)
|
|
495
|
+
if label_cellpadding is not None:
|
|
496
|
+
_kwargs["label_cellpadding"] = label_cellpadding
|
|
497
|
+
if node_margin is not None:
|
|
498
|
+
_kwargs["node_margin"] = node_margin
|
|
499
|
+
|
|
500
|
+
dot = build_dot(
|
|
501
|
+
nodes,
|
|
502
|
+
edges,
|
|
503
|
+
**_kwargs,
|
|
504
|
+
)
|
|
505
|
+
png = _dot_png(dot)
|
|
506
|
+
|
|
507
|
+
# Lazy import to keep module light
|
|
508
|
+
import matplotlib.pyplot as plt # type: ignore
|
|
509
|
+
from PIL import Image # type: ignore
|
|
510
|
+
|
|
511
|
+
im = Image.open(BytesIO(png)).convert("RGBA")
|
|
512
|
+
if ax is None:
|
|
513
|
+
fig, ax = plt.subplots(figsize=(im.width / 96, im.height / 96), dpi=300)
|
|
514
|
+
return_fig = True
|
|
515
|
+
else:
|
|
516
|
+
return_fig = False
|
|
517
|
+
ax.imshow(im)
|
|
518
|
+
ax.axis("off")
|
|
519
|
+
|
|
520
|
+
if return_fig:
|
|
521
|
+
plt.close()
|
|
522
|
+
return fig
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def prune_nodes_to_edges(nodes: Sequence[Node], edges: Sequence[Edge]) -> List[Node]:
|
|
526
|
+
"""Keep only nodes referenced by edges."""
|
|
527
|
+
keep = {u for (u, *_r) in edges} | {v for (_u, v, *_r) in edges}
|
|
528
|
+
return [n for n in nodes if n[0] in keep]
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def show_matplotlib_bioicons(
|
|
532
|
+
nodes: Sequence[Node],
|
|
533
|
+
edges: Sequence[Edge],
|
|
534
|
+
fontsize: int = 12,
|
|
535
|
+
rankdir: str = "TD",
|
|
536
|
+
nodesep: float = 0.6,
|
|
537
|
+
ranksep: float = 0.6,
|
|
538
|
+
same_rank_groups: Optional[Sequence[Sequence[str]]] = None,
|
|
539
|
+
ax=None,
|
|
540
|
+
*,
|
|
541
|
+
icon_size: float = 0.08,
|
|
542
|
+
icon_zoom: float = 1.0,
|
|
543
|
+
local_icons_dir: Optional[str] = None,
|
|
544
|
+
# edge styling
|
|
545
|
+
edge_color: str = "#999999",
|
|
546
|
+
edge_penwidth: float = 1.6,
|
|
547
|
+
edge_font_scale: float = 0.8,
|
|
548
|
+
show_edge_labels: bool = True,
|
|
549
|
+
# node styling
|
|
550
|
+
node_facecolor: str = "#ffffff",
|
|
551
|
+
node_edgecolor: str = "#cccccc",
|
|
552
|
+
node_alpha: float = 0.95,
|
|
553
|
+
node_boxstyle: str = "round,pad=0.4",
|
|
554
|
+
):
|
|
555
|
+
"""
|
|
556
|
+
Draw a flowchart with bioicons in Matplotlib.
|
|
557
|
+
|
|
558
|
+
This function uses Graphviz for layout but renders the result natively
|
|
559
|
+
in Matplotlib, allowing bioicons to be displayed as images.
|
|
560
|
+
|
|
561
|
+
Parameters
|
|
562
|
+
----------
|
|
563
|
+
nodes : Sequence[Node]
|
|
564
|
+
List of nodes as (nid, icon, title, sub) tuples.
|
|
565
|
+
The icon field should be a bioicon ID or path to an image file.
|
|
566
|
+
edges : Sequence[Edge]
|
|
567
|
+
List of edges as (u, v) or (u, v, label) tuples.
|
|
568
|
+
fontsize : int
|
|
569
|
+
Base font size for text.
|
|
570
|
+
rankdir : str
|
|
571
|
+
Graph direction: "TD" (top-down), "LR" (left-right), etc.
|
|
572
|
+
nodesep, ranksep : float
|
|
573
|
+
Node and rank separation in Graphviz units.
|
|
574
|
+
same_rank_groups : Sequence[Sequence[str]], optional
|
|
575
|
+
Groups of node IDs to place at the same rank.
|
|
576
|
+
ax : Axes, optional
|
|
577
|
+
Matplotlib axes. Creates new figure if None.
|
|
578
|
+
icon_size : float
|
|
579
|
+
Size of icons relative to node width.
|
|
580
|
+
icon_zoom : float
|
|
581
|
+
Additional zoom factor for icons.
|
|
582
|
+
local_icons_dir : str, optional
|
|
583
|
+
Directory containing local icon files.
|
|
584
|
+
edge_color : str
|
|
585
|
+
Color for edges.
|
|
586
|
+
edge_penwidth : float
|
|
587
|
+
Width of edge lines.
|
|
588
|
+
edge_font_scale : float
|
|
589
|
+
Scale factor for edge label font.
|
|
590
|
+
show_edge_labels : bool
|
|
591
|
+
Whether to show edge labels.
|
|
592
|
+
node_facecolor : str
|
|
593
|
+
Background color for nodes.
|
|
594
|
+
node_edgecolor : str
|
|
595
|
+
Border color for nodes.
|
|
596
|
+
node_alpha : float
|
|
597
|
+
Transparency of node boxes.
|
|
598
|
+
node_boxstyle : str
|
|
599
|
+
Matplotlib boxstyle string for node boxes.
|
|
600
|
+
|
|
601
|
+
Returns
|
|
602
|
+
-------
|
|
603
|
+
fig : Figure or None
|
|
604
|
+
Returns figure if ax was None, otherwise None.
|
|
605
|
+
"""
|
|
606
|
+
import matplotlib.pyplot as plt
|
|
607
|
+
import matplotlib.patches as mpatches
|
|
608
|
+
from matplotlib.patches import FancyBboxPatch, FancyArrowPatch
|
|
609
|
+
from matplotlib.path import Path as MplPath
|
|
610
|
+
import matplotlib.patheffects as path_effects
|
|
611
|
+
|
|
612
|
+
from ..bioicons import BioIcons
|
|
613
|
+
|
|
614
|
+
# Build DOT for layout only (no icons in labels to keep sizing accurate)
|
|
615
|
+
dot = build_dot(
|
|
616
|
+
nodes,
|
|
617
|
+
edges,
|
|
618
|
+
fontsize=fontsize,
|
|
619
|
+
ranksep=ranksep,
|
|
620
|
+
nodesep=nodesep,
|
|
621
|
+
rankdir=rankdir,
|
|
622
|
+
same_rank_groups=same_rank_groups,
|
|
623
|
+
include_icon_row=False, # Icons rendered separately
|
|
624
|
+
splines="curved",
|
|
625
|
+
show_edge_labels=False, # We'll handle labels ourselves
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
W, H, pos = layout(dot)
|
|
629
|
+
|
|
630
|
+
# Setup figure
|
|
631
|
+
return_fig = False
|
|
632
|
+
if ax is None:
|
|
633
|
+
fig_width = W / 72 # Convert points to inches
|
|
634
|
+
fig_height = H / 72
|
|
635
|
+
fig, ax = plt.subplots(figsize=(fig_width, fig_height), dpi=150)
|
|
636
|
+
return_fig = True
|
|
637
|
+
else:
|
|
638
|
+
fig = ax.get_figure()
|
|
639
|
+
|
|
640
|
+
ax.set_xlim(0, W)
|
|
641
|
+
ax.set_ylim(0, H)
|
|
642
|
+
ax.set_aspect("equal")
|
|
643
|
+
ax.axis("off")
|
|
644
|
+
|
|
645
|
+
# Initialize bioicons
|
|
646
|
+
icons = BioIcons(
|
|
647
|
+
local_icons_dir=local_icons_dir,
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
# Draw nodes
|
|
651
|
+
for nid, icon_id, title, sub in nodes:
|
|
652
|
+
if nid not in pos:
|
|
653
|
+
continue
|
|
654
|
+
d = pos[nid]
|
|
655
|
+
x, y, w, h = d["x"], d["y"], d["w"], d["h"]
|
|
656
|
+
|
|
657
|
+
# Draw node box
|
|
658
|
+
box = FancyBboxPatch(
|
|
659
|
+
(x - w/2, y - h/2),
|
|
660
|
+
w, h,
|
|
661
|
+
boxstyle=node_boxstyle,
|
|
662
|
+
facecolor=node_facecolor,
|
|
663
|
+
edgecolor=node_edgecolor,
|
|
664
|
+
alpha=node_alpha,
|
|
665
|
+
linewidth=1,
|
|
666
|
+
zorder=1,
|
|
667
|
+
)
|
|
668
|
+
ax.add_patch(box)
|
|
669
|
+
|
|
670
|
+
# Calculate positions for icon and text
|
|
671
|
+
icon_h = h * 0.35 # Icon takes top 35% of node
|
|
672
|
+
text_area_top = y + h/2 - icon_h - 2
|
|
673
|
+
|
|
674
|
+
# Draw icon at top of node
|
|
675
|
+
if icon_id:
|
|
676
|
+
icon_y = y + h/2 - icon_h/2 - 2
|
|
677
|
+
icons.plot(
|
|
678
|
+
icon_id,
|
|
679
|
+
ax=ax,
|
|
680
|
+
x=x,
|
|
681
|
+
y=icon_y,
|
|
682
|
+
size=icon_size * icon_zoom,
|
|
683
|
+
zorder=2,
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
# Draw title
|
|
687
|
+
title_y = text_area_top - 4
|
|
688
|
+
ax.text(
|
|
689
|
+
x, title_y,
|
|
690
|
+
title,
|
|
691
|
+
ha="center", va="top",
|
|
692
|
+
fontsize=fontsize,
|
|
693
|
+
fontweight="medium",
|
|
694
|
+
color="#333333",
|
|
695
|
+
zorder=3,
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
# Draw subtitle
|
|
699
|
+
sub_y = title_y - fontsize * 1.2
|
|
700
|
+
ax.text(
|
|
701
|
+
x, sub_y,
|
|
702
|
+
sub,
|
|
703
|
+
ha="center", va="top",
|
|
704
|
+
fontsize=int(fontsize * 0.85),
|
|
705
|
+
color="#666666",
|
|
706
|
+
zorder=3,
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
# Draw edges
|
|
710
|
+
edge_fontsize = int(fontsize * edge_font_scale)
|
|
711
|
+
for edge in edges:
|
|
712
|
+
u, v = edge[0], edge[1]
|
|
713
|
+
label = edge[2] if len(edge) > 2 else None
|
|
714
|
+
|
|
715
|
+
if u not in pos or v not in pos:
|
|
716
|
+
continue
|
|
717
|
+
|
|
718
|
+
src = pos[u]
|
|
719
|
+
dst = pos[v]
|
|
720
|
+
|
|
721
|
+
# Calculate edge start/end points based on rankdir
|
|
722
|
+
if rankdir in ("TD", "BT"):
|
|
723
|
+
x1, y1 = src["x"], src["y"] - src["h"]/2
|
|
724
|
+
x2, y2 = dst["x"], dst["y"] + dst["h"]/2
|
|
725
|
+
else: # LR, RL
|
|
726
|
+
x1, y1 = src["x"] + src["w"]/2, src["y"]
|
|
727
|
+
x2, y2 = dst["x"] - dst["w"]/2, dst["y"]
|
|
728
|
+
|
|
729
|
+
# Draw curved arrow
|
|
730
|
+
arrow = FancyArrowPatch(
|
|
731
|
+
(x1, y1), (x2, y2),
|
|
732
|
+
connectionstyle="arc3,rad=0.1",
|
|
733
|
+
arrowstyle="->,head_width=4,head_length=6",
|
|
734
|
+
color=edge_color,
|
|
735
|
+
linewidth=edge_penwidth,
|
|
736
|
+
zorder=0,
|
|
737
|
+
)
|
|
738
|
+
ax.add_patch(arrow)
|
|
739
|
+
|
|
740
|
+
# Draw edge label if enabled
|
|
741
|
+
if show_edge_labels and label:
|
|
742
|
+
mid_x = (x1 + x2) / 2
|
|
743
|
+
mid_y = (y1 + y2) / 2
|
|
744
|
+
ax.text(
|
|
745
|
+
mid_x, mid_y,
|
|
746
|
+
str(label),
|
|
747
|
+
ha="center", va="center",
|
|
748
|
+
fontsize=edge_fontsize,
|
|
749
|
+
color="#666666",
|
|
750
|
+
bbox=dict(
|
|
751
|
+
facecolor="white",
|
|
752
|
+
edgecolor="none",
|
|
753
|
+
alpha=0.8,
|
|
754
|
+
pad=2,
|
|
755
|
+
),
|
|
756
|
+
zorder=2,
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
if return_fig:
|
|
760
|
+
plt.tight_layout()
|
|
761
|
+
return fig
|
|
762
|
+
return None
|