Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 127 additions & 25 deletions backend/app/readers/edge_reader.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -201,7 +201,6 @@ def column_summary(self, column: str) -> dict:
def query_grouped(
self,
bbox: Optional[tuple] = None,
excluded_lrms: Optional[list] = None,
min_lrm_count: int = 1,
density: float = 1.0,
max_limit: int = 500_000,
Expand All@@ -210,6 +209,14 @@ def query_grouped(
Return one row per directed edge (GROUP BY edge), pre-aggregated.
~500x fewer rows than query() for typical LRM-rich parquet files.

Returns structural columns (positions, metadata, lrm_count) plus
score_sum = SUM(score) over ALL LRMs — the unfiltered total used as
the default when no LRM filter is active.

LRM-filter-aware scores (visible_lrm_count / visible_score_sum) are
served separately by query_scores() so that LRM selection changes do
not require re-fetching the heavy structural data.

density=1.0 returns all edges in the viewport (up to max_limit).
density<1.0 uses bernoulli sampling so each edge is independently
included with probability `density` — spatially uniform.
Expand DownExpand Up@@ -246,34 +253,16 @@ def query_grouped(
if coord in schema_names:
agg_cols.append(f'FIRST("{coord}") AS "{coord}"')

# lrm_count = total LRMs for this edge (tissue graph — show all pairs)
# visible_lrm_count = LRMs not excluded (directed edges — hide when 0)
# visible_score_sum = SUM(score) for non-excluded LRMs (client-side lrm_set coloring)
excl_params: list = []
if has_lrm and excluded_lrms:
ph = ", ".join("?" for _ in excluded_lrms)
# lrm_count = total LRMs for this edge (tissue-graph structural layer)
# score_sum = SUM(all scores) — default visible_score_sum when no LRM filter
if has_lrm:
agg_cols.append("COUNT(*) AS lrm_count")
agg_cols.append(
f"SUM(CASE WHEN lrm NOT IN ({ph}) THEN 1 ELSE 0 END) AS visible_lrm_count"
)
if has_score:
agg_cols.append(
f"SUM(CASE WHEN lrm NOT IN ({ph}) THEN score ELSE 0 END) AS visible_score_sum"
)
excl_params = list(excluded_lrms) * (2 if has_score else 1)
elif has_lrm:
agg_cols.append("COUNT(*) AS lrm_count")
agg_cols.append("COUNT(*) AS visible_lrm_count")
if has_score:
agg_cols.append("SUM(score) AS visible_score_sum")
else:
agg_cols.append("1 AS lrm_count")
agg_cols.append("1 AS visible_lrm_count")
if has_score:
agg_cols.append("SUM(score) AS score_sum")

select = ", ".join(agg_cols)
# SELECT clause (lrm NOT IN ...) comes before WHERE in the SQL,
# so excl_params must be bound first, then where_params.
all_params = excl_params + where_params

# Bernoulli sampling: each grouped edge row is included independently
# at probability `density`. At density=1.0 no sampling clause is added
Expand All@@ -295,7 +284,7 @@ def query_grouped(
"""

with self._conn() as conn:
df = conn.execute(sql, all_params).df()
df = conn.execute(sql, where_params).df()

for col in ("x1", "y1", "x2", "y2"):
if col in df.columns:
Expand All@@ -310,6 +299,119 @@ def query_grouped(
for row in df.to_dict(orient="records")
]

def query_scores(
self,
bbox: Optional[tuple] = None,
included_lrms: Optional[list] = None,
excluded_lrms: Optional[list] = None,
max_limit: int = 500_000,
) -> list[dict]:
"""
Return per-edge LRM visibility scores: visible_lrm_count + visible_score_sum.
Much lighter than query_grouped — no coordinates or metadata columns.

Two query strategies, chosen by the caller based on set sizes:

included_lrms (preferred when visible set is small):
WHERE lrm IN (included_lrms) — DuckDB only reads matching rows,
giving roughly a (visible / total) fraction of the scan cost.
Edges absent from the result have visible_lrm_count = 0.

excluded_lrms (preferred when excluded set is small):
CASE WHEN lrm NOT IN (excluded_lrms) — full scan with per-row mask.
All bbox edges appear in the result.

The frontend picks whichever produces the smaller IN-list.
No density sampling — returns scores for all bbox edges so the
density-sampled structural edges always find their matching scores.
"""
ps = self.pixel_size
schema_names = set(self._parquet_schema().names)
has_lrm = "lrm" in schema_names
has_score = "score" in schema_names

if not has_lrm:
return []

where_conditions: list[str] = []
where_params: list = []

if bbox:
xmin, ymin, xmax, ymax = bbox
if None not in (xmin, ymin, xmax, ymax):
xmin_u, ymin_u = xmin * ps, ymin * ps
xmax_u, ymax_u = xmax * ps, ymax * ps
where_conditions.append(
"((x1 >= ? AND x1 <= ? AND y1 >= ? AND y1 <= ?) OR "
"(x2 >= ? AND x2 <= ? AND y2 >= ? AND y2 <= ?))"
)
where_params.extend([xmin_u, xmax_u, ymin_u, ymax_u,
xmin_u, xmax_u, ymin_u, ymax_u])

where = f"WHERE {' AND '.join(where_conditions)}" if where_conditions else ""

if included_lrms is not None:
# Fast path: filter to visible rows only, then aggregate.
# Edges with 0 visible LRMs simply don't appear — the frontend
# treats missing entries as visible_lrm_count = 0.
ph = ", ".join("?" for _ in included_lrms)
lrm_filter = f" AND lrm IN ({ph})" if included_lrms else " AND FALSE"
full_where = (
f"WHERE {' AND '.join(where_conditions)}{lrm_filter}"
if where_conditions
else f"WHERE lrm IN ({ph})" if included_lrms else "WHERE FALSE"
)
score_col = "SUM(score) AS visible_score_sum" if has_score else "0 AS visible_score_sum"
sql = f"""
SELECT edge,
COUNT(*) AS visible_lrm_count,
{score_col}
FROM {self._from()}
{full_where}
GROUP BY edge
LIMIT {max_limit}
"""
params = where_params + list(included_lrms)

else:
# Standard path: full scan with CASE WHEN exclusion mask.
excl = excluded_lrms or []
ph = ", ".join("?" for _ in excl)
vis_count = (
f"SUM(CASE WHEN lrm NOT IN ({ph}) THEN 1 ELSE 0 END)"
if excl else "COUNT(*)"
)
vis_score = ""
if has_score:
vis_score = (
f", SUM(CASE WHEN lrm NOT IN ({ph}) THEN score ELSE 0 END) AS visible_score_sum"
if excl else ", SUM(score) AS visible_score_sum"
)
# CASE WHEN placeholders come before WHERE placeholders in bind order
excl_params = list(excl) * (2 if (excl and has_score) else (1 if excl else 0))
sql = f"""
SELECT edge,
{vis_count} AS visible_lrm_count
{vis_score}
FROM {self._from()}
{where}
GROUP BY edge
LIMIT {max_limit}
"""
params = excl_params + where_params

with self._conn() as conn:
df = conn.execute(sql, params).df()

if df.empty:
return []

return [
{c: (None if isinstance(v, float) and not math.isfinite(v) else v)
for c, v in row.items()}
for row in df.to_dict(orient="records")
]

def query(
self,
bbox: Optional[tuple] = None,
Expand Down
42 changes: 37 additions & 5 deletions backend/app/routers/edges.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -114,7 +114,6 @@ class EdgeGroupedQueryRequest(BaseModel):
ymin: Optional[float] = None
xmax: Optional[float] = None
ymax: Optional[float] = None
excluded_lrms: Optional[List[Optional[str]]] = None
min_strength: Optional[float] = None
density: float = 1.0 # fraction of viewport edges to return (0.01–1.0)

Expand All@@ -125,20 +124,53 @@ def query_edges_grouped(dataset: str, body: EdgeGroupedQueryRequest,
"""
Return one row per directed edge (pre-aggregated by edge).
~500x fewer rows than /query for LRM-rich parquet files.
Accepts excluded_lrms list so visible_lrm_count reflects only visible mechanisms.
Returns structural columns + score_sum (unfiltered total).
LRM-filter-aware scores are served by /query-scores.
"""
bbox = (body.xmin, body.ymin, body.xmax, body.ymax) \
if body.xmin is not None else None
# Strip any null entries that can arise from null lrm values in the parquet
excluded = [x for x in (body.excluded_lrms or []) if x is not None]
density = max(0.001, min(1.0, body.density))
return _reader(dataset, edge_file).query_grouped(
bbox=bbox,
excluded_lrms=excluded,
density=density,
)


class EdgeScoreQueryRequest(BaseModel):
xmin: Optional[float] = None
ymin: Optional[float] = None
xmax: Optional[float] = None
ymax: Optional[float] = None
# Exactly one of included_lrms / excluded_lrms should be provided.
# The frontend sends whichever produces the smaller IN-list:
# included_lrms — when the visible set is small (fast WHERE lrm IN path)
# excluded_lrms — when the excluded set is small (CASE WHEN path)
included_lrms: Optional[List[Optional[str]]] = None
excluded_lrms: Optional[List[Optional[str]]] = None


@router.post("/{dataset}/query-scores")
def query_edge_scores(dataset: str, body: EdgeScoreQueryRequest,
edge_file: str = Query("edges.parquet")):
"""
Return per-edge LRM visibility scores: {edge, visible_lrm_count, visible_score_sum}.
Lightweight complement to /query-grouped — only two aggregate columns, no coordinates.
Re-runs whenever hiddenLrms changes without requiring a full structural re-fetch.
"""
bbox = (body.xmin, body.ymin, body.xmax, body.ymax) \
if body.xmin is not None else None
# Strip nulls that can arise from null lrm values in the parquet
included = [x for x in (body.included_lrms or []) if x is not None] \
if body.included_lrms is not None else None
excluded = [x for x in (body.excluded_lrms or []) if x is not None] \
if body.excluded_lrms is not None else None
return _reader(dataset, edge_file).query_scores(
bbox=bbox,
included_lrms=included,
excluded_lrms=excluded,
)


class EdgeColorRequest(BaseModel):
mode: str # "lrm_set" | "metadata"
lrms: Optional[List[str]] = None # for lrm_set: list of "ligand|receptor" strings
Expand Down
1 change: 1 addition & 0 deletions frontend/index.html
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #root { width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; }
@keyframes tp-spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
Expand Down
78 changes: 78 additions & 0 deletions frontend/src/components/RenderingStatus.jsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
/**
* RenderingStatus — floating badge that appears when any data layer for this
* panel is still fetching from the backend.
*
* Visibility is delayed by ONSET_MS so fast requests (< ~400ms) never flash the
* badge at all. Disappears immediately once all fetches complete so the user
* gets instant confirmation that the view is stable.
*
* Positioned at the bottom-right of the viewer canvas, above OSD controls.
* pointer-events: none so it never blocks clicks.
*/
import React, { useEffect, useRef, useState } from "react";
import { useStore } from "../store";

const ONSET_MS = 400;

export default function RenderingStatus({ panelIndex }) {
const isLoading = useStore((s) => s.loadingKeys.has(`panel-${panelIndex}`));
const [visible, setVisible] = useState(false);
const timerRef = useRef(null);

useEffect(() => {
clearTimeout(timerRef.current);
if (isLoading) {
// Only show the badge if loading persists beyond the onset threshold.
// This prevents a flicker on fast requests that resolve within ~400ms.
timerRef.current = setTimeout(() => setVisible(true), ONSET_MS);
} else {
// Disappear immediately — instant feedback that the view is stable.
setVisible(false);
}
return () => clearTimeout(timerRef.current);
}, [isLoading]);

if (!visible) return null;

return (
<div
style={{
position: "absolute",
bottom: 40,
right: 10,
display: "flex",
alignItems: "center",
gap: 6,
background: "rgba(0,0,0,0.72)",
color: "#bbb",
fontFamily: "monospace",
fontSize: 11,
padding: "5px 10px",
borderRadius: 4,
pointerEvents: "none",
zIndex: 20,
userSelect: "none",
}}
>
{/* SVG spinner — uses the tp-spin keyframe defined in index.html */}
<svg
width="13"
height="13"
viewBox="0 0 13 13"
style={{ animation: "tp-spin 0.75s linear infinite", flexShrink: 0 }}
>
{/* Track ring */}
<circle cx="6.5" cy="6.5" r="5" fill="none" stroke="#444" strokeWidth="1.8" />
{/* Leading arc */}
<path
d="M 6.5 1.5 A 5 5 0 0 1 11.5 6.5"
fill="none"
stroke="#ccc"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
Computing…
</div>
);
}
Loading