Beanbagdzf commited on
Commit
755a449
Β·
verified Β·
1 Parent(s): f329675

Upload folder using huggingface_hub

Browse files
Files changed (2) hide show
  1. hf_space/streamlit_app.py +10 -3
  2. hf_space/workflow_graph.py +268 -112
hf_space/streamlit_app.py CHANGED
@@ -1353,9 +1353,11 @@ def _workflow_run_id(paper_input: str) -> str:
1353
 
1354
 
1355
  def _mount_graph_shell(graph_slot) -> None:
1356
- """Mount the persistent graph canvas once (constant HTML β†’ Streamlit reuses it)."""
1357
  if graph_slot is None:
1358
  return
 
 
1359
  from workflow_graph import render_graph_shell
1360
 
1361
  render_graph_shell(graph_slot)
@@ -3212,19 +3214,24 @@ def _render_annotation_process_view(
3212
  courier_slot=courier_slot,
3213
  )
3214
  # Keep the same demo selected after finish; do not advance to Paper 2.
 
 
 
3215
  if not live_run_mode and paper_input.strip():
3216
- _set_demo_paper(paper_input.strip())
3217
- st.rerun()
3218
 
3219
  with tabs[1]:
3220
  paper_dir, discovery, contributions, payload = _load_result_bundle(
3221
  st.session_state.get("paper_input", "")
 
3222
  )
3223
  _render_clusters_tab(discovery, contributions)
3224
 
3225
  with tabs[2]:
3226
  paper_dir, discovery, contributions, payload = _load_result_bundle(
3227
  st.session_state.get("paper_input", "")
 
3228
  )
3229
  _render_claims_tab(payload)
3230
 
 
1353
 
1354
 
1355
  def _mount_graph_shell(graph_slot) -> None:
1356
+ """Mount the graph canvas shell (st.iframe HTML srcdoc + tree layout JS)."""
1357
  if graph_slot is None:
1358
  return
1359
+ # Call-time import; avoid importlib.reload so identical shell HTML can be
1360
+ # reused across Streamlit runs instead of forcing a fresh iframe each time.
1361
  from workflow_graph import render_graph_shell
1362
 
1363
  render_graph_shell(graph_slot)
 
3214
  courier_slot=courier_slot,
3215
  )
3216
  # Keep the same demo selected after finish; do not advance to Paper 2.
3217
+ # No st.rerun() here: the stream already painted the final work panel,
3218
+ # overview metrics, and graph via placeholders/courier. A full rerun
3219
+ # remounts the page (and graph iframe) and looks like a refresh.
3220
  if not live_run_mode and paper_input.strip():
3221
+ st.session_state["live_run_mode"] = False
3222
+ st.session_state["selected_demo_url"] = paper_input.strip()
3223
 
3224
  with tabs[1]:
3225
  paper_dir, discovery, contributions, payload = _load_result_bundle(
3226
  st.session_state.get("paper_input", "")
3227
+ or st.session_state.get("selected_demo_url", "")
3228
  )
3229
  _render_clusters_tab(discovery, contributions)
3230
 
3231
  with tabs[2]:
3232
  paper_dir, discovery, contributions, payload = _load_result_bundle(
3233
  st.session_state.get("paper_input", "")
3234
+ or st.session_state.get("selected_demo_url", "")
3235
  )
3236
  _render_claims_tab(payload)
3237
 
hf_space/workflow_graph.py CHANGED
@@ -1,8 +1,9 @@
1
  """Evolving SciPaths workflow graph (Neo4j-backed).
2
 
3
- Visual formatting follows Mina Brain's knowledge-graph canvas strictly:
4
- filled circle + white inner stroke + colored ring, 25-char labels with white
5
- halo, expanding write-pulse rings, #fafbfd stage, click detail card.
 
6
  """
7
 
8
  from __future__ import annotations
@@ -93,8 +94,8 @@ KIND_META = {
93
  },
94
  }
95
 
96
- # Category order + compact per-kind palette the canvas shell + rail consume.
97
- _KIND_ORDER = ["target", "claim", "ingredient", "cluster", "study", "citing"]
98
  _KIND_META_JS = {
99
  k: {"fill": v["fill"], "ring": v["ring"], "text": v["text"], "label": v["type_label"]}
100
  for k, v in KIND_META.items()
@@ -518,12 +519,11 @@ def build_graph_model(
518
  }
519
 
520
 
521
- def _graph_payload(model: dict[str, Any]) -> dict[str, Any]:
522
- """Serialize a graph model into the payload the canvas shell consumes."""
523
- nodes_in = model.get("nodes") or []
524
- edges_in = model.get("edges") or []
525
-
526
- nodes_js = []
527
  for n in nodes_in:
528
  kind = str(n.get("kind") or "citing")
529
  meta = KIND_META.get(kind, KIND_META["citing"])
@@ -542,7 +542,7 @@ def _graph_payload(model: dict[str, Any]) -> dict[str, Any]:
542
  "typeLabel": n.get("type_label") or meta["type_label"],
543
  }
544
  )
545
- edges_js = []
546
  for e in edges_in:
547
  edges_js.append(
548
  {
@@ -553,12 +553,28 @@ def _graph_payload(model: dict[str, Any]) -> dict[str, Any]:
553
  "muted": bool(e.get("muted")),
554
  }
555
  )
556
- return {
 
 
 
 
 
 
 
 
557
  "nodes": nodes_js,
558
  "edges": edges_js,
559
  "kindMeta": _KIND_META_JS,
560
  "kindOrder": _KIND_ORDER,
 
561
  }
 
 
 
 
 
 
 
562
 
563
 
564
  def courier_html(payload: dict[str, Any]) -> str:
@@ -583,19 +599,23 @@ def courier_html(payload: dict[str, Any]) -> str:
583
  )
584
 
585
 
 
 
 
 
586
  def graph_shell_html(height: int = 520) -> str:
587
- """Stable, model-free canvas shell. Data arrives via postMessage (incremental).
588
 
589
- The HTML is constant for a given height, so Streamlit reuses the same iframe
590
- across reruns instead of remounting it. The graph is populated / grown by
591
- ``courier_html`` messages.
592
  """
593
  payload = json.dumps(
594
  {"nodes": [], "edges": [], "kindMeta": _KIND_META_JS, "kindOrder": _KIND_ORDER}
595
  )
596
  row_h = max(300, height - 30)
 
597
  return f"""<!DOCTYPE html>
598
- <html>
599
  <head>
600
  <meta charset="utf-8" />
601
  <link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -670,7 +690,7 @@ def graph_shell_html(height: int = 520) -> str:
670
  </head>
671
  <body>
672
  <div class="wrap">
673
- <div class="kicker">Workflow</div>
674
  <div class="row">
675
  <aside class="rail">
676
  <div class="rail-head">Graph controls</div>
@@ -701,7 +721,7 @@ def graph_shell_html(height: int = 520) -> str:
701
  <div class="card-title" id="cardTitle"></div>
702
  <p class="card-detail" id="cardDetail"></p>
703
  </div>
704
- <div class="hint">Drag to move Β· scroll to zoom Β· click a node for details</div>
705
  </div>
706
  </div>
707
  </div>
@@ -729,8 +749,16 @@ def graph_shell_html(height: int = 520) -> str:
729
  resize();
730
  window.addEventListener('resize', () => {{ resize(); }});
731
 
732
- // ── Position memory across reruns (Mina keeps xy so the graph grows, not jumps).
733
- const memKey = 'scipaths-mina-pos';
 
 
 
 
 
 
 
 
734
  let saved = {{}};
735
  try {{ saved = JSON.parse(sessionStorage.getItem(memKey) || '{{}}'); }} catch (e) {{ saved = {{}}; }}
736
 
@@ -739,6 +767,10 @@ def graph_shell_html(height: int = 520) -> str:
739
  const byId = new Map();
740
  let edges = [];
741
  let target = null;
 
 
 
 
742
 
743
  // ── Filter / search state ──
744
  const hiddenKinds = new Set();
@@ -754,11 +786,10 @@ def graph_shell_html(height: int = 520) -> str:
754
  let pulseEdgeIds = new Set();
755
  let pulseStart = 0;
756
 
757
- // ── View transform (zoom + pan), Mina-style ──
758
  let zoom = 0.95;
759
  let panX = (W / 2) * (1 - zoom);
760
- let panY = (H / 2) * (1 - zoom);
761
- let alpha = 1;
762
  const toWorld = (px, py) => ({{ x: (px - panX) / zoom, y: (py - panY) / zoom }});
763
 
764
  let dragNode = null, panning = false, moved = false;
@@ -771,6 +802,82 @@ def graph_shell_html(height: int = 520) -> str:
771
  try {{ sessionStorage.setItem(memKey, JSON.stringify(out)); }} catch (e) {{}}
772
  }}
773
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
774
  function nodeAt(px, py) {{
775
  const w = toWorld(px, py);
776
  for (let i = nodes.length - 1; i >= 0; i--) {{
@@ -785,12 +892,13 @@ def graph_shell_html(height: int = 520) -> str:
785
 
786
  canvas.addEventListener('mousedown', (ev) => {{
787
  const p = getPos(ev); const n = nodeAt(p.x, p.y); moved = false;
788
- if (n) {{ dragNode = n; alpha = Math.max(alpha, 0.4); }} else {{ panning = true; }}
 
789
  last = p; canvas.classList.add('dragging');
790
  }});
791
  canvas.addEventListener('mousemove', (ev) => {{
792
  const p = getPos(ev);
793
- if (dragNode) {{ const w = toWorld(p.x, p.y); dragNode.x = w.x; dragNode.y = w.y; dragNode.vx = 0; dragNode.vy = 0; moved = true; }}
794
  else if (panning) {{ panX += p.x - last.x; panY += p.y - last.y; moved = true; }}
795
  else {{ canvas.style.cursor = nodeAt(p.x, p.y) ? 'pointer' : 'grab'; }}
796
  last = p;
@@ -806,7 +914,6 @@ def graph_shell_html(height: int = 520) -> str:
806
  card.classList.add('open');
807
  }} else {{ selected = null; card.classList.remove('open'); }}
808
  }}
809
- if (dragNode) persist();
810
  dragNode = null; panning = false; canvas.classList.remove('dragging');
811
  }});
812
  canvas.addEventListener('wheel', (ev) => {{
@@ -817,61 +924,65 @@ def graph_shell_html(height: int = 520) -> str:
817
  panX = p.x - before.x * zoom; panY = p.y - before.y * zoom;
818
  }}, {{ passive: false }});
819
 
820
- // ── Force layout β€” Mina model: inverse-square repulsion + springs +
821
- // center gravity + collision relaxation + annealing alpha (looser). ──
822
  function tick() {{
823
- const N = nodes.length;
824
- for (let i = 0; i < N; i++) {{
825
- for (let j = i + 1; j < N; j++) {{
826
- const a = nodes[i], b = nodes[j];
827
- let dx = b.x - a.x, dy = b.y - a.y;
828
- let d2 = dx * dx + dy * dy; if (d2 < 1) d2 = 1;
829
- const d = Math.sqrt(d2);
830
- const force = (5600 * alpha) / d2;
831
- dx /= d; dy /= d;
832
- if (a !== dragNode && !a.fixed) {{ a.vx -= dx * force; a.vy -= dy * force; }}
833
- if (b !== dragNode && !b.fixed) {{ b.vx += dx * force; b.vy += dy * force; }}
834
- }}
835
- }}
836
- edges.forEach(e => {{
837
- const a = byId.get(e.source), b = byId.get(e.target);
838
- if (!a || !b) return;
839
- const ideal = 155;
840
- let dx = b.x - a.x, dy = b.y - a.y;
841
- const d = Math.max(1, Math.sqrt(dx * dx + dy * dy));
842
- const force = ((d - ideal) / d) * 0.045 * alpha;
843
- dx *= force; dy *= force;
844
- if (!a.fixed && a !== dragNode) {{ a.vx += dx; a.vy += dy; }}
845
- if (!b.fixed && b !== dragNode) {{ b.vx -= dx; b.vy -= dy; }}
846
- }});
847
- const cx = W / 2, cy = H / 2;
848
  nodes.forEach(n => {{
849
- if (n.fixed || n === dragNode) return;
850
- n.vx += (cx - n.x) * 0.0042 * alpha;
851
- n.vy += (cy - n.y) * 0.0042 * alpha;
852
- n.vx *= 0.86; n.vy *= 0.86;
853
- n.x += n.vx; n.y += n.vy;
 
854
  }});
855
- // Hard collision relaxation so nothing overlaps (with label breathing room).
856
- for (let i = 0; i < N; i++) {{
857
- for (let j = i + 1; j < N; j++) {{
858
- const a = nodes[i], b = nodes[j];
859
- const dx = b.x - a.x, dy = b.y - a.y;
860
- const min = a.r + b.r + 18;
861
- const d = Math.sqrt(dx * dx + dy * dy) || 0.01;
862
- if (d < min) {{
863
- const push = (min - d) / d;
864
- const ox = dx * push * 0.5, oy = dy * push * 0.5;
865
- if (a !== dragNode && !a.fixed) {{ a.x -= ox; a.y -= oy; }}
866
- if (b !== dragNode && !b.fixed) {{ b.x += ox; b.y += oy; }}
867
- }}
868
- }}
869
  }}
870
- if (target) {{ target.x = cx; target.y = cy; target.vx = 0; target.vy = 0; }}
871
- alpha = Math.max(0.02, alpha * 0.9955);
872
  for (const n of nodes) saved[n.id] = {{ x: n.x, y: n.y }};
873
  }}
874
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
875
  function draw() {{
876
  tick();
877
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
@@ -963,32 +1074,37 @@ def graph_shell_html(height: int = 520) -> str:
963
  setTimeout(persist, 900);
964
 
965
  // ── Fit / reset view ──
966
- function fitView() {{
 
967
  const vis = nodes.filter(isVisible);
968
  if (!vis.length) return;
969
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
970
- vis.forEach(n => {{ minX = Math.min(minX, n.x - n.r); minY = Math.min(minY, n.y - n.r);
971
- maxX = Math.max(maxX, n.x + n.r + 20); maxY = Math.max(maxY, n.y + n.r + 20); }});
 
 
 
972
  const gw = Math.max(1, maxX - minX), gh = Math.max(1, maxY - minY);
973
- zoom = Math.max(0.3, Math.min(1.6, Math.min((W - 40) / gw, (H - 40) / gh)));
974
  panX = W / 2 - ((minX + maxX) / 2) * zoom;
975
- panY = H / 2 - ((minY + maxY) / 2) * zoom;
976
  }}
977
- document.getElementById('fitBtn').onclick = fitView;
 
 
 
978
  document.getElementById('resetBtn').onclick = () => {{
979
  hiddenKinds.clear(); query = ''; document.getElementById('q').value = '';
980
  document.getElementById('qClr').style.display = 'none';
981
  selected = null; card.classList.remove('open');
982
  try {{ sessionStorage.removeItem(memKey); }} catch (e) {{}}
983
- alpha = 1; renderCats();
984
- const cx = W / 2, cy = H / 2;
985
- nodes.forEach((n, i) => {{
986
- const a = (i / Math.max(nodes.length, 1)) * Math.PI * 2;
987
- n.x = n.id === 'target' ? cx : cx + Math.cos(a) * (90 + Math.random() * 70);
988
- n.y = n.id === 'target' ? cy : cy + Math.sin(a) * (90 + Math.random() * 70);
989
- n.vx = 0; n.vy = 0;
990
- }});
991
- zoom = 0.95; panX = (W / 2) * (1 - zoom); panY = (H / 2) * (1 - zoom);
992
  }};
993
 
994
  // ── Search ──
@@ -1020,34 +1136,43 @@ def graph_shell_html(height: int = 520) -> str:
1020
  '<span class="cnt">' + (c[k] || 0) + '</span>';
1021
  btn.onclick = () => {{
1022
  if (hiddenKinds.has(k)) hiddenKinds.delete(k); else hiddenKinds.add(k);
1023
- alpha = Math.max(alpha, 0.3); renderCats();
 
1024
  }};
1025
  host.appendChild(btn);
1026
  }});
1027
  }}
1028
- document.getElementById('allBtn').onclick = () => {{ hiddenKinds.clear(); alpha = Math.max(alpha, 0.3); renderCats(); }};
 
 
 
 
1029
  document.getElementById('noneBtn').onclick = () => {{
1030
  Object.keys(counts()).forEach(k => hiddenKinds.add(k)); renderCats();
1031
  }};
1032
  renderCats();
1033
 
1034
- // ── Incremental model merge (Mina reveal): add new nodes near a neighbor,
1035
- // keep existing positions, pulse writes, drop nodes no longer present. ──
1036
  let lastSig = '';
 
1037
  function applyModel(model) {{
1038
  if (!model) return;
 
 
 
 
 
 
1039
  const inNodes = model.nodes || [];
1040
  const inEdges = model.edges || [];
1041
  const sig = inNodes.map(n => n.id + (n.pulse ? '*' : '')).join(',') + '|' + inEdges.map(e => e.id).join(',');
1042
  if (sig === lastSig) return;
1043
  lastSig = sig;
1044
- if (model.kindMeta) Object.assign(KM, model.kindMeta);
1045
- if (model.kindOrder) payload.kindOrder = model.kindOrder;
1046
  const incIds = new Set(inNodes.map(n => n.id));
1047
- const adj = {{}};
1048
- inEdges.forEach(e => {{ (adj[e.source] = adj[e.source] || []).push(e.target); (adj[e.target] = adj[e.target] || []).push(e.source); }});
1049
  const pN = new Set(), pE = new Set();
1050
  let added = false;
 
 
1051
  inNodes.forEach(n => {{
1052
  let nd = byId.get(n.id);
1053
  if (nd) {{
@@ -1055,28 +1180,46 @@ def graph_shell_html(height: int = 520) -> str:
1055
  nd.text = n.text; nd.r = n.r; nd.typeLabel = n.typeLabel; nd.fullTitle = n.fullTitle; nd.detail = n.detail;
1056
  if (n.pulse) pN.add(n.id);
1057
  }} else {{
 
 
 
 
1058
  const prev = saved[n.id];
1059
- let x, y;
 
 
 
 
 
 
 
 
 
 
1060
  if (prev) {{ x = prev.x; y = prev.y; }}
1061
- else {{
1062
- const nb = (adj[n.id] || []).map(id => byId.get(id)).find(Boolean);
1063
- const base = nb ? {{ x: nb.x, y: nb.y }} : {{ x: W / 2, y: H / 2 }};
1064
- const a = Math.random() * Math.PI * 2, rr = 70 + Math.random() * 60;
1065
- x = base.x + Math.cos(a) * rr; y = base.y + Math.sin(a) * rr;
1066
  }}
1067
- nd = Object.assign({{}}, n, {{ x, y, vx: 0, vy: 0, fixed: n.id === 'target' }});
1068
- nodes.push(nd); byId.set(n.id, nd); added = true; pN.add(n.id);
1069
  }}
1070
  }});
1071
  for (let i = nodes.length - 1; i >= 0; i--) {{
1072
  if (!incIds.has(nodes[i].id)) {{ byId.delete(nodes[i].id); nodes.splice(i, 1); }}
1073
  }}
1074
- edges = inEdges.filter(e => byId.get(e.source) && byId.get(e.target)).map(e => Object.assign({{}}, e));
1075
  edges.forEach(e => {{ if (e.pulse) pE.add(e.id); }});
1076
  pulseNodeIds = pN; pulseEdgeIds = pE;
1077
- if (pN.size || pE.size || added) {{ pulseStart = performance.now(); alpha = Math.max(alpha, 0.55); }}
1078
- target = byId.get('target'); if (target) target.fixed = true;
 
 
 
1079
  renderCats();
 
1080
  }}
1081
  window.addEventListener('message', (e) => {{
1082
  const d = e.data;
@@ -1157,11 +1300,24 @@ def build_synced_model(
1157
  visible_step=visible,
1158
  pulse_step=pulse,
1159
  )
1160
- return sync_and_load_graph(run_id or "default", model)
 
 
 
 
 
 
 
 
 
 
 
 
 
1161
 
1162
 
1163
  def render_graph_shell(placeholder, *, height: int = 520) -> None:
1164
- """Mount the persistent, model-free canvas shell (constant HTML β†’ no remount)."""
1165
  html = graph_shell_html(height)
1166
  if placeholder is None:
1167
  st.iframe(html, height=height)
 
1
  """Evolving SciPaths workflow graph (Neo4j-backed).
2
 
3
+ Visual formatting follows Mina Brain's node chrome (filled circle + white
4
+ inner stroke + colored ring, 25-char labels, write-pulse rings, #fafbfd
5
+ stage, click detail card) but lays nodes out as a strict top-down n-ary
6
+ tree: Target β†’ Citing β†’ Theme β†’ Claim β†’ Ingredient β†’ Prior study.
7
  """
8
 
9
  from __future__ import annotations
 
94
  },
95
  }
96
 
97
+ # Category order (top-down tree levels) + palette the canvas shell / rail consume.
98
+ _KIND_ORDER = ["target", "citing", "cluster", "claim", "ingredient", "study"]
99
  _KIND_META_JS = {
100
  k: {"fill": v["fill"], "ring": v["ring"], "text": v["text"], "label": v["type_label"]}
101
  for k, v in KIND_META.items()
 
519
  }
520
 
521
 
522
+ def _nodes_edges_payload(
523
+ nodes_in: list[dict[str, Any]], edges_in: list[dict[str, Any]]
524
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
525
+ """Serialize nodes/edges for the canvas shell."""
526
+ nodes_js: list[dict[str, Any]] = []
 
527
  for n in nodes_in:
528
  kind = str(n.get("kind") or "citing")
529
  meta = KIND_META.get(kind, KIND_META["citing"])
 
542
  "typeLabel": n.get("type_label") or meta["type_label"],
543
  }
544
  )
545
+ edges_js: list[dict[str, Any]] = []
546
  for e in edges_in:
547
  edges_js.append(
548
  {
 
553
  "muted": bool(e.get("muted")),
554
  }
555
  )
556
+ return nodes_js, edges_js
557
+
558
+
559
+ def _graph_payload(model: dict[str, Any]) -> dict[str, Any]:
560
+ """Serialize a graph model into the payload the canvas shell consumes."""
561
+ nodes_js, edges_js = _nodes_edges_payload(
562
+ list(model.get("nodes") or []), list(model.get("edges") or [])
563
+ )
564
+ out: dict[str, Any] = {
565
  "nodes": nodes_js,
566
  "edges": edges_js,
567
  "kindMeta": _KIND_META_JS,
568
  "kindOrder": _KIND_ORDER,
569
+ "frameKey": str(model.get("frame_key") or ""),
570
  }
571
+ frame = model.get("frame")
572
+ if isinstance(frame, dict) and frame.get("nodes"):
573
+ f_nodes, f_edges = _nodes_edges_payload(
574
+ list(frame.get("nodes") or []), list(frame.get("edges") or [])
575
+ )
576
+ out["frame"] = {"nodes": f_nodes, "edges": f_edges}
577
+ return out
578
 
579
 
580
  def courier_html(payload: dict[str, Any]) -> str:
 
599
  )
600
 
601
 
602
+ # Bump when canvas JS changes so Streamlit remounts the component iframe.
603
+ _GRAPH_SHELL_VERSION = "tree-v4"
604
+
605
+
606
  def graph_shell_html(height: int = 520) -> str:
607
+ """Model-free canvas shell. Data arrives via postMessage (incremental).
608
 
609
+ HTML includes ``_GRAPH_SHELL_VERSION`` so layout/code updates remount the
610
+ iframe. The graph is populated / grown by ``courier_html`` messages.
 
611
  """
612
  payload = json.dumps(
613
  {"nodes": [], "edges": [], "kindMeta": _KIND_META_JS, "kindOrder": _KIND_ORDER}
614
  )
615
  row_h = max(300, height - 30)
616
+ ver = _GRAPH_SHELL_VERSION
617
  return f"""<!DOCTYPE html>
618
+ <html data-scipaths-shell="{ver}">
619
  <head>
620
  <meta charset="utf-8" />
621
  <link rel="preconnect" href="https://fonts.googleapis.com" />
 
690
  </head>
691
  <body>
692
  <div class="wrap">
693
+ <div class="kicker">Workflow tree</div>
694
  <div class="row">
695
  <aside class="rail">
696
  <div class="rail-head">Graph controls</div>
 
721
  <div class="card-title" id="cardTitle"></div>
722
  <p class="card-detail" id="cardDetail"></p>
723
  </div>
724
+ <div class="hint">Top-down tree Β· scroll to zoom Β· drag canvas to pan Β· click a node</div>
725
  </div>
726
  </div>
727
  </div>
 
749
  resize();
750
  window.addEventListener('resize', () => {{ resize(); }});
751
 
752
+ // Semantic depth bands (visual tree levels β€” not edge hop count).
753
+ const KIND_LEVEL = {{ target: 0, citing: 1, cluster: 2, claim: 3, ingredient: 4, study: 5 }};
754
+ const KIND_SIBLING_ORDER = {{ citing: 0, cluster: 1, claim: 2, ingredient: 3, study: 4, target: -1 }};
755
+ const ROW_GAP = 108;
756
+ const TOP_Y = 52;
757
+ const MIN_GAP = 78;
758
+ const levelOf = (n) => (KIND_LEVEL[n.kind] != null ? KIND_LEVEL[n.kind] : 1);
759
+
760
+ // Position memory for spawn animation only (tree slots are recomputed).
761
+ const memKey = 'scipaths-tree-pos-v1';
762
  let saved = {{}};
763
  try {{ saved = JSON.parse(sessionStorage.getItem(memKey) || '{{}}'); }} catch (e) {{ saved = {{}}; }}
764
 
 
767
  const byId = new Map();
768
  let edges = [];
769
  let target = null;
770
+ let fitPending = false;
771
+ // Camera locked to the final tree frame so zoom stays fixed while the graph grows.
772
+ let cameraLocked = false;
773
+ let lockedFrameKey = '';
774
 
775
  // ── Filter / search state ──
776
  const hiddenKinds = new Set();
 
786
  let pulseEdgeIds = new Set();
787
  let pulseStart = 0;
788
 
789
+ // ── View transform (zoom + pan) ──
790
  let zoom = 0.95;
791
  let panX = (W / 2) * (1 - zoom);
792
+ let panY = 18;
 
793
  const toWorld = (px, py) => ({{ x: (px - panX) / zoom, y: (py - panY) / zoom }});
794
 
795
  let dragNode = null, panning = false, moved = false;
 
802
  try {{ sessionStorage.setItem(memKey, JSON.stringify(out)); }} catch (e) {{}}
803
  }}
804
 
805
+ function parentNode(n, edgeList) {{
806
+ const list = edgeList || edges;
807
+ let best = null, bestLvl = -Infinity;
808
+ for (const e of list) {{
809
+ if (e.target !== n.id) continue;
810
+ const p = byId.get(e.source);
811
+ if (!p) continue;
812
+ const pl = levelOf(p);
813
+ if (pl < levelOf(n) && pl >= bestLvl) {{ best = p; bestLvl = pl; }}
814
+ }}
815
+ if (!best && n.id !== 'target') best = byId.get('target') || null;
816
+ return best;
817
+ }}
818
+
819
+ function layoutTree() {{
820
+ if (!nodes.length) return;
821
+ // Always assign depth; pack only visible nodes so filters reflow cleanly.
822
+ nodes.forEach(n => {{ n.ty = TOP_Y + levelOf(n) * ROW_GAP; }});
823
+ const vis = nodes.filter(isVisible);
824
+ if (!vis.length) return;
825
+ const children = new Map();
826
+ const visibleParent = (n) => {{
827
+ let p = parentNode(n);
828
+ while (p && !isVisible(p)) p = parentNode(p);
829
+ return p;
830
+ }};
831
+ vis.forEach(n => {{
832
+ const p = visibleParent(n);
833
+ n._parentId = p ? p.id : null;
834
+ if (!p) return;
835
+ if (!children.has(p.id)) children.set(p.id, []);
836
+ children.get(p.id).push(n);
837
+ }});
838
+ for (const kids of children.values()) {{
839
+ kids.sort((a, b) => {{
840
+ const oa = KIND_SIBLING_ORDER[a.kind] != null ? KIND_SIBLING_ORDER[a.kind] : 9;
841
+ const ob = KIND_SIBLING_ORDER[b.kind] != null ? KIND_SIBLING_ORDER[b.kind] : 9;
842
+ if (oa !== ob) return oa - ob;
843
+ return String(a.id).localeCompare(String(b.id));
844
+ }});
845
+ }}
846
+ const leafGap = (n) => Math.max(MIN_GAP, (n.r || 14) * 2 + 36);
847
+ const subtreeWidth = (n) => {{
848
+ const kids = children.get(n.id) || [];
849
+ if (!kids.length) return leafGap(n);
850
+ let w = 0;
851
+ kids.forEach(k => {{ w += subtreeWidth(k); }});
852
+ return Math.max(leafGap(n), w);
853
+ }};
854
+ const place = (n, centerX) => {{
855
+ n.tx = centerX;
856
+ n.ty = TOP_Y + levelOf(n) * ROW_GAP;
857
+ const kids = children.get(n.id) || [];
858
+ if (!kids.length) return;
859
+ const widths = kids.map(subtreeWidth);
860
+ const total = widths.reduce((s, w) => s + w, 0);
861
+ let x = centerX - total / 2;
862
+ kids.forEach((k, i) => {{
863
+ const w = widths[i];
864
+ place(k, x + w / 2);
865
+ x += w;
866
+ }});
867
+ }};
868
+ const roots = vis.filter(n => !n._parentId);
869
+ const orderedRoots = roots.length ? roots : [vis[0]];
870
+ orderedRoots.sort((a, b) => String(a.id).localeCompare(String(b.id)));
871
+ const widths = orderedRoots.map(subtreeWidth);
872
+ const total = widths.reduce((s, w) => s + w, 0);
873
+ let x = -total / 2;
874
+ orderedRoots.forEach((r, i) => {{
875
+ const w = widths[i];
876
+ place(r, x + w / 2);
877
+ x += w;
878
+ }});
879
+ }}
880
+
881
  function nodeAt(px, py) {{
882
  const w = toWorld(px, py);
883
  for (let i = nodes.length - 1; i >= 0; i--) {{
 
892
 
893
  canvas.addEventListener('mousedown', (ev) => {{
894
  const p = getPos(ev); const n = nodeAt(p.x, p.y); moved = false;
895
+ // Nodes snap back to tree slots β€” drag is for temporary peek only; prefer pan.
896
+ if (n && ev.shiftKey) {{ dragNode = n; }} else {{ panning = true; }}
897
  last = p; canvas.classList.add('dragging');
898
  }});
899
  canvas.addEventListener('mousemove', (ev) => {{
900
  const p = getPos(ev);
901
+ if (dragNode) {{ const w = toWorld(p.x, p.y); dragNode.x = w.x; dragNode.y = w.y; moved = true; }}
902
  else if (panning) {{ panX += p.x - last.x; panY += p.y - last.y; moved = true; }}
903
  else {{ canvas.style.cursor = nodeAt(p.x, p.y) ? 'pointer' : 'grab'; }}
904
  last = p;
 
914
  card.classList.add('open');
915
  }} else {{ selected = null; card.classList.remove('open'); }}
916
  }}
 
917
  dragNode = null; panning = false; canvas.classList.remove('dragging');
918
  }});
919
  canvas.addEventListener('wheel', (ev) => {{
 
924
  panX = p.x - before.x * zoom; panY = p.y - before.y * zoom;
925
  }}, {{ passive: false }});
926
 
927
+ // ── Top-down tree layout: recompute slots, ease nodes into place. ──
 
928
  function tick() {{
929
+ layoutTree();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
930
  nodes.forEach(n => {{
931
+ if (n === dragNode) return;
932
+ if (n.tx == null || n.ty == null) return;
933
+ n.x += (n.tx - n.x) * 0.22;
934
+ n.y += (n.ty - n.y) * 0.28;
935
+ if (Math.abs(n.tx - n.x) < 0.15) n.x = n.tx;
936
+ if (Math.abs(n.ty - n.y) < 0.15) n.y = n.ty;
937
  }});
938
+ if (fitPending && !cameraLocked) {{
939
+ const settled = nodes.every(n => n.tx == null || (Math.abs(n.tx - n.x) < 1.5 && Math.abs(n.ty - n.y) < 1.5));
940
+ if (settled || nodes.length <= 1) {{ fitView({{ topBias: true }}); fitPending = false; }}
941
+ }} else if (fitPending && cameraLocked) {{
942
+ fitPending = false;
 
 
 
 
 
 
 
 
 
943
  }}
 
 
944
  for (const n of nodes) saved[n.id] = {{ x: n.x, y: n.y }};
945
  }}
946
 
947
+ function frameIsReady(frame) {{
948
+ if (!frame || !frame.nodes || !frame.nodes.length) return false;
949
+ // Prefer locking once the final annotation trunk exists; else a wide citing row.
950
+ return frame.nodes.some(n => n.kind === 'cluster' || n.kind === 'claim' || n.kind === 'study' || n.kind === 'ingredient')
951
+ || frame.nodes.filter(n => n.kind === 'citing').length >= 3;
952
+ }}
953
+
954
+ function lockCameraFromFrame(model, force) {{
955
+ const frame = model && model.frame;
956
+ const key = (model && model.frameKey) || '';
957
+ if (!frameIsReady(frame)) return;
958
+ if (!force && cameraLocked && key && key === lockedFrameKey) return;
959
+ const prevNodes = nodes.slice();
960
+ const prevEdges = edges.slice();
961
+ const prevHidden = new Set(hiddenKinds);
962
+ nodes.length = 0;
963
+ byId.clear();
964
+ (frame.nodes || []).forEach(n => {{
965
+ const nd = Object.assign({{}}, n, {{ x: 0, y: TOP_Y, tx: 0, ty: TOP_Y }});
966
+ nodes.push(nd);
967
+ byId.set(nd.id, nd);
968
+ }});
969
+ edges = (frame.edges || []).filter(e => byId.get(e.source) && byId.get(e.target));
970
+ hiddenKinds.clear();
971
+ layoutTree();
972
+ nodes.forEach(n => {{ if (n.tx != null) {{ n.x = n.tx; n.y = n.ty; }} }});
973
+ fitView({{ topBias: true }});
974
+ cameraLocked = true;
975
+ lockedFrameKey = key;
976
+ fitPending = false;
977
+ // Restore the live (possibly partial) graph; camera stays put.
978
+ nodes.length = 0;
979
+ byId.clear();
980
+ prevNodes.forEach(n => {{ nodes.push(n); byId.set(n.id, n); }});
981
+ edges = prevEdges;
982
+ hiddenKinds.clear();
983
+ prevHidden.forEach(k => hiddenKinds.add(k));
984
+ }}
985
+
986
  function draw() {{
987
  tick();
988
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
 
1074
  setTimeout(persist, 900);
1075
 
1076
  // ── Fit / reset view ──
1077
+ function fitView(opts) {{
1078
+ const topBias = !!(opts && opts.topBias);
1079
  const vis = nodes.filter(isVisible);
1080
  if (!vis.length) return;
1081
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1082
+ vis.forEach(n => {{
1083
+ const x = n.tx != null ? n.tx : n.x, y = n.ty != null ? n.ty : n.y;
1084
+ minX = Math.min(minX, x - n.r); minY = Math.min(minY, y - n.r);
1085
+ maxX = Math.max(maxX, x + n.r + 20); maxY = Math.max(maxY, y + n.r + 28);
1086
+ }});
1087
  const gw = Math.max(1, maxX - minX), gh = Math.max(1, maxY - minY);
1088
+ zoom = Math.max(0.28, Math.min(1.55, Math.min((W - 48) / gw, (H - 56) / gh)));
1089
  panX = W / 2 - ((minX + maxX) / 2) * zoom;
1090
+ panY = topBias ? (28 - minY * zoom) : (H / 2 - ((minY + maxY) / 2) * zoom);
1091
  }}
1092
+ document.getElementById('fitBtn').onclick = () => {{
1093
+ // Manual fit is allowed; keep lock so later step updates still won't auto-zoom.
1094
+ fitView({{ topBias: true }});
1095
+ }};
1096
  document.getElementById('resetBtn').onclick = () => {{
1097
  hiddenKinds.clear(); query = ''; document.getElementById('q').value = '';
1098
  document.getElementById('qClr').style.display = 'none';
1099
  selected = null; card.classList.remove('open');
1100
  try {{ sessionStorage.removeItem(memKey); }} catch (e) {{}}
1101
+ saved = {{}};
1102
+ layoutTree();
1103
+ nodes.forEach(n => {{ if (n.tx != null) {{ n.x = n.tx; n.y = n.ty; }} }});
1104
+ // Re-apply final-frame camera when available; otherwise fit current nodes.
1105
+ if (lastFrameModel) lockCameraFromFrame(lastFrameModel, true);
1106
+ else fitView({{ topBias: true }});
1107
+ renderCats();
 
 
1108
  }};
1109
 
1110
  // ── Search ──
 
1136
  '<span class="cnt">' + (c[k] || 0) + '</span>';
1137
  btn.onclick = () => {{
1138
  if (hiddenKinds.has(k)) hiddenKinds.delete(k); else hiddenKinds.add(k);
1139
+ if (!cameraLocked) fitPending = true;
1140
+ renderCats();
1141
  }};
1142
  host.appendChild(btn);
1143
  }});
1144
  }}
1145
+ document.getElementById('allBtn').onclick = () => {{
1146
+ hiddenKinds.clear();
1147
+ if (!cameraLocked) fitPending = true;
1148
+ renderCats();
1149
+ }};
1150
  document.getElementById('noneBtn').onclick = () => {{
1151
  Object.keys(counts()).forEach(k => hiddenKinds.add(k)); renderCats();
1152
  }};
1153
  renderCats();
1154
 
1155
+ // ── Incremental model merge: spawn under parent, expand tree downward. ──
 
1156
  let lastSig = '';
1157
+ let lastFrameModel = null;
1158
  function applyModel(model) {{
1159
  if (!model) return;
1160
+ if (model.kindMeta) Object.assign(KM, model.kindMeta);
1161
+ if (model.kindOrder) payload.kindOrder = model.kindOrder;
1162
+ if (model.frame) lastFrameModel = {{ frame: model.frame, frameKey: model.frameKey || '' }};
1163
+ // Lock zoom/pan to the final tree before merging visible nodes.
1164
+ lockCameraFromFrame(model);
1165
+
1166
  const inNodes = model.nodes || [];
1167
  const inEdges = model.edges || [];
1168
  const sig = inNodes.map(n => n.id + (n.pulse ? '*' : '')).join(',') + '|' + inEdges.map(e => e.id).join(',');
1169
  if (sig === lastSig) return;
1170
  lastSig = sig;
 
 
1171
  const incIds = new Set(inNodes.map(n => n.id));
 
 
1172
  const pN = new Set(), pE = new Set();
1173
  let added = false;
1174
+ // Edges first so parent lookups work while spawning.
1175
+ const pendingEdges = inEdges.map(e => Object.assign({{}}, e));
1176
  inNodes.forEach(n => {{
1177
  let nd = byId.get(n.id);
1178
  if (nd) {{
 
1180
  nd.text = n.text; nd.r = n.r; nd.typeLabel = n.typeLabel; nd.fullTitle = n.fullTitle; nd.detail = n.detail;
1181
  if (n.pulse) pN.add(n.id);
1182
  }} else {{
1183
+ // Temporary register for parent resolution against in-flight nodes.
1184
+ nd = Object.assign({{}}, n, {{ x: 0, y: TOP_Y, tx: 0, ty: TOP_Y }});
1185
+ nodes.push(nd); byId.set(n.id, nd);
1186
+ let x = 0, y = TOP_Y;
1187
  const prev = saved[n.id];
1188
+ const parent = (() => {{
1189
+ let best = null, bestLvl = -Infinity;
1190
+ for (const e of pendingEdges) {{
1191
+ if (e.target !== n.id) continue;
1192
+ const p = byId.get(e.source);
1193
+ if (!p) continue;
1194
+ const pl = levelOf(p);
1195
+ if (pl < levelOf(nd) && pl >= bestLvl) {{ best = p; bestLvl = pl; }}
1196
+ }}
1197
+ return best || byId.get('target');
1198
+ }})();
1199
  if (prev) {{ x = prev.x; y = prev.y; }}
1200
+ else if (parent) {{
1201
+ x = parent.x;
1202
+ y = parent.y + Math.max(36, (levelOf(nd) - levelOf(parent)) * ROW_GAP * 0.55);
1203
+ }} else {{
1204
+ x = 0; y = TOP_Y;
1205
  }}
1206
+ nd.x = x; nd.y = y; nd.tx = x; nd.ty = y;
1207
+ added = true; pN.add(n.id);
1208
  }}
1209
  }});
1210
  for (let i = nodes.length - 1; i >= 0; i--) {{
1211
  if (!incIds.has(nodes[i].id)) {{ byId.delete(nodes[i].id); nodes.splice(i, 1); }}
1212
  }}
1213
+ edges = pendingEdges.filter(e => byId.get(e.source) && byId.get(e.target));
1214
  edges.forEach(e => {{ if (e.pulse) pE.add(e.id); }});
1215
  pulseNodeIds = pN; pulseEdgeIds = pE;
1216
+ if (pN.size || pE.size || added) {{ pulseStart = performance.now(); }}
1217
+ target = byId.get('target');
1218
+ layoutTree();
1219
+ // Only auto-fit before the final-frame camera is locked.
1220
+ if (!cameraLocked && (added || pN.size)) fitPending = true;
1221
  renderCats();
1222
+ persist();
1223
  }}
1224
  window.addEventListener('message', (e) => {{
1225
  const d = e.data;
 
1300
  visible_step=visible,
1301
  pulse_step=pulse,
1302
  )
1303
+ # Final tree used only to lock camera zoom/pan from the first reveal.
1304
+ frame = build_graph_model(
1305
+ paper_dir=paper_dir,
1306
+ payload=payload,
1307
+ visible_step=8,
1308
+ pulse_step=0,
1309
+ )
1310
+ model["frame"] = {"nodes": frame.get("nodes") or [], "edges": frame.get("edges") or []}
1311
+ model["frame_key"] = run_id or "default"
1312
+ synced = sync_and_load_graph(run_id or "default", model)
1313
+ # Keep frame on the payload even if Neo4j sync rewrites node chrome.
1314
+ synced["frame"] = model["frame"]
1315
+ synced["frame_key"] = model["frame_key"]
1316
+ return synced
1317
 
1318
 
1319
  def render_graph_shell(placeholder, *, height: int = 520) -> None:
1320
+ """Mount the canvas shell. Streamlit 1.60+ embeds HTML strings via srcdoc."""
1321
  html = graph_shell_html(height)
1322
  if placeholder is None:
1323
  st.iframe(html, height=height)