"""Figure Engine (BioNexus 2.0, Component 8).
Turns engine results into *publication figures* rather than charts: every
rendered figure carries panel labels (A, B, ...), a title, an auto-generated
caption, a legend, statistical notes and a citation/footer block — Nature/IEEE
readable SVG with zero binary dependencies.
Renderers here are pure string SVG. Higher-level code (app/services/
experiment_figures.py) composes per-experiment multi-panel figures from a
stored result context using the panel primitives below.
"""
from __future__ import annotations
import html
from typing import Any
#: Six-color palette tuned for color-blind-safe scientific figures.
PALETTE = ["#2563eb", "#dc2626", "#059669", "#d97706", "#7c3aed", "#0e7490"]
def esc(text: Any) -> str:
return html.escape(str(text if text is not None else ""), quote=True)
def svg_canvas(width: int, height: int) -> str:
return (
f'"
def title_block(text: str, x: int = 20, y: int = 34, size: int = 20) -> str:
return f'{esc(text)}'
def footer_block(generated: str, citations: list[str], x: int = 20, y: int = 780) -> str:
"""Attribution + citation block pinned near the bottom of the canvas."""
parts = [f'Generated by BioNexus Figure Engine · {esc(generated)}']
y += 18
for i, cite in enumerate(citations[:4]):
parts.append(f'{esc(cite[:140])}')
y += 13
return "".join(parts)
def panel_header(letter: str, title: str, x: int, y: int) -> str:
badge = (
f''
f'{esc(letter)}'
)
ttl = f'{esc(title)}'
return badge + ttl
def panel_caption(text: str, x: int, y: int, width: int = 380) -> str:
"""A wrapped caption line under a panel (statistical note)."""
words = str(text).split()
lines: list[str] = []
cur = ""
for w in words:
if len(cur) + len(w) + 1 <= width // 6:
cur = f"{cur} {w}".strip()
else:
lines.append(cur)
cur = w
if cur:
lines.append(cur)
out = []
y0 = y
for line in lines[:3]:
out.append(f'{esc(line)}')
y0 += 12
return "".join(out)
def bar_chart_panel(
rows: list[tuple[str, float]],
*,
x: int, y: int, w: int, h: int,
value_label: str = "value",
max_value: float | None = None,
) -> str:
"""Horizontal bar chart inside the (w x h) viewport anchored at (x, y)."""
if not rows:
return f'No data'
max_val = max_value or max(r[1] for r in rows) or 1.0
bar_h = 22
bar_gap = 30
top = y
out = []
for i, (label, value) in enumerate(rows[:9]):
by = top + i * bar_gap
bw = max(2, int((value / max_val) * (w - 110)))
color = PALETTE[i % len(PALETTE)]
out.append(
f''
)
out.append(
f'{esc(label[:26])}'
)
out.append(
f'{int(value)} {esc(value_label)}'
)
out.append(f'')
return "".join(out)
def domain_blocks_panel(
domains: list[dict],
*,
seq_len: int,
x: int, y: int, w: int, h: int,
) -> str:
"""Domain architecture: colored blocks along a scaled sequence axis."""
if not domains or not seq_len:
return f'No domain data'
baseline = y + h // 2
bar = (
f''
)
out = [bar]
block_h = 26
for i, d in enumerate(domains[:16]):
start = max(1, int(d.get("start") or 1))
end = min(seq_len, int(d.get("end") or seq_len))
px = x + int((start - 1) / max(1, seq_len) * w)
pw = max(4, int((end - start + 1) / max(1, seq_len) * w))
color = PALETTE[i % len(PALETTE)]
out.append(
f''
)
label = esc(d.get("name") or d.get("accession") or "?")
out.append(f'{label[:10]}')
out.append(f'1')
out.append(f'{seq_len} aa')
return "".join(out)
def text_lines_panel(
lines: list[str],
*,
x: int, y: int, w: int, h: int,
size: int = 11,
max_lines: int = 10,
) -> str:
"""Wrapped text panel (interpretation previews, phylogeny, notes)."""
if not lines:
return f'No text'
out = []
row = y + 14
for line in lines[:max_lines]:
out.append(f'{esc(line)}')
row += size + 5
return "".join(out)
def wrap_text(text: str, width_chars: int = 58) -> list[str]:
words = str(text).split()
lines: list[str] = []
cur = ""
for word in words:
if len(cur) + len(word) + 1 <= width_chars:
cur = f"{cur} {word}".strip()
else:
lines.append(cur)
cur = word
if cur:
lines.append(cur)
return lines
# --- panel position grid ---------------------------------------------------
#: Fixed 3-col grid on a 1200x800 canvas. Each panel 380x200.
CELL_W, CELL_H = 380, 200
GRID = [(40 + i * 400, 60 + j * 230) for j in range(3) for i in range(3)]
def panel_xy(index: int) -> tuple[int, int]:
x, y = GRID[index % len(GRID)]
return x, y