From cd5478d56daae0947540d5cdbe9e5c7bc7b734e9 Mon Sep 17 00:00:00 2001
From: Micha Sam Brickman Raredon <42100128+msraredon@users.noreply.github.com>
Date: Fri, 29 May 2026 11:53:12 -0400
Subject: [PATCH 1/5] attempt to fix issue #36
---
docs/setup.md | 7 +++++++
frontend/nginx.conf | 9 ++++++++-
2 files changed, 15 insertions(+), 1 deletion(-)
diff --git a/docs/setup.md b/docs/setup.md
index 06cc053..8e446e9 100644
--- a/docs/setup.md
+++ b/docs/setup.md
@@ -103,6 +103,11 @@ docker compose up --build
The `--build` flag rebuilds the images with any code changes. Your data and tile cache are unaffected.
+> **Windows note:** If you see a black screen or a browser console error about a wrong MIME type after updating, Docker may have used a stale build cache. Force a clean rebuild with:
+> ```bash
+> docker compose build --no-cache && docker compose up
+> ```
+
---
## Shared lab server
@@ -138,3 +143,5 @@ If `edges.parquet` is absent, the Edges layer is hidden — all other layers wor
**Port conflict**: If port 3000 or 8000 is already in use, change the host ports in `docker-compose.yml` and rebuild.
**Tile cache corruption**: Run `docker compose down -v` to clear the cache, then restart.
+
+**Black screen / MIME type error in browser console** (common on Windows after an update): Docker reused a stale build cache and the frontend is serving outdated files. Run `docker compose build --no-cache && docker compose up` to force a clean rebuild.
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index ab285e5..ac8202c 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -20,7 +20,14 @@ server {
proxy_busy_buffers_size 2m;
}
- # SPA fallback
+ # Hashed static assets — serve directly; never fall back to index.html.
+ # Vite fingerprints every file in /assets/ so a 404 here means the Docker
+ # image is stale (use --no-cache to force a clean rebuild).
+ location /assets/ {
+ try_files $uri =404;
+ }
+
+ # SPA fallback for all other routes
location / {
try_files $uri $uri/ /index.html;
}
From c0f45d159e47f35bd30b63f614882326562a17e4 Mon Sep 17 00:00:00 2001
From: Micha Sam Brickman Raredon <42100128+msraredon@users.noreply.github.com>
Date: Fri, 29 May 2026 12:01:21 -0400
Subject: [PATCH 2/5] Update index.html
---
docs/index.html | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/docs/index.html b/docs/index.html
index 257b2c9..b73e093 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -390,6 +390,15 @@
Download TissuePlex
back to this folder (cd ~/Documents/TissuePlex), run
git pull, then docker compose up --build.
+
+
+ Windows users: After a git pull,
+ Docker Desktop sometimes reuses a stale build cache and serves an outdated
+ version of the app, resulting in a black screen with an error about a wrong
+ MIME type in the browser console. If this happens, force a clean rebuild:
+
docker compose build --no-cache
+docker compose up
+
@@ -1084,6 +1093,11 @@
Troubleshooting
TissuePlex is still processing the morphology image. Wait 15–30 seconds
and refresh the page.
+
+
Black screen after updating (especially on Windows)
+
Docker reused a stale build cache. Force a clean rebuild:
+ docker compose build --no-cache && docker compose up
+
My dataset doesn't appear in the dropdown
Check that DATA_PATH points to the parent folder and that
From 1e659c1e3c07576e78e0215b82e747816de563a9 Mon Sep 17 00:00:00 2001
From: Micha Sam Brickman Raredon <42100128+msraredon@users.noreply.github.com>
Date: Mon, 1 Jun 2026 16:55:02 -0400
Subject: [PATCH 3/5] perf: debounce final-state rendering and decouple LRM
score fetches
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Addresses issue #32 and related UX feedback: the app now only computes
and renders the final state after a user stops interacting, stale
in-flight responses are cancelled rather than overwriting current data,
and a 'Computing...' badge gives real-time feedback during any fetch.
AbortController added to all five data-fetching hooks (useEdges,
useTranscripts, useCellBoundaries, useCellColors, useEdgeColors): when a
new debounce fires the previous in-flight request is cancelled, so
intermediate pan positions or partially-dialled settings never flash onto
the canvas. Debounce for settings-driven hooks (useCellColors,
useEdgeColors metadata) bumped from 150 ms to 400 ms so rapid sequential
gene/LRM/colorBy changes collapse to a single server request.
useEdges is split into two independent effects:
- Structural effect (viewport/density deps only): POSTs to /query-grouped
for positions, metadata, lrm_count, and score_sum. Never re-runs on
LRM filter changes.
- Score effect (viewport + hiddenLrms deps): POSTs to new /query-scores
endpoint for {edge, visible_lrm_count, visible_score_sum} only. Picks
the cheaper query direction automatically — WHERE lrm IN (visible_set)
when the visible set is small (e.g. user selects 10/559 LRMs → ~98%
less parquet scanned), CASE WHEN NOT IN otherwise. Short-circuits
entirely when no LRM filter is active.
New RenderingStatus component shows a spinner badge in the viewer corner
after a 400 ms onset delay whenever any layer for that panel is fetching.
Disappears immediately on completion. Works per-panel in split-screen.
Loading state tracked via loadingKeys Set in Zustand store.
Co-Authored-By: Claude Sonnet 4.5
---
backend/app/readers/edge_reader.py | 152 ++++++++++++---
backend/app/routers/edges.py | 42 ++++-
frontend/index.html | 1 +
frontend/src/components/RenderingStatus.jsx | 78 ++++++++
frontend/src/components/Viewer.jsx | 30 ++-
frontend/src/hooks/useCellBoundaries.js | 27 ++-
frontend/src/hooks/useCellColors.js | 30 ++-
frontend/src/hooks/useEdgeColors.js | 27 ++-
frontend/src/hooks/useEdges.js | 193 ++++++++++++++++++--
frontend/src/hooks/useTranscripts.js | 22 ++-
frontend/src/store.js | 10 +
11 files changed, 552 insertions(+), 60 deletions(-)
create mode 100644 frontend/src/components/RenderingStatus.jsx
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.
From 1800faef89eff1988b63d9b2c20c3cf8fff17c7a Mon Sep 17 00:00:00 2001
From: Micha Sam Brickman Raredon <42100128+msraredon@users.noreply.github.com>
Date: Mon, 1 Jun 2026 18:17:08 -0400
Subject: [PATCH 4/5] fix: color range slider and reset now update canvas
instantly
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two related bugs fixed in useCellColors and useEdgeColors:
1. Clamp/palette changes (slider drag, palette swap, reset range) were
triggering a full 400 ms debounce + server re-fetch before the canvas
updated, because clamp?.low, clamp?.high, and palette were deps of the
fetch effect. Slider position and canvas were therefore out of sync
until the fetch returned, producing the "drag then image jumps" effect.
Fix: split each hook into a fetch effect (data params only — no clamp
or palette deps) and a separate remap effect that applies clamp +
palette synchronously to cached raw values. Canvas now updates in the
same render cycle as the slider.
2. After "reset range" in lrm_set edge color mode the canvas showed a
false bright-yellow render (most edges saturated) while the legend
correctly showed vmax. Root cause: lrmSetResult useMemo used
clamp?.high ?? p95 as the upper bound, but ClampableLegend used
clamp?.high ?? vmax. When clamp.high was null after reset these
diverged, so the rendering silently clamped at p95 while the legend
displayed vmax.
Fix: lrmSetResult now uses clamp?.high ?? max (matching the legend).
The Viewer.jsx auto-calibration effect that sets clamp.high = edgeP95
on each data load still fires normally and provides the p95-calibrated
initial view.
Co-Authored-By: Claude Sonnet 4.5
---
frontend/src/hooks/useCellColors.js | 72 +++++++++++++++++++----------
frontend/src/hooks/useEdgeColors.js | 62 ++++++++++++++++---------
2 files changed, 89 insertions(+), 45 deletions(-)
diff --git a/frontend/src/hooks/useCellColors.js b/frontend/src/hooks/useCellColors.js
index 793d7bd..07aa2c0 100644
--- a/frontend/src/hooks/useCellColors.js
+++ b/frontend/src/hooks/useCellColors.js
@@ -5,10 +5,22 @@ 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.
+ * Three-effect design keeps server fetches and client-side remapping separate:
+ *
+ * Effect 1 — fetch raw server data
+ * Deps: apiBase, dataset, colorBy, allGenes, selectedGenes, enabled
+ * Clamp and palette are intentionally excluded — they don't affect the
+ * server response, only how the returned values are mapped to colors.
+ * Debounced at 400 ms; in-flight requests are aborted when superseded.
+ *
+ * Effect 2 — apply clamp + palette to continuous raw data (no fetch)
+ * Deps: rawCont, clamp, palette
+ * Fires synchronously (no debounce) so slider drags and "reset range"
+ * update the canvas in the same render cycle as the slider itself.
+ *
+ * Effect 3 — remap categorical colors (no fetch)
+ * Deps: rawCat, categoryColorOverrides, colorBy.field
+ * Unchanged from the previous design.
*
* Modes:
* gene_set — POST with selected genes; returns continuous sum
@@ -31,41 +43,45 @@ export function useCellColors(apiBase, dataset, colorBy, allGenes, selectedGenes
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.
- const [rawCat, setRawCat] = useState(null);
+ // rawCat: last categorical server response { categories, values }
+ // rawCont: last continuous server response { values, min, max }
+ // Stored separately so palette/clamp changes recompute colors without re-fetching.
+ const [rawCat, setRawCat] = useState(null);
+ const [rawCont, setRawCont] = useState(null);
- // ── Effect 1: fetch server data ──────────────────────────────────────────
+ // ── Effect 1: fetch raw server data ──────────────────────────────────────
+ // palette and clamp deliberately excluded — they don't change the server response.
useEffect(() => {
if (!enabled || !colorBy || colorBy.mode === "off") {
setResult({ colorValues: null, type: "continuous", vmin: 0, vmax: 0, categories: [], categoryColors: new Map() });
setRawCat(null);
+ setRawCont(null);
setLoading(false);
return;
}
const { mode, field } = colorBy;
- // gene_set: null selectedGenes means show all; Set means use only those genes
const genesToSend = mode === "gene_set"
? (selectedGenes === null ? allGenes : [...selectedGenes])
: null;
if (mode === "gene_set" && (!genesToSend || genesToSend.length === 0)) {
setResult({ colorValues: null, type: "continuous", vmin: 0, vmax: 0, categories: [], categoryColors: new Map() });
setRawCat(null);
+ setRawCont(null);
setLoading(false);
return;
}
if (mode === "metadata" && !field) {
setResult({ colorValues: null, type: "continuous", vmin: 0, vmax: 0, categories: [], categoryColors: new Map() });
setRawCat(null);
+ setRawCont(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;
@@ -84,35 +100,43 @@ export function useCellColors(apiBase, dataset, colorBy, allGenes, selectedGenes
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
-
if (!data?.values) throw new Error("color-values response missing 'values'");
if (data.type === "categorical") {
- // Store raw server response; color mapping happens in Effect 2.
setRawCat({ categories: data.categories, values: data.values });
+ setRawCont(null);
} else {
+ setRawCont({ values: data.values, min: data.min, max: data.max });
setRawCat(null);
- const { min, max } = data;
- const lo = clamp?.low ?? min;
- const hi = clamp?.high ?? max;
- const colorMap = new Map(
- Object.entries(data.values).map(([id, v]) => [id, valueToColor(v, lo, hi, palette)])
- );
- setResult({ colorValues: colorMap, type: "continuous", vmin: min, vmax: max, categories: [], categoryColors: new Map() });
}
} catch (e) {
- if (e.name === "AbortError") return; // silently ignore — a newer fetch is in flight
+ if (e.name === "AbortError") return;
setRawCat(null);
+ setRawCont(null);
setResult({ colorValues: null, type: "continuous", vmin: 0, vmax: 0, categories: [], categoryColors: new Map() });
} finally {
if (abortRef.current === ctrl) setLoading(false);
}
}, 400);
return () => clearTimeout(timerRef.current);
- }, [apiBase, dataset, colorBy?.mode, colorBy?.field, allGenes, selectedGenes, palette, enabled, clamp?.low, clamp?.high]); // eslint-disable-line
+ }, [apiBase, dataset, colorBy?.mode, colorBy?.field, allGenes, selectedGenes, enabled]); // eslint-disable-line
+
+ // ── Effect 2: apply clamp + palette to continuous data (no fetch, no debounce) ──
+ // Fires immediately when rawCont, clamp, or palette changes so slider drags
+ // and "reset range" update the canvas in the same render cycle as the slider.
+ useEffect(() => {
+ if (!rawCont) return;
+ const { values, min, max } = rawCont;
+ const lo = clamp?.low ?? min;
+ const hi = clamp?.high ?? max;
+ const colorMap = new Map(
+ Object.entries(values).map(([id, v]) => [id, valueToColor(v, lo, hi, palette)])
+ );
+ setResult({ colorValues: colorMap, type: "continuous", vmin: min, vmax: max, categories: [], categoryColors: new Map() });
+ }, [rawCont, clamp?.low, clamp?.high, palette]); // eslint-disable-line
- // ── Effect 2: remap categorical colors client-side (no server call) ──────
- // Runs whenever raw server data changes OR the user edits a swatch color.
+ // ── Effect 3: remap categorical colors client-side (no fetch) ────────────
+ // Runs whenever raw server data or the user edits a swatch color.
useEffect(() => {
if (!rawCat) return;
const { categories, values } = rawCat;
@@ -130,7 +154,7 @@ 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
+ // ── Abort in-flight request on unmount ────────────────────────────────────
useEffect(() => {
return () => {
clearTimeout(timerRef.current);
diff --git a/frontend/src/hooks/useEdgeColors.js b/frontend/src/hooks/useEdgeColors.js
index 20f0fd3..0af42f6 100644
--- a/frontend/src/hooks/useEdgeColors.js
+++ b/frontend/src/hooks/useEdgeColors.js
@@ -4,12 +4,16 @@ import { valueToColor, QUAL_PALETTE } from "../utils/colormap";
/**
* Computes per-directed-edge color values.
*
- * 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.
+ * lrm_set mode: computed synchronously client-side via useMemo from
+ * visible_score_sum in the edges array (no server call).
+ * Clamp and palette changes update the canvas immediately.
+ *
+ * metadata mode: fetched from the backend (requires server-side GROUP BY).
+ * Uses the same two-effect split as useCellColors:
+ * Effect 1 — fetch raw values (palette/clamp NOT in deps; they don't
+ * affect the server response). Debounced + abortable.
+ * Effect 2 — apply clamp + palette to rawMeta synchronously (no debounce)
+ * so "reset range" and slider drags update the canvas instantly.
*
* Returns:
* colorValues Map or null when mode is "default"
@@ -33,6 +37,10 @@ export function useEdgeColors(
const timerRef = useRef(null);
const abortRef = useRef(null);
+ // rawMeta: last continuous metadata server response { values, min, max, p95 }
+ // Cached so clamp/palette remapping never needs a re-fetch.
+ const [rawMeta, setRawMeta] = useState(null);
+
// ── lrm_set: compute synchronously from edges.visible_score_sum ──────────
const lrmSetResult = useMemo(() => {
if (!enabled || edgeColorBy?.mode !== "lrm_set" || !edges || edges.length === 0) {
@@ -49,20 +57,22 @@ export function useEdgeColors(
if (!isFinite(max)) max = 0;
const sorted = [...scores].sort((a, b) => a - b);
const p95 = sorted[Math.floor(sorted.length * 0.95)] ?? max;
- const lo = clamp?.low ?? min;
- const hi = clamp?.high ?? p95;
+ const lo = clamp?.low ?? min;
+ const hi = clamp?.high ?? max; // use max (not p95) so reset matches the legend
const colorMap = new Map(
entries.map(([id, v]) => [id, valueToColor(v, lo, hi, palette)])
);
return { colorValues: colorMap, type: "continuous", vmin: min, vmax: max, p95, categories: [], categoryColors: new Map() };
}, [enabled, edgeColorBy?.mode, edges, palette, clamp?.low, clamp?.high]);
- // ── metadata: fetch from backend ─────────────────────────────────────────
+ // ── Effect 1: fetch raw metadata values (no clamp, no palette) ───────────
+ // palette and clamp deliberately excluded — they don't change the server response.
useEffect(() => {
if (!enabled || !edgeColorBy || edgeColorBy.mode !== "metadata") {
if (edgeColorBy?.mode !== "lrm_set") {
setResult({ colorValues: null, type: "continuous", vmin: 0, vmax: 0, p95: null, categories: [], categoryColors: new Map() });
}
+ setRawMeta(null);
setLoading(false);
return;
}
@@ -70,13 +80,13 @@ export function useEdgeColors(
const { field } = edgeColorBy;
if (!field) {
setResult({ colorValues: null, type: "continuous", vmin: 0, vmax: 0, p95: null, categories: [], categoryColors: new Map() });
+ setRawMeta(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;
@@ -93,6 +103,7 @@ export function useEdgeColors(
const data = await res.json();
if (data.type === "categorical") {
+ // Categorical: map colors immediately (no clamp applies)
const { categories } = data;
const colorMap = new Map(
categories.map((cat, i) => [cat, QUAL_PALETTE[i % QUAL_PALETTE.length]])
@@ -100,29 +111,38 @@ export function useEdgeColors(
const edgeColors = new Map(
Object.entries(data.values).map(([id, label]) => [id, colorMap.get(label) ?? [128, 128, 128, 255]])
);
+ setRawMeta(null);
setResult({ colorValues: edgeColors, type: "categorical", vmin: 0, vmax: 0, p95: null, categories, categoryColors: colorMap });
} else {
- const { min, max } = data;
+ // Continuous: cache raw values; Effect 2 applies clamp + palette
const sorted = Object.values(data.values).sort((a, b) => a - b);
- const p95 = sorted.length > 0 ? sorted[Math.floor(sorted.length * 0.95)] : max;
- const lo = clamp?.low ?? min;
- const hi = clamp?.high ?? p95;
- const colorMap = new Map(
- Object.entries(data.values).map(([id, v]) => [id, valueToColor(v, lo, hi, palette)])
- );
- setResult({ colorValues: colorMap, type: "continuous", vmin: min, vmax: max, p95, categories: [], categoryColors: new Map() });
+ const p95 = sorted.length > 0 ? sorted[Math.floor(sorted.length * 0.95)] : data.max;
+ setRawMeta({ values: data.values, min: data.min, max: data.max, p95 });
}
} catch (e) {
- if (e.name === "AbortError") return; // silently ignore — a newer fetch is in flight
+ if (e.name === "AbortError") return;
+ setRawMeta(null);
setResult({ colorValues: null, type: "continuous", vmin: 0, vmax: 0, p95: null, categories: [], categoryColors: new Map() });
} finally {
if (abortRef.current === ctrl) setLoading(false);
}
}, 400);
return () => clearTimeout(timerRef.current);
- }, [apiBase, dataset, edgeColorBy?.mode, edgeColorBy?.field, palette, enabled, clamp?.low, clamp?.high]);
+ }, [apiBase, dataset, edgeColorBy?.mode, edgeColorBy?.field, enabled]); // eslint-disable-line
+
+ // ── Effect 2: apply clamp + palette to continuous metadata (no fetch, no debounce) ──
+ useEffect(() => {
+ if (!rawMeta) return;
+ const { values, min, max, p95 } = rawMeta;
+ const lo = clamp?.low ?? min;
+ const hi = clamp?.high ?? p95;
+ const colorMap = new Map(
+ Object.entries(values).map(([id, v]) => [id, valueToColor(v, lo, hi, palette)])
+ );
+ setResult({ colorValues: colorMap, type: "continuous", vmin: min, vmax: max, p95, categories: [], categoryColors: new Map() });
+ }, [rawMeta, clamp?.low, clamp?.high, palette]); // eslint-disable-line
- // Abort in-flight request on unmount
+ // ── Abort in-flight request on unmount ────────────────────────────────────
useEffect(() => {
return () => {
clearTimeout(timerRef.current);
From f049c182d67a05e0a4ef2c23e374014d8e508ab8 Mon Sep 17 00:00:00 2001
From: Micha Sam Brickman Raredon <42100128+msraredon@users.noreply.github.com>
Date: Mon, 1 Jun 2026 18:50:37 -0400
Subject: [PATCH 5/5] feat: per-panel rotation + version bump to v0.3.0
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rotation (resolves #31):
- ⟲ / ⟳ toolbar buttons rotate each panel ±90°; number input accepts any
arbitrary angle 0–359°. A green ×-reset appears when non-zero.
- OSD viewport.setRotation() keeps tile display and pan/zoom correct.
- All 14 deck.gl layers receive a modelMatrix (CW rotation around the
current viewport pivot), recomputed on every OSD viewport-change event.
Deck.gl applies the same matrix during its picking pass, so
click-to-select requires no manual coordinate transforms.
- syncDeckFromOSD expands the data-fetch bbox to cover the full rotated
rectangle's footprint (rotatedBbox helper), ensuring corner data is
always fetched at arbitrary angles.
- viewportActual added to store (un-expanded OSD bounds) so the ⇔ Match
zoom feature continues to use the true visible width rather than the
padded fetch bbox — fixes the Match regression introduced by rotation.
- screenToData inverse-rotates screen→image coords so annotation
polygons and measurements are stored in original image space.
- Measurement labels forward-rotate before screen projection.
- panelRotations [0,0] in store; per-panel, survives split ↔ single toggle.
Versioning:
- Bump to v0.3.0 in backend/app/main.py and frontend/package.json.
- vite.config.js injects __APP_VERSION__ from package.json at build time.
- App.jsx sets document.title = "TissuePlex v0.3.0" on mount.
- LayerPanel version badge now shows immediately (build-time constant)
rather than waiting for the /health fetch; /health is still checked and
a mismatch warning is shown if frontend/backend versions differ.
Co-Authored-By: Claude Sonnet 4.5
---
backend/app/main.py | 2 +-
frontend/package.json | 2 +-
frontend/src/App.jsx | 13 +-
frontend/src/components/AnnotationToolbar.jsx | 73 ++++++++--
frontend/src/components/LayerPanel.jsx | 30 ++--
frontend/src/components/Viewer.jsx | 130 ++++++++++++++++--
frontend/src/store.js | 21 +++
frontend/vite.config.js | 7 +
8 files changed, 241 insertions(+), 37 deletions(-)
diff --git a/backend/app/main.py b/backend/app/main.py
index e17b073..82d7f49 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -3,7 +3,7 @@
from app.routers import tiles, spatial, edges, layers
-APP_VERSION = "0.2.0"
+APP_VERSION = "0.3.0"
app = FastAPI(title="TissuePlex API", version=APP_VERSION)
diff --git a/frontend/package.json b/frontend/package.json
index 385ec05..d66f47d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "tissueplex",
- "version": "0.2.0",
+ "version": "0.3.0",
"private": true,
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 3aa8c84..144b964 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,4 +1,8 @@
-import React from "react";
+import React, { useEffect } from "react";
+
+/* eslint-disable no-undef */
+const APP_VERSION = __APP_VERSION__;
+/* eslint-enable no-undef */
import Viewer from "./components/Viewer";
import LayerPanel from "./components/LayerPanel";
import CellInfoPanel from "./components/CellInfoPanel";
@@ -30,7 +34,14 @@ class ErrorBoundary extends React.Component {
}
}
+export { APP_VERSION };
+
export default function App() {
+ // Keep browser tab title in sync with the build version.
+ useEffect(() => {
+ document.title = `TissuePlex v${APP_VERSION}`;
+ }, []);
+
return (