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 */} + + {/* Track ring */} + + {/* Leading arc */} + + + 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.