Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
152 changes: 127 additions & 25 deletions backend/app/readers/edge_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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,
Expand Down
42 changes: 37 additions & 5 deletions backend/app/routers/edges.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,15 @@ <h3>Download TissuePlex</h3>
back to this folder (<code>cd ~/Documents/TissuePlex</code>), run
<code>git pull</code>, then <code>docker compose up --build</code>.
</div>

<div class="callout warn">
<strong class="label">Windows users:</strong> After a <code>git pull</code>,
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:
<pre style="margin-top:.6rem;"><code>docker compose build --no-cache
docker compose up</code></pre>
</div>
</div>
</div>

Expand Down Expand Up @@ -1084,6 +1093,11 @@ <h2>Troubleshooting</h2>
<td>TissuePlex is still processing the morphology image. Wait 15–30 seconds
and refresh the page.</td>
</tr>
<tr>
<td>Black screen after updating (especially on Windows)</td>
<td>Docker reused a stale build cache. Force a clean rebuild:<br>
<code>docker compose build --no-cache &amp;&amp; docker compose up</code></td>
</tr>
<tr>
<td>My dataset doesn't appear in the dropdown</td>
<td>Check that <code>DATA_PATH</code> points to the parent folder and that
Expand Down
7 changes: 7 additions & 0 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
1 change: 1 addition & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #root { width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; }
@keyframes tp-spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
Expand Down
9 changes: 8 additions & 1 deletion frontend/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tissueplex",
"version": "0.2.0",
"version": "0.3.0",
"private": true,
"scripts": {
"dev": "vite",
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/App.jsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 (
<ErrorBoundary>
<div style={{ display: "flex", width: "100%", height: "100%", background: "#1a1a1a" }}>
Expand Down
Loading
Loading