"""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 svg_close() -> str: return "" 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