diff --git a/backend/app/readers/edge_reader.py b/backend/app/readers/edge_reader.py
index c81f279..ad6a841 100644
--- a/backend/app/readers/edge_reader.py
+++ b/backend/app/readers/edge_reader.py
@@ -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,
@@ -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.
@@ -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
@@ -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:
@@ -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,
diff --git a/backend/app/routers/edges.py b/backend/app/routers/edges.py
index 6825f41..d8c5b3b 100644
--- a/backend/app/routers/edges.py
+++ b/backend/app/routers/edges.py
@@ -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)
@@ -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
diff --git a/frontend/index.html b/frontend/index.html
index 28c821f..0474da2 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -7,6 +7,7 @@
diff --git a/frontend/src/components/RenderingStatus.jsx b/frontend/src/components/RenderingStatus.jsx
new file mode 100644
index 0000000..b438b75
--- /dev/null
+++ b/frontend/src/components/RenderingStatus.jsx
@@ -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 (
+
+ {/* SVG spinner — uses the tp-spin keyframe defined in index.html */}
+
+ Computing…
+
+ );
+}
diff --git a/frontend/src/components/Viewer.jsx b/frontend/src/components/Viewer.jsx
index 641f993..1be0df2 100644
--- a/frontend/src/components/Viewer.jsx
+++ b/frontend/src/components/Viewer.jsx
@@ -29,6 +29,7 @@ import { useEdgeColors } from "../hooks/useEdgeColors";
import { geneColor } from "../utils/geneColor";
import AnnotationToolbar from "./AnnotationToolbar";
import EdgeInfoPanel from "./EdgeInfoPanel";
+import RenderingStatus from "./RenderingStatus";
// Default edge color when no color mode is active
const DEFAULT_EDGE_COLOR = [255, 150, 0, 160];
@@ -84,6 +85,7 @@ function ViewerPanel({ panelIndex }) {
panelCount,
transcriptFraction, setTranscriptStats,
cellBoundaryFraction, setCellBoundaryStats,
+ setLoadingKey,
} = useStore();
// Per-panel viewport from store
@@ -420,7 +422,7 @@ function ViewerPanel({ panelIndex }) {
const hasTranscripts = platformCapabilities?.has_transcripts ?? true;
const hasBoundaries = platformCapabilities?.has_boundaries ?? true;
- const { transcripts, total: transcriptTotal } = useTranscripts(
+ const { transcripts, total: transcriptTotal, loading: transcriptsLoading } = useTranscripts(
apiBase, dataset, viewport, imageSize, transcriptsVisible && hasTranscripts, transcriptFraction, selectedGenes
);
@@ -439,6 +441,7 @@ function ViewerPanel({ panelIndex }) {
const {
cells: cellPolygons,
total: cellBoundaryTotal,
+ loading: cellBoundariesLoading,
} = useCellBoundaries(
apiBase, dataset, viewport, imageSize, cellSegmentsVisible && hasBoundaries, cellBoundaryFraction
);
@@ -449,11 +452,12 @@ function ViewerPanel({ panelIndex }) {
if (panelIndex === 0) setCellBoundaryStats(cellPolygons.length, cellBoundaryTotal);
}, [cellPolygons.length, cellBoundaryTotal]); // eslint-disable-line
- const { edges } = useEdges(
- apiBase, dataset, viewport, imageSize, edgesVisible || tissueGraphVisible, edgeMinStrength, hiddenLrms, edgeDensity
+ const { edges, loading: edgesLoading } = useEdges(
+ apiBase, dataset, viewport, imageSize, edgesVisible || tissueGraphVisible,
+ edgeMinStrength, hiddenLrms, lrmCatalogue, edgeDensity
);
- const { colorValues, vmin: cellVmin, vmax: cellVmax } = useCellColors(
+ const { colorValues, vmin: cellVmin, vmax: cellVmax, loading: cellColorsLoading } = useCellColors(
apiBase, dataset, colorBy, allGenes, selectedGenes, cellColorPalette, cellColorEnabled, cellColorClamp, categoryColorOverrides
);
// Only update shared store ranges from panel 0 to avoid redundant updates
@@ -462,7 +466,7 @@ function ViewerPanel({ panelIndex }) {
}, [cellVmin, cellVmax]); // eslint-disable-line
const edgeColorEnabled = edgeColorBy.mode !== "default";
- const { colorValues: edgeColorValues, vmin: edgeVmin, vmax: edgeVmax, p95: edgeP95 } = useEdgeColors(
+ const { colorValues: edgeColorValues, vmin: edgeVmin, vmax: edgeVmax, p95: edgeP95, loading: edgeColorsLoading } = useEdgeColors(
apiBase, dataset, edgeColorBy, hiddenLrms, lrmCatalogue, edgeColorPalette, edgeColorEnabled, edgeColorClamp, edges
);
useEffect(() => {
@@ -477,6 +481,19 @@ function ViewerPanel({ panelIndex }) {
useEffect(() => { setSelectedEdge(null); }, [dataset]); // eslint-disable-line
+ // ── Loading state → store (for RenderingStatus badge) ────────────────────
+ // Aggregate all hook loading booleans into a single per-panel key so the
+ // badge appears whenever *any* layer for this panel is still fetching.
+ useEffect(() => {
+ const anyLoading = transcriptsLoading || cellBoundariesLoading || edgesLoading || cellColorsLoading || edgeColorsLoading;
+ setLoadingKey(`panel-${panelIndex}`, anyLoading);
+ }, [transcriptsLoading, cellBoundariesLoading, edgesLoading, cellColorsLoading, edgeColorsLoading, panelIndex, setLoadingKey]);
+
+ // Clear this panel's loading key when the panel unmounts (split ↔ single toggle)
+ useEffect(() => {
+ return () => setLoadingKey(`panel-${panelIndex}`, false);
+ }, []); // eslint-disable-line
+
// ── deck.gl layers ────────────────────────────────────────────────────────
const selectedId = selectedCell?.cell_id ?? null;
@@ -881,6 +898,9 @@ function ViewerPanel({ panelIndex }) {
{/* Annotation toolbar — split toggle only shown on panel 0 */}
+ {/* Loading / computing badge — appears ~400ms after any fetch starts */}
+
+
{/* Edge info panel — only rendered in panel 0 to avoid duplication */}
{panelIndex === 0 && selectedEdge && (
{
- if (!enabled || !dataset) return;
+ if (!enabled || !dataset) {
+ setLoading(false);
+ return;
+ }
clearTimeout(timerRef.current);
timerRef.current = setTimeout(async () => {
+ // Cancel any in-flight request before starting a new one
+ if (abortRef.current) abortRef.current.abort();
+ const ctrl = new AbortController();
+ abortRef.current = ctrl;
+
// Compute the fraction we'll actually send this round
const autoFrac = Math.min(1.0, TARGET_CELLS / Math.max(1, prevTotalRef.current));
const eff = fraction !== null
@@ -50,7 +62,7 @@ export function useCellBoundaries(
} else {
url += `?${fracParam}`;
}
- const res = await fetch(url);
+ const res = await fetch(url, { signal: ctrl.signal });
if (!res.ok) { setCells([]); setTotal(0); return; }
const data = await res.json();
@@ -74,14 +86,23 @@ export function useCellBoundaries(
Array.from(byCell.entries()).map(([cell_id, polygon]) => ({ cell_id, polygon }))
);
} catch (e) {
+ if (e.name === "AbortError") return; // silently ignore — a newer fetch is in flight
setError(e.message);
} finally {
- setLoading(false);
+ if (abortRef.current === ctrl) setLoading(false);
}
}, 200);
return () => clearTimeout(timerRef.current);
}, [apiBase, dataset, viewport?.xmin, viewport?.ymin, viewport?.xmax, viewport?.ymax, enabled, fraction]);
+ // Abort in-flight request on unmount
+ useEffect(() => {
+ return () => {
+ clearTimeout(timerRef.current);
+ if (abortRef.current) abortRef.current.abort();
+ };
+ }, []);
+
return { cells, total, effectiveFraction, loading, error };
}
diff --git a/frontend/src/hooks/useCellColors.js b/frontend/src/hooks/useCellColors.js
index 349fadb..793d7bd 100644
--- a/frontend/src/hooks/useCellColors.js
+++ b/frontend/src/hooks/useCellColors.js
@@ -5,6 +5,11 @@ import { geneColor } from "../utils/geneColor";
/**
* Fetches per-cell color values and maps them to RGBA.
*
+ * Debounced at 400ms so rapid sequential changes (e.g. toggling genes one by
+ * one) collapse into a single server request for the final state.
+ * In-flight requests are aborted when superseded, so intermediate results never
+ * overwrite the response for the user's actual target settings.
+ *
* Modes:
* gene_set — POST with selected genes; returns continuous sum
* metadata — POST with field; backend auto-detects continuous vs. categorical
@@ -24,6 +29,7 @@ export function useCellColors(apiBase, dataset, colorBy, allGenes, selectedGenes
});
const [loading, setLoading] = useState(false);
const timerRef = useRef(null);
+ const abortRef = useRef(null);
// rawCat: the last categorical response from the server { categories, values }
// stored separately so color remapping doesn't trigger a re-fetch.
@@ -34,6 +40,7 @@ export function useCellColors(apiBase, dataset, colorBy, allGenes, selectedGenes
if (!enabled || !colorBy || colorBy.mode === "off") {
setResult({ colorValues: null, type: "continuous", vmin: 0, vmax: 0, categories: [], categoryColors: new Map() });
setRawCat(null);
+ setLoading(false);
return;
}
@@ -46,16 +53,23 @@ export function useCellColors(apiBase, dataset, colorBy, allGenes, selectedGenes
if (mode === "gene_set" && (!genesToSend || genesToSend.length === 0)) {
setResult({ colorValues: null, type: "continuous", vmin: 0, vmax: 0, categories: [], categoryColors: new Map() });
setRawCat(null);
+ setLoading(false);
return;
}
if (mode === "metadata" && !field) {
setResult({ colorValues: null, type: "continuous", vmin: 0, vmax: 0, categories: [], categoryColors: new Map() });
setRawCat(null);
+ setLoading(false);
return;
}
clearTimeout(timerRef.current);
timerRef.current = setTimeout(async () => {
+ // Cancel any in-flight request before starting a new one
+ if (abortRef.current) abortRef.current.abort();
+ const ctrl = new AbortController();
+ abortRef.current = ctrl;
+
setLoading(true);
try {
const body = mode === "gene_set"
@@ -66,6 +80,7 @@ export function useCellColors(apiBase, dataset, colorBy, allGenes, selectedGenes
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
+ signal: ctrl.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
@@ -85,13 +100,14 @@ export function useCellColors(apiBase, dataset, colorBy, allGenes, selectedGenes
);
setResult({ colorValues: colorMap, type: "continuous", vmin: min, vmax: max, categories: [], categoryColors: new Map() });
}
- } catch {
+ } catch (e) {
+ if (e.name === "AbortError") return; // silently ignore — a newer fetch is in flight
setRawCat(null);
setResult({ colorValues: null, type: "continuous", vmin: 0, vmax: 0, categories: [], categoryColors: new Map() });
} finally {
- setLoading(false);
+ if (abortRef.current === ctrl) setLoading(false);
}
- }, 150);
+ }, 400);
return () => clearTimeout(timerRef.current);
}, [apiBase, dataset, colorBy?.mode, colorBy?.field, allGenes, selectedGenes, palette, enabled, clamp?.low, clamp?.high]); // eslint-disable-line
@@ -114,5 +130,13 @@ export function useCellColors(apiBase, dataset, colorBy, allGenes, selectedGenes
setResult({ colorValues: cellColors, type: "categorical", vmin: 0, vmax: 0, categories, categoryColors: colorMap });
}, [rawCat, categoryColorOverrides, colorBy?.field]); // eslint-disable-line
+ // Abort in-flight request on unmount
+ useEffect(() => {
+ return () => {
+ clearTimeout(timerRef.current);
+ if (abortRef.current) abortRef.current.abort();
+ };
+ }, []);
+
return { ...result, loading };
}
diff --git a/frontend/src/hooks/useEdgeColors.js b/frontend/src/hooks/useEdgeColors.js
index 75eacbc..20f0fd3 100644
--- a/frontend/src/hooks/useEdgeColors.js
+++ b/frontend/src/hooks/useEdgeColors.js
@@ -7,6 +7,9 @@ import { valueToColor, QUAL_PALETTE } from "../utils/colormap";
* lrm_set mode: computed client-side from visible_score_sum in the edges array
* (no server call — eliminates the 13.8s global scan).
* metadata mode: fetched from the backend (requires server-side GROUP BY + field lookup).
+ * Debounced at 400ms so rapid field-switching collapses to a single
+ * request for the final selected field. In-flight requests are aborted
+ * when superseded.
*
* Returns:
* colorValues Map or null when mode is "default"
@@ -28,6 +31,7 @@ export function useEdgeColors(
});
const [loading, setLoading] = useState(false);
const timerRef = useRef(null);
+ const abortRef = useRef(null);
// ── lrm_set: compute synchronously from edges.visible_score_sum ──────────
const lrmSetResult = useMemo(() => {
@@ -59,23 +63,31 @@ export function useEdgeColors(
if (edgeColorBy?.mode !== "lrm_set") {
setResult({ colorValues: null, type: "continuous", vmin: 0, vmax: 0, p95: null, categories: [], categoryColors: new Map() });
}
+ setLoading(false);
return;
}
const { field } = edgeColorBy;
if (!field) {
setResult({ colorValues: null, type: "continuous", vmin: 0, vmax: 0, p95: null, categories: [], categoryColors: new Map() });
+ setLoading(false);
return;
}
clearTimeout(timerRef.current);
timerRef.current = setTimeout(async () => {
+ // Cancel any in-flight request before starting a new one
+ if (abortRef.current) abortRef.current.abort();
+ const ctrl = new AbortController();
+ abortRef.current = ctrl;
+
setLoading(true);
try {
const res = await fetch(`${apiBase}/edges/${dataset}/edge-color-values`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode: "metadata", field }),
+ signal: ctrl.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
@@ -100,15 +112,24 @@ export function useEdgeColors(
);
setResult({ colorValues: colorMap, type: "continuous", vmin: min, vmax: max, p95, categories: [], categoryColors: new Map() });
}
- } catch {
+ } catch (e) {
+ if (e.name === "AbortError") return; // silently ignore — a newer fetch is in flight
setResult({ colorValues: null, type: "continuous", vmin: 0, vmax: 0, p95: null, categories: [], categoryColors: new Map() });
} finally {
- setLoading(false);
+ if (abortRef.current === ctrl) setLoading(false);
}
- }, 150);
+ }, 400);
return () => clearTimeout(timerRef.current);
}, [apiBase, dataset, edgeColorBy?.mode, edgeColorBy?.field, palette, enabled, clamp?.low, clamp?.high]);
+ // Abort in-flight request on unmount
+ useEffect(() => {
+ return () => {
+ clearTimeout(timerRef.current);
+ if (abortRef.current) abortRef.current.abort();
+ };
+ }, []);
+
// Return lrm_set result immediately (synchronous), or the fetched metadata result
if (edgeColorBy?.mode === "lrm_set") {
return { ...(lrmSetResult ?? result), loading: false };
diff --git a/frontend/src/hooks/useEdges.js b/frontend/src/hooks/useEdges.js
index 571c819..80c6d12 100644
--- a/frontend/src/hooks/useEdges.js
+++ b/frontend/src/hooks/useEdges.js
@@ -1,38 +1,203 @@
-import { useState, useEffect, useRef } from "react";
+/**
+ * useEdges — fetch directed edge data from the backend.
+ *
+ * Splits work into two independent effects:
+ *
+ * Structural effect (deps: viewport, density, enabled, minStrength)
+ * POSTs to /query-grouped — heavy fetch that returns edge positions,
+ * metadata, lrm_count, and score_sum (unfiltered total score).
+ * Only re-runs when the viewport or density changes, NOT on LRM filter changes.
+ *
+ * Score effect (deps: viewport, hiddenLrms, lrmCatalogue)
+ * POSTs to /query-scores — lightweight fetch that returns only
+ * {edge, visible_lrm_count, visible_score_sum} for the current LRM filter.
+ * Automatically chooses the faster query strategy:
+ * • included_lrms (WHERE lrm IN …) when the visible set is smaller —
+ * DuckDB reads only the matching fraction of the parquet.
+ * • excluded_lrms (CASE WHEN NOT IN …) when the excluded set is smaller.
+ * Short-circuits with no backend call when hiddenLrms is empty (show all).
+ *
+ * The two results are merged client-side: structural edges get their
+ * visible_lrm_count / visible_score_sum fields overlaid from the score map.
+ * When no filter is active, score_sum from the structural fetch is used directly.
+ *
+ * Returns { edges, loading }.
+ */
+import { useState, useEffect, useRef, useMemo } from "react";
const DEBOUNCE_MS = 400;
-export function useEdges(apiBase, dataset, viewport, imageSize, enabled, minStrength, hiddenLrms, density = 1.0) {
- const [edges, setEdges] = useState([]);
- const timerRef = useRef(null);
+export function useEdges(
+ apiBase, dataset, viewport, imageSize, enabled,
+ minStrength, hiddenLrms, lrmCatalogue, density = 1.0
+) {
+ // ── Structural state ──────────────────────────────────────────────────────
+ const [structuralEdges, setStructuralEdges] = useState([]);
+ const [loadingStructural, setLoadingStructural] = useState(false);
+ const structTimerRef = useRef(null);
+ const structAbortRef = useRef(null);
+ // ── Score state ───────────────────────────────────────────────────────────
+ // Map | null
+ // null = no filter active; merge falls back to score_sum from structural
+ // Map = active filter; may be empty if all edges have 0 visible LRMs
+ const [edgeScores, setEdgeScores] = useState(null);
+ const [loadingScores, setLoadingScores] = useState(false);
+ const scoreTimerRef = useRef(null);
+ const scoreAbortRef = useRef(null);
+
+ // ── Effect 1: structural fetch ─────────────────────────────────────────────
+ // Depends on viewport / density / enabled / minStrength only.
+ // hiddenLrms is intentionally excluded — LRM changes don't re-fetch positions.
useEffect(() => {
if (!enabled || !viewport || !imageSize?.w) {
- setEdges([]);
+ setStructuralEdges([]);
+ setLoadingStructural(false);
return;
}
- clearTimeout(timerRef.current);
- timerRef.current = setTimeout(async () => {
+ clearTimeout(structTimerRef.current);
+ structTimerRef.current = setTimeout(async () => {
+ if (structAbortRef.current) structAbortRef.current.abort();
+ const ctrl = new AbortController();
+ structAbortRef.current = ctrl;
+
+ setLoadingStructural(true);
const { xmin, ymin, xmax, ymax } = viewport;
const body = { xmin, ymin, xmax, ymax, density: Math.max(0.01, Math.min(1.0, density)) };
if (minStrength != null && minStrength > 0) body.min_strength = minStrength;
- if (hiddenLrms?.size > 0) body.excluded_lrms = [...hiddenLrms];
try {
const res = await fetch(`${apiBase}/edges/${dataset}/query-grouped`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
+ signal: ctrl.signal,
+ });
+ setStructuralEdges(res.ok ? await res.json() : []);
+ } catch (e) {
+ if (e.name !== "AbortError") setStructuralEdges([]);
+ } finally {
+ if (structAbortRef.current === ctrl) setLoadingStructural(false);
+ }
+ }, DEBOUNCE_MS);
+
+ return () => clearTimeout(structTimerRef.current);
+ }, [apiBase, dataset, viewport, imageSize, enabled, minStrength, density]); // eslint-disable-line
+
+ // ── Effect 2: score fetch ──────────────────────────────────────────────────
+ // Runs when viewport OR hiddenLrms changes.
+ // Short-circuits when hiddenLrms is empty (no filter → use score_sum from structural).
+ useEffect(() => {
+ if (!enabled || !viewport || !imageSize?.w) {
+ setEdgeScores(null);
+ setLoadingScores(false);
+ return;
+ }
+
+ const hiddenCount = hiddenLrms?.size ?? 0;
+ const totalLrms = lrmCatalogue?.length ?? 0;
+
+ // No filter active: clear any stale score map so merge uses structural score_sum
+ if (hiddenCount === 0 || totalLrms === 0) {
+ clearTimeout(scoreTimerRef.current);
+ if (scoreAbortRef.current) { scoreAbortRef.current.abort(); scoreAbortRef.current = null; }
+ setEdgeScores(null);
+ setLoadingScores(false);
+ return;
+ }
+
+ // All LRMs hidden: every edge has visible_lrm_count = 0; no fetch needed
+ const visibleCount = totalLrms - hiddenCount;
+ if (visibleCount <= 0) {
+ clearTimeout(scoreTimerRef.current);
+ if (scoreAbortRef.current) { scoreAbortRef.current.abort(); scoreAbortRef.current = null; }
+ setEdgeScores(new Map()); // empty Map signals "filter active, all hidden"
+ setLoadingScores(false);
+ return;
+ }
+
+ clearTimeout(scoreTimerRef.current);
+ scoreTimerRef.current = setTimeout(async () => {
+ if (scoreAbortRef.current) scoreAbortRef.current.abort();
+ const ctrl = new AbortController();
+ scoreAbortRef.current = ctrl;
+
+ setLoadingScores(true);
+ const { xmin, ymin, xmax, ymax } = viewport;
+ const body = { xmin, ymin, xmax, ymax };
+
+ // Choose the smaller set to minimise the IN-list and maximise DuckDB pruning.
+ // included_lrms → WHERE lrm IN (...): reads only matching rows (~visibleCount/totalLrms fraction).
+ // excluded_lrms → CASE WHEN NOT IN (...): full scan, but exclusion list is short.
+ if (visibleCount <= hiddenCount) {
+ body.included_lrms = lrmCatalogue
+ .filter(l => !hiddenLrms.has(l.lrm))
+ .map(l => l.lrm);
+ } else {
+ body.excluded_lrms = [...hiddenLrms];
+ }
+
+ try {
+ const res = await fetch(`${apiBase}/edges/${dataset}/query-scores`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ signal: ctrl.signal,
});
- setEdges(res.ok ? await res.json() : []);
- } catch {
- setEdges([]);
+ if (res.ok) {
+ const arr = await res.json();
+ setEdgeScores(new Map(arr.map(s => [
+ s.edge,
+ { visible_lrm_count: s.visible_lrm_count ?? 0,
+ visible_score_sum: s.visible_score_sum ?? 0 },
+ ])));
+ } else {
+ setEdgeScores(null); // fallback: treat as no filter
+ }
+ } catch (e) {
+ if (e.name !== "AbortError") setEdgeScores(null);
+ } finally {
+ if (scoreAbortRef.current === ctrl) setLoadingScores(false);
}
}, DEBOUNCE_MS);
- return () => clearTimeout(timerRef.current);
- }, [apiBase, dataset, viewport, imageSize, enabled, minStrength, hiddenLrms, density]);
+ return () => clearTimeout(scoreTimerRef.current);
+ }, [apiBase, dataset, viewport, imageSize, enabled, hiddenLrms, lrmCatalogue]); // eslint-disable-line
+
+ // ── Merge: overlay scores onto structural edges ───────────────────────────
+ // edgeScores === null → no filter; use score_sum from structural as visible_score_sum
+ // edgeScores is a Map → filter active; look up each edge, default to 0 if absent
+ const edges = useMemo(() => {
+ if (structuralEdges.length === 0) return [];
+ if (edgeScores === null) {
+ // No LRM filter — visible_* fields equal the unfiltered totals
+ return structuralEdges.map(e => ({
+ ...e,
+ visible_lrm_count: e.lrm_count ?? 1,
+ visible_score_sum: e.score_sum ?? null,
+ }));
+ }
+ // LRM filter active — overlay scores; missing entries → 0 visible LRMs
+ return structuralEdges.map(e => {
+ const s = edgeScores.get(e.edge);
+ return {
+ ...e,
+ visible_lrm_count: s?.visible_lrm_count ?? 0,
+ visible_score_sum: s?.visible_score_sum ?? 0,
+ };
+ });
+ }, [structuralEdges, edgeScores]);
+
+ // ── Abort on unmount ──────────────────────────────────────────────────────
+ useEffect(() => {
+ return () => {
+ clearTimeout(structTimerRef.current);
+ clearTimeout(scoreTimerRef.current);
+ if (structAbortRef.current) structAbortRef.current.abort();
+ if (scoreAbortRef.current) scoreAbortRef.current.abort();
+ };
+ }, []);
- return { edges };
+ return { edges, loading: loadingStructural || loadingScores };
}
diff --git a/frontend/src/hooks/useTranscripts.js b/frontend/src/hooks/useTranscripts.js
index 934f7cc..fda23d0 100644
--- a/frontend/src/hooks/useTranscripts.js
+++ b/frontend/src/hooks/useTranscripts.js
@@ -3,6 +3,8 @@ import { useState, useEffect, useRef } from "react";
/**
* Fetches transcripts from the backend, filtered by viewport bbox.
* Debounced so rapid pan/zoom doesn't hammer the API.
+ * In-flight requests are aborted when a newer fetch supersedes them, so stale
+ * responses from intermediate viewport positions never overwrite current data.
*
* @param fraction 0–1 fraction of viewport transcripts to request.
* @param selectedGenes null = all species; Set = only those genes.
@@ -17,6 +19,7 @@ export function useTranscripts(apiBase, dataset, viewport, imageSize, enabled =
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const timerRef = useRef(null);
+ const abortRef = useRef(null);
// Stable string representation of the gene set for use as a dep.
const genesKey = selectedGenes === null ? "" : [...selectedGenes].sort().join(",");
@@ -25,11 +28,17 @@ export function useTranscripts(apiBase, dataset, viewport, imageSize, enabled =
if (!enabled || !dataset) {
setTranscripts([]);
setTotal(0);
+ setLoading(false);
return;
}
clearTimeout(timerRef.current);
timerRef.current = setTimeout(async () => {
+ // Cancel any in-flight request before starting a new one
+ if (abortRef.current) abortRef.current.abort();
+ const ctrl = new AbortController();
+ abortRef.current = ctrl;
+
setLoading(true);
setError(null);
try {
@@ -49,7 +58,7 @@ export function useTranscripts(apiBase, dataset, viewport, imageSize, enabled =
url += `&xmin=${xmin}&ymin=${ymin}&xmax=${xmax}&ymax=${ymax}`;
}
- const res = await fetch(url);
+ const res = await fetch(url, { signal: ctrl.signal });
if (!res.ok) { setTranscripts([]); setTotal(0); return; }
const data = await res.json();
// Response is { transcripts: [...], total: N }
@@ -58,15 +67,24 @@ export function useTranscripts(apiBase, dataset, viewport, imageSize, enabled =
setTranscripts(arr);
setTotal(tot);
} catch (e) {
+ if (e.name === "AbortError") return; // silently ignore — a newer fetch is in flight
setError(e.message);
setTotal(0);
} finally {
- setLoading(false);
+ if (abortRef.current === ctrl) setLoading(false);
}
}, 200);
return () => clearTimeout(timerRef.current);
}, [apiBase, dataset, viewport?.xmin, viewport?.ymin, viewport?.xmax, viewport?.ymax, enabled, fraction, genesKey]); // eslint-disable-line
+ // Abort in-flight request on unmount
+ useEffect(() => {
+ return () => {
+ clearTimeout(timerRef.current);
+ if (abortRef.current) abortRef.current.abort();
+ };
+ }, []);
+
return { transcripts, total, loading, error };
}
diff --git a/frontend/src/store.js b/frontend/src/store.js
index 7f5c194..6cc258b 100644
--- a/frontend/src/store.js
+++ b/frontend/src/store.js
@@ -223,6 +223,16 @@ export const useStore = create((set, get) => ({
clearAnnotations: () =>
set({ activeRegion: [], regions: [], measurements: [] }),
+ // ── Rendering / loading state ─────────────────────────────────────────────
+ // loadingKeys: Set of string keys currently in flight (one entry per panel).
+ // The status badge is visible whenever loadingKeys.size > 0.
+ loadingKeys: new Set(),
+ setLoadingKey: (key, loading) => set((s) => {
+ const next = new Set(s.loadingKeys);
+ if (loading) next.add(key); else next.delete(key);
+ return { loadingKeys: next };
+ }),
+
// ── Transcript species filter ──────────────────────────────────────────────
// selectedGenes: null = no filter (show all); Set = allowlist (show only these).
// The selection is dataset-scoped and persists across pan/zoom.