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
@@ -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