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/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/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
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/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/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;
}
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 (
);
}
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..e01cb3d 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];
@@ -36,6 +37,61 @@ const DEFAULT_AUTOCRINE_COLOR = [255, 150, 0, 200];
const VIEW_ID = "main";
+// ── Rotation helpers (pure, module-level) ─────────────────────────────────
+
+/**
+ * Build a column-major 4×4 deck.gl modelMatrix for CW rotation by angleDeg
+ * around pivot (cx, cy) in image pixel space. Returns null at 0° (identity).
+ */
+function makeRotMatrix(angleDeg, cx, cy) {
+ if (!angleDeg) return null;
+ const theta = (angleDeg * Math.PI) / 180;
+ const cos = Math.cos(theta), sin = Math.sin(theta);
+ const tx = cx * (1 - cos) + cy * sin;
+ const ty = cy * (1 - cos) - cx * sin;
+ // prettier-ignore
+ return [cos, sin, 0, 0, -sin, cos, 0, 0, 0, 0, 1, 0, tx, ty, 0, 1];
+}
+
+/**
+ * Expand a viewport bbox to fully cover the corners of a rotated rectangle.
+ * At 0°/180° the bbox is unchanged; at other angles the box grows outward.
+ */
+function rotatedBbox(cx, cy, halfW, halfH, angleDeg) {
+ if (!angleDeg || angleDeg === 180) {
+ return { xmin: cx - halfW, ymin: cy - halfH, xmax: cx + halfW, ymax: cy + halfH };
+ }
+ const theta = (angleDeg * Math.PI) / 180;
+ const ac = Math.abs(Math.cos(theta)), as = Math.abs(Math.sin(theta));
+ const bw = halfW * ac + halfH * as;
+ const bh = halfW * as + halfH * ac;
+ return { xmin: cx - bw, ymin: cy - bh, xmax: cx + bw, ymax: cy + bh };
+}
+
+/**
+ * Inverse-rotate a point from rotated view space back to original image coords.
+ * Undoes the same CW rotation around (cx, cy) that makeRotMatrix applies.
+ */
+function inverseRotate(ix, iy, cx, cy, angleDeg) {
+ if (!angleDeg) return [ix, iy];
+ const theta = (angleDeg * Math.PI) / 180;
+ const cos = Math.cos(theta), sin = Math.sin(theta);
+ const dx = ix - cx, dy = iy - cy;
+ return [cx + dx * cos + dy * sin, cy - dx * sin + dy * cos];
+}
+
+/**
+ * Forward-rotate a point from original image coords into rotated view space.
+ * Used to compute the screen positions of annotation labels after rotation.
+ */
+function forwardRotate(x, y, cx, cy, angleDeg) {
+ if (!angleDeg) return [x, y];
+ const theta = (angleDeg * Math.PI) / 180;
+ const cos = Math.cos(theta), sin = Math.sin(theta);
+ const dx = x - cx, dy = y - cy;
+ return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos];
+}
+
// ── ViewerPanel ───────────────────────────────────────────────────────────────
// One instance per visible panel. Each has its own OSD + deck.gl + viewport.
// All layer/color/filter settings come from the shared Zustand store.
@@ -56,10 +112,14 @@ function ViewerPanel({ panelIndex }) {
// re-apply morphology visibility after each OSD (re)initialization.
const [osdOpenCount, setOsdOpenCount] = useState(0);
+ // deck.gl modelMatrix for the current rotation + viewport pivot.
+ // Recomputed in syncDeckFromOSD on every viewport change and on rotation change.
+ const [rotModelMatrix, setRotModelMatrix] = useState(null);
+
const {
apiBase, dataset, activeImage,
imageSize, setImageSize,
- setViewport,
+ setViewport, setViewportActual,
layers: layerState,
platformCapabilities, setPlatformCapabilities,
cellColorEnabled, colorBy, cellColorPalette, categoryColorOverrides,
@@ -84,8 +144,12 @@ function ViewerPanel({ panelIndex }) {
panelCount,
transcriptFraction, setTranscriptStats,
cellBoundaryFraction, setCellBoundaryStats,
+ setLoadingKey,
+ panelRotations, setPanelRotation,
} = useStore();
+ const panelRotation = panelRotations[panelIndex] ?? 0;
+
// Per-panel viewport from store
const viewport = useStore((s) => s.viewports[panelIndex]);
@@ -115,14 +179,27 @@ function ViewerPanel({ panelIndex }) {
setDeckViewState((prev) => ({ ...prev, target: [cx, cy, 0], zoom }));
- setViewport({
+ // Store the true OSD bounds (un-expanded) for the ⇔ Match zoom feature,
+ // which needs the actual visible width/height rather than the padded fetch bbox.
+ const actualBbox = {
xmin: bounds.x * imgW,
ymin: bounds.y * imgW,
- xmax: (bounds.x + bounds.width) * imgW,
+ xmax: (bounds.x + bounds.width) * imgW,
ymax: (bounds.y + bounds.height) * imgW,
- }, panelIndex);
+ };
+ setViewportActual(actualBbox, panelIndex);
+
+ // Expand bbox to cover the full rotated viewport rectangle.
+ // At 0° this is identical to the un-rotated bbox; at other angles
+ // the box grows so all visible data is fetched.
+ const halfW = (bounds.width * imgW) / 2;
+ const halfH = (bounds.height * imgW) / 2;
+ setViewport(rotatedBbox(cx, cy, halfW, halfH, panelRotation), panelIndex);
+
+ // Recompute the deck.gl layer rotation matrix around the new viewport pivot.
+ setRotModelMatrix(makeRotMatrix(panelRotation, cx, cy));
},
- [imageSize, setViewport, panelIndex]
+ [imageSize, setViewport, setViewportActual, panelIndex, panelRotation, setRotModelMatrix]
);
useEffect(() => { syncRef.current = syncDeckFromOSD; }, [syncDeckFromOSD]);
useEffect(() => { deckViewStateRef.current = deckViewState; }, [deckViewState]);
@@ -216,6 +293,15 @@ function ViewerPanel({ panelIndex }) {
if (navItem) navItem.setOpacity(0.35);
}, [morphologyVisible, morphologyOpacity, osdOpenCount]);
+ // ── Rotation — keep OSD tiles and deck.gl modelMatrix in sync ────────────
+ // Fires when rotation angle changes OR when OSD reinitialises (osdOpenCount).
+ useEffect(() => {
+ viewerRef.current?.viewport?.setRotation(panelRotation);
+ // Re-run syncDeckFromOSD via the stable ref so the modelMatrix pivot and
+ // expanded bbox are immediately recomputed for the new angle.
+ if (viewerRef.current) syncRef.current?.(viewerRef.current);
+ }, [panelRotation, osdOpenCount]); // eslint-disable-line
+
// ── Match zoom (from "⇔ Match" button) ───────────────────────────────────
// The source panel sets pendingZoomMatch = { fromPanel }. The target panel
// (fromPanel !== panelIndex) keeps its own center but adopts the source's zoom.
@@ -225,7 +311,9 @@ function ViewerPanel({ panelIndex }) {
if (fromPanel === panelIndex) return; // I'm the source — ignore
if (!viewerRef.current || !imageSize.w) return;
- const srcVp = useStore.getState().viewports[fromPanel];
+ // Use viewportActual (un-expanded OSD bounds) so rotation-padded fetch
+ // bboxes don't skew the matched zoom level.
+ const srcVp = useStore.getState().viewportActual[fromPanel];
if (!srcVp) return;
const imgW = imageSize.w;
@@ -333,11 +421,12 @@ function ViewerPanel({ panelIndex }) {
if (!vs || !containerRef.current) return null;
const { width: cW, height: cH } = containerRef.current.getBoundingClientRect();
const scale = Math.pow(2, vs.zoom);
- return [
- vs.target[0] + (sx - cW / 2) / scale,
- vs.target[1] + (sy - cH / 2) / scale,
- ];
- }, []);
+ // Project screen → rotated view space (same as un-rotated image space for deck.gl)
+ const ix = vs.target[0] + (sx - cW / 2) / scale;
+ const iy = vs.target[1] + (sy - cH / 2) / scale;
+ // Inverse-rotate back to original image coordinates
+ return inverseRotate(ix, iy, vs.target[0], vs.target[1], panelRotation);
+ }, [panelRotation]);
// ── Annotation overlay events ─────────────────────────────────────────────
const handleOverlayMouseMove = useCallback((e) => {
@@ -420,7 +509,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 +528,7 @@ function ViewerPanel({ panelIndex }) {
const {
cells: cellPolygons,
total: cellBoundaryTotal,
+ loading: cellBoundariesLoading,
} = useCellBoundaries(
apiBase, dataset, viewport, imageSize, cellSegmentsVisible && hasBoundaries, cellBoundaryFraction
);
@@ -449,11 +539,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 +553,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 +568,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;
@@ -505,6 +609,7 @@ function ViewerPanel({ panelIndex }) {
const cellFillLayer = new SolidPolygonLayer({
id: "cell-segments-fill",
data: cellPolygons,
+ modelMatrix: rotModelMatrix,
visible: cellSegmentsVisible,
opacity: cellSegmentsOpacity,
getPolygon: (d) => d.polygon,
@@ -519,6 +624,7 @@ function ViewerPanel({ panelIndex }) {
const cellOutlineLayer = new PathLayer({
id: "cell-segments-outline",
data: cellPolygons,
+ modelMatrix: rotModelMatrix,
visible: cellSegmentsVisible,
opacity: cellOutlineOpacity,
getPath: (d) => [...d.polygon, d.polygon[0]],
@@ -536,6 +642,7 @@ function ViewerPanel({ panelIndex }) {
const transcriptLayer = new ScatterplotLayer({
id: "transcripts",
data: visibleTranscripts,
+ modelMatrix: rotModelMatrix,
visible: transcriptsVisible,
opacity: transcriptsOpacity,
getPosition: (d) => [d.x_location, d.y_location],
@@ -620,6 +727,7 @@ function ViewerPanel({ panelIndex }) {
const tissueGraphLayer = new LineLayer({
id: "tissue-graph",
data: allDirectedEdges,
+ modelMatrix: rotModelMatrix,
visible: tissueGraphVisible,
opacity: tissueGraphOpacity,
getSourcePosition: (d) => [d.x1, d.y1],
@@ -634,6 +742,7 @@ function ViewerPanel({ panelIndex }) {
const edgeDirectedLayer = new LineLayer({
id: "edges-directed",
data: directedEdgesWithOffset,
+ modelMatrix: rotModelMatrix,
visible: edgesVisible,
opacity: edgesOpacity,
getSourcePosition: (d) => edgeDirectional ? [d.sx, d.sy] : [d.x1, d.y1],
@@ -655,6 +764,7 @@ function ViewerPanel({ panelIndex }) {
const edgeArrowheadLayer = new SolidPolygonLayer({
id: "edges-arrowheads",
data: arrowheadTriangles,
+ modelMatrix: rotModelMatrix,
visible: edgesVisible && showArrowheads && edgeDirectional,
opacity: edgesOpacity,
getPolygon: (d) => d.polygon,
@@ -674,6 +784,7 @@ function ViewerPanel({ panelIndex }) {
const edgeAutocrineLayer = new ScatterplotLayer({
id: "edges-autocrine",
data: autocrineCells,
+ modelMatrix: rotModelMatrix,
visible: edgesVisible && showAutocrine,
opacity: edgesOpacity,
getPosition: (d) => [d.x, d.y],
@@ -696,6 +807,7 @@ function ViewerPanel({ panelIndex }) {
new SolidPolygonLayer({
id: `region-fill-${r.id}`,
data: [r],
+ modelMatrix: rotModelMatrix,
getPolygon: (d) => d.points,
getFillColor: [...r.color, 40],
filled: true,
@@ -707,6 +819,7 @@ function ViewerPanel({ panelIndex }) {
new PathLayer({
id: `region-outline-${r.id}`,
data: [[...r.points, r.points[0]]],
+ modelMatrix: rotModelMatrix,
getPath: (d) => d,
getColor: [...r.color, 220],
getWidth: 2,
@@ -720,6 +833,7 @@ function ViewerPanel({ panelIndex }) {
const activeRegionLayer = new PathLayer({
id: "active-region",
data: activePts.length > 1 ? [activePts] : [],
+ modelMatrix: rotModelMatrix,
getPath: (d) => d,
getColor: [255, 255, 255, 200],
getWidth: 2,
@@ -731,6 +845,7 @@ function ViewerPanel({ panelIndex }) {
const activeVertexLayer = new ScatterplotLayer({
id: "active-vertices",
data: activeRegion,
+ modelMatrix: rotModelMatrix,
getPosition: (d) => d,
getRadius: 4,
radiusMinPixels: 4,
@@ -740,6 +855,7 @@ function ViewerPanel({ panelIndex }) {
const measureLineLayer = new LineLayer({
id: "measure-lines",
data: measurements,
+ modelMatrix: rotModelMatrix,
getSourcePosition: (d) => d.p1,
getTargetPosition: (d) => d.p2,
getColor: [255, 220, 60, 220],
@@ -750,6 +866,7 @@ function ViewerPanel({ panelIndex }) {
const measureEndpointLayer = new ScatterplotLayer({
id: "measure-endpoints",
data: measurements.flatMap((m) => [m.p1, m.p2]),
+ modelMatrix: rotModelMatrix,
getPosition: (d) => d,
getRadius: 5,
radiusMinPixels: 5,
@@ -759,6 +876,7 @@ function ViewerPanel({ panelIndex }) {
const measureFirstLayer = new ScatterplotLayer({
id: "measure-first",
data: measureFirstRef.current ? [measureFirstRef.current] : [],
+ modelMatrix: rotModelMatrix,
getPosition: (d) => d,
getRadius: 5,
radiusMinPixels: 5,
@@ -780,10 +898,13 @@ function ViewerPanel({ panelIndex }) {
if (!vs || !containerRef.current) return null;
const { width: cW, height: cH } = containerRef.current.getBoundingClientRect();
const scale = Math.pow(2, vs.zoom);
+ const [cx, cy] = vs.target;
const mx = (m.p1[0] + m.p2[0]) / 2;
const my = (m.p1[1] + m.p2[1]) / 2;
- const sx = (mx - vs.target[0]) * scale + cW / 2;
- const sy = (my - vs.target[1]) * scale + cH / 2;
+ // Forward-rotate image midpoint into the rotated view space before projecting
+ const [rx, ry] = forwardRotate(mx, my, cx, cy, panelRotation);
+ const sx = (rx - cx) * scale + cW / 2;
+ const sy = (ry - cy) * scale + cH / 2;
const distUm = m.distPx * pixelSize;
return { id: m.id, sx, sy, label: `${distUm.toFixed(1)} µm` };
}).filter(Boolean);
@@ -881,6 +1002,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..07aa2c0 100644
--- a/frontend/src/hooks/useCellColors.js
+++ b/frontend/src/hooks/useCellColors.js
@@ -5,6 +5,23 @@ import { geneColor } from "../utils/geneColor";
/**
* Fetches per-cell color values and maps them to RGBA.
*
+ * 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
* metadata — POST with field; backend auto-detects continuous vs. categorical
@@ -24,38 +41,51 @@ 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.
- 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 () => {
+ if (abortRef.current) abortRef.current.abort();
+ const ctrl = new AbortController();
+ abortRef.current = ctrl;
+
setLoading(true);
try {
const body = mode === "gene_set"
@@ -66,37 +96,47 @@ 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();
-
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 {
+ } catch (e) {
+ if (e.name === "AbortError") return;
setRawCat(null);
+ setRawCont(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
+ }, [apiBase, dataset, colorBy?.mode, colorBy?.field, allGenes, selectedGenes, enabled]); // 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 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 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;
@@ -114,5 +154,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..0af42f6 100644
--- a/frontend/src/hooks/useEdgeColors.js
+++ b/frontend/src/hooks/useEdgeColors.js
@@ -4,9 +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).
+ * 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"
@@ -28,6 +35,11 @@ export function useEdgeColors(
});
const [loading, setLoading] = useState(false);
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(() => {
@@ -45,42 +57,53 @@ 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;
}
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 () => {
+ 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();
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]])
@@ -88,26 +111,44 @@ 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 {
+ } catch (e) {
+ if (e.name === "AbortError") return;
+ setRawMeta(null);
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]);
+ }, [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 ────────────────────────────────────
+ 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") {
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..4eff5f8 100644
--- a/frontend/src/store.js
+++ b/frontend/src/store.js
@@ -22,12 +22,23 @@ export const useStore = create((set, get) => ({
// ── Viewport (image pixel coords, kept in sync with OpenSeadragon) ────────
// One entry per panel; panel 1 is only used in split-screen mode.
+ // viewports — expanded bbox used by data-fetching hooks (may be larger than
+ // the true visible area when the panel is rotated, to ensure all
+ // visible corners are covered).
+ // viewportActual — un-expanded OSD bounds (true visible area); used only by the
+ // ⇔ Match zoom feature so it matches the real viewport width.
viewports: [null, null],
setViewport: (viewport, panelIndex = 0) => set((s) => {
const next = [...s.viewports];
next[panelIndex] = viewport;
return { viewports: next };
}),
+ viewportActual: [null, null],
+ setViewportActual: (viewport, panelIndex = 0) => set((s) => {
+ const next = [...s.viewportActual];
+ next[panelIndex] = viewport;
+ return { viewportActual: next };
+ }),
// ── Split-screen ──────────────────────────────────────────────────────────
panelCount: 1,
@@ -40,6 +51,16 @@ export const useStore = create((set, get) => ({
requestZoomMatch: (fromPanel) => set({ pendingZoomMatch: { fromPanel } }),
clearZoomMatch: () => set({ pendingZoomMatch: null }),
+ // ── Per-panel rotation ────────────────────────────────────────────────────
+ // Rotation angle in degrees (0–359) for each panel.
+ // Applied to OSD tile display (setRotation) and deck.gl layer modelMatrix.
+ panelRotations: [0, 0],
+ setPanelRotation: (panelIndex, angle) => set((s) => {
+ const next = [...s.panelRotations];
+ next[panelIndex] = ((Math.round(angle) % 360) + 360) % 360;
+ return { panelRotations: next };
+ }),
+
// ── Layer visibility ───────────────────────────────────────────────────────
layers: {
morphology: { visible: false, opacity: 1.0 },
@@ -223,6 +244,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.
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 16f122c..c7c731e 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -1,7 +1,14 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
+import { readFileSync } from "fs";
+
+const { version } = JSON.parse(readFileSync("./package.json", "utf8"));
export default defineConfig({
+ define: {
+ // Injected at build time from package.json — single source of truth.
+ __APP_VERSION__: JSON.stringify(version),
+ },
plugins: [react()],
server: {
port: 3000,