Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- hf_space/streamlit_app.py +10 -3
- 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
|
| 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 |
-
|
| 3217 |
-
|
| 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
|
| 4 |
-
|
| 5 |
-
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
from __future__ import annotations
|
|
@@ -93,8 +94,8 @@ KIND_META = {
|
|
| 93 |
},
|
| 94 |
}
|
| 95 |
|
| 96 |
-
# Category order
|
| 97 |
-
_KIND_ORDER = ["target", "
|
| 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
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 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 |
-
"""
|
| 588 |
|
| 589 |
-
|
| 590 |
-
|
| 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">
|
| 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 |
-
//
|
| 733 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
| 758 |
let zoom = 0.95;
|
| 759 |
let panX = (W / 2) * (1 - zoom);
|
| 760 |
-
let panY =
|
| 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 |
-
|
|
|
|
| 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;
|
| 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 |
-
// ββ
|
| 821 |
-
// center gravity + collision relaxation + annealing alpha (looser). ββ
|
| 822 |
function tick() {{
|
| 823 |
-
|
| 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
|
| 850 |
-
n.
|
| 851 |
-
n.
|
| 852 |
-
n.
|
| 853 |
-
n.
|
|
|
|
| 854 |
}});
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 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 => {{
|
| 971 |
-
|
|
|
|
|
|
|
|
|
|
| 972 |
const gw = Math.max(1, maxX - minX), gh = Math.max(1, maxY - minY);
|
| 973 |
-
zoom = Math.max(0.
|
| 974 |
panX = W / 2 - ((minX + maxX) / 2) * zoom;
|
| 975 |
-
panY = H / 2 - ((minY + maxY) / 2) * zoom;
|
| 976 |
}}
|
| 977 |
-
document.getElementById('fitBtn').onclick =
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 984 |
-
|
| 985 |
-
nodes.forEach(
|
| 986 |
-
|
| 987 |
-
|
| 988 |
-
|
| 989 |
-
|
| 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 |
-
|
|
|
|
| 1024 |
}};
|
| 1025 |
host.appendChild(btn);
|
| 1026 |
}});
|
| 1027 |
}}
|
| 1028 |
-
document.getElementById('allBtn').onclick = () => {{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1029 |
document.getElementById('noneBtn').onclick = () => {{
|
| 1030 |
Object.keys(counts()).forEach(k => hiddenKinds.add(k)); renderCats();
|
| 1031 |
}};
|
| 1032 |
renderCats();
|
| 1033 |
|
| 1034 |
-
// ββ Incremental model merge
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1060 |
if (prev) {{ x = prev.x; y = prev.y; }}
|
| 1061 |
-
else {{
|
| 1062 |
-
|
| 1063 |
-
|
| 1064 |
-
|
| 1065 |
-
x =
|
| 1066 |
}}
|
| 1067 |
-
nd =
|
| 1068 |
-
|
| 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 =
|
| 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();
|
| 1078 |
-
target = byId.get('target');
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1161 |
|
| 1162 |
|
| 1163 |
def render_graph_shell(placeholder, *, height: int = 520) -> None:
|
| 1164 |
-
"""Mount the
|
| 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)
|