Spaces:
Running
Running
| """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) | |