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/AnnotationToolbar.jsx b/frontend/src/components/AnnotationToolbar.jsx index a45e516..b8f9480 100644 --- a/frontend/src/components/AnnotationToolbar.jsx +++ b/frontend/src/components/AnnotationToolbar.jsx @@ -1,5 +1,6 @@ /** - * Floating annotation toolbar — Pan / Draw Region / Measure / Clear. + * Floating annotation toolbar — Pan / Draw Region / Measure / Clear + * + per-panel rotation controls (⟲ / angle input / ⟳). */ import React from "react"; import { useStore } from "../store"; @@ -20,14 +21,21 @@ const BTN = { cursor: "pointer", }; +const SEP = { width: 1, background: "#333", margin: "2px 2px" }; + export default function AnnotationToolbar({ onScreenshot, panelIndex = 0 }) { const { annotationMode, setAnnotationMode, clearAnnotations, regions, measurements, panelCount, setPanelCount, requestZoomMatch, + panelRotations, setPanelRotation, } = useStore(); + const hasAnnotations = regions.length > 0 || measurements.length > 0; const isSplit = panelCount >= 2; + const rotation = panelRotations[panelIndex] ?? 0; + + const rotate = (delta) => setPanelRotation(panelIndex, rotation + delta); return (
))} -
+
+ + + {/* ── Rotation controls ─────────────────────────────── */} +
+ { + const v = parseFloat(e.target.value); + if (!isNaN(v)) setPanelRotation(panelIndex, v); + }} + style={{ + width: 38, + background: rotation !== 0 ? "#1a2a1a" : "transparent", + border: "1px solid #444", + borderRadius: 3, + color: rotation !== 0 ? "#9f9" : "#888", + fontFamily: "monospace", + fontSize: 11, + textAlign: "center", + padding: "2px 2px", + }} + /> + ° + + {rotation !== 0 && ( + + )} {panelIndex === 0 && ( <> -
+