SciPaths-Demo / hf_space /workflow_graph.py
Beanbagdzf's picture
Upload folder using huggingface_hub
755a449 verified
Raw
History Blame Contribute Delete
52.5 kB
"""Evolving SciPaths workflow graph (Neo4j-backed).
Visual formatting follows Mina Brain's node chrome (filled circle + white
inner stroke + colored ring, 25-char labels, write-pulse rings, #fafbfd
stage, click detail card) but lays nodes out as a strict top-down n-ary
tree: Target → Citing → Theme → Claim → Ingredient → Prior study.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any, Optional
import streamlit as st
try:
import neo4j_workflow as neo4j_store
except Exception: # pragma: no cover
neo4j_store = None # type: ignore
# Short labels shared by Steps panel + workflow graph captions.
STEP_COPY: dict[int, str] = {
1: "Load the paper",
2: "Find where it’s cited",
3: "Collect citation contexts",
4: "Classify how it’s used",
5: "Keep real reuse (uses / extends)",
6: "Pull the citing passages",
7: "Group similar reuse themes and summarize target contributions",
8: "Find enabling contributions and corresponding prior studies",
}
WORK_PANEL_STEPS: list[tuple[int, str]] = [(n, STEP_COPY[n]) for n in range(1, 9)]
CANVAS_LABEL_MAX = 25
# Mina Brain NODE_COLORS mapped onto SciPaths kinds (fill / ring / text).
KIND_META = {
"target": {
"fill": "#4F6EF7",
"ring": "#A5B4FC",
"text": "#1E3A8A",
"r": 22,
"type_label": "Target paper",
"color": "#4F6EF7",
"size": 22,
},
"citing": {
"fill": "#60A5FA",
"ring": "#BFDBFE",
"text": "#1D4ED8",
"r": 14,
"type_label": "Citing paper",
"color": "#60A5FA",
"size": 14,
},
"cluster": {
"fill": "#8B5CF6",
"ring": "#DDD6FE",
"text": "#5B21B6",
"r": 16,
"type_label": "Reuse theme",
"color": "#8B5CF6",
"size": 16,
},
"claim": {
"fill": "#FBBF24",
"ring": "#FDE68A",
"text": "#B45309",
"r": 15,
"type_label": "Target contribution",
"color": "#FBBF24",
"size": 15,
},
"ingredient": {
"fill": "#94A3B8",
"ring": "#E2E8F0",
"text": "#475569",
"r": 12,
"type_label": "Enabling contribution",
"color": "#94A3B8",
"size": 12,
},
"study": {
"fill": "#34D399",
"ring": "#A7F3D0",
"text": "#047857",
"r": 13,
"type_label": "Prior study",
"color": "#34D399",
"size": 13,
},
}
# Category order (top-down tree levels) + palette the canvas shell / rail consume.
_KIND_ORDER = ["target", "citing", "cluster", "claim", "ingredient", "study"]
_KIND_META_JS = {
k: {"fill": v["fill"], "ring": v["ring"], "text": v["text"], "label": v["type_label"]}
for k, v in KIND_META.items()
}
def _load_json(path: Path) -> Any | None:
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
def _clean(text: str) -> str:
return re.sub(r"\s+", " ", (text or "").strip())
def canvas_label(text: str, *, limit: int = CANVAS_LABEL_MAX) -> str:
cleaned = _clean(text)
if len(cleaned) <= limit:
return cleaned
return cleaned[:limit].rstrip() + "…"
def visible_step_from_events(events: list[str]) -> int:
started = 0
for raw in events or []:
text = str(raw)
m = re.search(r"Step\s+(\d+)\s*(?:/|\s+complete)", text, re.IGNORECASE)
if m:
started = max(started, int(m.group(1)))
continue
lower = text.lower()
if "[annotation]" in lower or "annotate" in lower:
started = max(started, 8)
return min(8, started)
def pulse_step_from_events(events: list[str]) -> int:
started: set[int] = set()
completed: set[int] = set()
for raw in events or []:
text = str(raw)
m_done = re.search(r"Step\s+(\d+)\s+complete", text, re.IGNORECASE)
if m_done:
completed.add(int(m_done.group(1)))
continue
m_start = re.search(r"Step\s+(\d+)\s*/", text)
if m_start:
started.add(int(m_start.group(1)))
continue
lower = text.lower()
if "[annotation]" in lower or "annotate" in lower:
if "complete" in lower or "skipped" in lower:
completed.add(8)
else:
started.add(8)
active = [n for n in sorted(started) if n not in completed]
return active[-1] if active else 0
def _paper_title_from_dir(paper_dir: Optional[Path]) -> str:
if not paper_dir:
return "Target paper"
data = _load_json(paper_dir / "paper_metadata.json")
record = None
if isinstance(data, list) and data and isinstance(data[0], dict):
record = data[0]
elif isinstance(data, dict):
record = data
return _clean(str((record or {}).get("title") or "")) or "Target paper"
def _paper_abstract_from_dir(paper_dir: Optional[Path]) -> str:
if not paper_dir:
return ""
data = _load_json(paper_dir / "paper_metadata.json")
record = None
if isinstance(data, list) and data and isinstance(data[0], dict):
record = data[0]
elif isinstance(data, dict):
record = data
abstract = _clean(str((record or {}).get("abstract") or ""))
abstract = re.sub(r"^(abstract)\s*[:.]?\s*", "", abstract, flags=re.IGNORECASE)
abstract = re.sub(r"(?<=[.!?\)\]\"'”’])\d+$", "", abstract)
return abstract
def _node(
*,
nid: str,
kind: str,
short: str,
title: str,
detail: str,
step_added: int,
pulse: bool,
) -> dict[str, Any]:
meta = KIND_META.get(kind, KIND_META["citing"])
return {
"id": nid,
"kind": kind,
"label": canvas_label(short),
"title": _clean(title) or short,
"detail": _clean(detail),
"step_added": step_added,
"pulse": pulse,
"color": meta["fill"],
"fill": meta["fill"],
"ring": meta["ring"],
"text": meta["text"],
"r": meta["r"],
"size": meta["r"],
"type_label": meta["type_label"],
}
def _collect_citing(paper_dir: Optional[Path], limit: int = 6) -> list[dict[str, str]]:
if not paper_dir:
return []
out: list[dict[str, str]] = []
seen: set[str] = set()
contrib = _load_json(paper_dir / "usage_contributions.json") or {}
items = contrib.get("contributions") if isinstance(contrib, dict) else None
if isinstance(items, list):
for item in items:
if not isinstance(item, dict):
continue
cid = str(item.get("citing_paper_id") or "").strip()
title = _clean(str(item.get("citing_title") or "Citing paper"))
if not cid or cid in seen:
continue
seen.add(cid)
out.append(
{
"id": f"citing:{cid}",
"title": title,
"detail": _clean(
str(item.get("paper_claim") or item.get("claim") or item.get("evidence_span") or "")
),
"label_tag": item.get("label") or "",
}
)
if len(out) >= limit:
return out
return out
def _collect_clusters(paper_dir: Optional[Path], limit: int = 4) -> list[dict[str, str]]:
if not paper_dir:
return []
discovery = _load_json(paper_dir / "usage_discovery_from_contributions.json") or {}
clusters = discovery.get("clusters") if isinstance(discovery, dict) else None
out: list[dict[str, str]] = []
if not isinstance(clusters, list):
return out
for item in clusters[:limit]:
if not isinstance(item, dict):
continue
cid = str(item.get("cluster_id") or f"C{len(out) + 1}")
title = _clean(
str(item.get("representative_claim") or item.get("cluster_title") or f"Theme {cid}")
)
out.append(
{
"id": f"cluster:{cid}",
"cluster_id": cid,
"title": title,
"detail": _clean(str(item.get("merge_rationale") or f"{item.get('count', '')} contribution instances")),
}
)
return out
def _collect_annotation(payload: Optional[dict]) -> tuple[list[dict], list[dict], list[dict]]:
claims: list[dict] = []
ingredients: list[dict] = []
studies: list[dict] = []
if not isinstance(payload, dict):
return claims, ingredients, studies
for claim in (payload.get("claims") or [])[:4]:
if not isinstance(claim, dict):
continue
claim_id = str(claim.get("claim_id") or f"C{len(claims) + 1}")
title = _clean(str(claim.get("rewritten_claim") or claim.get("text") or claim_id))
claims.append(
{
"id": f"claim:{claim_id}",
"claim_id": claim_id,
"cluster_id": str(claim.get("cluster_id") or ""),
"title": title,
"detail": _clean(str(claim.get("decision") or "")),
}
)
for ing in (claim.get("ingredients") or [])[:3]:
if not isinstance(ing, dict):
continue
iid = str(ing.get("ingredient_id") or f"{claim_id}.I{len(ingredients) + 1}")
ann = ing.get("canonical_annotation") if isinstance(ing.get("canonical_annotation"), dict) else {}
ingredients.append(
{
"id": f"ing:{iid}",
"ingredient_id": iid,
"claim_id": claim_id,
"title": _clean(str(ing.get("ingredient") or iid)),
"detail": _clean(
str((ann or {}).get("contribution") or (ann or {}).get("rationale") or "")
),
"role": _clean(str((ann or {}).get("role") or "")),
}
)
g = ing.get("canonical_grounding") if isinstance(ing.get("canonical_grounding"), dict) else None
if g:
sid = str(g.get("paper_id") or g.get("ref_id") or g.get("ref_title") or iid)
studies.append(
{
"id": f"study:{sid}",
"ingredient_id": iid,
"title": _clean(str(g.get("ref_title") or g.get("ref_id") or "Prior study")),
"detail": _clean(str(g.get("ref_authors") or g.get("ref_year") or "")),
}
)
seen: set[str] = set()
uniq: list[dict] = []
for s in studies:
if s["id"] in seen:
continue
seen.add(s["id"])
uniq.append(s)
return claims, ingredients, uniq[:6]
def build_graph_model(
*,
paper_dir: Optional[Path],
payload: Optional[dict],
visible_step: int,
pulse_step: int = 0,
caption: str = "",
) -> dict[str, Any]:
step = max(0, min(8, int(visible_step or 0)))
nodes: list[dict[str, Any]] = []
edges: list[dict[str, Any]] = []
if step >= 1:
title = _paper_title_from_dir(paper_dir)
abstract = _paper_abstract_from_dir(paper_dir)
nodes.append(
_node(
nid="target",
kind="target",
short="Target",
title=title,
detail=abstract[:500],
step_added=1,
pulse=pulse_step == 1,
)
)
citing = _collect_citing(paper_dir) if step >= 2 else []
if step >= 2:
rows = citing or [
{"id": f"citing:placeholder:{i}", "title": f"Citing paper {i+1}", "detail": "Waiting for citation artifacts…", "label_tag": ""}
for i in range(3)
]
for i, item in enumerate(rows):
nodes.append(
_node(
nid=item["id"],
kind="citing",
short=f"Citing {i + 1}",
title=item["title"],
detail=item.get("detail") or item.get("label_tag") or "",
step_added=2,
pulse=pulse_step in {2, 3, 4, 5, 6},
)
)
edges.append(
{
"id": f"e-target-{item['id']}",
"source": "target",
"target": item["id"],
"kind": "cite",
"muted": step < 5,
"pulse": pulse_step in {2, 3, 4, 5},
"step_added": 2,
}
)
# Step 7: reuse themes + target contributions arrive together.
clusters = _collect_clusters(paper_dir) if step >= 7 else []
claims, ingredients, studies = (
_collect_annotation(payload) if step >= 7 else ([], [], [])
)
if step >= 7 and clusters:
for item in clusters:
cid = item.get("cluster_id") or "?"
nodes.append(
_node(
nid=item["id"],
kind="cluster",
short=f"Theme {cid}",
title=item["title"],
detail=item.get("detail") or "",
step_added=7,
pulse=pulse_step == 7,
)
)
edges.append(
{
"id": f"e-cluster-{item['id']}",
"source": "target",
"target": item["id"],
"kind": "theme",
"pulse": pulse_step == 7,
"muted": False,
"step_added": 7,
}
)
if step >= 7:
for item in claims:
cid = item.get("claim_id") or "?"
nodes.append(
_node(
nid=item["id"],
kind="claim",
short=f"Claim {cid}",
title=item["title"],
detail=item.get("detail") or "",
step_added=7,
pulse=pulse_step == 7,
)
)
src = f"cluster:{item.get('cluster_id')}" if item.get("cluster_id") else "target"
if not any(n["id"] == src for n in nodes):
src = "target"
edges.append(
{
"id": f"e-claim-{item['id']}",
"source": src,
"target": item["id"],
"kind": "derive",
"pulse": pulse_step == 7,
"muted": False,
"step_added": 7,
}
)
# Step 8: enabling contributions + prior studies.
if step >= 8:
for i, item in enumerate(ingredients):
nodes.append(
_node(
nid=item["id"],
kind="ingredient",
short=f"Enable {i + 1}",
title=item["title"],
detail=" · ".join(x for x in [item.get("role") or "", item.get("detail") or ""] if x),
step_added=8,
pulse=pulse_step == 8,
)
)
src = f"claim:{item.get('claim_id')}"
if any(n["id"] == src for n in nodes):
edges.append(
{
"id": f"e-ing-{item['id']}",
"source": src,
"target": item["id"],
"kind": "enable",
"pulse": pulse_step == 8,
"muted": False,
"step_added": 8,
}
)
for i, item in enumerate(studies):
nodes.append(
_node(
nid=item["id"],
kind="study",
short=f"Prior {i + 1}",
title=item["title"],
detail=item.get("detail") or "",
step_added=8,
pulse=pulse_step == 8,
)
)
src = f"ing:{item.get('ingredient_id')}"
if any(n["id"] == src for n in nodes):
edges.append(
{
"id": f"e-study-{item['id']}",
"source": src,
"target": item["id"],
"kind": "ground",
"pulse": pulse_step == 8,
"muted": False,
"step_added": 8,
}
)
if step <= 0:
phase_caption = "Waiting to run"
elif pulse_step:
phase_caption = STEP_COPY.get(pulse_step, "")
elif step >= 8:
phase_caption = "Workflow complete"
else:
phase_caption = caption or STEP_COPY.get(step, "")
return {
"step": step,
"pulse_step": pulse_step,
"caption": phase_caption,
"nodes": nodes,
"edges": edges,
"backend": "memory",
}
def _nodes_edges_payload(
nodes_in: list[dict[str, Any]], edges_in: list[dict[str, Any]]
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Serialize nodes/edges for the canvas shell."""
nodes_js: list[dict[str, Any]] = []
for n in nodes_in:
kind = str(n.get("kind") or "citing")
meta = KIND_META.get(kind, KIND_META["citing"])
nodes_js.append(
{
"id": n["id"],
"label": n.get("label") or "",
"kind": kind,
"fill": n.get("fill") or meta["fill"],
"ring": n.get("ring") or meta["ring"],
"text": n.get("text") or meta["text"],
"r": float(n.get("r") or meta["r"]),
"pulse": bool(n.get("pulse")),
"fullTitle": n.get("title") or "",
"detail": n.get("detail") or "",
"typeLabel": n.get("type_label") or meta["type_label"],
}
)
edges_js: list[dict[str, Any]] = []
for e in edges_in:
edges_js.append(
{
"id": e.get("id"),
"source": e.get("source"),
"target": e.get("target"),
"pulse": bool(e.get("pulse")),
"muted": bool(e.get("muted")),
}
)
return nodes_js, edges_js
def _graph_payload(model: dict[str, Any]) -> dict[str, Any]:
"""Serialize a graph model into the payload the canvas shell consumes."""
nodes_js, edges_js = _nodes_edges_payload(
list(model.get("nodes") or []), list(model.get("edges") or [])
)
out: dict[str, Any] = {
"nodes": nodes_js,
"edges": edges_js,
"kindMeta": _KIND_META_JS,
"kindOrder": _KIND_ORDER,
"frameKey": str(model.get("frame_key") or ""),
}
frame = model.get("frame")
if isinstance(frame, dict) and frame.get("nodes"):
f_nodes, f_edges = _nodes_edges_payload(
list(frame.get("nodes") or []), list(frame.get("edges") or [])
)
out["frame"] = {"nodes": f_nodes, "edges": f_edges}
return out
def courier_html(payload: dict[str, Any]) -> str:
"""Tiny invisible frame that posts a graph model into the persistent shell.
Runs in its own Streamlit component iframe, reaches the parent document, and
postMessages the model to the graph iframe — so the graph updates in place
instead of the whole component being re-emitted (which reloads it).
"""
model_str = json.dumps(payload)
embed = json.dumps(model_str).replace("</", "<\\/")
return (
"<!DOCTYPE html><html><head><meta charset=\"utf-8\" /></head><body>"
"<script>"
"(function(){"
"var model=JSON.parse(" + embed + ");"
"function post(){try{var f=window.parent.document.querySelectorAll('iframe');"
"for(var i=0;i<f.length;i++){try{f[i].contentWindow.postMessage({type:'scipaths-graph',model:model},'*');}catch(e){}}}catch(e){}}"
"post();var n=0;var t=setInterval(function(){n++;post();if(n>10)clearInterval(t);},160);"
"})();"
"</script></body></html>"
)
# Bump when canvas JS changes so Streamlit remounts the component iframe.
_GRAPH_SHELL_VERSION = "tree-v4"
def graph_shell_html(height: int = 520) -> str:
"""Model-free canvas shell. Data arrives via postMessage (incremental).
HTML includes ``_GRAPH_SHELL_VERSION`` so layout/code updates remount the
iframe. The graph is populated / grown by ``courier_html`` messages.
"""
payload = json.dumps(
{"nodes": [], "edges": [], "kindMeta": _KIND_META_JS, "kindOrder": _KIND_ORDER}
)
row_h = max(300, height - 30)
ver = _GRAPH_SHELL_VERSION
return f"""<!DOCTYPE html>
<html data-scipaths-shell="{ver}">
<head>
<meta charset="utf-8" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
html, body {{ margin:0; padding:0; background:transparent; font-family: Inter, system-ui, sans-serif; color:#13201d; }}
* {{ box-sizing:border-box; }}
.wrap {{ border-top:1px solid rgba(19,32,29,0.12); padding-top:0.5rem; }}
.kicker {{ font-size:0.68rem; font-weight:700; letter-spacing:0.12em; text-transform:uppercase; color:#6a7a74; margin-bottom:0.4rem; }}
.row {{ display:flex; gap:0.6rem; align-items:stretch; height:{row_h}px; }}
/* ── Control rail ── */
.rail {{
width:210px; flex:0 0 210px; display:flex; flex-direction:column; overflow:hidden;
border:1px solid rgba(15,23,42,0.10); border-radius:11px; background:#ffffff;
}}
.rail-head {{ padding:0.55rem 0.75rem; border-bottom:1px solid rgba(15,23,42,0.08);
font-size:0.66rem; font-weight:700; letter-spacing:0.09em; text-transform:uppercase; color:#6a7a74; }}
.rail-body {{ flex:1; overflow-y:auto; padding:0.6rem 0.65rem; }}
.rail-search {{ position:relative; margin-bottom:0.7rem; }}
.rail-search input {{
width:100%; height:30px; padding:0 1.6rem 0 0.6rem; font-size:0.76rem; font-family:inherit;
border:1px solid rgba(15,23,42,0.16); border-radius:7px; outline:none; color:#13201d;
}}
.rail-search input:focus {{ border-color:#4F6EF7; box-shadow:0 0 0 3px rgba(79,110,247,0.16); }}
.rail-search .clr {{ position:absolute; right:6px; top:50%; transform:translateY(-50%);
border:0; background:transparent; color:#94a3b8; cursor:pointer; font-size:0.9rem; line-height:1; }}
.rail-actions {{ display:grid; grid-template-columns:1fr 1fr; gap:0.35rem; margin-bottom:0.75rem; }}
.rail-actions button {{
font-size:0.66rem; font-weight:600; font-family:inherit; padding:0.34rem 0.3rem; cursor:pointer;
border:1px solid rgba(15,23,42,0.16); border-radius:7px; background:#fff; color:#13201d;
}}
.rail-actions button:hover {{ background:#f4f6fb; }}
.rail-actions button.on {{ border-color:#4F6EF7; background:#eef1fe; color:#1E3A8A; }}
.sec-head {{ display:flex; align-items:center; justify-content:space-between; margin:0 0 0.35rem 0.15rem; }}
.sec-head .lbl {{ font-size:0.63rem; font-weight:700; letter-spacing:0.07em; text-transform:uppercase; color:#94a3b8; }}
.sec-head .acts {{ display:flex; gap:0.45rem; }}
.sec-head .acts button {{ border:0; background:transparent; font-size:0.62rem; font-weight:600; color:#4F6EF7; cursor:pointer; padding:0; }}
.cat {{
width:100%; display:flex; align-items:center; gap:0.5rem; padding:0.28rem 0.35rem; cursor:pointer;
border:0; background:transparent; border-radius:6px; text-align:left; font-family:inherit;
}}
.cat:hover {{ background:#f4f6fb; }}
.cat .box {{ width:13px; height:13px; flex:0 0 13px; border-radius:3px; border:1.5px solid rgba(15,23,42,0.28);
display:flex; align-items:center; justify-content:center; color:#fff; font-size:9px; }}
.cat .box.on {{ background:#4F6EF7; border-color:#4F6EF7; }}
.cat .dot {{ width:12px; height:12px; flex:0 0 12px; border-radius:50%; }}
.cat .name {{ flex:1; font-size:0.74rem; color:#334155; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }}
.cat .cnt {{ font-size:0.68rem; color:#94a3b8; font-variant-numeric:tabular-nums; }}
.cat.off .name {{ color:#b6c0cc; text-decoration:line-through; }}
.cat.off .dot {{ opacity:0.35; }}
/* ── Stage ── */
.stage {{
position:relative; flex:1; min-width:0; border-radius:11px; overflow:hidden;
background:#fafbfd; border:1px solid rgba(15,23,42,0.08);
}}
canvas {{ display:block; width:100%; height:100%; cursor:grab; }}
canvas.dragging {{ cursor:grabbing; }}
.card {{
position:absolute; left:12px; top:12px; width:250px; max-height:calc(100% - 24px);
overflow:auto; background:rgba(255,255,255,0.97); border:1px solid rgba(15,23,42,0.10);
border-radius:10px; padding:0.7rem 0.75rem; box-shadow:0 10px 30px rgba(15,23,42,0.10);
display:none; z-index:2;
}}
.card.open {{ display:block; }}
.card-type {{ font-size:0.66rem; font-weight:700; letter-spacing:0.08em; text-transform:uppercase; color:#4F6EF7; margin-bottom:0.25rem; }}
.card-title {{ font-size:0.9rem; font-weight:700; line-height:1.3; margin:0 0 0.4rem 0; color:#13201d; }}
.card-detail {{ font-size:0.78rem; line-height:1.45; color:#3d4f4a; margin:0; white-space:pre-wrap; }}
.card-close {{ position:absolute; right:8px; top:6px; border:0; background:transparent; color:#6a7a74; font-size:1rem; cursor:pointer; }}
.hint {{ position:absolute; left:10px; bottom:8px; font-size:0.66rem; color:#94a3b8; pointer-events:none;
background:rgba(255,255,255,0.9); padding:0.2rem 0.45rem; border-radius:6px; }}
</style>
</head>
<body>
<div class="wrap">
<div class="kicker">Workflow tree</div>
<div class="row">
<aside class="rail">
<div class="rail-head">Graph controls</div>
<div class="rail-body">
<div class="rail-search">
<input id="q" type="text" placeholder="Highlight nodes" autocomplete="off" />
<button class="clr" id="qClr" title="Clear" style="display:none">×</button>
</div>
<div class="rail-actions">
<button id="fitBtn">Fit view</button>
<button id="resetBtn">Reset</button>
</div>
<div class="sec-head">
<span class="lbl">Node categories</span>
<span class="acts">
<button id="allBtn">All</button>
<button id="noneBtn">None</button>
</span>
</div>
<div id="cats"></div>
</div>
</aside>
<div class="stage" id="stage">
<canvas id="cv"></canvas>
<div id="card" class="card">
<button class="card-close" id="closeBtn" aria-label="Close">×</button>
<div class="card-type" id="cardType"></div>
<div class="card-title" id="cardTitle"></div>
<p class="card-detail" id="cardDetail"></p>
</div>
<div class="hint">Top-down tree · scroll to zoom · drag canvas to pan · click a node</div>
</div>
</div>
</div>
<script>
const payload = {payload};
const KM = payload.kindMeta || {{}};
const stage = document.getElementById('stage');
const canvas = document.getElementById('cv');
const ctx = canvas.getContext('2d');
const card = document.getElementById('card');
const cardType = document.getElementById('cardType');
const cardTitle = document.getElementById('cardTitle');
const cardDetail = document.getElementById('cardDetail');
document.getElementById('closeBtn').onclick = () => {{ selected = null; card.classList.remove('open'); }};
let W = 0, H = 0, dpr = 1;
function resize() {{
dpr = window.devicePixelRatio || 1;
W = stage.clientWidth; H = stage.clientHeight;
canvas.width = Math.floor(W * dpr);
canvas.height = Math.floor(H * dpr);
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
}}
resize();
window.addEventListener('resize', () => {{ resize(); }});
// Semantic depth bands (visual tree levels — not edge hop count).
const KIND_LEVEL = {{ target: 0, citing: 1, cluster: 2, claim: 3, ingredient: 4, study: 5 }};
const KIND_SIBLING_ORDER = {{ citing: 0, cluster: 1, claim: 2, ingredient: 3, study: 4, target: -1 }};
const ROW_GAP = 108;
const TOP_Y = 52;
const MIN_GAP = 78;
const levelOf = (n) => (KIND_LEVEL[n.kind] != null ? KIND_LEVEL[n.kind] : 1);
// Position memory for spawn animation only (tree slots are recomputed).
const memKey = 'scipaths-tree-pos-v1';
let saved = {{}};
try {{ saved = JSON.parse(sessionStorage.getItem(memKey) || '{{}}'); }} catch (e) {{ saved = {{}}; }}
// Mutable graph state — grown in place by incremental postMessage updates.
let nodes = [];
const byId = new Map();
let edges = [];
let target = null;
let fitPending = false;
// Camera locked to the final tree frame so zoom stays fixed while the graph grows.
let cameraLocked = false;
let lockedFrameKey = '';
// ── Filter / search state ──
const hiddenKinds = new Set();
let query = '';
const isVisible = (n) => !hiddenKinds.has(n.kind);
// ── Write pulse — Mina: staggered one-shot expanding rings + travelling dashes.
const PULSE_RING_MS = 1200;
const PULSE_RING_OFFSETS = [0, 0.35];
const PULSE_LIFETIME_MS = PULSE_RING_MS * (1 + Math.max(...PULSE_RING_OFFSETS));
const PULSE_DASH = [7, 5];
let pulseNodeIds = new Set();
let pulseEdgeIds = new Set();
let pulseStart = 0;
// ── View transform (zoom + pan) ──
let zoom = 0.95;
let panX = (W / 2) * (1 - zoom);
let panY = 18;
const toWorld = (px, py) => ({{ x: (px - panX) / zoom, y: (py - panY) / zoom }});
let dragNode = null, panning = false, moved = false;
let last = {{ x: 0, y: 0 }};
let selected = null;
function persist() {{
const out = {{}};
nodes.forEach(n => {{ out[n.id] = {{ x: n.x, y: n.y }}; }});
try {{ sessionStorage.setItem(memKey, JSON.stringify(out)); }} catch (e) {{}}
}}
function parentNode(n, edgeList) {{
const list = edgeList || edges;
let best = null, bestLvl = -Infinity;
for (const e of list) {{
if (e.target !== n.id) continue;
const p = byId.get(e.source);
if (!p) continue;
const pl = levelOf(p);
if (pl < levelOf(n) && pl >= bestLvl) {{ best = p; bestLvl = pl; }}
}}
if (!best && n.id !== 'target') best = byId.get('target') || null;
return best;
}}
function layoutTree() {{
if (!nodes.length) return;
// Always assign depth; pack only visible nodes so filters reflow cleanly.
nodes.forEach(n => {{ n.ty = TOP_Y + levelOf(n) * ROW_GAP; }});
const vis = nodes.filter(isVisible);
if (!vis.length) return;
const children = new Map();
const visibleParent = (n) => {{
let p = parentNode(n);
while (p && !isVisible(p)) p = parentNode(p);
return p;
}};
vis.forEach(n => {{
const p = visibleParent(n);
n._parentId = p ? p.id : null;
if (!p) return;
if (!children.has(p.id)) children.set(p.id, []);
children.get(p.id).push(n);
}});
for (const kids of children.values()) {{
kids.sort((a, b) => {{
const oa = KIND_SIBLING_ORDER[a.kind] != null ? KIND_SIBLING_ORDER[a.kind] : 9;
const ob = KIND_SIBLING_ORDER[b.kind] != null ? KIND_SIBLING_ORDER[b.kind] : 9;
if (oa !== ob) return oa - ob;
return String(a.id).localeCompare(String(b.id));
}});
}}
const leafGap = (n) => Math.max(MIN_GAP, (n.r || 14) * 2 + 36);
const subtreeWidth = (n) => {{
const kids = children.get(n.id) || [];
if (!kids.length) return leafGap(n);
let w = 0;
kids.forEach(k => {{ w += subtreeWidth(k); }});
return Math.max(leafGap(n), w);
}};
const place = (n, centerX) => {{
n.tx = centerX;
n.ty = TOP_Y + levelOf(n) * ROW_GAP;
const kids = children.get(n.id) || [];
if (!kids.length) return;
const widths = kids.map(subtreeWidth);
const total = widths.reduce((s, w) => s + w, 0);
let x = centerX - total / 2;
kids.forEach((k, i) => {{
const w = widths[i];
place(k, x + w / 2);
x += w;
}});
}};
const roots = vis.filter(n => !n._parentId);
const orderedRoots = roots.length ? roots : [vis[0]];
orderedRoots.sort((a, b) => String(a.id).localeCompare(String(b.id)));
const widths = orderedRoots.map(subtreeWidth);
const total = widths.reduce((s, w) => s + w, 0);
let x = -total / 2;
orderedRoots.forEach((r, i) => {{
const w = widths[i];
place(r, x + w / 2);
x += w;
}});
}}
function nodeAt(px, py) {{
const w = toWorld(px, py);
for (let i = nodes.length - 1; i >= 0; i--) {{
const n = nodes[i];
if (!isVisible(n)) continue;
const dx = n.x - w.x, dy = n.y - w.y;
if (dx * dx + dy * dy <= (n.r + 5) * (n.r + 5)) return n;
}}
return null;
}}
const getPos = (ev) => {{ const r = canvas.getBoundingClientRect(); return {{ x: ev.clientX - r.left, y: ev.clientY - r.top }}; }};
canvas.addEventListener('mousedown', (ev) => {{
const p = getPos(ev); const n = nodeAt(p.x, p.y); moved = false;
// Nodes snap back to tree slots — drag is for temporary peek only; prefer pan.
if (n && ev.shiftKey) {{ dragNode = n; }} else {{ panning = true; }}
last = p; canvas.classList.add('dragging');
}});
canvas.addEventListener('mousemove', (ev) => {{
const p = getPos(ev);
if (dragNode) {{ const w = toWorld(p.x, p.y); dragNode.x = w.x; dragNode.y = w.y; moved = true; }}
else if (panning) {{ panX += p.x - last.x; panY += p.y - last.y; moved = true; }}
else {{ canvas.style.cursor = nodeAt(p.x, p.y) ? 'pointer' : 'grab'; }}
last = p;
}});
window.addEventListener('mouseup', (ev) => {{
if (ev.target === canvas && !moved) {{
const p = getPos(ev); const n = nodeAt(p.x, p.y);
if (n) {{
selected = n;
cardType.textContent = n.typeLabel || n.kind || 'Node';
cardTitle.textContent = n.fullTitle || n.label || '';
cardDetail.textContent = n.detail || 'No additional detail for this node.';
card.classList.add('open');
}} else {{ selected = null; card.classList.remove('open'); }}
}}
dragNode = null; panning = false; canvas.classList.remove('dragging');
}});
canvas.addEventListener('wheel', (ev) => {{
ev.preventDefault();
const p = getPos(ev); const before = toWorld(p.x, p.y);
const factor = ev.deltaY < 0 ? 1.1 : 0.9;
zoom = Math.max(0.3, Math.min(2.5, zoom * factor));
panX = p.x - before.x * zoom; panY = p.y - before.y * zoom;
}}, {{ passive: false }});
// ── Top-down tree layout: recompute slots, ease nodes into place. ──
function tick() {{
layoutTree();
nodes.forEach(n => {{
if (n === dragNode) return;
if (n.tx == null || n.ty == null) return;
n.x += (n.tx - n.x) * 0.22;
n.y += (n.ty - n.y) * 0.28;
if (Math.abs(n.tx - n.x) < 0.15) n.x = n.tx;
if (Math.abs(n.ty - n.y) < 0.15) n.y = n.ty;
}});
if (fitPending && !cameraLocked) {{
const settled = nodes.every(n => n.tx == null || (Math.abs(n.tx - n.x) < 1.5 && Math.abs(n.ty - n.y) < 1.5));
if (settled || nodes.length <= 1) {{ fitView({{ topBias: true }}); fitPending = false; }}
}} else if (fitPending && cameraLocked) {{
fitPending = false;
}}
for (const n of nodes) saved[n.id] = {{ x: n.x, y: n.y }};
}}
function frameIsReady(frame) {{
if (!frame || !frame.nodes || !frame.nodes.length) return false;
// Prefer locking once the final annotation trunk exists; else a wide citing row.
return frame.nodes.some(n => n.kind === 'cluster' || n.kind === 'claim' || n.kind === 'study' || n.kind === 'ingredient')
|| frame.nodes.filter(n => n.kind === 'citing').length >= 3;
}}
function lockCameraFromFrame(model, force) {{
const frame = model && model.frame;
const key = (model && model.frameKey) || '';
if (!frameIsReady(frame)) return;
if (!force && cameraLocked && key && key === lockedFrameKey) return;
const prevNodes = nodes.slice();
const prevEdges = edges.slice();
const prevHidden = new Set(hiddenKinds);
nodes.length = 0;
byId.clear();
(frame.nodes || []).forEach(n => {{
const nd = Object.assign({{}}, n, {{ x: 0, y: TOP_Y, tx: 0, ty: TOP_Y }});
nodes.push(nd);
byId.set(nd.id, nd);
}});
edges = (frame.edges || []).filter(e => byId.get(e.source) && byId.get(e.target));
hiddenKinds.clear();
layoutTree();
nodes.forEach(n => {{ if (n.tx != null) {{ n.x = n.tx; n.y = n.ty; }} }});
fitView({{ topBias: true }});
cameraLocked = true;
lockedFrameKey = key;
fitPending = false;
// Restore the live (possibly partial) graph; camera stays put.
nodes.length = 0;
byId.clear();
prevNodes.forEach(n => {{ nodes.push(n); byId.set(n.id, n); }});
edges = prevEdges;
hiddenKinds.clear();
prevHidden.forEach(k => hiddenKinds.add(k));
}}
function draw() {{
tick();
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#fafbfd';
ctx.fillRect(0, 0, W, H);
ctx.translate(panX, panY);
ctx.scale(zoom, zoom);
const q = query.trim().toLowerCase();
const pulseAge = performance.now() - pulseStart;
const pulseAlive = pulseAge < PULSE_LIFETIME_MS;
const pulseFade = pulseAlive ? 1 - pulseAge / PULSE_LIFETIME_MS : 0;
// edges
edges.forEach(e => {{
const a = byId.get(e.source), b = byId.get(e.target);
if (!a || !b || !isVisible(a) || !isVisible(b)) return;
const pulsing = pulseAlive && pulseEdgeIds.has(e.id);
ctx.beginPath();
ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y);
ctx.strokeStyle = e.muted ? 'rgba(148,163,184,0.30)' : 'rgba(100,116,139,0.42)';
ctx.lineWidth = (e.muted ? 1 : 1.4) + (pulsing ? 1.2 * pulseFade : 0);
if (pulsing) {{
ctx.setLineDash(PULSE_DASH);
ctx.lineDashOffset = -((pulseAge / 26) % (PULSE_DASH[0] + PULSE_DASH[1]));
}} else {{
ctx.setLineDash(e.muted ? [3, 3] : []);
ctx.lineDashOffset = 0;
}}
ctx.stroke();
ctx.setLineDash([]); ctx.lineDashOffset = 0;
}});
// nodes — exact Mina draw order
nodes.forEach(n => {{
if (!isVisible(n)) return;
const x = n.x, y = n.y, r = n.r;
const isSelected = selected && selected.id === n.id;
const dimmed = q.length > 0 && !String(n.label || '').toLowerCase().includes(q);
ctx.globalAlpha = dimmed ? 0.15 : 1;
if (pulseAlive && pulseNodeIds.has(n.id)) {{
for (const offset of PULSE_RING_OFFSETS) {{
const p = pulseAge / PULSE_RING_MS - offset;
if (p <= 0 || p >= 1) continue;
ctx.beginPath();
ctx.arc(x, y, r + 3 + p * 26, 0, Math.PI * 2);
ctx.strokeStyle = n.fill;
ctx.globalAlpha = (1 - p) * 0.55;
ctx.lineWidth = 2.5 * (1 - p) + 0.5;
ctx.stroke();
}}
ctx.globalAlpha = dimmed ? 0.15 : 1;
}}
if (isSelected) {{
ctx.beginPath();
ctx.arc(x, y, r + 7, 0, Math.PI * 2);
ctx.fillStyle = n.fill + '22'; ctx.fill();
ctx.beginPath();
ctx.arc(x, y, r + 5, 0, Math.PI * 2);
ctx.strokeStyle = n.ring; ctx.lineWidth = 1.5; ctx.stroke();
}}
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = n.fill; ctx.fill();
ctx.lineWidth = 2; ctx.strokeStyle = '#fff'; ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, r + 1.5, 0, Math.PI * 2);
ctx.lineWidth = 1.25; ctx.strokeStyle = n.ring; ctx.stroke();
const raw = String(n.label || '');
const label = raw.length > 26 ? raw.slice(0, 25) + '…' : raw;
const weight = n.kind === 'target' ? '700 12px' : (n.kind === 'cluster' ? '600 11px' : '500 10px');
ctx.font = weight + ' Inter, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic';
ctx.lineWidth = 3; ctx.strokeStyle = 'rgba(250,251,253,0.9)';
ctx.strokeText(label, x, y + r + 13);
ctx.fillStyle = n.text;
ctx.fillText(label, x, y + r + 13);
ctx.globalAlpha = 1;
}});
requestAnimationFrame(draw);
}}
requestAnimationFrame(draw);
setTimeout(persist, 900);
// ── Fit / reset view ──
function fitView(opts) {{
const topBias = !!(opts && opts.topBias);
const vis = nodes.filter(isVisible);
if (!vis.length) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
vis.forEach(n => {{
const x = n.tx != null ? n.tx : n.x, y = n.ty != null ? n.ty : n.y;
minX = Math.min(minX, x - n.r); minY = Math.min(minY, y - n.r);
maxX = Math.max(maxX, x + n.r + 20); maxY = Math.max(maxY, y + n.r + 28);
}});
const gw = Math.max(1, maxX - minX), gh = Math.max(1, maxY - minY);
zoom = Math.max(0.28, Math.min(1.55, Math.min((W - 48) / gw, (H - 56) / gh)));
panX = W / 2 - ((minX + maxX) / 2) * zoom;
panY = topBias ? (28 - minY * zoom) : (H / 2 - ((minY + maxY) / 2) * zoom);
}}
document.getElementById('fitBtn').onclick = () => {{
// Manual fit is allowed; keep lock so later step updates still won't auto-zoom.
fitView({{ topBias: true }});
}};
document.getElementById('resetBtn').onclick = () => {{
hiddenKinds.clear(); query = ''; document.getElementById('q').value = '';
document.getElementById('qClr').style.display = 'none';
selected = null; card.classList.remove('open');
try {{ sessionStorage.removeItem(memKey); }} catch (e) {{}}
saved = {{}};
layoutTree();
nodes.forEach(n => {{ if (n.tx != null) {{ n.x = n.tx; n.y = n.ty; }} }});
// Re-apply final-frame camera when available; otherwise fit current nodes.
if (lastFrameModel) lockCameraFromFrame(lastFrameModel, true);
else fitView({{ topBias: true }});
renderCats();
}};
// ── Search ──
const qInput = document.getElementById('q');
const qClr = document.getElementById('qClr');
qInput.addEventListener('input', () => {{ query = qInput.value; qClr.style.display = query ? 'block' : 'none'; }});
qClr.onclick = () => {{ query = ''; qInput.value = ''; qClr.style.display = 'none'; }};
// ── Node category filter rail ──
function counts() {{
const m = {{}};
nodes.forEach(n => {{ m[n.kind] = (m[n.kind] || 0) + 1; }});
return m;
}}
function renderCats() {{
const c = counts();
const order = (payload.kindOrder || Object.keys(KM)).filter(k => (c[k] || 0) > 0);
const host = document.getElementById('cats');
host.innerHTML = '';
order.forEach(k => {{
const meta = KM[k] || {{ fill: '#94a3b8', ring: '#e2e8f0', label: k }};
const on = !hiddenKinds.has(k);
const btn = document.createElement('button');
btn.className = 'cat' + (on ? '' : ' off');
btn.innerHTML =
'<span class="box' + (on ? ' on' : '') + '">' + (on ? '✓' : '') + '</span>' +
'<span class="dot" style="background:' + meta.fill + ';box-shadow:0 0 0 2px ' + meta.ring + '"></span>' +
'<span class="name">' + meta.label + '</span>' +
'<span class="cnt">' + (c[k] || 0) + '</span>';
btn.onclick = () => {{
if (hiddenKinds.has(k)) hiddenKinds.delete(k); else hiddenKinds.add(k);
if (!cameraLocked) fitPending = true;
renderCats();
}};
host.appendChild(btn);
}});
}}
document.getElementById('allBtn').onclick = () => {{
hiddenKinds.clear();
if (!cameraLocked) fitPending = true;
renderCats();
}};
document.getElementById('noneBtn').onclick = () => {{
Object.keys(counts()).forEach(k => hiddenKinds.add(k)); renderCats();
}};
renderCats();
// ── Incremental model merge: spawn under parent, expand tree downward. ──
let lastSig = '';
let lastFrameModel = null;
function applyModel(model) {{
if (!model) return;
if (model.kindMeta) Object.assign(KM, model.kindMeta);
if (model.kindOrder) payload.kindOrder = model.kindOrder;
if (model.frame) lastFrameModel = {{ frame: model.frame, frameKey: model.frameKey || '' }};
// Lock zoom/pan to the final tree before merging visible nodes.
lockCameraFromFrame(model);
const inNodes = model.nodes || [];
const inEdges = model.edges || [];
const sig = inNodes.map(n => n.id + (n.pulse ? '*' : '')).join(',') + '|' + inEdges.map(e => e.id).join(',');
if (sig === lastSig) return;
lastSig = sig;
const incIds = new Set(inNodes.map(n => n.id));
const pN = new Set(), pE = new Set();
let added = false;
// Edges first so parent lookups work while spawning.
const pendingEdges = inEdges.map(e => Object.assign({{}}, e));
inNodes.forEach(n => {{
let nd = byId.get(n.id);
if (nd) {{
nd.label = n.label; nd.kind = n.kind; nd.fill = n.fill; nd.ring = n.ring;
nd.text = n.text; nd.r = n.r; nd.typeLabel = n.typeLabel; nd.fullTitle = n.fullTitle; nd.detail = n.detail;
if (n.pulse) pN.add(n.id);
}} else {{
// Temporary register for parent resolution against in-flight nodes.
nd = Object.assign({{}}, n, {{ x: 0, y: TOP_Y, tx: 0, ty: TOP_Y }});
nodes.push(nd); byId.set(n.id, nd);
let x = 0, y = TOP_Y;
const prev = saved[n.id];
const parent = (() => {{
let best = null, bestLvl = -Infinity;
for (const e of pendingEdges) {{
if (e.target !== n.id) continue;
const p = byId.get(e.source);
if (!p) continue;
const pl = levelOf(p);
if (pl < levelOf(nd) && pl >= bestLvl) {{ best = p; bestLvl = pl; }}
}}
return best || byId.get('target');
}})();
if (prev) {{ x = prev.x; y = prev.y; }}
else if (parent) {{
x = parent.x;
y = parent.y + Math.max(36, (levelOf(nd) - levelOf(parent)) * ROW_GAP * 0.55);
}} else {{
x = 0; y = TOP_Y;
}}
nd.x = x; nd.y = y; nd.tx = x; nd.ty = y;
added = true; pN.add(n.id);
}}
}});
for (let i = nodes.length - 1; i >= 0; i--) {{
if (!incIds.has(nodes[i].id)) {{ byId.delete(nodes[i].id); nodes.splice(i, 1); }}
}}
edges = pendingEdges.filter(e => byId.get(e.source) && byId.get(e.target));
edges.forEach(e => {{ if (e.pulse) pE.add(e.id); }});
pulseNodeIds = pN; pulseEdgeIds = pE;
if (pN.size || pE.size || added) {{ pulseStart = performance.now(); }}
target = byId.get('target');
layoutTree();
// Only auto-fit before the final-frame camera is locked.
if (!cameraLocked && (added || pN.size)) fitPending = true;
renderCats();
persist();
}}
window.addEventListener('message', (e) => {{
const d = e.data;
if (d && d.type === 'scipaths-graph') applyModel(d.model);
}});
if (payload.nodes && payload.nodes.length) applyModel(payload);
</script>
</body>
</html>"""
def sync_and_load_graph(run_id: str, model: dict[str, Any]) -> dict[str, Any]:
"""Push cumulative graph to Neo4j and read it back for rendering."""
if not run_id or neo4j_store is None:
model = dict(model)
model["backend"] = "memory"
return model
nodes = model.get("nodes") or []
edges = model.get("edges") or []
ok = neo4j_store.upsert_graph(run_id, nodes, edges)
if not ok:
model = dict(model)
model["backend"] = "memory"
return model
fetched = neo4j_store.fetch_graph(run_id)
if not fetched:
model = dict(model)
model["backend"] = "neo4j-write"
return model
# Preserve caption/step and enrich fetched nodes with Mina colors.
out_nodes = []
for n in fetched.get("nodes") or []:
kind = str(n.get("kind") or "citing")
meta = KIND_META.get(kind, KIND_META["citing"])
out_nodes.append(
{
**n,
"type_label": meta["type_label"],
"color": n.get("color") or meta["fill"],
"fill": n.get("fill") or meta["fill"],
"ring": n.get("ring") or meta["ring"],
"text": n.get("text") or meta["text"],
"r": n.get("r") or meta["r"],
"size": n.get("size") or meta["r"],
}
)
return {
"step": model.get("step"),
"pulse_step": model.get("pulse_step"),
"caption": model.get("caption"),
"nodes": out_nodes,
"edges": fetched.get("edges") or [],
"backend": "neo4j",
}
def build_synced_model(
*,
paper_dir: Optional[Path],
payload: Optional[dict],
events: list[str],
run_id: str = "",
) -> dict[str, Any]:
"""Build the cumulative graph model for the given events and sync to Neo4j."""
visible = visible_step_from_events(events)
pulse = pulse_step_from_events(events)
joined = " ".join(str(e) for e in (events or []))
if "Pipeline completed successfully." in joined or (
"Step 8 complete" in joined or ("annotation" in joined.lower() and "complete" in joined.lower())
):
if visible >= 8:
pulse = 0
visible = 8
model = build_graph_model(
paper_dir=paper_dir,
payload=payload,
visible_step=visible,
pulse_step=pulse,
)
# Final tree used only to lock camera zoom/pan from the first reveal.
frame = build_graph_model(
paper_dir=paper_dir,
payload=payload,
visible_step=8,
pulse_step=0,
)
model["frame"] = {"nodes": frame.get("nodes") or [], "edges": frame.get("edges") or []}
model["frame_key"] = run_id or "default"
synced = sync_and_load_graph(run_id or "default", model)
# Keep frame on the payload even if Neo4j sync rewrites node chrome.
synced["frame"] = model["frame"]
synced["frame_key"] = model["frame_key"]
return synced
def render_graph_shell(placeholder, *, height: int = 520) -> None:
"""Mount the canvas shell. Streamlit 1.60+ embeds HTML strings via srcdoc."""
html = graph_shell_html(height)
if placeholder is None:
st.iframe(html, height=height)
return
with placeholder.container():
st.iframe(html, height=height)
def push_graph_update(courier_slot, model: dict[str, Any], *, height: int = 0) -> None:
"""Push a model into the mounted shell via an invisible courier frame."""
html = courier_html(_graph_payload(model))
if courier_slot is None:
st.components.v1.html(html, height=height)
return
with courier_slot.container():
st.components.v1.html(html, height=height)
def render_workflow_into(
placeholder,
*,
paper_dir: Optional[Path],
payload: Optional[dict],
events: list[str],
run_id: str = "",
height: int = 520,
) -> None:
"""Back-compat single-slot render: mount shell then push once into it."""
render_graph_shell(placeholder, height=height)
model = build_synced_model(
paper_dir=paper_dir, payload=payload, events=events, run_id=run_id
)
push_graph_update(None, model)