diff --git a/CLAUDE.md b/CLAUDE.md
index e8d1559..90e9977 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -109,7 +109,8 @@ backend/
tiles.py DZI descriptor + tile serving; auto-builds pyramid on first request
spatial.py Platform-agnostic router: all /spatial/... endpoints
xenium.py DEPRECATED — kept for reference; not registered in main.py
- edges.py edge query, LRM catalogue, edge color values, edge detail;
+ edges.py edge query, LRM catalogue, edge color values, edge detail,
+ per-cell neighbourhood summary (#60);
all endpoints take an edge_file param (multi-file support);
/files lists edge sources (top-level + edges/ folder)
layers.py generic parquet layer router
@@ -203,6 +204,13 @@ docs/
cloud-deploy.md DigitalOcean deployment runbook (~$106–116/mo)
public_datasets.md Public datasets used for development, and the classic
Visium pair the reader was verified against
+ split_screen_phase2.md Spec for making panel *settings* per-panel. Phase 1
+ (per-panel datasets) shipped in v0.8.4; stages 2a–2e
+ shipped in v0.8.5. Kept as the design record.
+ edge_filter_independence.md Plan for issue #59. Decouples the tissue graph and
+ the cell filter from edge filtering, then adds
+ independent sending/receiving filters. Not started —
+ read before touching the edge filter path.
index.html The user manual, published to GitHub Pages at
https://raredonlab.github.io/TissuePlex/ — hand-written
HTML, no build step. Update it when a UI control changes.
@@ -233,8 +241,16 @@ Platform-agnostic — works with any spatial dataset as long as cell barcodes ma
| `score_norm` | float | Score normalized within edge (sums to 1) |
| `x1`, `y1` | float | Sending cell centroid, native µm coords |
| `x2`, `y2` | float | Receiving cell centroid |
-| `sending_type` | string | Optional cell type label |
-| `receiving_type` | string | Optional cell type label |
+| `sending_type` | string | Optional cell type label — **often absent; see below** |
+| `receiving_type` | string | Optional cell type label — **often absent; see below** |
+
+**`sending_type` / `receiving_type` cannot be relied on.** NICHESv2 populates them
+only when given a `celltype.col`, and only `r/niches_xenium.R` exposes that flag
+(`--celltype`) — the other five scripts pass `celltype.col = NULL`, so the columns
+are simply not there. The values in `sample_data` are *simulated* by
+`make_edges.py`. They are also frozen at scoring time and can disagree with a cells
+table re-annotated since. Resolve cell attributes per edge against the cells table
+(`filter_cell_ids`), not these columns.
**Important**: Coordinates in edges.parquet are in native µm. The backend divides by
`pixel_size` (from the reader) when serving to the frontend.
@@ -332,6 +348,37 @@ source, and a supplemental column silently shadowing a real one would be painful
---
+## Local Neighbourhood (issue #60)
+
+`GET /edges/{dataset}/neighborhood/{cell_id}?field=` returns every
+cell the clicked one is joined to by any edge, plus counts, the enclosing radius
+in µm, a breakdown by any cell metadata column, and the top LRMs over its
+incident edges.
+
+**It is deliberately unfiltered and unsampled.** A neighbourhood is a property of
+the tissue, not of the current view, so density, the viewport, the endpoint
+filters and the LRM checklist are all ignored. This is also why it cannot be
+computed in the frontend from the `edges` array already in memory: that array is
+density-sampled (a tenth of the neighbours by default) and viewport-bounded, so
+the answer would be silently short and would change as you pan. Cost is not a
+reason to avoid the query — 15 ms on the 3.8M-row CosMx file.
+
+Composition resolves against the **cells table**, keyed on its `cell_id` column
+(the frame carries a plain RangeIndex, so indexing by position matches nothing
+and reports every neighbour as missing). Not the edge file's `sending_type` —
+see the caveat under the edges.parquet schema.
+
+Two marks are drawn, and both are needed. The **connected cells** are the honest
+answer, since connectivity is anisotropic: a cell at a tissue boundary has
+neighbours on one side only, and the enclosing **circle** contains many cells it
+is not connected to. The circle is what the issue asked for and gives the spatial
+scale; the points say which cells actually count.
+
+`neighborhood` in the store carries its `panelIndex` like annotations and
+selection, and is dropped whenever the selection changes or its own panel changes
+dataset — a highlight left over from a previous cell would sit on unrelated
+tissue and read as the answer for the cell now selected.
+
## Multiple Edge Files (edges/ folder)
A dataset can carry more than one edge set so users can flip between different
@@ -368,12 +415,14 @@ param (e.g. `"edges/edge.raw.minimum.parquet"`); `label` is the display name
`(dataset, edge_file)`. `_reader()` resolves `edge_file` under the dataset directory
and rejects anything that escapes it (path-traversal guard → 400; missing file → 404).
-**Frontend** — a single global `edgeFile` in the store applies to **all** open viewer
-panels (see the Split-Screen note; a per-panel edge file was deliberately deferred
-because LRM catalogue / color ranges are edge-file-specific and the sidebar is shared).
-The picker is a `` at the top of the Edge Data section in `LayerPanel.jsx`,
-shown only when the dataset has >1 edge file. `setEdgeFile` and `setDataset` both
-reset the edge-file-scoped state (`lrmCatalogue`, `hiddenLrms`, `selectedEdge`,
+**Frontend** — `edgeFile` is **per panel** (`panels[i].edgeFile`), so two panels can
+compare two edge sources. It was global until v0.8.4; the deferral recorded here
+(LRM catalogue and colour ranges are edge-file-specific, and one sidebar cannot drive
+two of them) was resolved by Phase 1 of the split-screen work, which moved every
+dataset-bound value into `panels[i]`. In split mode the picker sits in each panel's
+header via `DatasetPicker`; in single-panel mode it stays in the Edge Data section of
+`LayerPanel.jsx`. Either way it is shown only when the dataset has >1 edge file.
+`setPanelEdgeFile` and `setPanelDataset` both reset the edge-file-scoped state (`lrmCatalogue`, `hiddenLrms`, `selectedEdge`,
`edgeColorRange`, `edgeColorClamp`) so stale LRM/color state from the previous file
never leaks. `edgeFile` is threaded as `?edge_file=…` through all six edge fetch
sites: `useEdges` (query-grouped, query-scores), `useEdgeColors` (edge-color-values),
@@ -388,21 +437,36 @@ All shared state lives in a single Zustand store. Key sections:
- **Dataset / image**: `dataset` (null on init, auto-set from `/spatial/datasets`),
`activeImage` (which OME-TIFF to show; auto-set from `/spatial/{dataset}/images`)
-- **Edge file**: `edgeFile` (default `"edges.parquet"`) — which edge-source parquet to
- render; global (applies to all panels). `setEdgeFile` / `setDataset` reset the
- edge-file-scoped state. See "Multiple Edge Files" above.
+- **Edge file**: `panels[i].edgeFile` (default `"edges.parquet"`) — which edge-source
+ parquet that panel renders. Per-panel since v0.8.4. `setPanelEdgeFile` /
+ `setPanelDataset` reset the edge-file-scoped state. See "Multiple Edge Files" above.
- **Layer visibility**: `layers` object — each layer has `visible` + `opacity`;
`cellSegments` also has `outlineOpacity` (independent from fill opacity)
- **Cell color**: `cellColorEnabled`, `colorBy` (`mode`: off/gene_set/metadata, `field`),
`cellColorPalette`, `cellColorClamp` (squish/oob cutoffs). `cellColorType` /
`cellColorCategories` hold the type the backend actually returned, written by
panel 0 — the LayerPanel reads these instead of guessing from the schema dtype.
+
+**`usePanelSettings` ignores `viewports` / `viewportActual`.** Both are rewritten on
+every OpenSeadragon viewport-change event, and no consumer of that hook reads either
+— `ViewerPanel` takes `viewports[panelIndex]` through its own selector, and ⇔ Match
+zoom reads `viewportActual` via `getState()`. Measured over one simulated pan (120
+writes): **121 re-renders of every sidebar section before, 0 after**, with ordinary
+settings changes still delivered. Before adding a key to `IGNORED_KEYS`, check that
+nothing reading it comes through the hook — ignoring a key a consumer *does* read
+makes that consumer silently stale, which is far worse than a redundant render.
- **Categorical override**: `categoricalOverrides`, keyed `cell::` /
`edge::` → `true | false`; absent means auto-detect (issue #35).
-- **Metadata filter**: `cellFilter` / `edgeFilter`, each
- `{ field, values, min, max, includeMissing }` or null (issue #45). `cellFilter`
- also governs edges — both endpoints must survive it. Both reset on dataset change
- (column names are dataset-specific); `edgeFilter` also resets on edge-file change.
+- **Metadata filters**: each is `{ field, values, min, max, includeMissing }` or null.
+ - `cellFilter` — the cell layers only. It no longer governs edges (issue #59):
+ filtering cells and filtering edges are independent actions.
+ - `sendingFilter` / `receivingFilter` — a *cell* metadata predicate on one end of
+ an edge. Both set gives the intersection; one set leaves the other end free.
+ - `edgeFilters` — a **list**, and-ed, on the edge table / `edge-metadata/`.
+
+ All reset on dataset change (column names are dataset-specific); `edgeFilters`
+ also resets on edge-file change, since those column names belong to one file
+ while cell metadata does not.
- **Transcript gene filter**: `selectedGenes` — `null` = no filter (show all);
`Set` = allowlist (show only those genes). Dataset-scoped; resets on
dataset change. See Gene Filter section below.
@@ -564,8 +628,14 @@ dataset a panel shows* lives in `panels[panelIndex]` (store.js `makePanel()`):
stats. Each of those differs between datasets, so none of them can be global.
Style and choice settings — layer opacity, palettes, colour-by, filters, LRM
-selection, edge geometry — deliberately stay **shared**: one sidebar drives both
-panels, which is what makes a side-by-side comparison comparable. The sidebar
+selection, edge geometry — are **shared by default** and can be unlinked per
+panel from the sidebar tabs (Phase 2a/2b). One sidebar driving both panels is
+what makes a side-by-side comparison comparable, so linked stays the default.
+A `copy panel N → panel M` button under the toggle pushes one panel's settings
+onto the other in one shot, dropping any that name a column, gene or mechanism
+the target dataset lacks — an inherited filter on a missing column 400s on every
+viewport change and the panel silently stops rendering. See
+`docs/split_screen_phase2.md`; only the docs pass (2e) remains. The sidebar
reconciles across panels with `hooks/usePanels.js`, whose rule is **union, then
degrade per panel**: offer a control if *either* panel can use it, and let the
panel that cannot render nothing. Intersecting instead would hide controls that
@@ -577,11 +647,10 @@ Consequences worth knowing:
- **The dataset / image / edge-source pickers move into each panel's header** in
split mode, because an image name or edge file only means something relative to
one dataset. In single-panel mode they stay in the sidebar, unchanged.
-- **Changing either panel's dataset resets the shared column-, gene- and
- mechanism-named settings** (filters, colour-by field, gene allowlist, hidden
- LRMs). It has to: a filter naming a column the new dataset lacks 400s on every
- viewport change. The cost is that switching one panel clears the other's
- filter. That goes away when these become per-panel.
+- **Changing a panel's dataset resets its column-, gene- and mechanism-named
+ settings** (filters, colour-by field, gene allowlist, hidden LRMs). It has to:
+ a filter naming a column the new dataset lacks 400s on every viewport change.
+ The other panel is reset too only while `linkSettings` is on.
- **Selection carries its panel index** (`selection = {panelIndex, kind, …}`), so
`CellInfoPanel` / `EdgeInfoPanel` and region export resolve against the dataset
that was actually clicked. `EdgeInfoPanel` used to be pinned to panel 0.
@@ -608,12 +677,51 @@ Consequences worth knowing:
`imageSize.w` changed (fixes the bug where morphology stayed visible after
dataset switches with same-dimension images, and in panel 2 on first open)
+**Annotations belong to the panel that drew them.** `regions` and `measurements`
+each carry a `panelIndex`, and `ViewerPanel` renders only its own. This is not
+cosmetic: coordinates are image pixels of *that panel's* dataset, so a polygon
+over a 6.5 mm Visium capture area reappearing in a 55 µm seqFISH panel lands
+nowhere meaningful. Two consequences were worse because they were silent — CSV
+export resolves `selectedCellIds` against `panels[r.panelIndex].dataset` (it read
+that field before anything wrote it, so every export used panel 0), and a
+measurement label is `distPx * pixelSize` for its own panel (a 100 px line reads
+100 µm on CosMx and 10.8 µm on MERSCOPE). `clearAnnotations(panelIndex)` is
+likewise scoped, since the button lives in each panel's own toolbar; omitting the
+index still clears everything. Anything created before this carries no
+`panelIndex` and is treated as panel 0.
+
+**Display settings live in `panels[i].settings`, not at the store root** (Phase 2a).
+`makeSettings()` builds them — a factory, not a constant, because `layers` and
+`hiddenLrms` are containers and sharing one object across panels would alias them.
+Reads go through `usePanelSettings()` (`hooks/usePanelSettings.js`), which returns the
+store merged with one panel's settings; the panel comes from `PanelIndexContext`, or is
+passed explicitly by `ViewerPanel`, which already knows its index. Writes go through
+`patchSettings(patch, panelIndex = null)` — a null index writes to **every** panel, which
+is what keeps one sidebar driving both and makes 2a behaviour-identical to the global
+state it replaced. `linkSettings` (default true) and `activePanel` decide where a write lands: all panels
+when linked, the active tab when not. `patchSettings` is the single place that decision
+is made, so no setter knows about tabs. `getSetting` reads the *active* panel, which
+read-modify-write setters depend on — unlinked, `toggleLrm` must toggle against the panel
+it is about to write, not panel 0.
+
+Re-linking (`setLinkSettings(true)`) makes every panel adopt the active panel's settings
+via `cloneSettings`, rather than just resuming propagation: a control labelled "linked"
+over two visibly different panels would not be telling the truth, and a shallow copy would
+leave the panels aliasing so the next unlinked edit wrote through to both.
+
+Note `setPanelDataset` resets only the *name-bound* settings (filters, colour-by, gene
+allowlist, hidden LRMs). Geometry, palettes and layer visibility survive a dataset change
+and always have — rebuilding the panel from `makePanel()` would silently wipe them.
+
+The reset always reaches the panel that changed, and the **other** panels only while
+linked. Unlinked, reaching across would contradict the toggle: the sidebar says "editing
+panel 1 only" while an action on panel 2 clears panel 1. That was the cost recorded here
+before v0.8.5, and the link toggle is what made it fixable.
+
**What is shared (global store):**
- All layer toggles, opacities, color-by settings, LRM filter, edge density, etc.
-- `edgeFile` — the selected edge-source parquet applies to both panels. A per-panel
- edge file was deferred (issue #46 discussion): LRM catalogue + color ranges are
- edge-file-specific, and the single sidebar can't drive two different edge sets
- equally. Revisit if side-by-side comparison of different edge files is needed.
+- `edgeFile` is **not** shared — it moved to `panels[i]` in Phase 1, along with the
+ LRM catalogue and colour ranges that made sharing it incoherent.
- `selectedCell`, `selectedEdge` (global — EdgeInfoPanel only renders in panel 0)
- `imageSize` (both panels open the same DZI; panel 0 sets it, panel 1 may also set
the same values redundantly — harmless)
@@ -1066,6 +1174,15 @@ Things to preserve when editing these methods:
the same rows or the layer visibly flickers.
- **`USING SAMPLE` goes on a subquery** wrapping the filtered SELECT. Applied alongside a
WHERE clause, DuckDB may sample before filtering.
+- **Edge density is a deterministic hash, not `USING SAMPLE`** (`density_predicate`).
+ The tissue graph and the edge data are two separate queries and must select the
+ *same* edges, or edge data is drawn where the graph beneath it was sampled away —
+ two independent bernoulli draws at 10% overlap only ~1% of the time. Hashing the
+ edge id gives every edge the same verdict in every query, so the predicate commutes
+ with the filters (density-then-filter and filter-then-density are one set) and
+ B ⊆ A holds at every density. It is also stable across re-fetches, where bernoulli
+ flickers on each pan. `backend/tests/edge_pipeline_check.py` asserts this on every
+ dataset.
- **DuckDB cannot bind numpy scalars.** `bbox_predicate()` casts to builtin `float` for
this reason.
- A fresh `connect()` per call is deliberate — DuckDB's global connection is not
@@ -1127,6 +1244,14 @@ cannot drift apart.
`sort_categories()` sorts numerically when every label parses as a number, so cluster
10 comes after cluster 2 rather than between 1 and 2.
+**A column can be present in the schema and hold nothing.** `fov` and
+`transcript_count` are entirely null on the bundled MERSCOPE dataset. Such columns
+now come back with `empty: True` rather than as a continuous 0–0 range, and the
+filter section says "no values in this column" instead of drawing a range slider
+that does nothing over a filter that correctly matches no cells. `type` is still
+set, so nothing switching on categorical-vs-continuous needs a third case, and the
+cross-panel merge treats a column as empty only when it is empty in *every* panel.
+
**`_color_values_meta` now lives on the base class.** Every reader used to carry a
near-identical copy, and the six copies had already drifted — CosMx filled NaN with
`""`/`0` where the others dropped it, and only some passed `key=str` to `sorted`.
@@ -1166,12 +1291,31 @@ subset renders at full density.
count and the sample**. The five implementations differ too much to share code:
Xenium and CosMx join it into their DuckDB query, MERSCOPE skips non-matching rows
before decoding WKB, Visium HD and seqFISH mask their in-memory frames.
-- `EdgeReader.query_grouped()` takes `cell_ids` and `edge_filter`. An edge survives
- the cell filter only when **both** endpoints do — the point of "focus on 2–3 cell
- types" is the signalling within that subset, and a half-outside edge would run off
- to a cell that is not drawn. `edge_filter` becomes a real SQL predicate when the
- column is in the parquet, and a semi-join against a registered frame when it comes
- from `edge-metadata/`.
+- **`EdgeReader` runs one pipeline, and the order is the contract** (issue #59):
+
+ ```
+ all edges in viewport
+ → density filter deterministic, spatially random
+ → EDGESET A → tissue-graph layer (query_structure)
+ → sending filter
+ → receiving filter
+ → edge-table filters
+ → EDGESET B → edge-data layer (query_grouped)
+ ```
+
+ `query_structure` takes **no filter arguments at all** — not "they default to
+ none", but no parameter to pass — because the tissue graph is ground truth: the
+ total set of edges, shown or hidden, never subset.
+
+ `sending_ids` / `receiving_ids` constrain the two endpoints independently, so both
+ set gives the intersection and one set leaves the other end free. They resolve from
+ *cell* metadata via `filter_cell_ids`, and are independent of the cell layer's own
+ filter — an edge may terminate on a cell that is not drawn. That reverses an earlier
+ both-endpoints rule: filtering cells and filtering edges are separate actions.
+
+ `edge_filters` is a **list**, and-ed — the composition gap deferred in #45. Each
+ becomes a real SQL predicate when the column is in the parquet, and a semi-join
+ against a registered frame when it comes from `edge-metadata/`.
**Large id sets go through `duck.register_ids()`, not `IN (?, ?, …)`.** A filter can
keep hundreds of thousands of cells; binding that many parameters is unworkable and
@@ -1282,6 +1426,15 @@ and `CACHE_DIR` relocates both the DZI pyramids and the spatial index off the da
unless it is enabled anyone with the URL can view the data. There is no application-level
auth, no user accounts, and no per-dataset permissions.
+**Releasing.** The version lives in **three** places and they must move together —
+`frontend/package.json` (read at build time via vite's `__APP_VERSION__`),
+`backend/app/main.py` (`APP_VERSION`, served by `/health`), and the line under the
+title in `README.md`. The sidebar badge compares the first two and turns red when
+they disagree, so a half-bump is visible but only once the app is running.
+
+Every other `v0.x.y` in the tree is a *historical* reference — "shipped in v0.8.4",
+"present since v0.2.0" — and must not be swept along by a bump.
+
**Regression guard.** `backend/tests/golden_snapshot.py` exercises every reader method
against all local datasets, digests the results, and diffs them against a recorded
baseline (238 probes across 8 datasets). Run it after any reader change:
@@ -1296,6 +1449,29 @@ Two determinism rules keep it honest: record-list digests are order-independent
`query_grouped` uses `ORDER BY RANDOM()`), and sampling is seeded (`duck.SAMPLE_SEED`).
If a probe changes and you cannot explain why, that is the point of the tool.
+**Frontend tests** run under Vitest, added with the annotation fix in v0.8.5:
+
+```bash
+cd frontend && npm test # vitest run
+cd frontend && npm run test:watch
+```
+
+Two further backend checks exist alongside the golden snapshot, both covering
+things it structurally cannot:
+
+```bash
+cd backend && python3 tests/edge_pipeline_check.py # issue #59 invariants
+cd backend && python3 tests/duckdb_config_check.py # DUCKDB_MEMORY_LIMIT forms
+```
+
+`edge_pipeline_check` asserts, on every dataset with edges and at several
+densities, that the tissue graph is unmoved by any filter, that the edge data is
+always a subset of it, and that sampling is stable between identical calls.
+
+`src/store.annotations.test.js` is the first of them. Store logic is plain JS, so
+these need no DOM and no jsdom dependency — reducers can be exercised directly
+through `useStore.getState()`. Coverage is currently annotations only.
+
There is still **no CI and no linter** — no `.github/workflows`, no ESLint or Python lint
config. The snapshot is a guard, not a test suite: it catches "this changed" but does not
assert correctness. Be correspondingly careful with the OSD ↔ deck.gl coordinate bridge,
diff --git a/README.md b/README.md
index 655acff..bf0b883 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# TissuePlex
+**v0.8.8**
+
An interactive spatial transcriptomics viewer for exploring cell-cell communication from [NICHESv2](https://github.com/RaredonLab/NICHESv2) directly on the tissue image.

diff --git a/backend/app/main.py b/backend/app/main.py
index ee1c810..56622ba 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.8.4"
+APP_VERSION = "0.8.8"
app = FastAPI(title="TissuePlex API", version=APP_VERSION)
diff --git a/backend/app/readers/base_reader.py b/backend/app/readers/base_reader.py
index 87dfd66..dcc1043 100644
--- a/backend/app/readers/base_reader.py
+++ b/backend/app/readers/base_reader.py
@@ -175,8 +175,18 @@ def _metadata_frame(self) -> Optional[pd.DataFrame]:
def _color_values_meta(self, field: str,
categorical: Optional[bool] = None) -> dict:
- """Per-cell values for one metadata column, typed for the frontend."""
- empty = {"type": "continuous", "values": {}, "min": 0.0, "max": 0.0}
+ """Per-cell values for one metadata column, typed for the frontend.
+
+ A column with no usable values is reported as ``empty: True`` rather than
+ as a continuous 0–0 range. Several real files ship such columns —
+ ``fov`` and ``transcript_count`` are entirely null on the bundled MERSCOPE
+ dataset — and the old shape gave the UI a range slider that did nothing,
+ a legend with no span, and a filter that correctly matched no cells while
+ looking broken. ``type`` is still set so nothing switching on
+ categorical-vs-continuous has to learn a third case.
+ """
+ empty = {"type": "continuous", "values": {}, "min": 0.0, "max": 0.0,
+ "empty": True}
df = self._metadata_frame()
if df is None or df.empty or "cell_id" not in df.columns \
or field not in df.columns:
@@ -193,6 +203,7 @@ def _color_values_meta(self, field: str,
"type": "categorical",
"values": values,
"categories": metadata_filter.sort_categories(set(values.values())),
+ "empty": not values,
}
# to_numeric rather than float(): a forced-continuous request can land on a
@@ -208,7 +219,7 @@ def _color_values_meta(self, field: str,
if not finite:
return empty
return {"type": "continuous", "values": values,
- "min": min(finite), "max": max(finite)}
+ "min": min(finite), "max": max(finite), "empty": False}
def filter_cell_ids(self, spec: Optional[MetadataFilter]) -> Optional[set]:
"""Resolve a metadata filter to the set of cell ids it keeps (issue #45).
diff --git a/backend/app/readers/duck.py b/backend/app/readers/duck.py
index 7d7f2f6..76c4008 100644
--- a/backend/app/readers/duck.py
+++ b/backend/app/readers/duck.py
@@ -67,7 +67,10 @@ def _default_memory_limit() -> str:
return f"{mb}MB"
-_MEMORY_LIMIT = os.getenv("DUCKDB_MEMORY_LIMIT") or _default_memory_limit()
+# .strip() matters: an unset variable and one set to "" or " " must all fall
+# through to the computed default. A whitespace value is truthy, so without it
+# DuckDB receives `SET memory_limit=' '` and raises a ParserException.
+_MEMORY_LIMIT = (os.getenv("DUCKDB_MEMORY_LIMIT") or "").strip() or _default_memory_limit()
_THREADS = os.getenv("DUCKDB_THREADS", "4")
diff --git a/backend/app/readers/edge_reader.py b/backend/app/readers/edge_reader.py
index 6896852..4ede10f 100644
--- a/backend/app/readers/edge_reader.py
+++ b/backend/app/readers/edge_reader.py
@@ -22,12 +22,58 @@
from app.readers import duck, metadata_filter, supplemental
from app.readers.metadata_filter import MetadataFilter
-_DUCKDB_MEMORY_LIMIT = os.getenv("DUCKDB_MEMORY_LIMIT", "8GB")
_UNSET = object()
+# The pipeline, in one place:
+#
+# all edges in viewport
+# → density filter (spatially random, this predicate)
+# → EDGESET A → tissue-graph layer
+# → sending filter
+# → receiving filter
+# → edge-table filters
+# → EDGESET B → edge-data layer
+#
+# So B ⊆ A always: edge data can never be drawn where the graph beneath it has
+# been sampled away.
+#
+# The two layers are fetched by separate queries, and the order above is only
+# guaranteed because this predicate is *deterministic per edge*. A given edge
+# gets the same verdict in every query regardless of what else ran, so the
+# predicate commutes with the filters and "density then filter" and "filter then
+# density" select the identical set.
+#
+# `USING SAMPLE` cannot do this. Two independent bernoulli draws at 10% give the
+# graph one random tenth and the edge layer a different one — an overlap of ~1%,
+# with edge data floating free of the structure it is supposed to sit on.
+#
+# A deterministic hash of the edge identity fixes that. The same edge gets the
+# same verdict in every query, whatever filters ran, so the edge layer is always
+# a subset of the graph:
+#
+# graph = { e : keep(e) }
+# edge data = { e : passes_filters(e) and keep(e) } ⊆ graph
+#
+# It is also spatially uniform (the hash ignores position) and stable across
+# re-fetches, which matters because the alternative flickers on every pan.
+#
+# Unlike USING SAMPLE this is an ordinary predicate, so it composes with the
+# filters by AND and cannot be reordered below them — the property the
+# subquery-wrapping was there to guarantee.
+_DENSITY_MODULUS = 1_000_000
+
+
+def density_predicate(density: float, key_sql: str = '"edge"') -> str:
+ """SQL keeping a deterministic, spatially uniform fraction of edges."""
+ if density is None or density >= 1.0:
+ return ""
+ cut = max(1, int(density * _DENSITY_MODULUS))
+ return f"(hash(CAST({key_sql} AS VARCHAR)) % {_DENSITY_MODULUS}) < {cut}"
+
+
class EdgeReader:
def __init__(self, path: Path, pixel_size: float = 1.0):
self.path = path
@@ -86,12 +132,22 @@ def _from(self) -> str:
return f"read_parquet('{self._sql_path}')"
def _conn(self):
- # Each call gets a fresh, isolated connection — duckdb's default connection
- # is not thread-safe and causes empty/corrupt results under FastAPI concurrency.
- conn = duckdb.connect()
- conn.execute(f"SET memory_limit='{_DUCKDB_MEMORY_LIMIT}'")
- conn.execute("SET threads=4")
- return conn
+ # Shared with the spatial readers via duck.connect(), which also gives a
+ # fresh isolated connection per call (duckdb's default connection is not
+ # thread-safe and returns empty or corrupt results under FastAPI's
+ # threadpool rather than raising).
+ #
+ # This used to build its own connection with
+ # `os.getenv("DUCKDB_MEMORY_LIMIT", "8GB")`. That default only applies
+ # when the variable is *absent*: set-but-empty returns "", so
+ # `SET memory_limit=''` raised a ParserException and every /edges
+ # endpoint 500'd. Compose sets it empty on purpose now, to let the limit
+ # be sized from available memory — so the two disagreed and edges stopped
+ # loading everywhere. Going through duck.connect() means there is one
+ # defaulting rule instead of two, and EdgeReader also picks up the
+ # temp_directory it never had, so a large edge query can spill instead of
+ # failing outright.
+ return duck.connect()
def schema(self) -> dict:
"""Column names and dtypes, parquet columns plus any supplemental ones.
@@ -264,21 +320,131 @@ def edge_filter_sql(self, spec: Optional[MetadataFilter], conn) -> tuple[str, li
return f'CAST("edge" AS VARCHAR) {pred}', []
@staticmethod
- def cell_filter_sql(cell_ids: Optional[set], conn) -> tuple[str, list]:
- """WHERE fragment keeping only edges whose **both** endpoints survive the
- cell filter.
-
- Both, not either: the point of "focus on 2–3 cell types" is the signalling
- *within* that subset. An edge with one endpoint outside would be drawn
- running off to a cell that is not on screen.
+ def endpoint_filter_sql(
+ sending_ids: Optional[set],
+ receiving_ids: Optional[set],
+ conn,
+ ) -> tuple[str, list]:
+ """WHERE fragment constraining each endpoint of an edge independently.
+
+ Either side may be None, which leaves that end unconstrained — so setting
+ only `sending_ids` answers "everything sent *from* these cells, to
+ anywhere". With both set the result is the intersection: an edge is kept
+ when its sender is in one set and its receiver in the other.
+
+ This replaces a single set applied to both ends. That older rule tied edge
+ visibility to the *cell* filter, which the lab wants independent: filtering
+ cells is one action, filtering edges another, and an edge may now terminate
+ on a cell that is not drawn. See docs/edge_filter_independence.md.
+
+ An empty set means "nothing matches" and short-circuits to FALSE. It must
+ not fall through to no-predicate, or a filter matching no cells would
+ return every edge — and `register_ids` refuses an empty frame anyway.
+
+ Both predicates are ordinary semi-joins in the WHERE clause, so they run
+ before the GROUP BY and before the density sample.
"""
- if cell_ids is None:
- return "", []
- if not cell_ids:
- return "FALSE", []
- pred = duck.register_ids(conn, cell_ids, name="tp_cell_filter")
- return (f'CAST("sending_cell" AS VARCHAR) {pred} '
- f'AND CAST("receiving_cell" AS VARCHAR) {pred}'), []
+ conds: list[str] = []
+ for ids, col, name in (
+ (sending_ids, "sending_cell", "tp_send_filter"),
+ (receiving_ids, "receiving_cell", "tp_recv_filter"),
+ ):
+ if ids is None:
+ continue
+ if not ids:
+ return "FALSE", []
+ pred = duck.register_ids(conn, ids, name=name)
+ conds.append(f'CAST("{col}" AS VARCHAR) {pred}')
+ return (" AND ".join(conds), []) if conds else ("", [])
+
+ def neighborhood(self, cell_id: str, top_lrms: int = 12) -> dict | None:
+ """Everything a cell is connected to, unfiltered and unsampled (issue #60).
+
+ The neighbourhood is a property of the tissue, not of the current view, so
+ this deliberately ignores density, the viewport and every filter. Computing
+ it from the frontend's `edges` array instead would look like it worked and
+ be wrong twice over: that array is density-sampled (a tenth of the
+ neighbours by default) and viewport-bounded (missing any neighbour just
+ off-screen, and changing as you pan).
+
+ Direction is ignored for membership — a cell that only *sends* to this one
+ is still a neighbour — but the returned edge counts are directed, because
+ that is what the edge layer draws.
+
+ Returns None when the cell appears in no edge at all, which is an ordinary
+ outcome rather than an error.
+ """
+ cols = set(self._parquet_schema().names)
+ if not {"sending_cell", "receiving_cell"} <= cols:
+ return None
+ ps = self.pixel_size
+ has_lrm, has_score = "lrm" in cols, "score" in cols
+
+ with self._conn() as conn:
+ rows = conn.execute(f"""
+ SELECT * FROM {self._from()}
+ WHERE CAST("sending_cell" AS VARCHAR) = ?
+ OR CAST("receiving_cell" AS VARCHAR) = ?
+ """, [str(cell_id), str(cell_id)]).df()
+ if rows.empty:
+ return None
+
+ me = str(cell_id)
+ send = rows["sending_cell"].astype(str)
+ recv = rows["receiving_cell"].astype(str)
+
+ # The partner cell, and the coordinates of whichever end is not us. Both
+ # come from the edge file, which is the one source every platform has —
+ # MERSCOPE and CosMx ship no boundary polygons to derive a centroid from.
+ partner = recv.where(send == me, send)
+ px = rows["x2"].where(send == me, rows["x1"]) if "x2" in rows else None
+ py = rows["y2"].where(send == me, rows["y1"]) if "y2" in rows else None
+
+ self_x = rows["x1"].where(send == me, rows["x2"]) if "x1" in rows else None
+ self_y = rows["y1"].where(send == me, rows["y2"]) if "y1" in rows else None
+ centre = ([float(self_x.iloc[0]) / ps, float(self_y.iloc[0]) / ps]
+ if self_x is not None and len(self_x) else None)
+
+ # One point per distinct partner. Autocrine rows name us as the partner;
+ # they are counted separately rather than drawn as a neighbour of ourself.
+ pts, radius_px = [], 0.0
+ if px is not None:
+ frame = {}
+ for pid, x, y in zip(partner, px, py):
+ if pid == me or pid in frame:
+ continue
+ frame[pid] = (float(x) / ps, float(y) / ps)
+ pts = [{"cell_id": k, "x": v[0], "y": v[1]} for k, v in frame.items()]
+ if centre and pts:
+ radius_px = max(((q["x"] - centre[0]) ** 2 +
+ (q["y"] - centre[1]) ** 2) ** 0.5 for q in pts)
+
+ neighbours = sorted({p for p in partner if p != me})
+
+ lrms = []
+ if has_lrm:
+ agg = rows.groupby("lrm", dropna=True)
+ summed = (agg["score"].sum() if has_score else agg.size()).sort_values(ascending=False)
+ lrms = [{"lrm": str(k),
+ "value": float(v) if has_score else int(v),
+ "n": int(agg.size()[k])}
+ for k, v in summed.head(top_lrms).items()]
+
+ return {
+ "cell_id": me,
+ "center": centre,
+ "neighbors": neighbours,
+ "neighbor_points": pts,
+ "n_neighbors": len(neighbours),
+ # Directed edges incident to this cell, deduplicated by edge id — the
+ # raw rows are one per (edge x LRM), which would overcount by ~500x.
+ "n_edges": int(rows["edge"].nunique()) if "edge" in rows else len(rows),
+ "n_autocrine": int(((send == me) & (recv == me)).any()),
+ "radius_px": radius_px,
+ "radius_um": radius_px * ps,
+ "lrm_composition": lrms,
+ "score_basis": "score" if has_score else "count",
+ }
def edge_detail(self, edge_id: str) -> dict | None:
"""Return all LRM rows for a single directed edge, structured for the info panel."""
@@ -348,14 +514,74 @@ def column_summary(self, column: str) -> dict:
).df().iloc[:, 0].tolist()
return {"type": "categorical", "values": vals, "count": len(vals)}
+ def query_structure(
+ self,
+ bbox: Optional[tuple] = None,
+ density: float = 1.0,
+ max_limit: int = 500_000,
+ ) -> list[dict]:
+ """Every distinct edge in the viewport, with no filters of any kind.
+
+ This backs the tissue-graph layer, which is *ground truth*: the total set
+ of edges, shown or hidden, never subset. It deliberately takes no filter
+ arguments at all — not "filters default to none", but no way to pass one,
+ so the layer cannot be narrowed by a future caller wiring one through.
+
+ It is a separate query from `query_grouped`, not a flag on it, because the
+ two want opposite orderings of the same pipeline. The edge-data layer must
+ filter *then* sample, so a rare subset draws at full density; the graph
+ must not filter at all. Returning every edge with a `passes_filter` column
+ would sample the rare subset away before the flag was ever read.
+
+ The projection is deliberately lean — `edge` and the four coordinates.
+ Scores, types and LRM counts are most of `query_grouped`'s payload and the
+ structural layer draws none of them; `edge` is kept only because the layer
+ is pickable and the info panel resolves by id.
+ """
+ cols = set(self._parquet_schema().names)
+ if not {"x1", "y1", "x2", "y2"} <= cols:
+ return []
+ ps = self.pixel_size
+
+ where, params = "", []
+ if bbox and None not in bbox:
+ xmin, ymin, xmax, ymax = (v * ps for v in bbox)
+ where = ("WHERE ((x1 >= ? AND x1 <= ? AND y1 >= ? AND y1 <= ?) OR "
+ "(x2 >= ? AND x2 <= ? AND y2 >= ? AND y2 <= ?))")
+ params = [xmin, xmax, ymin, ymax, xmin, xmax, ymin, ymax]
+
+ has_edge = "edge" in cols
+ key = "edge" if has_edge else "x1, y1, x2, y2"
+ sel = ("edge, " if has_edge else "") + \
+ "FIRST(x1) AS x1, FIRST(y1) AS y1, FIRST(x2) AS x2, FIRST(y2) AS y2"
+ # The *same* predicate query_grouped uses, on the same key, so the two
+ # layers select an identical subset and edge data is never drawn where
+ # the graph beneath it has been sampled away.
+ dens = density_predicate(
+ density, '"edge"' if has_edge else "concat_ws('|',x1,y1,x2,y2)")
+ sample = f"WHERE {dens}" if dens else ""
+
+ with self._conn() as conn:
+ df = conn.execute(f"""
+ SELECT * FROM (
+ SELECT {sel} FROM {self._from()} {where} GROUP BY {key}
+ ) {sample}
+ LIMIT {max_limit}
+ """, params).df()
+
+ for c in ("x1", "y1", "x2", "y2"):
+ df[c] = df[c] / ps
+ return df.to_dict("records")
+
def query_grouped(
self,
bbox: Optional[tuple] = None,
min_lrm_count: int = 1,
density: float = 1.0,
max_limit: int = 500_000,
- cell_ids: Optional[set] = None,
- edge_filter: Optional[MetadataFilter] = None,
+ sending_ids: Optional[set] = None,
+ receiving_ids: Optional[set] = None,
+ edge_filters: Optional[list] = None,
) -> list[dict]:
"""
Return one row per directed edge (GROUP BY edge), pre-aggregated.
@@ -373,10 +599,13 @@ def query_grouped(
density<1.0 uses bernoulli sampling so each edge is independently
included with probability `density` — spatially uniform.
- `cell_ids` and `edge_filter` are the metadata filters from issue #45. Both
- go into the WHERE clause, so they run before the GROUP BY and before the
- density sample: filtering to a rare cell type keeps that type's edges at
- full density rather than sampling them away.
+ `sending_ids` / `receiving_ids` constrain the two endpoints independently
+ (issue #59); `edge_filters` is a list of MetadataFilter and-ed together,
+ which is the composition #45 deferred. All of them go into the WHERE
+ clause, so they run before the GROUP BY and before the density sample:
+ narrowing to a rare subset keeps it at full density rather than sampling
+ it away. Density is last, and deliberately so — it is a rendering-volume
+ control, not a selection criterion.
"""
ps = self.pixel_size
schema_names = set(self._parquet_schema().names)
@@ -419,13 +648,10 @@ def query_grouped(
select = ", ".join(agg_cols)
- # Bernoulli sampling: each grouped edge row is included independently
- # at probability `density`. At density=1.0 no sampling clause is added
- # and all viewport edges are returned (up to max_limit safety cap).
- sample_clause = (
- f"USING SAMPLE {density * 100:.4f} PERCENT (bernoulli)"
- if density < 1.0 else ""
- )
+ # Deterministic, shared with query_structure so the edge layer is always a
+ # subset of the tissue graph at the same density. See density_predicate.
+ dens = density_predicate(density)
+ sample_clause = f"WHERE {dens}" if dens else ""
# The connection is opened before the WHERE clause is finalised because the
# metadata filters may need to register a relation on it to semi-join
@@ -435,13 +661,12 @@ def query_grouped(
# An edge file without endpoint columns cannot be filtered by cell; the
# tissue graph still draws, it just ignores the cell subset.
if not {"sending_cell", "receiving_cell"} <= schema_names:
- cell_ids = None
+ sending_ids = receiving_ids = None
with self._conn() as conn:
- for cond, prm in (
- self.cell_filter_sql(cell_ids, conn),
- self.edge_filter_sql(edge_filter, conn),
- ):
+ fragments = [self.endpoint_filter_sql(sending_ids, receiving_ids, conn)]
+ fragments += [self.edge_filter_sql(f, conn) for f in (edge_filters or [])]
+ for cond, prm in fragments:
if cond:
where_conditions.append(cond)
where_params.extend(prm)
diff --git a/backend/app/routers/edges.py b/backend/app/routers/edges.py
index 58540af..36912f8 100644
--- a/backend/app/routers/edges.py
+++ b/backend/app/routers/edges.py
@@ -169,9 +169,27 @@ class EdgeGroupedQueryRequest(BaseModel):
ymax: Optional[float] = None
min_strength: Optional[float] = None
density: float = 1.0 # fraction of viewport edges to return (0.01–1.0)
- # cell_filter restricts by *cell* metadata: an edge survives only if both of
- # its endpoints do. edge_filter restricts by a column of the edge table itself
- # (or of edge-metadata/). They compose.
+
+ # Endpoint filters (issue #59). Each is a *cell* metadata predicate resolved
+ # against the cells table and applied to one end of the edge, so the two
+ # compose as an intersection: sender in A, receiver in B. Either may be
+ # omitted, leaving that end unconstrained.
+ #
+ # These are resolved against the cells table rather than the edge file's own
+ # sending_type/receiving_type: those are absent on five of six platforms as
+ # the r/ export scripts stand, carry one label where any cell column is
+ # wanted, and are frozen at scoring time. See docs/edge_filter_independence.md.
+ sending_filter: Optional[MetadataFilterSpec] = None
+ receiving_filter: Optional[MetadataFilterSpec] = None
+
+ # Filters on the edge table itself (or edge-metadata/), and-ed together.
+ # A list rather than one filter — the composition gap deferred in #45.
+ edge_filters: Optional[List[MetadataFilterSpec]] = None
+
+ # Superseded. cell_filter applied one cell predicate to *both* endpoints and
+ # tied edge visibility to the cell layer's filter; cell and edge filtering are
+ # now independent actions. edge_filter is the pre-list singular form. Both are
+ # still accepted so an older frontend against a newer backend keeps working.
cell_filter: Optional[MetadataFilterSpec] = None
edge_filter: Optional[MetadataFilterSpec] = None
@@ -194,6 +212,30 @@ def _cell_ids_for(dataset: str, spec: Optional[MetadataFilterSpec]) -> Optional[
raise HTTPException(400, str(exc))
+class EdgeStructureRequest(BaseModel):
+ xmin: Optional[float] = None
+ ymin: Optional[float] = None
+ xmax: Optional[float] = None
+ ymax: Optional[float] = None
+ density: float = 1.0
+ # No filter fields, deliberately. The tissue graph is the total set of edges;
+ # see EdgeReader.query_structure.
+
+
+@router.post("/{dataset}/query-structure")
+def query_edge_structure(dataset: str, body: EdgeStructureRequest,
+ edge_file: str = Query("edges.parquet")):
+ """Every edge in the viewport, unfiltered — the tissue-graph layer.
+
+ Separate from /query-grouped so that filtering the edge *data* can never
+ subset the structural graph, and so the two can be sampled independently.
+ """
+ bbox = (body.xmin, body.ymin, body.xmax, body.ymax) \
+ if body.xmin is not None else None
+ return _reader(dataset, edge_file).query_structure(
+ bbox=bbox, density=max(0.001, min(1.0, body.density)))
+
+
@router.post("/{dataset}/query-grouped")
def query_edges_grouped(dataset: str, body: EdgeGroupedQueryRequest,
edge_file: str = Query("edges.parquet")):
@@ -207,11 +249,24 @@ def query_edges_grouped(dataset: str, body: EdgeGroupedQueryRequest,
if body.xmin is not None else None
density = max(0.001, min(1.0, body.density))
try:
+ # Legacy cell_filter means "both endpoints", i.e. the same set on each.
+ legacy = _cell_ids_for(dataset, body.cell_filter)
+ sending = _cell_ids_for(dataset, body.sending_filter)
+ receiving = _cell_ids_for(dataset, body.receiving_filter)
+ if legacy is not None:
+ sending = legacy if sending is None else sending & legacy
+ receiving = legacy if receiving is None else receiving & legacy
+
+ filters = [f.build() for f in (body.edge_filters or [])]
+ if body.edge_filter is not None:
+ filters.append(body.edge_filter.build())
+
return _reader(dataset, edge_file).query_grouped(
bbox=bbox,
density=density,
- cell_ids=_cell_ids_for(dataset, body.cell_filter),
- edge_filter=body.edge_filter.build() if body.edge_filter else None,
+ sending_ids=sending,
+ receiving_ids=receiving,
+ edge_filters=[f for f in filters if f is not None],
)
except ValueError as exc:
raise HTTPException(400, str(exc))
@@ -274,6 +329,48 @@ def edge_color_values(dataset: str, body: EdgeColorRequest,
)
+@router.get("/{dataset}/neighborhood/{cell_id:path}")
+def cell_neighborhood(dataset: str, cell_id: str,
+ field: Optional[str] = Query(None),
+ edge_file: str = Query("edges.parquet")):
+ """Everything one cell is connected to, plus a summary of it (issue #60).
+
+ Unfiltered and unsampled by design: a neighbourhood describes the tissue, not
+ the current view, so density, the viewport and every filter are ignored.
+
+ `field` names a *cell* metadata column to break the neighbourhood down by.
+ Composition comes from the cells table rather than the edge file's
+ sending_type — that column is absent on five of six platforms, carries one
+ label, and is frozen at scoring time (see docs/edge_filter_independence.md).
+ """
+ nb = _reader(dataset, edge_file).neighborhood(cell_id)
+ if nb is None:
+ # Not an error: a cell with no edges is an ordinary outcome, and the panel
+ # says "no connections" rather than showing an empty summary.
+ return {"cell_id": cell_id, "n_neighbors": 0, "n_edges": 0,
+ "neighbors": [], "neighbor_points": [], "lrm_composition": []}
+
+ if field and nb["neighbors"]:
+ from app.routers import spatial
+ try:
+ frame = spatial._reader(dataset)._metadata_frame()
+ except Exception:
+ frame = None
+ # Keyed on the cell_id *column*, matching filter_cell_ids — the frame
+ # carries a plain RangeIndex, so indexing by position would silently
+ # match nothing and report every neighbour as missing.
+ if frame is not None and field in frame.columns and "cell_id" in frame.columns:
+ ids = frame["cell_id"].astype(str)
+ sub = frame.loc[ids.isin(set(nb["neighbors"])), field].dropna()
+ counts = sub.astype(str).value_counts()
+ nb["composition_field"] = field
+ nb["composition"] = [{"value": k, "n": int(v)} for k, v in counts.items()]
+ # Neighbours the column says nothing about are reported rather than
+ # dropped, so the parts always add up to n_neighbors.
+ nb["composition_missing"] = nb["n_neighbors"] - int(counts.sum())
+ return nb
+
+
@router.get("/{dataset}/edge/{edge_id:path}")
def edge_detail(dataset: str, edge_id: str,
edge_file: str = Query("edges.parquet")):
diff --git a/backend/tests/duckdb_config_check.py b/backend/tests/duckdb_config_check.py
new file mode 100644
index 0000000..7b6a94c
--- /dev/null
+++ b/backend/tests/duckdb_config_check.py
@@ -0,0 +1,96 @@
+#!/usr/bin/env python3
+"""
+DuckDB is configured correctly however DUCKDB_MEMORY_LIMIT is (or is not) set.
+
+Why this exists
+---------------
+`DUCKDB_MEMORY_LIMIT` stopped having a fixed default in v0.8.4, because a flat
+`8GB` on a stock ~8 GB Docker Desktop VM let DuckDB take all of RAM and the
+spatial-index build was OOM-killed mid-request. Both compose files now pass it
+through empty so the limit is sized from memory actually available.
+
+That broke every `/edges` endpoint. `edge_reader.py` had its own connection
+setup reading `os.getenv("DUCKDB_MEMORY_LIMIT", "8GB")` — and that default only
+applies when the variable is *absent*. Set-but-empty yields `""`, so the driver
+got `SET memory_limit=''` and raised
+
+ duckdb.duckdb.ParserException: Parser Error: Memory limit must have a number
+
+on every edge query. Two modules defaulting the same environment variable two
+different ways is the actual defect; EdgeReader now goes through
+`duck.connect()`, so there is one rule.
+
+The golden snapshot cannot catch this: it exercises readers in whatever
+environment it happens to run in, and the failure only appears when the variable
+is set to an empty string.
+
+ python3 tests/duckdb_config_check.py
+"""
+import os
+import sys
+import traceback
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+DATA_ROOT = Path(os.getenv("DATA_ROOT", Path(__file__).resolve().parents[2] / "sample_data"))
+
+# The values that have actually appeared in a compose file or a shell, plus the
+# shapes a user might reasonably put in .env.prod.
+CASES = [
+ ("unset", None),
+ ("empty", ""), # ${DUCKDB_MEMORY_LIMIT:-} — the one that broke edges
+ ("blank", " "),
+ ("explicit", "512MB"),
+]
+
+failures = []
+
+
+def check_connect(label):
+ """A connection must be usable, whatever the environment says."""
+ import importlib
+ from app.readers import duck
+ importlib.reload(duck)
+ limit = duck._MEMORY_LIMIT
+ with duck.connect() as conn:
+ conn.execute("SELECT 1").fetchone()
+ # A bare number is not a valid DuckDB memory limit either.
+ if not any(limit.upper().endswith(u) for u in ("KB", "MB", "GB", "TB", "B")):
+ failures.append(f"[{label}] memory_limit {limit!r} has no unit suffix")
+ return limit
+
+
+def check_edges(label):
+ """Every edge endpoint shares one connection helper — exercise a real query."""
+ from app.readers.edge_reader import EdgeReader
+ found = sorted(DATA_ROOT.glob("*/edges.parquet"))
+ if not found:
+ return "no edge datasets present — skipped"
+ src = found[0]
+ reader = EdgeReader(src, pixel_size=1.0)
+ n = len(reader.lrm_catalogue())
+ reader.query_grouped(bbox=None, density=1.0)
+ return f"{src.parent.name}: {n} LRMs"
+
+
+for label, value in CASES:
+ if value is None:
+ os.environ.pop("DUCKDB_MEMORY_LIMIT", None)
+ else:
+ os.environ["DUCKDB_MEMORY_LIMIT"] = value
+ try:
+ limit = check_connect(label)
+ detail = check_edges(label)
+ print(f" OK {label:<9} {str(value)!r:<10} -> memory_limit={limit:<10} {detail}")
+ except Exception as exc:
+ failures.append(f"[{label}] {type(exc).__name__}: {exc}")
+ print(f" FAIL {label:<9} {str(value)!r:<10} -> {type(exc).__name__}: {exc}")
+ traceback.print_exc()
+
+print()
+if failures:
+ for f in failures:
+ print(" ✗", f)
+ sys.exit(1)
+print("DuckDB configuration is valid for every DUCKDB_MEMORY_LIMIT form.")
diff --git a/backend/tests/edge_pipeline_check.py b/backend/tests/edge_pipeline_check.py
new file mode 100644
index 0000000..c3f1a4f
--- /dev/null
+++ b/backend/tests/edge_pipeline_check.py
@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+"""
+The edge pipeline, asserted (issue #59).
+
+ all edges in viewport
+ → density filter (spatially random, deterministic per edge)
+ → EDGESET A → tissue-graph layer
+ → sending filter
+ → receiving filter
+ → edge-table filters
+ → EDGESET B → edge-data layer
+
+Three properties fall out, and all three are things a plausible refactor breaks
+silently rather than loudly:
+
+1. **The tissue graph is ground truth.** Its count must not move no matter what
+ filters are set on the edge data. `query_structure` takes no filter arguments
+ at all, so this is really asserting that nobody wired one in.
+2. **B is a subset of A, at every density.** Edge data must never be drawn where
+ the graph beneath it was sampled away. This is why density is a deterministic
+ hash of the edge id rather than `USING SAMPLE`: two independent bernoulli
+ draws at 10% overlap only ~1% of the time.
+3. **Sampling is stable.** The same request twice returns the same edges, or the
+ layer flickers on every pan.
+
+Run against whatever datasets are present:
+
+ python3 tests/edge_pipeline_check.py
+"""
+import os
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+os.environ.setdefault("DATA_ROOT", str(Path(__file__).resolve().parents[2] / "sample_data"))
+
+from app.readers.metadata_filter import MetadataFilter # noqa: E402
+from app.routers import spatial, edges as edges_router # noqa: E402
+
+DATA_ROOT = Path(os.environ["DATA_ROOT"])
+DENSITIES = (1.0, 0.5, 0.1)
+failures: list[str] = []
+
+
+def usable_field(frame):
+ """A metadata column with real, varied values — not an all-NaN one."""
+ for c in frame.columns:
+ if c in ("x_centroid", "y_centroid", "cell_id"):
+ continue
+ if frame[c].notna().any() and frame[c].nunique() > 1:
+ return c
+ return None
+
+
+def spec_for(reader, field):
+ # color_values(mode, field, genes, categorical) — passing field positionally
+ # into `genes` silently returns the empty result, which made spec_for return
+ # None and skipped the subset assertion below on most datasets.
+ cv = reader.color_values("metadata", field)
+ if cv.get("categories"):
+ return MetadataFilter.build(field, values=cv["categories"][:1])
+ lo, hi = cv.get("min"), cv.get("max")
+ if lo is None or hi is None or lo == hi:
+ return None
+ return MetadataFilter.build(field, vmin=lo + (hi - lo) * 0.4, vmax=hi)
+
+
+def check(ds: str) -> str:
+ sr = spatial._reader(ds)
+ er = edges_router._reader(ds, "edges.parquet")
+ field = usable_field(sr._metadata_frame())
+ spec = spec_for(sr, field) if field else None
+ ids = sr.filter_cell_ids(spec) if spec else None
+
+ baseline = len(er.query_structure(density=1.0))
+ for d in DENSITIES:
+ A = {r["edge"] for r in er.query_structure(density=d)}
+ B = {r["edge"] for r in er.query_grouped(density=d)}
+ if B != A:
+ failures.append(f"[{ds} d={d}] unfiltered edge data != graph "
+ f"({len(B)} vs {len(A)})")
+ if ids:
+ Bf = {r["edge"] for r in er.query_grouped(density=d, sending_ids=ids)}
+ if not Bf <= A:
+ failures.append(f"[{ds} d={d}] filtered edge data is not a subset "
+ f"of the graph ({len(Bf - A)} strays)")
+ # Property 1: the graph is unmoved by anything the edge data did.
+ if len(er.query_structure(density=1.0)) != baseline:
+ failures.append(f"[{ds} d={d}] tissue graph count moved")
+
+ # Property 3
+ if {r["edge"] for r in er.query_structure(density=0.1)} != \
+ {r["edge"] for r in er.query_structure(density=0.1)}:
+ failures.append(f"[{ds}] sampling is not stable between identical calls")
+
+ if ids:
+ return f"{baseline:>8,} edges filter=[{field}] -> {len(ids):,} cells"
+ # Reported rather than passed over in silence: with no resolvable filter the
+ # subset assertion above never runs, and a check that skips its own core
+ # property while printing OK is worse than no check.
+ return f"{baseline:>8,} edges NO FILTER RESOLVED — subset assertion skipped"
+
+
+found = sorted(p.name for p in DATA_ROOT.iterdir() if (p / "edges.parquet").exists())
+if not found:
+ print("no edge datasets present — nothing to check")
+ sys.exit(0)
+
+for ds in found:
+ try:
+ print(f" OK {ds:26} {check(ds)}")
+ except Exception as exc:
+ failures.append(f"[{ds}] {type(exc).__name__}: {exc}")
+ print(f" FAIL {ds:26} {type(exc).__name__}: {exc}")
+
+print()
+if failures:
+ for f in failures:
+ print(" ✗", f)
+ sys.exit(1)
+print(f"edge pipeline holds across {len(found)} datasets "
+ f"at densities {', '.join(str(d) for d in DENSITIES)}.")
diff --git a/backend/tests/golden_baseline.json b/backend/tests/golden_baseline.json
index 87d87cb..55b2ec0 100644
--- a/backend/tests/golden_baseline.json
+++ b/backend/tests/golden_baseline.json
@@ -179,6 +179,17 @@
],
"n": 169219
},
+ "edge__edges.parquet__structure": {
+ "digest": "c41185a78aa1dea2",
+ "keys": [
+ "edge",
+ "x1",
+ "x2",
+ "y1",
+ "y2"
+ ],
+ "n": 169219
+ },
"gene_list": {
"digest": "b4a6b682f016cebd",
"n": 960
@@ -391,6 +402,17 @@
],
"n": 14503
},
+ "edge__edges.parquet__structure": {
+ "digest": "e5e502e5a4f78494",
+ "keys": [
+ "edge",
+ "x1",
+ "x2",
+ "y1",
+ "y2"
+ ],
+ "n": 14503
+ },
"gene_list": {
"digest": "ba0a34a6d5631ace",
"n": 130
@@ -626,6 +648,17 @@
],
"n": 223
},
+ "edge__edges.parquet__structure": {
+ "digest": "0676dafca6cb1c65",
+ "keys": [
+ "edge",
+ "x1",
+ "x2",
+ "y1",
+ "y2"
+ ],
+ "n": 223
+ },
"edge__edges__edge.normalized.product.parquet__catalogue": {
"digest": "556c70f23429ddd8",
"n": 20
@@ -703,6 +736,17 @@
],
"n": 289
},
+ "edge__edges__edge.normalized.product.parquet__structure": {
+ "digest": "a7f95b42286cb2bf",
+ "keys": [
+ "edge",
+ "x1",
+ "x2",
+ "y1",
+ "y2"
+ ],
+ "n": 289
+ },
"edge__edges__edge.raw.minimum.parquet__catalogue": {
"digest": "556c70f23429ddd8",
"n": 20
@@ -780,6 +824,17 @@
],
"n": 155
},
+ "edge__edges__edge.raw.minimum.parquet__structure": {
+ "digest": "2cea4f58c308f7c1",
+ "keys": [
+ "edge",
+ "x1",
+ "x2",
+ "y1",
+ "y2"
+ ],
+ "n": 155
+ },
"filter_bounds__transcript_counts": {
"n_cells": 19,
"total": 19
@@ -1034,6 +1089,17 @@
],
"n": 372
},
+ "edge__edges.parquet__structure": {
+ "digest": "dcebdff0bd9e434e",
+ "keys": [
+ "edge",
+ "x1",
+ "x2",
+ "y1",
+ "y2"
+ ],
+ "n": 372
+ },
"gene_list": {
"digest": "6f1e76b0bd839528",
"n": 12
@@ -1245,6 +1311,17 @@
],
"n": 250
},
+ "edge__edges.parquet__structure": {
+ "digest": "1be5a577ef35b7a4",
+ "keys": [
+ "edge",
+ "x1",
+ "x2",
+ "y1",
+ "y2"
+ ],
+ "n": 250
+ },
"gene_list": {
"digest": "66064248776a3e2f",
"n": 36
@@ -1454,6 +1531,17 @@
],
"n": 66001
},
+ "edge__edges.parquet__structure": {
+ "digest": "b51d25a17fca5fc2",
+ "keys": [
+ "edge",
+ "x1",
+ "x2",
+ "y1",
+ "y2"
+ ],
+ "n": 66001
+ },
"gene_list": {
"digest": "40a2e076472f6c63",
"n": 32245
@@ -1631,6 +1719,17 @@
],
"n": 1642
},
+ "edge__edges.parquet__structure": {
+ "digest": "f8bfac32075532cd",
+ "keys": [
+ "edge",
+ "x1",
+ "x2",
+ "y1",
+ "y2"
+ ],
+ "n": 1642
+ },
"filter_bounds__array_row": {
"n_cells": 18,
"total": 18
@@ -1831,6 +1930,17 @@
],
"n": 44759
},
+ "edge__edges.parquet__structure": {
+ "digest": "f9eacf53fffc98ca",
+ "keys": [
+ "edge",
+ "x1",
+ "x2",
+ "y1",
+ "y2"
+ ],
+ "n": 44759
+ },
"edge__edges__niches_rad30.parquet__catalogue": {
"digest": "931e3a76e6dcb943",
"n": 32
@@ -1908,6 +2018,17 @@
],
"n": 181523
},
+ "edge__edges__niches_rad30.parquet__structure": {
+ "digest": "abc6dda554c2ad29",
+ "keys": [
+ "edge",
+ "x1",
+ "x2",
+ "y1",
+ "y2"
+ ],
+ "n": 181523
+ },
"filter_bounds__control_probe_counts": {
"n_cells": 7272,
"total": 7272
diff --git a/backend/tests/golden_snapshot.py b/backend/tests/golden_snapshot.py
index ab3a347..527ae03 100644
--- a/backend/tests/golden_snapshot.py
+++ b/backend/tests/golden_snapshot.py
@@ -311,9 +311,14 @@ def probe_edges(dataset_dir: Path, pixel_size: float, p: Probes) -> None:
continue
spec = MetadataFilter.build(col, values=cv["categories"][:1])
p.record(f"edge__{key}__filter__{col}", lambda er=er, spec=spec:
- Probes.rows(er.query_grouped(density=1.0, edge_filter=spec)))
+ Probes.rows(er.query_grouped(density=1.0, edge_filters=[spec])))
break
+ # Issue #59: the structural query is the tissue graph's ground truth and
+ # takes no filters, so its count must equal the unfiltered edge count.
+ p.record(f"edge__{key}__structure",
+ lambda er=er: Probes.rows(er.query_structure(density=1.0)))
+
def collect() -> dict:
from app.readers.reader_factory import ReaderFactory
diff --git a/docs/data_format.md b/docs/data_format.md
index 4f27b7b..4b7157a 100644
--- a/docs/data_format.md
+++ b/docs/data_format.md
@@ -62,8 +62,19 @@ TissuePlex discovers `edges.parquet` automatically — no configuration needed.
| Column | Type | Description |
|--------|------|-------------|
-| `sending_type` | string | Cell/spot type label for the sending unit |
-| `receiving_type` | string | Cell/spot type label for the receiving unit |
+| `sending_type` | string | Cell/spot type label for the sending unit — **optional, often absent** |
+| `receiving_type` | string | Cell/spot type label for the receiving unit — **optional, often absent** |
+
+**Do not rely on `sending_type` / `receiving_type`.** `export_to_TissuePlex()`
+populates them only when given a `celltype.col`, and of the scripts in `r/` only
+`niches_xenium.R` exposes that (`--celltype`); the other five pass
+`celltype.col = NULL`, so the columns are absent. The values in the bundled
+`sample_data` fixtures are *simulated* by `make_edges.py`, not analysis output.
+
+They are also frozen at scoring time, so they can disagree with a cells table
+that has since been re-annotated through `cell-metadata/`. Anything that needs a
+cell attribute per edge should resolve it against the cells table instead — see
+`docs/edge_filter_independence.md`.
`export_to_TissuePlex()` **always writes these two columns**, so a file it produces has
exactly the 16 columns above, in that order. Their *values* are `NA` when
diff --git a/docs/edge_filter_independence.md b/docs/edge_filter_independence.md
new file mode 100644
index 0000000..b529690
--- /dev/null
+++ b/docs/edge_filter_independence.md
@@ -0,0 +1,319 @@
+# Issue #59 — independent sending / receiving edge filters
+
+Status: **implemented** in v0.8.6. Kept as the design record.
+
+The pipeline as built:
+
+```
+all edges in viewport
+ → density filter deterministic, spatially random
+ → EDGESET A → tissue-graph layer (query_structure)
+ → sending filter
+ → receiving filter
+ → edge-table filters
+ → EDGESET B → edge-data layer (query_grouped)
+```
+
+Upstream ask
+([#59](https://github.com/RaredonLab/TissuePlex/issues/59)): designate a sending
+cell type and a receiving cell type independently, and see the resulting graph
+structure. The issue asks whether deriving this from *cell* metadata per edge is
+efficient. It is — the machinery already exists and the added cost is one extra
+hash semi-join.
+
+Two directives from the lab shape this beyond what the issue text says, and both
+change current behaviour:
+
+1. **The tissue graph is ground truth and must not be filtered.** It is the total
+ set of edges, shown or hidden, never subset. Only the edge-data layer drawn on
+ top of it responds to filters.
+2. **Cell filtration and edge filtration are completely independent.** Filtering
+ cells must not remove edges, and filtering edges must not remove cells.
+3. **Density is applied last, after every other filter.** It is a rendering-volume
+ control, not a selection criterion.
+
+---
+
+## What already works, and why it does not count
+
+`sending_type` / `receiving_type` are in `edges.parquet` and are not in
+`EDGE_FILTER_SKIP`, so the edge filter dropdown already offers them. Filtering
+one side works mechanically — measured on `mouse_ileum_tiny`:
+
+| filter | edges | sending types in result | receiving types in result |
+|---|---|---|---|
+| none | 223 | all five | all five |
+| `sending_type = Fibroblast` | 55 | `Fibroblast` only | all five |
+
+**This is not the feature, and the demo above is misleading.** Tracing where
+those labels come from:
+
+| source | populates the column? |
+|---|---|
+| `sample_data/make_edges.py` | yes — its own docstring says `cell type (simulated)` |
+| `r/niches_xenium.R` | only with an explicit `--celltype ` flag |
+| `niches_cosmx/merscope/seqfish/visium/visium_hd.R` | **no** — all pass `celltype.col = NULL` |
+
+So the five tidy labels above (`Endothelial`, `Immune`, `Fibroblast`, …) are
+**invented by the fixture generator**. On real output the column is absent on
+five of six platforms as the export scripts are written today, and present on
+Xenium only if the user opted in. It also carries exactly one column, where the
+ask is "any cell metadata column" — `mouse_ileum_tiny`'s cells table has
+`cluster`, `region`, `pseudotime` and `seurat_clusters`, none of which appears in
+the edge file.
+
+The conclusion is not "half of #59 ships" — it is that the mechanism exists but
+is pointed at the wrong source. The real blocker is twofold: `edgeFilter` holds
+exactly one filter (the composition gap deferred in #45), and the only column it
+can reach is one that mostly is not there.
+
+### Should `sending_type` exist at all?
+
+Worth deciding separately, and it is a NICHESv2-side question more than a
+TissuePlex one. The case against: it duplicates cell metadata into the edge
+table, freezing it at scoring time, and creates two sources of truth for the same
+question — re-annotate cells through `cell-metadata/` and the edge file still says
+the old thing. The case for: if NICHESv2 *used* the label when scoring, then it is
+provenance rather than duplication, and the honest record of what produced the
+number. Which of those is true is not answerable from this repo.
+
+Either way it should not back the two dropdowns. If it stays, document it as
+"the label used at scoring time — may be absent, may be stale", and leave it
+reachable through the edge-column filter where that framing is visible.
+
+## Directive 3 already holds — preserve it
+
+Density is already last on the edge path, and this is worth stating as an
+invariant because it is easy to break by accident. The grouped query samples the
+*outer* select, wrapping the filtered and grouped subquery:
+
+```sql
+SELECT * FROM (
+ SELECT …
+ FROM edges
+ WHERE
+ GROUP BY edge
+ HAVING lrm_count >= 1
+) USING SAMPLE PERCENT (bernoulli)
+LIMIT 500000
+```
+
+Everything selective runs first; the sample then draws from whatever survived, so
+narrowing to a rare subset renders that subset at full density instead of a tenth
+of it. The nesting is load-bearing — applied alongside the `WHERE`, DuckDB is free
+to sample before filtering, which silently reintroduces the problem.
+
+Every new predicate in this plan therefore belongs in the same `WHERE` clause, not
+in post-processing, and the new tissue-graph endpoint must keep the same shape.
+
+**This is also why the tissue graph needs its own query rather than a flag on the
+shared one** — see below. Filter-then-sample and don't-filter-then-sample are
+different orderings of the same pipeline, and one statement cannot do both.
+
+## The three current couplings to break
+
+`useEdges` issues **one** request whose result feeds **both** the tissue-graph
+layer and the directed-edge layer (`Viewer.jsx`: `id: "tissue-graph"`,
+`data: allDirectedEdges`). That request carries `cell_filter`, `edge_filter` and
+`density`. Consequently, today:
+
+- filtering **cells** removes edges, because `query_grouped(cell_ids=…)` emits
+ `sending_cell IN S AND receiving_cell IN S`;
+- filtering **edges** thins the tissue graph, because both layers read one array;
+- the tissue graph is density-sampled by the same slider as the edge data.
+
+LRM filtering is already correctly decoupled — the tissue graph reads `lrm_count`
+while the edge layer reads `visible_lrm_count`, so hiding mechanisms leaves the
+structural graph intact. That is the pattern the other two filters should follow.
+
+## Design
+
+### The tissue graph gets its own fetch
+
+Not a flag on the shared result, and the reason is not obvious. The two layers
+want *opposite* things from sampling:
+
+- the tissue graph wants **all** edges, then sampled for drawing volume;
+- the edge data wants **filtered first, then sampled**, so that narrowing to a
+ rare subset draws that subset at full density rather than a tenth of it. That
+ ordering is the whole point of resolving filters server-side (issue #45).
+
+One query cannot do both. Returning every edge with a `passes_filter` boolean
+would sample the rare subset away along with everything else — the flag would be
+read *after* the sample had thinned the rows it describes. So: two requests.
+
+**They must nonetheless select the same edges**, and this was the one real bug in
+the first implementation. Two independent `USING SAMPLE` draws at 10% overlap only
+about 1% of the time, so edge data appeared where the graph beneath it had been
+sampled away. `density_predicate` replaces bernoulli with a deterministic hash of
+the edge id: every edge gets the same verdict in every query, so the predicate
+commutes with the filters — density-then-filter and filter-then-density are the
+same set — and B ⊆ A holds at every density. It is also stable across re-fetches,
+where bernoulli flickers on each pan.
+
+Sizing, measured across the bundled datasets — unique directed edges:
+
+| dataset | rows | unique edges | file |
+|---|---|---|---|
+| cosmx-mousebrain | 3,819,698 | 169,219 | 31 MB |
+| visium_hd_tiny | 66,002 | 66,001 | 0.8 MB |
+| xenium_human_breast_2fov | 134,277 | 44,759 | 3.2 MB |
+| merscope-vpt-smallset | 21,587 | 14,503 | 0.3 MB |
+
+Rendering 169K–300K line segments is not the constraint; deck.gl handles that.
+The payload is — `query-grouped` returns ~15 fields per edge, so a whole-tissue
+view of a real run is tens of MB. The lean projection was therefore done up
+front rather than deferred: `edge` plus the four coordinates, measured **2.2–2.4×
+smaller** than the grouped payload (cosmx 17.6 MB vs 42.0 MB). The 500K-row cap
+stays as the backstop.
+
+**A second density slider was built and then removed.** The argument for one was
+that the graph draws far more lines than the filtered edge layer. It does not
+hold: unfiltered the two draw exactly the same number, and the graph already has
+an *opacity* control, which is the better lever for clutter — sampling down a
+layer whose purpose is to show complete structure misrepresents it. One slider
+now drives both.
+
+### Two cell-metadata pickers, sending and receiving
+
+This is the feature, not an add-on to an edge-column filter. The edge section
+gets **two dropdowns side by side** — *sending cell* and *receiving cell* — and
+each selects **any cell metadata column**, the same vocabulary the cell filter
+offers. An edge is drawn when **both** sides are satisfied: the intersection.
+
+```
+Sending cell Receiving cell
+[ region ▾ ] [ cluster ▾ ]
+[x] crypt [x] 3
+[ ] mid [ ] 4
+[ ] villus [x] 7
+```
+
+Either side may be left unset, which leaves that end unconstrained — so one
+dropdown alone answers "everything sent *from* crypt cells, to anywhere".
+
+### Resolve through the cells table, never the edge file's own labels
+
+Filtering on the edge file's own `sending_type` would be a plain SQL predicate,
+cheaper than resolving an id set. It is still the wrong source, for three reasons
+set out above: the column is **absent** on five of six platforms as the export
+scripts stand, it carries **one** label where the ask is any column, and it is
+**frozen** at scoring time so it can silently disagree with a re-annotated cells
+table.
+
+A fast path for the one case where it happens to be present would mean two code
+paths answering the same question differently depending on which one ran — the
+kind of disagreement that is very hard to notice in a figure.
+
+So both dropdowns resolve through `filter_cell_ids()` against the cells table,
+uniformly, with no fast path. The parquet's own labels stay reachable through the
+separate edge-column filter, where they are honestly labelled as the as-scored
+values.
+
+### The backend change
+
+`filter_cell_ids(spec)` already resolves a cell-metadata predicate to an id set
+and caches per (reader, spec). `duck.register_ids()` already turns a large set
+into a hash semi-join rather than an unusable `IN (?, ?, …)` list. The only change
+in `edge_reader.py` is to stop applying one set to both ends:
+
+```python
+# now — one set, both endpoints
+pred = duck.register_ids(conn, cell_ids, name="tp_cell_filter")
+f'CAST("sending_cell" AS VARCHAR) {pred} AND CAST("receiving_cell" AS VARCHAR) {pred}'
+
+# after — two frames, two predicates, either side optional
+send = duck.register_ids(conn, sending_ids, name="tp_send_filter")
+recv = duck.register_ids(conn, receiving_ids, name="tp_recv_filter")
+```
+
+Both predicates go in the same `WHERE` clause, so they run before the `GROUP BY`
+and before the sample — the ordering directive 3 requires.
+
+**Answering the issue's efficiency question directly:** this is the same cost as
+today. One hash semi-join becomes two, on a query that already performs one.
+
+### Edge-column filters stay, and become a list
+
+Separately from the two pickers, `edgeFilter` becomes `edgeFilters:
+MetadataFilter[]`, and-ed together. This is what `edge-metadata/` annotations
+(a confidence, a review flag) and the parquet's own columns are filtered through,
+and it closes the composition gap deferred in #45. It is a smaller, independent
+win — the two cell-metadata pickers are what #59 actually asks for.
+
+
+### Cell filtering stops touching edges
+
+`cellFilter` governs the cell layers only. The `cell_ids` argument stays on
+`query_grouped` — the new sending/receiving slots use it — but the frontend
+stops passing the cell-layer filter into the edge request.
+
+**This reverses a decision recorded in CLAUDE.md**, which argued that an edge
+should be drawn only when both endpoints are, since "a half-outside edge would
+run off to a cell that is not drawn". The lab's position is that edge structure
+is worth seeing independently of which cells are rendered, so edges may now
+terminate on cells that are not drawn. That is intended, not an oversight, and
+the docs need correcting rather than the behaviour.
+
+## Consequences to get right
+
+- **Autocrine edges** have `sending_cell == receiving_cell`, so an autocrine edge
+ survives only when the cell satisfies *both* sides. Correct, but it means
+ setting sending and receiving to disjoint groups hides every autocrine ring.
+ Worth a note in the manual rather than a special case in code.
+- **Direction is meaningless for the tissue graph**, which is undirected and now
+ unfiltered anyway — so the question does not arise. This is the main reason
+ directive 1 simplifies the feature rather than complicating it.
+- **An empty id set means "nothing matches"**, and `register_ids` refuses an
+ empty frame. `query_grouped` already short-circuits `cell_ids == set()` to
+ `FALSE`; both new slots need the same guard, or a filter matching no cells
+ silently returns every edge.
+- **`edgeFilter` → `edgeFilters` is a store migration.** Anything persisted or
+ copied between panels (`sanitiseSettings`) must handle both shapes, and the
+ push guard must validate every filter in the list, not just the first.
+
+## Surface
+
+| | |
+|---|---|
+| `backend/app/readers/edge_reader.py` | split the cell-id predicate; accept a filter list |
+| `backend/app/routers/edges.py` | request model gains `sending_filter`, `receiving_filter`, `edge_filters`; new lean structural endpoint |
+| `frontend/src/hooks/useEdges.js` | second unfiltered fetch for the graph; stop sending `cell_filter` |
+| `frontend/src/components/Viewer.jsx` | tissue-graph layer reads the new array |
+| `frontend/src/components/LayerPanel.jsx` | sending/receiving pickers; own density slider for the graph |
+| `frontend/src/store.js` | `edgeFilters` list, `sendingFilter`, `receivingFilter`, `tissueGraphDensity` |
+
+`MetadataFilterSection` is already generic over a field list and a value fetcher,
+so the two new pickers reuse it against the *cell* schema rather than the edge
+schema.
+
+## Staging
+
+**1 — decouple, no new features.** Tissue graph gets its own unfiltered fetch and
+density; `cellFilter` stops reaching the edge request. Directives 1 and 2, with
+no change to what can be expressed. Independently useful and independently
+reviewable.
+
+**2 — `edgeFilter` becomes a list.** Closes #45's deferred half; makes
+sending/receiving expressible wherever the columns exist in `edges.parquet`.
+
+**3 — cell-derived sending/receiving slots.** The two id sets, for metadata that
+is not in the edge file.
+
+**4 — lean structural projection** for the tissue-graph endpoint, if the payload
+proves to be the limit on real data. Measure before doing it.
+
+**5 — docs.** CLAUDE.md's "cellFilter also governs edges" statement is wrong
+after stage 1, in the store section and the metadata-subsetting section. The
+manual's filter section needs the same correction, plus the autocrine note.
+
+## Testing
+
+Frontend store tests exist now (Vitest, 46), and the filter-targeting logic is
+plain reducer code — the `edgeFilters` migration and the push-guard change belong
+there. On the backend the decisive checks are query-level, and should use a **cell
+metadata column** rather than `sending_type`, which the fixture only simulates:
+filtering sending alone must leave the receiving distribution untouched; the two
+sides together must return the intersection; and the tissue-graph endpoint must
+return an identical count with and without every filter applied, which is
+directive 1 stated as an assertion.
diff --git a/docs/index.html b/docs/index.html
index 2a502c6..6e2e23b 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -758,6 +758,19 @@ Dataset & Image Picker
image is viewed, TissuePlex builds a tile pyramid (10–30 seconds); if the
canvas looks blank, wait and refresh. |
+ In split-screen mode these dropdowns move into
+ each panel's own header instead, because the two panels can show different
+ datasets and an image or edge file only means something relative to one of
+ them.
+
+
+
Changing a panel's dataset clears the settings that name a column, gene or
+ mechanism — metadata filters, the color-by field, the gene selection, the
+ mechanism checklist. It has to: those names come from the dataset you just
+ left. Sliders, palettes and layer visibility are kept. While the panels are
+ linked, this clears both; unlinked, only the panel you changed.
+
+
Tip: TissuePlex works best in
Google Chrome or
Firefox . Switch to Chrome
@@ -891,10 +904,9 @@
What the filter affects
Cell segments — only matching cells are drawn, and only
they can be clicked or captured by a region selection.
- Edges and the tissue graph — an edge is drawn only when
- both of its endpoints match. The point of narrowing to two or three
- cell types is the signalling within that subset, and an edge with one endpoint
- outside would run off to a cell that is not on screen.
+ Nothing else. The cell filter does not touch edges or the
+ tissue graph — filtering cells and filtering edges are independent actions. To
+ narrow the edges, use the sending and receiving filters described below.
Transcripts are not filtered. Several platforms ship no
transcript-to-cell assignment at all, so there is nothing to match on.
@@ -908,15 +920,50 @@
What the filter affects
Cell Segments updates to describe the subset.
- Filtering edges
- The Edge Filter inside Edge
- Data works the same way but on columns of the edge table itself, including
- annotations from an edge-metadata/ folder — a curation call, a
- confidence score, a review flag. It composes with the cell filter: set both and
- you see only the annotated edges among your chosen cells.
- This is distinct from the LRM Mechanisms checklist
- below it. The checklist chooses which mechanisms are scored on every
- edge; the edge filter chooses which cell pairs are drawn at all.
+ Filtering edges by sending and receiving cell
+ Edge Filter inside Edge Data
+ has two dropdowns side by side — Sending cell and
+ Receiving cell . Each offers any cell metadata
+ column , the same list the cell filter uses, and each constrains one end
+ of an edge.
+
+
+ Set one and that end is constrained while the other is
+ free — "everything sent from crypt cells, to anywhere".
+ Set both and you get the intersection: senders in one
+ group, receivers in the other. This is how you ask for
+ fibroblast → endothelial signalling and nothing else.
+
+
+
+
These are independent of the Cell Filter above. Narrowing
+ the cells you draw does not narrow the edges, and vice versa. An edge may
+ therefore terminate on a cell that is not currently drawn — that is intended,
+ so the graph structure can be read on its own terms.
+
+
+ Below the two dropdowns, a third filter works on columns of the
+ edge table itself , including annotations from an
+ edge-metadata/ folder — a curation call, a confidence score, a
+ review flag.
+
+ All of these are distinct from the LRM Mechanisms
+ checklist. The checklist chooses which mechanisms are scored on every
+ edge; the filters choose which cell pairs are drawn at all.
+
+ The order things happen in
+ Understanding this makes the controls predictable:
+
+ every edge in view
+ density — keeps a spatially random fraction
+ the result is the Tissue Graph layer
+ sending , then receiving , then the
+ edge-table filter
+ the result is the Edge Data layer
+
+ Because the edge data is filtered from the tissue graph, it can never
+ appear where there is no graph edge beneath it. Both layers respond to the one
+ density slider and stay aligned as you move it.
@@ -989,6 +1036,16 @@ Tissue Graph
showing which cells are spatial neighbors, independent of any LRM information.
Each line indicates spatial adjacency between two cells. Use the checkbox to
toggle it and the opacity slider to adjust its visual weight.
+
+
+
The tissue graph is ground truth : the total set of edges in
+ view. No filter narrows it — it is shown or hidden, nothing else. Only the
+ density slider changes how many of its lines are drawn, and that applies
+ equally to the Edge Data layer on top, so the two always align.
+
Use opacity , not density, to make it recede behind the edge
+ data. Lowering density removes lines from a layer whose whole purpose is to
+ show the complete structure.
+
@@ -1113,6 +1170,37 @@ Clicking on a Cell
shows the barcode ID, all metadata values for that cell, and the current
color-by value highlighted. Click on empty canvas to dismiss.
+ The Local Neighbourhood
+ With a cell selected, show local neighbourhood at the
+ bottom of the info panel reveals everything that cell is connected to in the
+ tissue graph. The canvas marks the clicked cell in white, its connected
+ neighbours in yellow, and draws a circle at the distance of the furthest one.
+ The panel reports:
+
+
+ neighbours and local edges — how many
+ cells it touches, and how many directed edges that is;
+ radius in µm, so the reach can be quoted without
+ measuring it off the screen;
+ composition — the neighbours broken down by whichever
+ metadata column you are colouring cells by;
+ mechanisms — the strongest LRMs across its edges.
+
+
+
+
The circle is a convenience, not the answer. Connectivity is not evenly
+ spread — a cell at the edge of a tissue has neighbours on one side only — so
+ the circle usually contains cells it is not connected to. The yellow
+ marks are the neighbourhood; the circle only shows how far it reaches.
+
+
+
+
The summary is computed on the full data, so it is unaffected by the
+ density slider, by the sending and receiving filters, or by how far you are
+ zoomed in. It describes the tissue, not the current view. Clicking another
+ cell replaces it; hide highlight removes it.
+
+
Clicking on an Edge or Tissue Graph Line
Click on any line in the Tissue Graph or Directed Edges layer to open the
Edge Info Panel , showing the sending and receiving cell
@@ -1129,22 +1217,75 @@
Clicking on an Edge or Tissue Graph Line
Split-Screen Mode
- Split-screen mode divides the viewer into two independent side-by-side
- panels, each with its own pan and zoom position. Use it to compare two tissue
- regions or to view the same region with different filter settings.
+ Split-screen mode divides the viewer into two side-by-side panels. Each has
+ its own pan and zoom, and each can show a different dataset —
+ so you can put two samples, two platforms, or two timepoints next to each
+ other. Both panels can also show the same dataset rendered two different ways.
Click ⊞ Split in the annotation toolbar to
enter split-screen mode.
Click □ Single to return to a single panel.
- Both panels share all layer settings (visibility, color-by, LRM filter,
- etc.) but have independent pan and zoom positions.
+ In split mode each panel gets its own dataset, image and edge-source
+ picker in its header , since those only mean something relative to one
+ dataset.
+
+
+ Choosing Which Panel You Are Editing
+ The sidebar drives both panels. At the top of it,
+ Panel 1 / Panel 2 tabs choose
+ which one your edits apply to, and a checkbox below them controls whether they
+ are linked:
+
+
+ Linked (the default): every change — opacity, palette,
+ color-by, filters, mechanism selection, edge geometry — applies to both
+ panels at once. This is what makes a side-by-side comparison actually
+ comparable.
+ Unlinked : edits apply only to the tab you are on. Use
+ this to show cluster coloring on one side and gene expression on the other,
+ or two palettes, or two filters.
+
+
Switching the checkbox back on re-syncs both panels to the
+ tab you are currently on. That is deliberate: a control labelled "linked"
+ over two visibly different panels would not be telling you the truth. Pick
+ the tab whose look you want to keep before you re-link.
+
+
+ Copying Settings From One Panel to the Other
+ While the panels are unlinked, a
+ copy panel 1 → panel 2 button appears under the
+ checkbox. It copies every display setting from the tab you are on to the other
+ panel in one go, and then leaves the panels independent again — so you can
+ establish a common baseline and diverge from it.
+
+ Settings that name something the other dataset does not have are dropped
+ rather than copied: a metadata filter on a column it lacks, a gene allowlist
+ with no genes in common, mechanisms absent from its edge file. Color range
+ limits also reset when the two panels show different datasets, because a range
+ measured in one dataset's units is meaningless in another's.
+
Matching Zoom Between Panels
The ⇔ Match button sets the other panel to your
- current panel's zoom level while each panel remains centered on its own
- region.
+ current panel's zoom while each panel stays centered on its own region.
+
+
+
Match works in microns, not pixels , so the same physical
+ distance spans the same width on screen in both panels — exactly as a scale
+ bar would. This matters when the panels hold different platforms: 20% of a
+ 6.5 mm Visium capture area and 20% of a 55 µm seqFISH region differ
+ by more than fifty-fold.
+
+
+ Color Scales Across Panels
+ The shared colour scale across panels checkbox, on
+ by default, makes both panels map color through one range computed across both.
+ Leave it on for any figure. Two panels that each auto-scaled to their own data
+ look comparable and are not — one panel's yellow might be 40 counts and
+ the other's 4,000. Turn it off only when one panel's range is so much narrower
+ that sharing flattens it, and say so in the legend.
@@ -1162,8 +1303,17 @@ Annotation Tools
distance between them in micrometers.
- The Clear button removes all regions and
- measurements at once.
+ The Clear button removes the regions and
+ measurements belonging to that panel — each panel has its own toolbar,
+ so clearing one leaves the other's work alone.
+
+
+
In split-screen mode, regions and measurements belong to the panel they
+ were drawn in and appear only there. They are stored in that dataset's
+ coordinates, so a region drawn on one tissue would land somewhere meaningless
+ if it were drawn on the other. Exported cell lists and measured distances
+ likewise resolve against the panel that produced them.
+
Exporting Cells Within a Region
Each drawn region appears in the Layer Panel's Regions
diff --git a/docs/neighborhood_summary.md b/docs/neighborhood_summary.md
new file mode 100644
index 0000000..4172f4a
--- /dev/null
+++ b/docs/neighborhood_summary.md
@@ -0,0 +1,109 @@
+# Issue #60 — local neighbourhood highlighting and summaries
+
+Status: **implemented** in v0.8.7. Kept as the design record.
+
+`GET /edges/{dataset}/neighborhood/{cell_id}?field=` — unfiltered
+and unsampled. The panel shows a **show local neighbourhood** button under the
+cell detail; the canvas draws the connected cells, the clicked cell, and the
+enclosing radius.
+
+Upstream ask
+([#60](https://github.com/RaredonLab/TissuePlex/issues/60)): click a cell, press a
+button, and (a) see the local neighbourhood — the cells it is connected to in the
+tissue graph — drawn on the canvas, and (b) get summary metrics: composition by
+cell type or selected metadata, number of neighbours, number of local edges,
+local edge composition.
+
+---
+
+## The trap: this cannot be computed client-side
+
+The obvious implementation is to filter the `edges` array the frontend already
+holds. It would appear to work and be **silently wrong**, for two independent
+reasons:
+
+- that array is **density-sampled** — 10% by default, so nine of ten neighbours
+ are missing;
+- it is **viewport-bounded**, so a neighbour just off-screen does not exist as
+ far as the client is concerned, and the answer changes when you pan.
+
+A neighbourhood is a property of the tissue, not of the current view. It needs a
+backend query, and that query must ignore both density and the viewport.
+
+Cost is not a concern — measured on the incident-edge scan:
+
+| dataset | rows in file | incident rows | scan |
+|---|---|---|---|
+| cosmx-mousebrain | 3,819,698 | 172 | **15 ms** |
+| xenium_human_breast_2fov | 134,277 | 30 | 3 ms |
+| mouse_ileum_tiny | 669 | 30 | 1 ms |
+
+No index, no cache.
+
+## Definition
+
+**The neighbourhood is every cell joined to the clicked cell by any edge in the
+file** — unfiltered and unsampled, consistent with the tissue graph being ground
+truth (issue #59). Direction is ignored for membership: a cell that only *sends*
+to the clicked cell is still a neighbour.
+
+Deliberately *not* affected by: density, the viewport, the sending/receiving
+filters, the edge-table filters, the cell filter, or the LRM checklist. Those
+control what is drawn; this describes what the tissue is.
+
+## Drawing it
+
+The issue asks for "a local radius that encompasses all of the cells within that
+cell's neighborhood". Two things get drawn, because the radius alone would
+mislead:
+
+- **the connected cells themselves**, highlighted — this is the honest answer, since
+ connectivity is anisotropic. A cell at a tissue boundary has neighbours on one
+ side only, and a disc around it encloses many cells it is not connected to.
+- **a circle at the enclosing radius**, which is what was asked for and gives the
+ spatial scale at a glance.
+
+The radius is also reported numerically, in µm via the panel's `pixel_size`, so
+it can be quoted without measuring off the screen.
+
+## Summary metrics
+
+| metric | source |
+|---|---|
+| neighbours | distinct partner cells |
+| local edges | directed edges incident to the cell |
+| autocrine | whether the cell signals to itself |
+| radius | max centroid distance, µm |
+| composition | any cell metadata column, counted over the neighbours |
+| edge composition | top LRMs over the incident edges, by score |
+
+Composition uses the **cells table**, not the edge file's `sending_type` — same
+reasoning as #59: that column is absent on five of six platforms, carries one
+label, and is frozen at scoring time. Any cell metadata column is offerable, and
+the panel defaults to the active colour-by field when there is one, since that is
+what the user is already looking at.
+
+## Surface
+
+| | |
+|---|---|
+| `backend/app/readers/edge_reader.py` | `neighborhood(cell_id)` — incident edges, partners, radius, LRM composition |
+| `backend/app/routers/edges.py` | `GET /{dataset}/neighborhood/{cell_id}`; joins cell metadata for composition |
+| `frontend/src/components/CellInfoPanel.jsx` | a **Neighbourhood** button and the summary block |
+| `frontend/src/components/Viewer.jsx` | two layers — highlighted neighbours, and the radius circle |
+| `frontend/src/store.js` | `neighborhood` selection state, cleared with the cell selection |
+
+The `/edge/{edge_id}` detail endpoint is the precedent for shape and for how the
+info panel consumes it.
+
+## Consequences to get right
+
+- **Per panel.** The highlight belongs to the panel that was clicked, like
+ annotations and selection. `selection` already carries `panelIndex`.
+- **Clearing.** Selecting another cell, changing dataset, or closing the panel must
+ drop the highlight, or it strands over unrelated tissue.
+- **Platforms without boundaries.** MERSCOPE and CosMx return no polygons, so the
+ highlight cannot be a filled cell outline everywhere. Draw it as points at the
+ edge-file centroids, which every platform has.
+- **A cell with no edges** is a normal outcome, not an error — the panel should say
+ "no connections" rather than render an empty summary.
diff --git a/docs/split_screen_phase2.md b/docs/split_screen_phase2.md
new file mode 100644
index 0000000..05a511d
--- /dev/null
+++ b/docs/split_screen_phase2.md
@@ -0,0 +1,244 @@
+# Split screen, Phase 2 — per-panel settings
+
+Status: **complete.** Phase 1 shipped in v0.8.4 (PR #56); stages 2a–2e followed.
+Kept as the record of what was decided and why, not as outstanding work.
+
+Phase 1 made the two panels able to show two *different datasets*. Phase 2 makes
+their *settings* independent, with an explicit way to re-link them. This document
+exists because the Phase 2 plan was previously held only in conversation and was
+lost; treat it as the specification.
+
+---
+
+## Where Phase 1 left things
+
+Everything bound to *which dataset a panel shows* lives in `panels[panelIndex]`
+(`store.js::makePanel()`): dataset, image, image size, capabilities, pixel size,
+edge file, LRM catalogue, gene panel, colour ranges, and shown/total stats.
+
+Everything describing *how the data looks* is still global: layer visibility and
+opacity, palettes, colour-by, filters, LRM selection, edge geometry, sampling
+fractions. One sidebar drives both panels, reconciled by `hooks/usePanels.js`
+under a union-then-degrade rule.
+
+That was the right first cut — one sidebar driving both panels is what makes a
+side-by-side comparison *comparable*, and it is still the behaviour most users
+want most of the time. Phase 2 does not take it away; it makes it a mode rather
+than a constraint.
+
+## What Phase 2 is for
+
+Three concrete problems, in descending order of how often they bite.
+
+**1. Changing one panel's dataset clears the other panel's settings.**
+`setPanelDataset` resets every shared setting that names a column, gene or
+mechanism — `selectedGenes`, `hiddenLrms`, `categoricalOverrides`, `cellFilter`,
+`edgeFilter`, `colorBy`, the colour clamps, the colour overrides. It has to:
+a filter naming a column the new dataset lacks returns 400 on every viewport
+change. But because those settings are global, resetting them for one panel
+resets them for both. Set up a careful view on the left, change the dataset on
+the right, and the left panel's filter is gone. This is documented in CLAUDE.md
+as a known cost and is the single strongest reason to do Phase 2.
+
+**2. You cannot compare two renderings of the same data.** Same dataset in both
+panels, cluster colouring on the left and gene expression on the right, is
+currently impossible — `colorBy` is one value. The same is true of two LRM
+selections, two filters, or two palettes.
+
+**3. Genuinely unlike datasets want unlike settings.** A Visium spot panel and a
+Xenium transcript panel do not want the same opacity, sampling fraction or edge
+density. Union-then-degrade offers the control; it cannot give each panel its own
+value.
+
+## The design decision
+
+**Per-panel settings with a global link toggle, defaulting to linked.** Reads
+always resolve to `panels[i].settings[key]`. Writes go to the panel the sidebar is
+editing, or to every panel when `linkSettings` is true.
+
+Two alternatives were considered and rejected:
+
+- *Global settings plus per-panel overrides* (`override[i][key] ?? shared[key]`).
+ Smaller diff, but two sources of truth for every value. A slider then has to
+ answer "am I showing the shared value or this panel's override, and which does
+ dragging me write to?" — and the reset in problem 1 has to decide whether it
+ clears the override, the shared value, or both. Ambiguity in exactly the place
+ the current design is already confusing.
+
+- *Always independent, no link.* Matches "explore either side freely" but breaks
+ the default case: every setting change would need doing twice, and two panels
+ drifting silently apart is precisely the figure-integrity problem
+ `linkColorScale` was added to prevent.
+
+The link toggle gives today's behaviour by default, independence on request, and
+one unambiguous source of truth per panel.
+
+**The push button comes along for free.** Once settings are per-panel,
+"copy panel 1's settings to panel 2" is an object copy. This was the original
+request — explore on either side, then force-match the other — and it is the
+natural escape hatch when panels have drifted and you want them comparable again
+without redoing the work.
+
+## What moves, and what does not
+
+| Moves into `panels[i].settings` | Stays global |
+|---|---|
+| `layers` (visibility + opacity, incl. `outlineOpacity`) | `panelCount`, `panelRotations`, `viewports`, `viewportActual` |
+| `transcriptFraction`, `cellBoundaryFraction` | `selection` (already carries `panelIndex`) |
+| `cellColorEnabled`, `colorBy`, `cellColorPalette`, `cellColorClamp` | `linkColorScale`, `linkSettings`, `activePanel` |
+| `edgeColorBy`, `edgeColorPalette`, `edgeColorClamp` | `loadingKeys`, `apiBase` |
+| `edgeWidth`, `edgeDensity`, `edgeMinStrength`, `edgeOffset`, `edgeDirectional` | `pendingZoomMatch` |
+| `showArrowheads`, `arrowStyle`, `arrowheadScale` | |
+| `showAutocrine`, `autocrineRadius`, `autocrineLineWidth` | |
+| `hiddenLrms`, `selectedGenes` | |
+| `cellFilter`, `edgeFilter`, `categoricalOverrides` | |
+| `categoryColorOverrides`, `transcriptColorOverrides` | |
+
+Roughly 30 keys move.
+
+`linkColorScale` stays global and keeps its current meaning — it links the
+computed colour *range* across panels, which is a separate question from whether
+the two panels share a *palette* setting. Both linked is the common case; the
+combination "same palette, independent ranges" is deliberately still reachable.
+
+## Implementation surface
+
+Measured, not estimated:
+
+| | count | note |
+|---|---|---|
+| Files reading a moving setting | **4** | `LayerPanel.jsx`, `Viewer.jsx`, `AnnotationToolbar.jsx`, `CellInfoPanel.jsx` |
+| Bare `useStore()` destructure sites | **17** | `const { layers, setLayerProp } = useStore()` |
+| Selector-style `useStore((s) => …)` | **25** | across 6 files |
+| Section components in `LayerPanel` | **19** | each reads the store directly |
+| **Data hooks needing changes** | **0** | — |
+
+The last row is the important one. `useTranscripts`, `useCellBoundaries`,
+`useEdges`, `useCellColors` and `useEdgeColors` read nothing from the store —
+every setting arrives as a prop from `ViewerPanel`. So once `ViewerPanel` reads
+`panels[panelIndex].settings` instead of the globals, per-panel settings reach
+the right fetches with no change to the fetch layer at all.
+
+`ViewerPanel` is similarly cheap: it already receives `panelIndex`, so its reads
+change shape but need no new plumbing.
+
+**`LayerPanel` is the actual work.** Its 19 section components each call
+`useStore()` directly and have no idea which panel they are editing. Threading a
+`panelIndex` prop through all of them would be noisy and easy to get half-right.
+Use a `PanelSettingsContext` holding the active panel index and a
+`usePanelSettings()` hook returning `{...panels[i].settings, ...setters}`. Most
+sites then change by one identifier — `useStore()` becomes `usePanelSettings()` —
+and the destructuring below it is untouched.
+
+> Aside worth fixing while in here: bare `useStore()` subscribes to the *whole*
+> store, so every one of those 17 components re-renders on any state change,
+> including every viewport update during a pan. Moving to a scoped hook is a
+> natural moment to narrow those subscriptions.
+
+## Staged plan
+
+Each stage is independently reviewable and leaves the app working.
+
+**2a — container and hook, behaviour frozen. DONE.** The ~30 keys live in
+`panels[i].settings`, built by `makeSettings()`. `hooks/usePanelSettings.js` adds
+`PanelIndexContext` and `usePanelSettings()`, which returns the store merged with
+that panel's settings — so the 17 call sites changed by one identifier and their
+destructuring was untouched. Every write goes through `patchSettings(patch,
+panelIndex = null)`, which with a null index writes to all panels. No UI change.
+
+Verified behaviour-preserving by capturing all 28 effective setting values before
+the change and diffing after: **no differences**. `store.settings.test.js` (13
+tests) pins the contract, including the one real regression risk — a dataset
+change must reset the name-bound settings *only*, since rebuilding the panel from
+`makePanel()` would also have wiped geometry, palettes and layer visibility,
+which a dataset change never touched.
+
+**2b — sidebar tabs and the link toggle. DONE.** `activePanel` and
+`linkSettings: true` are in the store. `patchSettings` writes to all panels when
+linked and to `activePanel` when not; `getSetting` reads the active panel, which
+read-modify-write setters depend on — unlinked, `toggleLrm` must toggle against
+the panel it is about to write, not panel 0. `PanelTabs` renders only when
+`panelCount === 2`, so single-panel mode is untouched.
+
+Two decisions worth knowing:
+
+- **Re-linking adopts the active panel's settings** rather than merely resuming
+ propagation. The alternative leaves a control labelled "linked" over two
+ visibly different panels, converging them only partially on the next edit.
+ Which panel wins is the tab you are on, so it is chosen rather than
+ incidental. `cloneSettings` deep-copies the containers; a shallow copy would
+ leave the panels aliasing, so the next unlinked edit would write to both.
+- **`CellInfoPanel` reads the *clicked* panel's settings**, not the active tab's.
+ Identical while linked; unlinked, the active panel's gene selection would
+ describe the wrong cell.
+
+**2c — push settings. DONE.** `pushSettings(from, to, allowed)` deep-copies the
+source panel's settings onto the target, sanitised by `sanitiseSettings`. The
+button lives under the link toggle and appears only when the panels are unlinked,
+since pushing between linked panels is a no-op; its label names the direction
+(`copy panel 1 → panel 2`).
+
+The guard is the substance. Settings naming a column, gene or mechanism the
+target lacks are dropped, because a `cellFilter` on a missing column makes the
+backend 400 on *every* viewport change — the panel stops rendering with no
+indication why. Column names come from `/cells/schema` and `/edges/schema`, which
+the store never fetches, so the component gathers the vocabulary and the store
+action stays pure and testable; genes and LRMs it already holds per panel.
+
+Two rules worth knowing: a gene allowlist with no overlap falls back to `null`
+("no filter") rather than an empty Set ("show no species"), matching what a
+dataset change leaves behind; and the colour clamps reset when the datasets
+differ, since a range in one dataset's units — [0, 4000] onto data topping out at
+70 — paints everything the bottom colour and reads as a broken render.
+
+**2d — narrow the dataset-change reset. DONE, folded into 2b.** Sequencing it
+last turned out to be wrong. 2b makes the promise "editing panel 1 only", and a
+global reset breaks it: an action on panel 2 still destroys panel 1's work, so
+2b alone would ship a control that lies. The panel whose dataset — or edge file
+— changed is always reset; the others only while linked, where they share one
+set of values and a stale filter would 400 on every viewport change.
+
+**2e — docs. DONE.** CLAUDE.md was updated as each stage landed.
+`docs/index.html`, the hosted manual, needed more than an addition: it claimed
+"Both panels share all layer settings … but have independent pan and zoom
+positions", which stopped being true at Phase 1 and had been wrong through two
+releases. It now covers two datasets side by side, the panel tabs and link
+toggle (including that re-linking re-syncs to the active tab), the copy button
+and what it drops, micron-based zoom matching, the shared colour scale, and
+per-panel annotations. The dataset-picker section gained a note that the pickers
+move into the panel headers in split mode and what a dataset change clears.
+
+## Risks
+
+**Frontend test coverage is minimal.** Vitest now exists (`npm test` in
+`frontend/`), but it covers only annotation panel-scoping — the golden-snapshot
+guard is still backend-only, and a 30-key state migration across 19 components
+has almost no automated check behind it. This remains the main risk in the plan.
+Store logic is plain JS and testable without a DOM, so 2a is a good excuse to
+cover the settings reducers as they move.
+Mitigations: keep 2a strictly behaviour-preserving so it can be verified by
+comparing before/after; exercise both single and split mode against at least two
+platforms; and check the OSD ↔ deck.gl bridge explicitly, since the guard has
+never covered it.
+
+**`LayerPanel.jsx` is 1,750 lines** and will be touched throughout. Worth
+splitting the section components into their own files as part of 2a — but as a
+separate commit from the state migration, so the diff of each stays readable.
+
+**Persistence is still absent.** None of this survives a reload, which matters
+more once a user can build up two differently-configured panels. Out of scope
+here, but it becomes a more visible gap after Phase 2, not less.
+
+## Out of scope, but adjacent
+
+**Annotations were global — fixed before this plan started.** `regions` and
+`measurements` carried no panel index, so every annotation drew in both panels at
+identical image-pixel coordinates, CSV export always resolved against panel 0's
+dataset, and measurement labels used the *rendering* panel's `pixelSize`. Both
+were silent wrong answers rather than visible breakage. Each annotation now
+carries `panelIndex`, and `store.annotations.test.js` covers the scoping.
+
+**Per-panel edge file already exists** (`panels[i].edgeFile`, Phase 1). CLAUDE.md
+described it as global in three places, contradicting the code; corrected when this
+plan was written rather than deferred, since a stale architecture note is worse than
+a missing one.
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 11371b0..b25a9bc 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "tissueplex",
- "version": "0.1.0",
+ "version": "0.8.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tissueplex",
- "version": "0.1.0",
+ "version": "0.8.4",
"dependencies": {
"@deck.gl/core": "^9.0.12",
"@deck.gl/layers": "^9.0.12",
@@ -22,7 +22,8 @@
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
- "vite": "^5.3.4"
+ "vite": "^5.3.4",
+ "vitest": "^2.1.9"
}
},
"node_modules/@amcharts/amcharts5": {
@@ -2896,6 +2897,119 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@vitest/expect": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
+ "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "2.1.9",
+ "@vitest/utils": "2.1.9",
+ "chai": "^5.1.2",
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
+ "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "2.1.9",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.12"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
+ "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
+ "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "2.1.9",
+ "pathe": "^1.1.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
+ "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "2.1.9",
+ "magic-string": "^0.30.12",
+ "pathe": "^1.1.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
+ "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^3.0.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
+ "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "2.1.9",
+ "loupe": "^3.1.2",
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/@webcomponents/shadycss": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@webcomponents/shadycss/-/shadycss-1.11.2.tgz",
@@ -2977,6 +3091,16 @@
"node": ">=6"
}
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -3072,6 +3196,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -3152,6 +3286,23 @@
"colorbrewer": "1.5.6"
}
},
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -3192,6 +3343,16 @@
"node": "*"
}
},
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
"node_modules/clone": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
@@ -4028,6 +4189,16 @@
}
}
},
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/deep-equal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz",
@@ -4168,6 +4339,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -4248,6 +4426,26 @@
"deprecated": "Use @arcgis/core instead.",
"license": "Apache-2.0"
},
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/fast-xml-builder": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz",
@@ -4795,6 +4993,13 @@
"loose-envify": "cli.js"
}
},
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -4822,6 +5027,16 @@
"license": "ISC",
"optional": true
},
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
"node_modules/marked": {
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.3.0.tgz",
@@ -4986,6 +5201,23 @@
"node": ">=14.0.0"
}
},
+ "node_modules/pathe": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
+ "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
"node_modules/pbf": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz",
@@ -5349,6 +5581,13 @@
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/snappyjs": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/snappyjs/-/snappyjs-0.6.1.tgz",
@@ -5378,6 +5617,20 @@
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
@@ -5494,6 +5747,30 @@
"license": "MIT",
"peer": true
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
"node_modules/tinyqueue": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz",
@@ -5501,6 +5778,26 @@
"license": "ISC",
"peer": true
},
+ "node_modules/tinyrainbow": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
+ "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
+ "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -5670,6 +5967,112 @@
}
}
},
+ "node_modules/vite-node": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
+ "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.3.7",
+ "es-module-lexer": "^1.5.4",
+ "pathe": "^1.1.2",
+ "vite": "^5.0.0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
+ "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "2.1.9",
+ "@vitest/mocker": "2.1.9",
+ "@vitest/pretty-format": "^2.1.9",
+ "@vitest/runner": "2.1.9",
+ "@vitest/snapshot": "2.1.9",
+ "@vitest/spy": "2.1.9",
+ "@vitest/utils": "2.1.9",
+ "chai": "^5.1.2",
+ "debug": "^4.3.7",
+ "expect-type": "^1.1.0",
+ "magic-string": "^0.30.12",
+ "pathe": "^1.1.2",
+ "std-env": "^3.8.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.1",
+ "tinypool": "^1.0.1",
+ "tinyrainbow": "^1.2.0",
+ "vite": "^5.0.0",
+ "vite-node": "2.1.9",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "@vitest/browser": "2.1.9",
+ "@vitest/ui": "2.1.9",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/wordwrapjs": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index d9ed916..41a2203 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,27 +1,30 @@
{
"name": "tissueplex",
- "version": "0.8.4",
+ "version": "0.8.8",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test": "vitest run",
+ "test:watch": "vitest"
},
"dependencies": {
- "react": "^18.3.1",
- "react-dom": "^18.3.1",
- "openseadragon": "^5.0.0",
- "deck.gl": "^9.0.12",
"@deck.gl/core": "^9.0.12",
"@deck.gl/layers": "^9.0.12",
"@deck.gl/react": "^9.0.12",
"@luma.gl/core": "^9.0.20",
"apache-arrow": "^17.0.0",
+ "deck.gl": "^9.0.12",
+ "openseadragon": "^5.0.0",
"parquet-wasm": "^0.6.1",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
"zustand": "^4.5.4"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
- "vite": "^5.3.4"
+ "vite": "^5.3.4",
+ "vitest": "^2.1.9"
}
}
diff --git a/frontend/src/components/AnnotationToolbar.jsx b/frontend/src/components/AnnotationToolbar.jsx
index b8f9480..4c19435 100644
--- a/frontend/src/components/AnnotationToolbar.jsx
+++ b/frontend/src/components/AnnotationToolbar.jsx
@@ -4,6 +4,7 @@
*/
import React from "react";
import { useStore } from "../store";
+import { usePanelSettings } from "../hooks/usePanelSettings";
const MODES = [
{ id: "pan", label: "Pan", title: "Pan & zoom (default)" },
@@ -29,9 +30,14 @@ export default function AnnotationToolbar({ onScreenshot, panelIndex = 0 }) {
panelCount, setPanelCount,
requestZoomMatch,
panelRotations, setPanelRotation,
- } = useStore();
+ } = usePanelSettings(panelIndex); // this toolbar belongs to one panel
- const hasAnnotations = regions.length > 0 || measurements.length > 0;
+ // Count and clear only this panel's own annotations: the toolbar is rendered
+ // per panel, so a Clear here wiping the other panel's work would be a
+ // surprise, and the button greying out because the *other* panel is empty
+ // would be worse.
+ const mine = (a) => a.filter((x) => (x.panelIndex ?? 0) === panelIndex);
+ const hasAnnotations = mine(regions).length > 0 || mine(measurements).length > 0;
const isSplit = panelCount >= 2;
const rotation = panelRotations[panelIndex] ?? 0;
@@ -162,8 +168,8 @@ export default function AnnotationToolbar({ onScreenshot, panelIndex = 0 }) {
<>
clearAnnotations(panelIndex)}
style={{ ...BTN, color: "#c44" }}
>
Clear
diff --git a/frontend/src/components/CellInfoPanel.jsx b/frontend/src/components/CellInfoPanel.jsx
index c009f25..289e864 100644
--- a/frontend/src/components/CellInfoPanel.jsx
+++ b/frontend/src/components/CellInfoPanel.jsx
@@ -4,13 +4,21 @@
*/
import React, { useEffect, useState } from "react";
import { useStore } from "../store";
+import { usePanelSettings } from "../hooks/usePanelSettings";
const ROW = { display: "flex", justifyContent: "space-between", marginBottom: 3 };
const KEY = { color: "#666" };
const VAL = { color: "#ccc", textAlign: "right", marginLeft: 8, wordBreak: "break-all" };
export default function CellInfoPanel() {
- const { apiBase, colorBy, cellColorEnabled, selectedGenes, selection } = useStore();
+ const apiBase = useStore((s) => s.apiBase);
+ const selection = useStore((s) => s.selection);
+ // Colour-by settings come from the panel that produced the click, not the tab
+ // the sidebar happens to be on. Identical while the panels are linked; with
+ // them unlinked, the active panel's gene selection would describe the wrong
+ // cell.
+ const { colorBy, cellColorEnabled, selectedGenes } =
+ usePanelSettings(selection?.panelIndex ?? null);
// Resolve against the panel that produced the click, not panel 0 — with two
// datasets on screen, panel 0's would be the wrong one half the time.
const selectedCell = selection?.kind === "cell" ? selection.cell : null;
@@ -60,7 +68,11 @@ export default function CellInfoPanel() {
{loading && Loading… }
- {detail && !loading && (
+ {/* `detail` is state and outlives `selectedCell` by one render when the
+ selection is cleared — changing dataset, for instance. Guarding on both
+ stops stale detail being shown for a cell that is no longer selected,
+ and stops the section below dereferencing a null selection. */}
+ {detail && selectedCell && !loading && (
<>
{/* Color-by highlight — shown whenever cell coloring is active */}
{colorByInfo && (
@@ -110,12 +122,136 @@ export default function CellInfoPanel() {
))}
>
)}
+
+
>
)}
);
}
+/**
+ * Issue #60 — what this cell is connected to in the tissue graph.
+ *
+ * Fetched on demand rather than with the cell detail: it is a second query, and
+ * most clicks are just "what is this cell". The result comes from the server
+ * unfiltered and unsampled — see the store's `neighborhood` note for why it
+ * cannot be derived from the edges the frontend already holds.
+ */
+function NeighborhoodSection({ dataset, cellId, panelIndex, unitLabel }) {
+ const apiBase = useStore((s) => s.apiBase);
+ const neighborhood = useStore((s) => s.neighborhood);
+ const setNeighborhood = useStore((s) => s.setNeighborhood);
+ const clearNeighborhood = useStore((s) => s.clearNeighborhood);
+ const panel = useStore((s) => s.panels[panelIndex]);
+ const { colorBy } = usePanelSettings(panelIndex);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const shown = neighborhood?.cellId === cellId ? neighborhood.data : null;
+
+ const load = async () => {
+ setLoading(true); setError(null);
+ // Break the neighbourhood down by whatever the user is already colouring by;
+ // asking them to pick a column again would repeat a choice they just made.
+ const field = colorBy?.mode === "metadata" && colorBy.field ? colorBy.field : null;
+ const ef = `?edge_file=${encodeURIComponent(panel?.edgeFile ?? "edges.parquet")}`;
+ const q = field ? `${ef}&field=${encodeURIComponent(field)}` : ef;
+ try {
+ const r = await fetch(
+ `${apiBase}/edges/${dataset}/neighborhood/${encodeURIComponent(cellId)}${q}`);
+ if (!r.ok) { setError(`HTTP ${r.status}`); return; }
+ setNeighborhood({ panelIndex, cellId, data: await r.json() });
+ } catch (e) {
+ setError(e.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const pixelSize = panel?.pixelSize ?? 1;
+
+ return (
+ <>
+
+ {!shown && (
+
+ {loading ? "loading…" : `show local neighbourhood`}
+
+ )}
+ {error && {error} }
+
+ {shown && shown.n_neighbors === 0 && (
+
+ This {unitLabel} has no connections in the tissue graph.
+
+ )}
+
+ {shown && shown.n_neighbors > 0 && (
+ <>
+
+
+
+ {shown.n_autocrine > 0 && }
+
+ {shown.composition?.length > 0 && (
+ <>
+
+ {shown.composition.map((c) => (
+
+ ))}
+ {shown.composition_missing > 0 && (
+
+ )}
+ >
+ )}
+ {!shown.composition && (
+
+ Colour cells by a metadata column to see composition.
+
+ )}
+
+ {shown.lrm_composition?.length > 0 && (
+ <>
+
+ {shown.lrm_composition.map((m) => (
+
+ ))}
+ >
+ )}
+
+
+ hide highlight
+
+ >
+ )}
+ >
+ );
+}
+
/**
* Given a loaded cell detail and the current color-by state, return
* { label, display } for the highlighted box, or null if nothing to show.
diff --git a/frontend/src/components/LayerPanel.jsx b/frontend/src/components/LayerPanel.jsx
index 8532fe9..3ea3d36 100644
--- a/frontend/src/components/LayerPanel.jsx
+++ b/frontend/src/components/LayerPanel.jsx
@@ -3,6 +3,7 @@
*/
import React, { useEffect, useRef, useState } from "react";
import { useStore } from "../store";
+import { usePanelSettings } from "../hooks/usePanelSettings";
import { useActiveDatasets, useActivePanels, useUnionCapabilities, useUnionList } from "../hooks/usePanels";
import { DatasetPicker } from "./DatasetPicker";
import { APP_VERSION } from "../App";
@@ -105,6 +106,7 @@ export default function LayerPanel() {
Comparing {panelCount} panels — pick each dataset in its header
)}
+ {panelCount >= 2 && }
Layers
Core
@@ -134,7 +136,7 @@ export default function LayerPanel() {
Tissue Graph
-
+
Edge Data
@@ -195,7 +197,7 @@ function ColorBySection({ unitLabel = "cell" }) {
selectedGenes,
cellColorClamp, setCellColorClamp,
categoricalOverrides, setCategoricalOverride,
- } = useStore();
+ } = usePanelSettings();
// Genes and metadata columns are unioned across the visible panels, so a
// column that exists only in panel 1 is still selectable. A panel that lacks
@@ -413,7 +415,7 @@ function CategoricalLegend({ field, categories = [] }) {
setCategoryColorOverride,
mergeCategoryColorOverrides,
resetCategoryColorOverrides,
- } = useStore();
+ } = usePanelSettings();
const fileInputRef = useRef(null);
@@ -558,15 +560,20 @@ function CategoricalLegend({ field, categories = [] }) {
async function mergeColorValues(promises) {
const rs = (await Promise.all(promises)).filter(Boolean);
if (!rs.length) return null;
+ // A column can be present in the schema and hold nothing — `fov` and
+ // `transcript_count` are entirely null on the bundled MERSCOPE dataset. Empty
+ // only if it is empty in *every* panel: one panel having values is enough to
+ // make the control worth showing.
+ const empty = rs.every((r) => r.empty);
if (rs.some((r) => r.type === "categorical")) {
const seen = new Set(), cats = [];
for (const r of rs) for (const c of r.categories ?? [])
if (!seen.has(c)) { seen.add(c); cats.push(c); }
- return { type: "categorical", categories: cats };
+ return { type: "categorical", categories: cats, empty };
}
const mins = rs.map((r) => r.min).filter((v) => v != null);
const maxs = rs.map((r) => r.max).filter((v) => v != null);
- return { type: "continuous", min: Math.min(...mins), max: Math.max(...maxs) };
+ return { type: "continuous", min: Math.min(...mins), max: Math.max(...maxs), empty };
}
// ── Metadata filter (issue #45) ───────────────────────────────────────────────
@@ -630,7 +637,16 @@ function MetadataFilterSection({
loading values…
)}
- {field && !loading && meta?.type === "categorical" && (
+ {/* Present in the schema but holding nothing. Saying so beats a range
+ slider that spans 0–0 and a filter that correctly matches no cells
+ while looking broken. */}
+ {field && !loading && meta?.empty && (
+
+ no values in this column
+
+ )}
+
+ {field && !loading && !meta?.empty && meta?.type === "categorical" && (
{(meta.categories ?? []).map((cat) => (
@@ -668,7 +684,7 @@ function MetadataFilterSection({
)}
- {field && !loading && meta?.type === "continuous" && (
+ {field && !loading && !meta?.empty && meta?.type === "continuous" && (
min
fetch(`${apiBase}/spatial/${d}/cells/schema`)
@@ -738,8 +754,72 @@ function CellFilterSection({ unitLabel = "cell" }) {
);
}
+/**
+ * Issue #59 — the two endpoint filters, side by side.
+ *
+ * Both select from *cell* metadata, the same vocabulary the cell filter offers,
+ * and each constrains one end of an edge. Set both and you get the intersection:
+ * senders in A, receivers in B. Leave one unset and that end is unconstrained.
+ *
+ * These resolve against the cells table rather than the edge file's own
+ * `sending_type` / `receiving_type`, which are absent on five of six platforms as
+ * the r/ export scripts stand, carry one label where any cell column is wanted,
+ * and are frozen at scoring time. See docs/edge_filter_independence.md.
+ *
+ * Independent of the Cell Filter above it: filtering cells and filtering edges
+ * are separate actions, so an edge may terminate on a cell that is not drawn.
+ */
+function EndpointFilterSection() {
+ const { apiBase, sendingFilter, setSendingFilter,
+ receivingFilter, setReceivingFilter, categoricalOverrides } = usePanelSettings();
+ const datasets = useActiveDatasets();
+ const columns = useUnionList((d) =>
+ fetch(`${apiBase}/spatial/${d}/cells/schema`)
+ .then((r) => (r.ok ? r.json() : null))
+ .then((x) => (x?.columns ? Object.keys(x.columns) : [])));
+
+ const fetchValues = React.useCallback((field) => {
+ const categorical = categoricalOverrides[`cell::${field}`] ?? null;
+ return mergeColorValues(datasets.map((d) =>
+ fetch(`${apiBase}/spatial/${d}/color-values`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ mode: "metadata", field, categorical }),
+ }).then((r) => (r.ok ? r.json() : null)).catch(() => null)));
+ }, [apiBase, datasets.join(" "), categoricalOverrides]); // eslint-disable-line
+
+ if (!columns.length) return null;
+
+ const side = (label, filter, setFilter) => (
+ // minWidth 0 lets the select shrink inside the flex row instead of forcing
+ // the sidebar to scroll sideways.
+
+ );
+
+ return (
+
+
+ {side("Sending cell", sendingFilter, setSendingFilter)}
+ {side("Receiving cell", receivingFilter, setReceivingFilter)}
+
+ {sendingFilter && receivingFilter && (
+
+ showing edges from {sendingFilter.field}
+ {" → "}{receivingFilter.field} only
+
+ )}
+
+ );
+}
+
function EdgeFilterSection() {
- const { apiBase, edgeFilter, setEdgeFilter, categoricalOverrides } = useStore();
+ const { apiBase, edgeFilter, setEdgeFilter, categoricalOverrides } = usePanelSettings();
const active = useActivePanels();
const [columns, setColumns] = useState([]);
const sources = active.filter((p) => p.dataset)
@@ -791,8 +871,142 @@ const EDGE_FILTER_SKIP = new Set([
* Shown only in split mode. Default on, because independent auto-ranging makes
* two panels look comparable when they are not — see the store comment.
*/
+/**
+ * Which panel the sidebar edits, and whether edits reach both.
+ *
+ * Split mode only. The tabs also select which panel's values the controls
+ * *display* — unlinked, showing a blend or always panel 0's would make the
+ * sliders lie about the panel you are editing.
+ *
+ * Linked is the default and is the pre-2b behaviour. Switching it back on
+ * re-syncs both panels to the tab you are on, because a control labelled
+ * "linked" over two visibly different panels would not be telling the truth.
+ */
+function PanelTabs() {
+ const { activePanel, setActivePanel, linkSettings, setLinkSettings, panels } =
+ useStore();
+
+ const tab = (i) => {
+ const on = activePanel === i;
+ const name = panels[i]?.dataset;
+ return (
+ setActivePanel(i)}
+ title={name ? `Edit panel ${i + 1} — ${name}` : `Edit panel ${i + 1}`}
+ style={{
+ flex: 1, padding: "3px 6px", fontFamily: "monospace", fontSize: 11,
+ cursor: "pointer", borderRadius: 3,
+ border: `1px solid ${on ? "#5a8" : "#3a3a3a"}`,
+ background: on ? "#2b3a33" : "transparent",
+ color: on ? "#8fd" : "#888",
+ }}
+ >
+ Panel {i + 1}
+
+ );
+ };
+
+ return (
+
+
+ {tab(0)}
+ {tab(1)}
+
+
+ setLinkSettings(e.target.checked)}
+ />
+
+ {linkSettings
+ ? "settings linked — edits apply to both panels"
+ : `editing panel ${activePanel + 1} only`}
+
+
+ {/* Only meaningful once the panels can differ. */}
+ {!linkSettings && }
+
+ );
+}
+
+/**
+ * One-shot copy of the active panel's settings onto the other.
+ *
+ * The explicit half of the original request: explore either side, then force
+ * the other to match. Unlike re-linking, the panels stay independent after, so
+ * you can push a baseline across and then diverge again from it.
+ *
+ * Settings naming a column, gene or mechanism the target does not have are
+ * dropped before writing — see sanitiseSettings. That check is here rather than
+ * in the store because the column names come from /cells/schema and
+ * /edges/schema, which the store never fetches.
+ */
+function PushSettingsButton() {
+ const apiBase = useStore((s) => s.apiBase);
+ const activePanel = useStore((s) => s.activePanel);
+ const panels = useStore((s) => s.panels);
+ const pushSettings = useStore((s) => s.pushSettings);
+ const [busy, setBusy] = useState(false);
+ const [done, setDone] = useState(false);
+
+ const from = activePanel;
+ const to = activePanel === 0 ? 1 : 0;
+ const target = panels[to];
+
+ const run = async () => {
+ if (!target?.dataset) return;
+ setBusy(true);
+ const cols = async (url) => {
+ try {
+ const r = await fetch(url);
+ if (!r.ok) return null;
+ return new Set(Object.keys((await r.json())?.columns ?? {}));
+ } catch { return null; }
+ };
+ const ef = `?edge_file=${encodeURIComponent(target.edgeFile)}`;
+ const [cellFields, edgeFields] = await Promise.all([
+ cols(`${apiBase}/spatial/${target.dataset}/cells/schema`),
+ cols(`${apiBase}/edges/${target.dataset}/schema${ef}`),
+ ]);
+ pushSettings(from, to, {
+ cellFields,
+ edgeFields,
+ // Already in the store, per panel — no fetch needed.
+ genes: target.allGenes?.length ? new Set(target.allGenes) : null,
+ lrms: target.lrmCatalogue?.length
+ ? new Set(target.lrmCatalogue.map((e) => e.lrm ?? `${e.ligand}|${e.receptor}`))
+ : null,
+ });
+ setBusy(false);
+ setDone(true);
+ setTimeout(() => setDone(false), 1500);
+ };
+
+ return (
+
+ {done ? "copied" : busy ? "copying…" : `copy panel ${from + 1} → panel ${to + 1}`}
+
+ );
+}
+
function LinkColorScaleRow() {
- const { linkColorScale, setLinkColorScale } = useStore();
+ const { linkColorScale, setLinkColorScale } = usePanelSettings();
return (
@@ -818,7 +1032,7 @@ function useSummedStat(key) {
// ── Morphology row ────────────────────────────────────────────────────────────
function MorphologyRow() {
- const { layers, setLayerProp } = useStore();
+ const { layers, setLayerProp } = usePanelSettings();
const state = layers.morphology ?? { visible: true, opacity: 1.0 };
return (
s.apiBase);
const datasets = useActiveDatasets();
// Union across panels: a 960-gene CosMx panel beside a 130-gene MERSCOPE one
@@ -1244,27 +1458,45 @@ const CHIP_STYLE = {
};
// ── Density row (top-level — applies to tissue graph + edge data) ─────────────
-function DensityRow() {
- const { edgeDensity, setEdgeDensity } = useStore();
+/**
+ * A rendering-volume control, not a filter.
+ *
+ * One slider drives both the tissue graph and the edge-data layer. They are
+ * separate *requests* — the graph is never filtered — but they do not need
+ * separate volume controls: unfiltered the two draw the same number of lines,
+ * and when the edge layer is filtered down, the graph's own opacity (5% by
+ * default) is the better lever for clutter. Sampling the graph to reduce clutter
+ * would misrepresent the structure of something that is meant to be ground truth.
+ *
+ * Sampling is applied last on both paths, after every filter, so lowering it
+ * never changes *which* edges qualify — only how many of them are drawn.
+ */
+function DensityRow({ label, value, onChange, note }) {
return (
);
}
+function EdgeDensityRow() {
+ const { edgeDensity, setEdgeDensity } = usePanelSettings();
+ return ;
+}
+
// ── Tissue graph section ──────────────────────────────────────────────────────
function TissueGraphSection() {
- const { layers, setLayerProp } = useStore();
+ const { layers, setLayerProp } = usePanelSettings();
const state = layers.tissueGraph ?? { visible: true, opacity: 0.25 };
return (
@@ -1303,7 +1535,7 @@ function EdgeSection() {
hiddenLrms, toggleLrm, setAllLrmsVisible, hideAllLrms,
edgeColorClamp, setEdgeColorClamp,
categoricalOverrides, setCategoricalOverride,
- } = useStore();
+ } = usePanelSettings();
const active = useActivePanels();
const state = layers.edges ?? { visible: true, opacity: 0.9 };
const [localStrength, setLocalStrength] = useState(edgeMinStrength ?? 0);
@@ -1607,6 +1839,7 @@ function EdgeSection() {
attribute of the pair (a curation call, a confidence), whereas the
checklist subsets the mechanisms scored on every edge. */}
Edge Filter
+
{/* ── LRM Mechanisms checklist ─────────────────────────────── */}
@@ -1687,11 +1920,12 @@ function EdgeCategoricalLegend({ categories = [] }) {
}
function RegionsSection() {
- const { apiBase, regions, removeRegion } = useStore();
+ const { apiBase, regions, removeRegion } = usePanelSettings();
// Regions are drawn in one panel's image space, so export resolves against
// that panel's dataset. Older regions carry no panelIndex; treat them as
// panel 0, which is where they could only have come from.
const panels = useStore((s) => s.panels);
+ const panelCount = useStore((s) => s.panelCount);
if (regions.length === 0) return null;
const exportRegion = async (region) => {
@@ -1723,6 +1957,12 @@ function RegionsSection() {
{r.selectedCellIds.length} cells
+ {/* The sidebar is shared, so in split mode a bare cell count does
+ not say which tissue it came from — and the two panels may be
+ different datasets entirely. */}
+ {panelCount >= 2 && (
+ · panel {(r.panelIndex ?? 0) + 1}
+ )}
s.viewports[panelIndex]);
+ // Annotations belong to the panel that drew them. Subscribe to the raw arrays
+ // so a change re-renders, then narrow — the selectors on the store are plain
+ // functions and would not themselves trigger an update.
+ // Issue #60. Scoped to this panel: the coordinates are this dataset's image
+ // pixels, so a highlight from the other panel would land nowhere meaningful.
+ const neighborhoodState = useStore((s) => s.neighborhood);
+ const neighborhood = neighborhoodState?.panelIndex === panelIndex
+ ? neighborhoodState.data : null;
+
+ const allRegions = useStore((s) => s.regions);
+ const allMeasurements = useStore((s) => s.measurements);
+ const activeRegionPanel = useStore((s) => s.activeRegionPanel);
+ const regions = useMemo(
+ () => allRegions.filter((r) => (r.panelIndex ?? 0) === panelIndex),
+ [allRegions, panelIndex]);
+ const measurements = useMemo(
+ () => allMeasurements.filter((m) => (m.panelIndex ?? 0) === panelIndex),
+ [allMeasurements, panelIndex]);
+ // The in-progress outline is only drawn by the panel actually drawing it.
+ const drawingHere = activeRegionPanel === null || activeRegionPanel === panelIndex;
+
// Zoom-match signal — fired when the Match button is clicked in either panel
const pendingZoomMatch = useStore((s) => s.pendingZoomMatch);
@@ -514,7 +536,7 @@ function ViewerPanel({ panelIndex }) {
const pt = screenToData(sx, sy);
if (!pt) return;
if (annotationMode === "region") {
- addRegionPoint(pt);
+ addRegionPoint(pt, panelIndex);
} else if (annotationMode === "measure") {
if (!measureFirstRef.current) {
measureFirstRef.current = pt;
@@ -523,11 +545,11 @@ function ViewerPanel({ panelIndex }) {
const p2 = pt;
const dx = p2[0] - p1[0], dy = p2[1] - p1[1];
const distPx = Math.sqrt(dx * dx + dy * dy);
- addMeasurement({ id: Date.now(), p1, p2, distPx });
+ addMeasurement({ id: Date.now(), p1, p2, distPx }, panelIndex);
measureFirstRef.current = null;
}
}
- }, [annotationMode, addRegionPoint, addMeasurement, screenToData]);
+ }, [annotationMode, addRegionPoint, addMeasurement, screenToData, panelIndex]);
const handleOverlayClick = useCallback((e) => {
if (annotationMode === "pan") return;
@@ -565,8 +587,8 @@ function ViewerPanel({ panelIndex }) {
[80, 255, 120], [255, 120, 60], [180, 100, 255],
];
const color = PALETTE[regions.length % PALETTE.length];
- commitRegion({ id: Date.now(), points: poly, selectedCellIds, color });
- }, [annotationMode, activeRegion, cancelActiveRegion, commitRegion, regions]);
+ commitRegion({ id: Date.now(), points: poly, selectedCellIds, color }, panelIndex);
+ }, [annotationMode, activeRegion, cancelActiveRegion, commitRegion, regions, panelIndex]);
// ── Data fetching ─────────────────────────────────────────────────────────
const transcriptsVisible = layerState.transcripts?.visible ?? true;
@@ -613,10 +635,18 @@ function ViewerPanel({ panelIndex }) {
patch({ cellBoundaryStats: { shown: cellPolygons.length, total: cellBoundaryTotal } });
}, [cellPolygons.length, cellBoundaryTotal]); // eslint-disable-line
- const { edges, loading: edgesLoading } = useEdges(
- apiBase, dataset, viewport, imageSize, edgesVisible || tissueGraphVisible,
+ // `edges` is the filtered edge-data layer; `graphEdges` is the unfiltered
+ // tissue graph. Two arrays from two requests — the graph is ground truth and
+ // must never be narrowed by an edge filter. cellFilter is deliberately absent:
+ // filtering cells and filtering edges are independent actions.
+ const { edges, graphEdges, loading: edgesLoading } = useEdges(
+ apiBase, dataset, viewport, imageSize, edgesVisible,
edgeMinStrength, hiddenLrms, lrmCatalogue, edgeDensity, edgeFile,
- cellFilter, edgeFilter
+ { sendingFilter, receivingFilter, edgeFilters,
+ // One density drives both fetches. It is a rendering-volume control, and
+ // the graph has its own opacity for visual weight — a better lever for
+ // clutter than sampling, which would misrepresent the structure.
+ graphEnabled: tissueGraphVisible, graphDensity: edgeDensity }
);
// Explicit categorical/continuous choice for the active color-by column, or
@@ -844,7 +874,7 @@ function ViewerPanel({ panelIndex }) {
const tissueGraphLayer = new LineLayer({
id: "tissue-graph",
- data: allDirectedEdges,
+ data: graphEdges,
modelMatrix: rotModelMatrix,
visible: tissueGraphVisible,
opacity: tissueGraphOpacity,
@@ -921,6 +951,54 @@ function ViewerPanel({ panelIndex }) {
});
// ── Annotation layers ─────────────────────────────────────────────────────
+ // Two marks, because the radius alone would mislead. Connectivity is
+ // anisotropic — a cell at a tissue boundary has neighbours on one side only —
+ // so a disc around it encloses many cells it is not connected to. The points
+ // are the honest answer; the circle is the spatial scale that was asked for.
+ const neighborRingLayer = new ScatterplotLayer({
+ id: "neighborhood-radius",
+ data: neighborhood?.center && neighborhood.radius_px > 0 ? [neighborhood] : [],
+ modelMatrix: rotModelMatrix,
+ getPosition: (d) => d.center,
+ getRadius: (d) => d.radius_px,
+ radiusUnits: "common",
+ filled: false,
+ stroked: true,
+ getLineColor: [255, 210, 80, 150],
+ getLineWidth: 1.5,
+ lineWidthMinPixels: 1,
+ pickable: false,
+ });
+ const neighborPointLayer = new ScatterplotLayer({
+ id: "neighborhood-cells",
+ data: neighborhood?.neighbor_points ?? [],
+ modelMatrix: rotModelMatrix,
+ getPosition: (d) => [d.x, d.y],
+ getRadius: 5,
+ radiusMinPixels: 3,
+ radiusMaxPixels: 9,
+ getFillColor: [255, 210, 80, 230],
+ stroked: true,
+ getLineColor: [40, 30, 0, 255],
+ lineWidthMinPixels: 0.5,
+ pickable: false,
+ });
+ // The clicked cell itself, so the centre of the neighbourhood is unambiguous.
+ const neighborCenterLayer = new ScatterplotLayer({
+ id: "neighborhood-center",
+ data: neighborhood?.center ? [neighborhood] : [],
+ modelMatrix: rotModelMatrix,
+ getPosition: (d) => d.center,
+ getRadius: 7,
+ radiusMinPixels: 4,
+ radiusMaxPixels: 12,
+ getFillColor: [255, 255, 255, 255],
+ stroked: true,
+ getLineColor: [255, 160, 0, 255],
+ lineWidthMinPixels: 1.5,
+ pickable: false,
+ });
+
const regionFillLayers = regions.map((r) =>
new SolidPolygonLayer({
id: `region-fill-${r.id}`,
@@ -945,9 +1023,12 @@ function ViewerPanel({ panelIndex }) {
pickable: false,
})
);
- const activePts = cursorPos && activeRegion.length > 0
- ? [...activeRegion, cursorPos]
- : activeRegion;
+ // Empty unless this panel is the one drawing, so the dashed outline and its
+ // vertex markers do not shadow the other panel while a polygon is in progress.
+ const ownActiveRegion = drawingHere ? activeRegion : [];
+ const activePts = cursorPos && ownActiveRegion.length > 0
+ ? [...ownActiveRegion, cursorPos]
+ : ownActiveRegion;
const activeRegionLayer = new PathLayer({
id: "active-region",
data: activePts.length > 1 ? [activePts] : [],
@@ -962,7 +1043,7 @@ function ViewerPanel({ panelIndex }) {
});
const activeVertexLayer = new ScatterplotLayer({
id: "active-vertices",
- data: activeRegion,
+ data: ownActiveRegion,
modelMatrix: rotModelMatrix,
getPosition: (d) => d,
getRadius: 4,
@@ -1005,6 +1086,9 @@ function ViewerPanel({ panelIndex }) {
const deckLayers = [
cellFillLayer, cellOutlineLayer, transcriptLayer,
tissueGraphLayer, edgeDirectedLayer, edgeArrowheadLayer, edgeAutocrineLayer,
+ // Above the edges so the highlight reads against them, below the annotations
+ // so a region outline is never hidden by it.
+ neighborRingLayer, neighborPointLayer, neighborCenterLayer,
...regionFillLayers, ...regionOutlineLayers,
activeRegionLayer, activeVertexLayer,
measureLineLayer, measureEndpointLayer, measureFirstLayer,
diff --git a/frontend/src/hooks/useEdges.js b/frontend/src/hooks/useEdges.js
index a1d0624..1a0f6f2 100644
--- a/frontend/src/hooks/useEdges.js
+++ b/frontend/src/hooks/useEdges.js
@@ -47,16 +47,31 @@ function filterBody(filter) {
export function useEdges(
apiBase, dataset, viewport, imageSize, enabled,
minStrength, hiddenLrms, lrmCatalogue, density = 1.0,
- edgeFile = "edges.parquet", cellFilter = null, edgeFilter = null
+ edgeFile = "edges.parquet",
+ { sendingFilter = null, receivingFilter = null, edgeFilters = null,
+ graphEnabled = false, graphDensity = 1.0 } = {}
) {
// Which edge-source parquet to query; appended to every /edges request.
const efParam = `?edge_file=${encodeURIComponent(edgeFile)}`;
- // Serialised so the structural effect can depend on filter *content* rather
- // than object identity, which changes on every render.
- const cellFilterBody = filterBody(cellFilter);
- const edgeFilterBody = filterBody(edgeFilter);
- const filterKey = JSON.stringify([cellFilterBody ?? null, edgeFilterBody ?? null]);
+ // Serialised so the effects can depend on filter *content* rather than object
+ // identity, which changes on every render.
+ const sendingBody = filterBody(sendingFilter);
+ const receivingBody = filterBody(receivingFilter);
+ const edgeFilterBodies = (edgeFilters ?? []).map(filterBody).filter(Boolean);
+ const filterKey = JSON.stringify(
+ [sendingBody ?? null, receivingBody ?? null, edgeFilterBodies]);
+
+ // ── Tissue-graph state ────────────────────────────────────────────────────
+ // A separate fetch, and separate on purpose. The tissue graph is the total set
+ // of edges — ground truth, shown or hidden, never subset by a filter. It also
+ // cannot share the edge-data request: that one filters *then* samples so a rare
+ // subset draws at full density, while the graph must not filter at all. A
+ // `passes_filter` flag on one shared result would be read after the sample had
+ // already thinned the rows it describes.
+ const [graphEdges, setGraphEdges] = useState([]);
+ const graphTimerRef = useRef(null);
+ const graphAbortRef = useRef(null);
// ── Structural state ──────────────────────────────────────────────────────
const [structuralEdges, setStructuralEdges] = useState([]);
const [loadingStructural, setLoadingStructural] = useState(false);
@@ -92,11 +107,13 @@ export function useEdges(
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;
- // Metadata filters go to the server so they apply before the density
- // sample; filtering the response instead would sample first and leave a
- // fraction of the subset.
- if (cellFilterBody) body.cell_filter = cellFilterBody;
- if (edgeFilterBody) body.edge_filter = edgeFilterBody;
+ // Filters go to the server so they apply before the density sample;
+ // filtering the response instead would sample first and leave a fraction
+ // of the subset. The cell layer's own filter is deliberately NOT sent —
+ // filtering cells and filtering edges are independent actions.
+ if (sendingBody) body.sending_filter = sendingBody;
+ if (receivingBody) body.receiving_filter = receivingBody;
+ if (edgeFilterBodies.length) body.edge_filters = edgeFilterBodies;
try {
const res = await fetch(`${apiBase}/edges/${dataset}/query-grouped${efParam}`, {
@@ -116,6 +133,37 @@ export function useEdges(
return () => clearTimeout(structTimerRef.current);
}, [apiBase, dataset, viewport, imageSize, enabled, minStrength, density, efParam, filterKey]); // eslint-disable-line
+ // ── Effect 1b: tissue-graph fetch ─────────────────────────────────────────
+ // Takes no filter arguments at all, and depends on no filter state — so no
+ // future edit can narrow the structural graph by wiring one through. Its only
+ // inputs are the viewport and its own density.
+ useEffect(() => {
+ if (!graphEnabled || !viewport || !imageSize?.w) {
+ setGraphEdges([]);
+ return;
+ }
+ clearTimeout(graphTimerRef.current);
+ graphTimerRef.current = setTimeout(async () => {
+ if (graphAbortRef.current) graphAbortRef.current.abort();
+ const ctrl = new AbortController();
+ graphAbortRef.current = ctrl;
+ const { xmin, ymin, xmax, ymax } = viewport;
+ try {
+ const res = await fetch(`${apiBase}/edges/${dataset}/query-structure${efParam}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ xmin, ymin, xmax, ymax,
+ density: Math.max(0.01, Math.min(1.0, graphDensity)) }),
+ signal: ctrl.signal,
+ });
+ setGraphEdges(res.ok ? await res.json() : []);
+ } catch (e) {
+ if (e.name !== "AbortError") setGraphEdges([]);
+ }
+ }, DEBOUNCE_MS);
+ return () => clearTimeout(graphTimerRef.current);
+ }, [apiBase, dataset, viewport, imageSize, graphEnabled, graphDensity, efParam]); // 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).
@@ -230,5 +278,7 @@ export function useEdges(
};
}, []);
- return { edges, loading: loadingStructural || loadingScores };
+ // graphEdges is returned separately from edges — the tissue-graph layer must
+ // read the unfiltered set, never the filtered one.
+ return { edges, graphEdges, loading: loadingStructural || loadingScores };
}
diff --git a/frontend/src/hooks/usePanelSettings.js b/frontend/src/hooks/usePanelSettings.js
new file mode 100644
index 0000000..494a436
--- /dev/null
+++ b/frontend/src/hooks/usePanelSettings.js
@@ -0,0 +1,83 @@
+/**
+ * Reading a panel's display settings.
+ *
+ * Settings live in `panels[i].settings` (see `store.js::makeSettings`), but most
+ * of the UI that edits them — the whole sidebar — has no idea which panel it is
+ * editing. Threading a `panelIndex` prop through LayerPanel's nineteen section
+ * components would be noisy and easy to get half-right, so the panel travels in
+ * context instead.
+ *
+ * `usePanelSettings()` returns the whole store *merged with* that panel's
+ * settings. That shape is deliberate: every call site was already
+ * `const { layers, setLayerProp } = useStore()`, so the migration is a one-word
+ * swap and the destructuring below it is untouched. Phase 2a is meant to be
+ * provably behaviour-preserving, and a mechanical diff is the way to be sure.
+ *
+ * It subscribes to the whole store, which is what the bare `useStore()` it
+ * replaced did. That is coarse, but only one thing made it actually expensive:
+ * `viewports` and `viewportActual` are rewritten on every OpenSeadragon
+ * viewport-change event, so a single pan pushed dozens of re-renders through
+ * every sidebar section. `IGNORED_KEYS` below drops exactly those, and nothing
+ * else — see the note there.
+ *
+ * Narrowing the rest properly means giving each of the nineteen sidebar sections
+ * its own selector, which is a real refactor with no component tests behind it.
+ * Not worth it for what remains once the pan storm is gone.
+ */
+import { createContext, useContext } from "react";
+import { useStore } from "../store";
+
+/**
+ * Which panel the subtree below is editing. `null` means "not inside a panel" —
+ * the sidebar today, which falls back to `activePanel`.
+ */
+export const PanelIndexContext = createContext(null);
+
+export const PanelIndexProvider = PanelIndexContext.Provider;
+
+/** The panel index in effect here, honouring an explicit override. */
+export function usePanelIndex(explicit = null) {
+ const fromContext = useContext(PanelIndexContext);
+ const activePanel = useStore((s) => s.activePanel ?? 0);
+ return explicit ?? fromContext ?? activePanel;
+}
+
+/**
+ * The store, with `panels[i].settings` spread over the top.
+ *
+ * @param explicitIndex pass when the component already knows its panel
+ * (ViewerPanel does); otherwise the context decides.
+ */
+/**
+ * State that changes continuously and that no consumer of this hook reads.
+ *
+ * Both are rewritten on every OSD viewport-change event — dozens of times during
+ * one pan — and both are read only through their own narrow selectors:
+ * `ViewerPanel` takes `viewports[panelIndex]`, and ⇔ Match zoom reads
+ * `viewportActual` via `getState()`. LayerPanel, CellInfoPanel and
+ * AnnotationToolbar reference neither.
+ *
+ * **Before adding a key here, check nothing reading it comes through this hook** —
+ * ignoring a key a consumer does read makes that consumer silently stale, which
+ * is a much worse bug than a redundant render.
+ */
+const IGNORED_KEYS = ["viewports", "viewportActual"];
+
+function equalIgnoringHotKeys(a, b) {
+ if (Object.is(a, b)) return true;
+ if (!a || !b) return false;
+ const keys = Object.keys(a);
+ if (keys.length !== Object.keys(b).length) return false;
+ for (const k of keys) {
+ if (IGNORED_KEYS.includes(k)) continue;
+ if (!Object.is(a[k], b[k])) return false;
+ }
+ return true;
+}
+
+export function usePanelSettings(explicitIndex = null) {
+ const i = usePanelIndex(explicitIndex);
+ const store = useStore((s) => s, equalIgnoringHotKeys);
+ const settings = store.panels[i]?.settings ?? store.panels[0].settings;
+ return { ...store, ...settings, panelIndex: i };
+}
diff --git a/frontend/src/store.annotations.test.js b/frontend/src/store.annotations.test.js
new file mode 100644
index 0000000..2b9d6d6
--- /dev/null
+++ b/frontend/src/store.annotations.test.js
@@ -0,0 +1,158 @@
+/**
+ * Annotations must belong to the panel they were drawn in.
+ *
+ * These are the first frontend tests in the repo. They exist because the split
+ * screen shipped with annotations still global: `regions` and `measurements`
+ * carried no panel, so a region drawn on one dataset was re-drawn on the other
+ * at identical *image pixel* coordinates — a polygon over a 6.5 mm Visium
+ * capture area reappearing over a 55 µm seqFISH ROI, where it means nothing.
+ *
+ * Two consequences were worse than the visual one, because they are silent:
+ *
+ * - CSV export read `region.panelIndex ?? 0` while nothing ever *wrote*
+ * panelIndex, so every export resolved against panel 0's dataset. Exporting
+ * a region drawn in panel 1 posted panel 1's cell ids to panel 0's endpoint.
+ * - Measurement labels multiply `distPx` by the *rendering* panel's pixelSize,
+ * so one measurement read as two different distances in the two panels.
+ *
+ * All three are the same root cause, so they are tested together. Store logic
+ * is plain JS and needs no DOM.
+ */
+import { describe, it, expect, beforeEach } from "vitest";
+import { useStore } from "./store";
+
+const S = () => useStore.getState();
+
+beforeEach(() => {
+ S().clearAnnotations();
+});
+
+describe("regions carry the panel they were drawn in", () => {
+ it("records panelIndex on commit", () => {
+ S().commitRegion({ id: 1, points: [[0, 0], [1, 0], [1, 1]], selectedCellIds: ["a"], color: [1, 2, 3] }, 1);
+ expect(S().regions[0].panelIndex).toBe(1);
+ });
+
+ it("defaults to panel 0 when no panel is given", () => {
+ S().commitRegion({ id: 2, points: [], selectedCellIds: [], color: [0, 0, 0] });
+ expect(S().regions[0].panelIndex).toBe(0);
+ });
+
+ it("selects only the regions belonging to a panel", () => {
+ S().commitRegion({ id: 1, points: [], selectedCellIds: [], color: [0, 0, 0] }, 0);
+ S().commitRegion({ id: 2, points: [], selectedCellIds: [], color: [0, 0, 0] }, 1);
+ S().commitRegion({ id: 3, points: [], selectedCellIds: [], color: [0, 0, 0] }, 1);
+
+ expect(S().regionsForPanel(0).map((r) => r.id)).toEqual([1]);
+ expect(S().regionsForPanel(1).map((r) => r.id)).toEqual([2, 3]);
+ });
+
+ it("treats a legacy region with no panelIndex as panel 0", () => {
+ // Regions persisted or constructed before this change have no panelIndex.
+ // Panel 0 is the only place they could have come from.
+ useStore.setState({ regions: [{ id: 9, points: [], selectedCellIds: [], color: [0, 0, 0] }] });
+ expect(S().regionsForPanel(0).map((r) => r.id)).toEqual([9]);
+ expect(S().regionsForPanel(1)).toEqual([]);
+ });
+});
+
+describe("measurements carry the panel they were drawn in", () => {
+ it("records panelIndex and selects per panel", () => {
+ S().addMeasurement({ id: 1, p1: [0, 0], p2: [10, 0], distPx: 10 }, 0);
+ S().addMeasurement({ id: 2, p1: [0, 0], p2: [20, 0], distPx: 20 }, 1);
+
+ expect(S().measurementsForPanel(0).map((m) => m.id)).toEqual([1]);
+ expect(S().measurementsForPanel(1).map((m) => m.id)).toEqual([2]);
+ });
+
+ it("a measurement is never shown by the panel that did not make it", () => {
+ // The label multiplies distPx by the rendering panel's pixelSize, so a
+ // measurement leaking across panels reports a wrong distance rather than a
+ // misplaced one. 100 px is 72.5 µm on Visium and 10.7 µm on seqFISH.
+ S().addMeasurement({ id: 1, p1: [0, 0], p2: [100, 0], distPx: 100 }, 0);
+ expect(S().measurementsForPanel(1)).toEqual([]);
+ });
+});
+
+describe("the in-progress polygon belongs to one panel", () => {
+ it("tracks which panel is drawing, and clears it on cancel", () => {
+ S().addRegionPoint([1, 1], 1);
+ expect(S().activeRegionPanel).toBe(1);
+ expect(S().activeRegion).toEqual([[1, 1]]);
+
+ S().cancelActiveRegion();
+ expect(S().activeRegionPanel).toBeNull();
+ expect(S().activeRegion).toEqual([]);
+ });
+
+ it("clears the drawing panel once the region is committed", () => {
+ S().addRegionPoint([0, 0], 1);
+ S().commitRegion({ id: 1, points: [[0, 0]], selectedCellIds: [], color: [0, 0, 0] }, 1);
+ expect(S().activeRegion).toEqual([]);
+ expect(S().activeRegionPanel).toBeNull();
+ });
+});
+
+describe("clearing annotations is scoped to a panel", () => {
+ beforeEach(() => {
+ S().commitRegion({ id: 1, points: [], selectedCellIds: [], color: [0, 0, 0] }, 0);
+ S().commitRegion({ id: 2, points: [], selectedCellIds: [], color: [0, 0, 0] }, 1);
+ S().addMeasurement({ id: 3, p1: [0, 0], p2: [1, 1], distPx: 1 }, 0);
+ S().addMeasurement({ id: 4, p1: [0, 0], p2: [1, 1], distPx: 1 }, 1);
+ });
+
+ it("clears only the given panel", () => {
+ // The Clear button lives in each panel's own toolbar, so clearing from one
+ // panel must not wipe the other panel's work.
+ S().clearAnnotations(0);
+ expect(S().regionsForPanel(0)).toEqual([]);
+ expect(S().measurementsForPanel(0)).toEqual([]);
+ expect(S().regionsForPanel(1).map((r) => r.id)).toEqual([2]);
+ expect(S().measurementsForPanel(1).map((m) => m.id)).toEqual([4]);
+ });
+
+ it("clears everything when no panel is given", () => {
+ S().clearAnnotations();
+ expect(S().regions).toEqual([]);
+ expect(S().measurements).toEqual([]);
+ });
+});
+
+describe("#60 — the neighbourhood highlight is scoped and cleared", () => {
+ const nb = { panelIndex: 1, cellId: "c1", data: { n_neighbors: 3 } };
+
+ beforeEach(() => {
+ useStore.setState({ neighborhood: null, selection: null });
+ });
+
+ it("is dropped when another cell is selected", () => {
+ // A highlight left over from a previous cell would sit on unrelated tissue
+ // and read as the answer for the cell now selected.
+ useStore.setState({ neighborhood: nb });
+ S().setSelectedCell({ cell_id: "c2" }, 1);
+ expect(S().neighborhood).toBeNull();
+ });
+
+ it("is dropped when an edge is selected, and when selection is cleared", () => {
+ useStore.setState({ neighborhood: nb });
+ S().setSelectedEdge({ edge: "a|b" }, 0);
+ expect(S().neighborhood).toBeNull();
+
+ useStore.setState({ neighborhood: nb });
+ S().clearSelection();
+ expect(S().neighborhood).toBeNull();
+ });
+
+ it("is dropped when its own panel changes dataset", () => {
+ // The ids and coordinates belong to the dataset that was showing.
+ useStore.setState({ neighborhood: nb });
+ S().setPanelDataset(1, "other-dataset");
+ expect(S().neighborhood).toBeNull();
+ });
+
+ it("survives a dataset change in the other panel", () => {
+ useStore.setState({ neighborhood: nb });
+ S().setPanelDataset(0, "other-dataset");
+ expect(S().neighborhood).toEqual(nb);
+ });
+});
diff --git a/frontend/src/store.js b/frontend/src/store.js
index f96d80b..1dfbd55 100644
--- a/frontend/src/store.js
+++ b/frontend/src/store.js
@@ -2,6 +2,162 @@ import { create } from "zustand";
const API = import.meta.env.VITE_API_URL ?? "/api";
+/**
+ * One panel's *display* settings — how the data looks, as opposed to which data
+ * it is.
+ *
+ * A factory rather than a constant because several values are mutable
+ * containers (the `layers` map, `hiddenLrms`). Sharing one object across panels
+ * would alias them: unhiding a mechanism in one panel would silently unhide it
+ * in the other, which is the exact bug this structure exists to prevent.
+ *
+ * Phase 2a moved these out of the top level of the store, where they were
+ * global. They are written to every panel at once for now (see `patchSettings`),
+ * so behaviour is unchanged; 2b adds the link toggle that lets them diverge.
+ * See docs/split_screen_phase2.md.
+ */
+export function makeSettings() {
+ return {
+ layers: {
+ morphology: { visible: false, opacity: 1.0 },
+ transcripts: { visible: false, opacity: 0.8 },
+ cellSegments: { visible: true, opacity: 1.0, outlineOpacity: 0.0 },
+ tissueGraph: { visible: true, opacity: 0.05 },
+ edges: { visible: false, opacity: 0.25 },
+ },
+
+ // null = auto (the hook targets ~5k cells and adapts to viewport density);
+ // a number is the user's slider override.
+ cellBoundaryFraction: null,
+ transcriptFraction: 0.1,
+
+ // Values outside [low, high] map to the palette ends (oob::squish).
+ cellColorClamp: { low: null, high: null },
+ edgeColorClamp: { low: null, high: null },
+
+ cellColorEnabled: false,
+ colorBy: { mode: "off", field: null }, // 'off' | 'gene_set' | 'metadata'
+ cellColorPalette: "viridis",
+
+ edgeWidth: 2,
+ showArrowheads: true,
+ arrowStyle: "half", // 'full' chevron | 'half' harpoon
+ arrowheadScale: 1.0,
+ edgeDensity: 0.1,
+ edgeMinStrength: 0,
+ edgeColorBy: { mode: "lrm_set", field: null },
+ edgeColorPalette: "viridis",
+ edgeDirectional: true,
+ edgeOffset: 0,
+ showAutocrine: false,
+ autocrineRadius: 14,
+ autocrineLineWidth: 2,
+
+ hiddenLrms: new Set(),
+ selectedGenes: null, // null = no filter; Set = allowlist
+ cellFilter: null,
+ // Edge-side filters (issue #59). Independent of cellFilter: filtering cells
+ // and filtering edges are separate actions, and an edge may terminate on a
+ // cell that is not drawn.
+ // sending/receivingFilter — a *cell* metadata predicate on one endpoint;
+ // both set gives the intersection, one set leaves the other end free.
+ // edgeFilters — predicates on the edge table / edge-metadata, and-ed.
+ sendingFilter: null,
+ receivingFilter: null,
+ edgeFilters: [],
+ categoricalOverrides: {}, // "cell::" | "edge::" -> bool
+ categoryColorOverrides: {}, // "::" -> [r,g,b,a]
+ transcriptColorOverrides: {}, // gene -> [r,g,b,a]
+ };
+}
+
+/**
+ * Copy a settings object deeply enough that the panels cannot alias.
+ *
+ * Only the mutable containers need rebuilding — every setter replaces rather
+ * than mutates, so the leaves can be shared.
+ */
+function cloneSettings(src) {
+ return {
+ ...src,
+ hiddenLrms: new Set(src.hiddenLrms),
+ selectedGenes: src.selectedGenes === null ? null : new Set(src.selectedGenes),
+ layers: Object.fromEntries(
+ Object.entries(src.layers).map(([k, v]) => [k, { ...v }])),
+ edgeFilters: [...(src.edgeFilters ?? [])],
+ categoricalOverrides: { ...src.categoricalOverrides },
+ categoryColorOverrides: { ...src.categoryColorOverrides },
+ transcriptColorOverrides: { ...src.transcriptColorOverrides },
+ };
+}
+
+/**
+ * Strip settings that name something the target dataset does not have.
+ *
+ * Copying settings across datasets is where this gets dangerous rather than
+ * merely wrong-looking: a `cellFilter` naming a column the target lacks makes
+ * the backend return 400 on *every* viewport change, so the panel stops
+ * rendering entirely and the cause is invisible from the UI. Dropping the
+ * setting degrades to "no filter", which is recoverable and obvious.
+ *
+ * `allowed` supplies the target's vocabulary — `{cellFields, edgeFields, genes,
+ * lrms}`, each a Set or null to skip that check. The caller provides it because
+ * metadata columns come from /cells/schema and /edges/schema, which the store
+ * does not fetch; genes and LRMs it already holds per panel.
+ *
+ * `sameDataset` governs the colour clamps. A clamp is a range in the *source*
+ * data's units — carrying [0, 4000] onto a dataset topping out at 70 paints
+ * everything the bottom colour, which reads as a broken render rather than a
+ * copied setting. Same dataset, the clamp is exactly what you meant to copy.
+ */
+export function sanitiseSettings(next, allowed, sameDataset) {
+ const { cellFields = null, edgeFields = null, genes = null, lrms = null } = allowed ?? {};
+ const has = (set, v) => set === null || set.has(v);
+ const filterKeys = (obj, keep) =>
+ Object.fromEntries(Object.entries(obj).filter(([k]) => keep(k)));
+
+ if (next.colorBy?.mode === "metadata" && !has(cellFields, next.colorBy.field)) {
+ next.colorBy = { mode: "off", field: null };
+ }
+ if (next.edgeColorBy?.mode === "metadata" && !has(edgeFields, next.edgeColorBy.field)) {
+ next.edgeColorBy = { mode: "lrm_set", field: null };
+ }
+ if (next.cellFilter && !has(cellFields, next.cellFilter.field)) next.cellFilter = null;
+ // The endpoint filters name *cell* columns even though they filter edges, so
+ // they validate against cellFields — getting this wrong would drop every one of
+ // them against a target whose edge table simply has different columns.
+ if (next.sendingFilter && !has(cellFields, next.sendingFilter.field)) next.sendingFilter = null;
+ if (next.receivingFilter && !has(cellFields, next.receivingFilter.field)) next.receivingFilter = null;
+ // Every filter in the list is validated, not just the first.
+ next.edgeFilters = (next.edgeFilters ?? []).filter((f) => has(edgeFields, f.field));
+
+ if (next.selectedGenes && genes) {
+ const keep = [...next.selectedGenes].filter((g) => genes.has(g));
+ // No overlap at all means the allowlist says nothing about this panel.
+ // Falling back to "no filter" matches what a dataset change does; an empty
+ // Set would mean "show no species", which is a stranger thing to inherit.
+ next.selectedGenes = keep.length ? new Set(keep) : null;
+ }
+ if (next.hiddenLrms && lrms) {
+ next.hiddenLrms = new Set([...next.hiddenLrms].filter((l) => lrms.has(l)));
+ }
+
+ next.categoricalOverrides = filterKeys(next.categoricalOverrides, (k) => {
+ const [scope, field] = k.split("::");
+ return has(scope === "edge" ? edgeFields : cellFields, field);
+ });
+ next.categoryColorOverrides = filterKeys(next.categoryColorOverrides,
+ (k) => has(cellFields, k.split("::")[0]));
+ next.transcriptColorOverrides = filterKeys(next.transcriptColorOverrides,
+ (k) => has(genes, k));
+
+ if (!sameDataset) {
+ next.cellColorClamp = { low: null, high: null };
+ next.edgeColorClamp = { low: null, high: null };
+ }
+ return next;
+}
+
/**
* One panel's dataset-bound state.
*
@@ -11,7 +167,7 @@ const API = import.meta.env.VITE_API_URL ?? "/api";
* image dimensions, pixel size, capabilities, gene panel, LRM vocabulary and
* value ranges all differ between them.
*/
-function makePanel() {
+export function makePanel() {
return {
dataset: null,
activeImage: null,
@@ -28,6 +184,7 @@ function makePanel() {
cellColorCategories: [],
transcriptStats: { shown: 0, total: 0 },
cellBoundaryStats: { shown: 0, total: 0 },
+ settings: makeSettings(),
};
}
@@ -60,32 +217,43 @@ export const useStore = create((set, get) => ({
// the new list asynchronously — without this, OSD spends that window asking
// the new dataset for the old dataset's image and logging 404s.
setPanelDataset: (i, dataset) => set((s) => {
- const next = [...s.panels];
- next[i] = { ...makePanel(), dataset };
- // Selections belong to a dataset; drop any that pointed at the old one.
- const sel = s.selection && s.selection.panelIndex === i ? null : s.selection;
- return {
- panels: next,
- selection: sel,
- // The shared settings that name a *column, gene or mechanism* are reset by
- // any panel's dataset change, even though they are shared. They have to be:
- // a filter naming a column the new dataset lacks 400s on every viewport
- // change, and a gene allowlist from a different panel is meaningless.
- //
- // The cost is that switching one panel's dataset clears the other panel's
- // filter too. That is the honest consequence of one sidebar driving both,
- // and it goes away in Phase 2 when these become per-panel.
+ // Only the settings that name a *column, gene or mechanism* are reset. The
+ // rest (widths, palettes, layer visibility, densities) survive a dataset
+ // change and always have — resetting them would be a regression, which is
+ // why this is a named patch rather than a fresh makeSettings().
+ const RESET = {
selectedGenes: null,
hiddenLrms: new Set(),
categoricalOverrides: {},
cellFilter: null,
- edgeFilter: null,
+ sendingFilter: null,
+ receivingFilter: null,
+ edgeFilters: [],
categoryColorOverrides: {},
transcriptColorOverrides: {},
colorBy: { mode: "off", field: null },
cellColorClamp: { low: null, high: null },
edgeColorClamp: { low: null, high: null },
};
+ // The panel whose dataset changed is always reset. The *others* are reset
+ // only while the panels are linked, where they share one set of values and
+ // leaving them would strand a filter naming a column the new dataset lacks
+ // — which 400s on every viewport change.
+ //
+ // Unlinked, reaching across would contradict the toggle the user just set:
+ // the sidebar says "editing panel 1 only" while an action on panel 2
+ // destroys panel 1's work. That was the pre-2b behaviour and the cost
+ // recorded in CLAUDE.md; the link toggle is what makes it fixable, so 2d
+ // lands with 2b rather than after it.
+ const panels = s.panels.map((p, idx) => {
+ const base = idx === i ? { ...makePanel(), dataset } : p;
+ const reset = idx === i || s.linkSettings;
+ return { ...base, settings: reset ? { ...p.settings, ...RESET } : p.settings };
+ });
+ // Selections belong to a dataset; drop any that pointed at the old one.
+ const sel = s.selection && s.selection.panelIndex === i ? null : s.selection;
+ const nb = s.neighborhood && s.neighborhood.panelIndex === i ? null : s.neighborhood;
+ return { panels, selection: sel, neighborhood: nb };
}),
// The LRM catalogue, colour range and edge filter are all specific to one
@@ -98,7 +266,12 @@ export const useStore = create((set, get) => ({
};
const sel = s.selection && s.selection.panelIndex === i && s.selection.kind === "edge"
? null : s.selection;
- return { panels: next, selection: sel, hiddenLrms: new Set(), edgeFilter: null };
+ // Mechanism and edge-column names are specific to one edge file, so the
+ // panel that changed is always cleared; the others only while linked.
+ const panels = next.map((p, idx) => (idx === i || s.linkSettings)
+ ? { ...p, settings: { ...p.settings, hiddenLrms: new Set(), edgeFilters: [] } }
+ : p);
+ return { panels, selection: sel };
}),
// ── Selection ─────────────────────────────────────────────────────────────
@@ -107,24 +280,34 @@ export const useStore = create((set, get) => ({
// dataset and show the wrong cell.
// { panelIndex, kind: "cell" | "edge", cell? , edge? }
selection: null,
+ // Every selection change drops the neighbourhood with it. A highlight left
+ // over from a previous cell would sit on unrelated tissue and look like the
+ // answer for the cell now selected.
setSelectedCell: (cell, panelIndex = 0) =>
- set({ selection: cell ? { panelIndex, kind: "cell", cell } : null }),
+ set({ selection: cell ? { panelIndex, kind: "cell", cell } : null,
+ neighborhood: null }),
setSelectedEdge: (edge, panelIndex = 0) =>
- set({ selection: edge ? { panelIndex, kind: "edge", edge } : null }),
- clearSelection: () => set({ selection: null }),
+ set({ selection: edge ? { panelIndex, kind: "edge", edge } : null,
+ neighborhood: null }),
+ clearSelection: () => set({ selection: null, neighborhood: null }),
+
+ // ── Local neighbourhood (issue #60) ───────────────────────────────────────
+ // { panelIndex, cellId, data } — data is the /neighborhood response. Carries
+ // its panel because the highlight is drawn in one panel only, like
+ // annotations and selection: the coordinates are that dataset's image pixels.
+ //
+ // Computed server-side and never from the frontend's `edges` array, which is
+ // density-sampled and viewport-bounded — deriving it here would silently
+ // under-count neighbours and change as you pan.
+ neighborhood: null,
+ setNeighborhood: (nb) => set({ neighborhood: nb }),
+ clearNeighborhood: () => set({ neighborhood: null }),
// ── Categorical / continuous override (issue #35) ─────────────────────────
// Keyed "cell::" / "edge::" → true | false. Absent means
// auto-detect, which is what the backend does when `categorical` is null.
// Seurat writes cluster IDs as integers, so dtype alone routes them to a
// viridis gradient; this is how the user says "these are twenty categories".
- categoricalOverrides: {},
- setCategoricalOverride: (scope, field, value) => set((s) => {
- const next = { ...s.categoricalOverrides };
- if (value === null || value === undefined) delete next[`${scope}::${field}`];
- else next[`${scope}::${field}`] = value;
- return { categoricalOverrides: next };
- }),
// ── Metadata subsetting (issue #45) ───────────────────────────────────────
// A filter is { field, values: string[] | null, min, max, includeMissing }.
@@ -133,7 +316,8 @@ export const useStore = create((set, get) => ({
// to a rare cluster shows all of it rather than a sample of a sample.
//
// cellFilter also governs edges: an edge is drawn only when BOTH endpoints
- // survive it. edgeFilter is independent and applies to the edge table itself.
+ // survive it — superseded by sendingFilter/receivingFilter, which apply to one
+ // endpoint each and are independent of the cell layer entirely (issue #59).
// ── Shared colour scale across panels ─────────────────────────────────────
// On by default, and this is a figure-integrity setting rather than a
@@ -148,10 +332,6 @@ export const useStore = create((set, get) => ({
linkColorScale: true,
setLinkColorScale: (v) => set({ linkColorScale: v }),
- cellFilter: null,
- setCellFilter: (f) => set({ cellFilter: f }),
- edgeFilter: null,
- setEdgeFilter: (f) => set({ edgeFilter: f }),
// ── Viewport (image pixel coords, kept in sync with OpenSeadragon) ────────
// One entry per panel; panel 1 is only used in split-screen mode.
@@ -194,166 +374,251 @@ export const useStore = create((set, get) => ({
return { panelRotations: next };
}),
- // ── Layer visibility ───────────────────────────────────────────────────────
- layers: {
- morphology: { visible: false, opacity: 1.0 },
- transcripts: { visible: false, opacity: 0.8 },
- cellSegments: { visible: true, opacity: 1.0, outlineOpacity: 0.0 },
- tissueGraph: { visible: true, opacity: 0.05 },
- edges: { visible: false, opacity: 0.25 },
+ // ── Display settings ──────────────────────────────────────────────────────
+ //
+ // These live in `panels[i].settings`, not at the top level. Every setter below
+ // goes through `patchSettings`, which in Phase 2a writes to *all* panels — so
+ // one sidebar still drives both and behaviour is identical to before the move.
+ // Phase 2b adds `linkSettings` and `activePanel`, at which point this one
+ // function becomes the single place where "write to one panel or all of them"
+ // is decided. Keeping the named setters means call sites never had to change.
+
+ // ── Which panel the sidebar edits, and whether edits propagate ────────────
+ //
+ // activePanel is the tab the sidebar is pointed at. It is also what the
+ // sidebar *reads*, so with the panels unlinked the controls show the values
+ // for the panel you are editing rather than some blend.
+ //
+ // linkSettings defaults to true, which reproduces the pre-2b behaviour
+ // exactly: one sidebar drives both panels. That default is deliberate — two
+ // panels that each drifted to their own palette and clamp look comparable and
+ // are not, which is the same figure-integrity argument behind linkColorScale.
+ activePanel: 0,
+ setActivePanel: (i) => set({ activePanel: i }),
+
+ linkSettings: true,
+ // Re-linking *syncs*: every panel adopts the active panel's settings. The
+ // alternative — start propagating future edits but leave the existing
+ // divergence in place — leaves a control labelled "linked" over two panels
+ // that visibly differ, and the next single edit converges them only
+ // partially. Adopting one panel's state is the only reading of "linked" that
+ // is true the moment you switch it on. Which panel wins is the tab you are
+ // on, so it is visible and chosen rather than incidental.
+ setLinkSettings: (v) => set((s) => {
+ if (!v) return { linkSettings: false };
+ const source = s.panels[s.activePanel]?.settings ?? s.panels[0].settings;
+ return {
+ linkSettings: true,
+ panels: s.panels.map((p) => ({ ...p, settings: cloneSettings(source) })),
+ };
+ }),
+
+ /**
+ * Copy one panel's settings onto another.
+ *
+ * The explicit form of what the link toggle does continuously: explore either
+ * side freely, then force the other to match. Unlike linking it is one-shot,
+ * so the panels stay independent afterwards.
+ *
+ * `allowed` is the target's vocabulary; see sanitiseSettings. Passing null
+ * copies verbatim, which is only safe when both panels show the same dataset.
+ */
+ pushSettings: (from, to, allowed = null) => set((s) => {
+ const src = s.panels[from]?.settings;
+ if (!src || !s.panels[to] || from === to) return {};
+ const sameDataset = s.panels[from].dataset === s.panels[to].dataset;
+ const next = sanitiseSettings(cloneSettings(src), allowed, sameDataset);
+ return { panels: s.panels.map((p, i) => (i === to ? { ...p, settings: next } : p)) };
+ }),
+
+ /**
+ * Merge a patch into panel settings.
+ *
+ * An explicit `panelIndex` always wins. Otherwise the link state decides: all
+ * panels when linked, just the active one when not. This is the single place
+ * that choice is made — every named setter routes through here, so none of
+ * them has to know about tabs or linking.
+ */
+ patchSettings: (patch, panelIndex = null) =>
+ set((s) => {
+ const targets = panelIndex !== null
+ ? [panelIndex]
+ : (s.linkSettings ? s.panels.map((_, i) => i) : [s.activePanel]);
+ return {
+ panels: s.panels.map((p, i) =>
+ targets.includes(i)
+ ? { ...p, settings: { ...p.settings, ...patch } }
+ : p),
+ };
+ }),
+
+ /**
+ * Read one setting. Defaults to the panel the sidebar is editing, which is
+ * what read-modify-write setters need: unlinked, `toggleLrm` must toggle
+ * against the active panel's Set, not panel 0's.
+ */
+ getSetting: (key, panelIndex = null) => {
+ const s = get();
+ const i = panelIndex ?? s.activePanel;
+ return s.panels[i]?.settings?.[key];
},
- // cellBoundaryFraction: fraction of cells in viewport to fetch.
- // null = auto (hook targets ~5k cells, adapts per viewport density).
- // number = user override (0–1, set by slider).
- cellBoundaryFraction: null,
- setCellBoundaryFraction: (v) => set({
+ // Read-modify-write on a nested map, so it derives from the active panel and
+ // then goes through the ordinary targeting rule.
+ setLayerProp: (id, prop, value) => {
+ const cur = get().getSetting("layers");
+ get().patchSettings({ layers: { ...cur, [id]: { ...cur[id], [prop]: value } } });
+ },
+
+ setCellBoundaryFraction: (v) => get().patchSettings({
cellBoundaryFraction: v !== null ? Math.max(0.0001, Math.min(1.0, v)) : null,
}),
-
- // ── Color clamp / squish (oob::squish): values outside [low,high] map to palette ends) ──
- cellColorClamp: { low: null, high: null },
- setCellColorClamp: (low, high) => set({ cellColorClamp: { low, high } }),
- edgeColorClamp: { low: null, high: null },
- setEdgeColorClamp: (low, high) => set({ edgeColorClamp: { low, high } }),
-
- // ── Edge style ────────────────────────────────────────────────────────────
- edgeWidth: 2,
- setEdgeWidth: (v) => set({ edgeWidth: v }),
- showArrowheads: true,
- setShowArrowheads: (v) => set({ showArrowheads: v }),
- // arrowStyle: "full" = filled chevron both sides; "half" = harpoon (outer barb only)
- arrowStyle: "half",
- setArrowStyle: (v) => set({ arrowStyle: v }),
- // arrowheadScale: multiplier on base arrowLen (edgeWidth * 4)
- arrowheadScale: 1.0,
- setArrowheadScale: (v) => set({ arrowheadScale: v }),
-
- // ── Edge filter + color state ─────────────────────────────────────────────
- // edgeDensity: fraction of available viewport edges to show (0.01–1.0)
- edgeDensity: 0.1,
- setEdgeDensity: (v) => set({ edgeDensity: v }),
- edgeMinStrength: 0,
- setEdgeMinStrength: (v) => set({ edgeMinStrength: v }),
-
- // mode: 'default' | 'lrm_set' | 'metadata'
- // field: for metadata = column name; unused for other modes
- edgeColorBy: { mode: "lrm_set", field: null },
- setEdgeColorBy: (mode, field) => set({ edgeColorBy: { mode, field } }),
-
- // Edge palette (for continuous metadata coloring)
- edgeColorPalette: "viridis",
- setEdgeColorPalette: (p) => set({ edgeColorPalette: p }),
-
- // Directional rendering: show perpendicular offset so A→B ≠ B→A visually
- edgeDirectional: true,
- setEdgeDirectional: (v) => set({ edgeDirectional: v }),
- // edgeOffset: perpendicular separation in image-pixels between A→B and B→A
- edgeOffset: 0,
- setEdgeOffset: (v) => set({ edgeOffset: v }),
-
- // Show autocrine self-loop rings
- showAutocrine: false,
- setShowAutocrine: (v) => set({ showAutocrine: v }),
- // Autocrine circle geometry — independent from directed-edge line width
- autocrineRadius: 14,
- setAutocrineRadius: (v) => set({ autocrineRadius: v }),
- autocrineLineWidth: 2,
- setAutocrineLineWidth: (v) => set({ autocrineLineWidth: v }),
+ setTranscriptFraction: (f) =>
+ get().patchSettings({ transcriptFraction: Math.max(0.0001, Math.min(1.0, f)) }),
+
+ setCellColorClamp: (low, high) => get().patchSettings({ cellColorClamp: { low, high } }),
+ setEdgeColorClamp: (low, high) => get().patchSettings({ edgeColorClamp: { low, high } }),
+
+ setCellColorEnabled: (v) => get().patchSettings({ cellColorEnabled: v }),
+ setColorBy: (mode, field) => get().patchSettings({ colorBy: { mode, field } }),
+ setCellColorPalette: (p) => get().patchSettings({ cellColorPalette: p }),
+
+ setEdgeWidth: (v) => get().patchSettings({ edgeWidth: v }),
+ setShowArrowheads: (v) => get().patchSettings({ showArrowheads: v }),
+ setArrowStyle: (v) => get().patchSettings({ arrowStyle: v }),
+ setArrowheadScale: (v) => get().patchSettings({ arrowheadScale: v }),
+ setEdgeDensity: (v) => get().patchSettings({ edgeDensity: v }),
+ setEdgeMinStrength: (v) => get().patchSettings({ edgeMinStrength: v }),
+ setEdgeColorBy: (mode, field) => get().patchSettings({ edgeColorBy: { mode, field } }),
+ setEdgeColorPalette: (p) => get().patchSettings({ edgeColorPalette: p }),
+ setEdgeDirectional: (v) => get().patchSettings({ edgeDirectional: v }),
+ setEdgeOffset: (v) => get().patchSettings({ edgeOffset: v }),
+ setShowAutocrine: (v) => get().patchSettings({ showAutocrine: v }),
+ setAutocrineRadius: (v) => get().patchSettings({ autocrineRadius: v }),
+ setAutocrineLineWidth: (v) => get().patchSettings({ autocrineLineWidth: v }),
+
+ setCellFilter: (f) => get().patchSettings({ cellFilter: f }),
+ setSendingFilter: (f) => get().patchSettings({ sendingFilter: f }),
+ setReceivingFilter: (f) => get().patchSettings({ receivingFilter: f }),
+ // Edge-table filters are a list, and-ed together. Index-addressed so the UI
+ // can add, replace and remove rows without rebuilding the array itself.
+ setEdgeFilterAt: (i, f) => {
+ const next = [...(get().getSetting("edgeFilters") ?? [])];
+ if (f === null) next.splice(i, 1); else next[i] = f;
+ get().patchSettings({ edgeFilters: next.filter(Boolean) });
+ },
+ setEdgeFilters: (list) => get().patchSettings({ edgeFilters: list ?? [] }),
// ── LRM mechanism filter ───────────────────────────────────────────────────
- // Shared across panels and keyed on the "ligand|receptor" string, so a
- // mechanism present in both datasets is one checkbox governing both — which is
- // the point of a comparison. The catalogue it is checked against is per panel
+ // Keyed on the "ligand|receptor" string, so a mechanism present in both
+ // datasets is one checkbox governing both — which is the point of a
+ // comparison. The catalogue it is checked against is per panel
// (panels[i].lrmCatalogue); the sidebar shows the union.
- hiddenLrms: new Set(),
- toggleLrm: (lrm) =>
- set((s) => {
- const next = new Set(s.hiddenLrms);
- if (next.has(lrm)) next.delete(lrm); else next.add(lrm);
- return { hiddenLrms: next };
- }),
- setAllLrmsVisible: () => set({ hiddenLrms: new Set() }),
- // The union across panels: hiddenLrms is shared, so "none" has to cover every
- // mechanism visible in either panel or one side keeps drawing.
- hideAllLrms: () =>
- set((s) => ({
- hiddenLrms: new Set(
- s.panels.flatMap((p) => p.lrmCatalogue)
- .map((e) => e.lrm ?? `${e.ligand}|${e.receptor}`)
- ),
- })),
- setLayerProp: (id, prop, value) =>
- set((s) => ({
- layers: { ...s.layers, [id]: { ...s.layers[id], [prop]: value } },
- })),
+ toggleLrm: (lrm) => {
+ const cur = get().getSetting("hiddenLrms") ?? new Set();
+ const next = new Set(cur);
+ if (next.has(lrm)) next.delete(lrm); else next.add(lrm);
+ get().patchSettings({ hiddenLrms: next });
+ },
+ setAllLrmsVisible: () => get().patchSettings({ hiddenLrms: new Set() }),
+ // "none" has to cover every mechanism visible in either panel, or one side
+ // keeps drawing.
+ hideAllLrms: () => {
+ const all = get().panels.flatMap((p) => p.lrmCatalogue)
+ .map((e) => e.lrm ?? `${e.ligand}|${e.receptor}`);
+ get().patchSettings({ hiddenLrms: new Set(all) });
+ },
- // ── Cell color ────────────────────────────────────────────────────────────
- // cellColorEnabled: drives the color-by layer on/off
- // colorBy.mode: 'off' | 'gene_set' | 'metadata'
- // colorBy.field: metadata column name (only used in metadata mode)
- // cellColorPalette: palette for continuous metadata (viridis/plasma/magma/inferno)
- cellColorEnabled: false,
- setCellColorEnabled: (v) => set({ cellColorEnabled: v }),
- colorBy: { mode: "off", field: null },
- setColorBy: (mode, field) => set({ colorBy: { mode, field } }),
- cellColorPalette: "viridis",
- setCellColorPalette: (p) => set({ cellColorPalette: p }),
-
- // transcriptFraction: fraction of viewport transcripts to request (0–1).
- // transcriptStats: live shown/total counts for the status display (panel 0).
- transcriptFraction: 0.1,
- setTranscriptFraction: (f) => set({ transcriptFraction: Math.max(0.0001, Math.min(1.0, f)) }),
-
- // categoryColorOverrides: user-chosen colors for categorical metadata columns.
- // keyed by `${field}::${category}` → [r, g, b, 255]. Reset on dataset change.
- categoryColorOverrides: {},
- setCategoryColorOverride: (field, cat, rgba) => set((s) => ({
- categoryColorOverrides: { ...s.categoryColorOverrides, [`${field}::${cat}`]: rgba },
- })),
- // Bulk-set: merges supplied map on top of existing overrides (used for CSV import).
- mergeCategoryColorOverrides: (map) => set((s) => ({
- categoryColorOverrides: { ...s.categoryColorOverrides, ...map },
- })),
- resetCategoryColorOverrides: () => set({ categoryColorOverrides: {} }),
-
- // transcriptColorOverrides: user-chosen colors for transcript species.
- // keyed by gene name → [r, g, b, 255]. Reset on dataset change.
- transcriptColorOverrides: {},
- setTranscriptColorOverride: (gene, rgba) => set((s) => ({
- transcriptColorOverrides: { ...s.transcriptColorOverrides, [gene]: rgba },
- })),
- mergeTranscriptColorOverrides: (map) => set((s) => ({
- transcriptColorOverrides: { ...s.transcriptColorOverrides, ...map },
- })),
- resetTranscriptColorOverrides: () => set({ transcriptColorOverrides: {} }),
+ // ── Categorical / continuous override (issue #35) ─────────────────────────
+ setCategoricalOverride: (scope, field, value) => {
+ const next = { ...(get().getSetting("categoricalOverrides") ?? {}) };
+ if (value === null || value === undefined) delete next[`${scope}::${field}`];
+ else next[`${scope}::${field}`] = value;
+ get().patchSettings({ categoricalOverrides: next });
+ },
+
+ // ── User-chosen colours ───────────────────────────────────────────────────
+ setCategoryColorOverride: (field, cat, rgba) => get().patchSettings({
+ categoryColorOverrides: {
+ ...(get().getSetting("categoryColorOverrides") ?? {}), [`${field}::${cat}`]: rgba },
+ }),
+ mergeCategoryColorOverrides: (map) => get().patchSettings({
+ categoryColorOverrides: { ...(get().getSetting("categoryColorOverrides") ?? {}), ...map },
+ }),
+ resetCategoryColorOverrides: () => get().patchSettings({ categoryColorOverrides: {} }),
+
+ setTranscriptColorOverride: (gene, rgba) => get().patchSettings({
+ transcriptColorOverrides: {
+ ...(get().getSetting("transcriptColorOverrides") ?? {}), [gene]: rgba },
+ }),
+ mergeTranscriptColorOverrides: (map) => get().patchSettings({
+ transcriptColorOverrides: { ...(get().getSetting("transcriptColorOverrides") ?? {}), ...map },
+ }),
+ resetTranscriptColorOverrides: () => get().patchSettings({ transcriptColorOverrides: {} }),
// ── Annotations ───────────────────────────────────────────────────────────
// annotationMode: current interaction mode
annotationMode: "pan", // "pan" | "region" | "measure"
setAnnotationMode: (mode) => set({ annotationMode: mode }),
- // activeRegion: vertices of the polygon currently being drawn (image px)
+ // Every annotation belongs to the panel it was drawn in, and this is not
+ // cosmetic. Coordinates are image pixels of *that panel's* dataset, so a
+ // polygon over a 6.5 mm Visium capture area reappearing in a panel showing a
+ // 55 µm seqFISH ROI lands somewhere meaningless. Two silent consequences are
+ // worse than the visual one: CSV export resolves the region's cell ids
+ // against its panel's dataset, and a measurement label multiplies distPx by
+ // its panel's pixelSize. Both give confidently wrong answers if an annotation
+ // is read by the wrong panel.
+ //
+ // `panelIndex` is absent on anything created before this existed; the
+ // selectors below treat that as panel 0, which is the only place it could
+ // have come from.
+
+ // activeRegion: vertices of the polygon currently being drawn (image px).
+ // activeRegionPanel: which panel is drawing, so the in-progress outline and
+ // its vertex markers do not also appear in the other panel.
activeRegion: [],
- addRegionPoint: (pt) => set((s) => ({ activeRegion: [...s.activeRegion, pt] })),
- cancelActiveRegion: () => set({ activeRegion: [] }),
+ activeRegionPanel: null,
+ addRegionPoint: (pt, panelIndex = 0) =>
+ set((s) => ({ activeRegion: [...s.activeRegion, pt], activeRegionPanel: panelIndex })),
+ cancelActiveRegion: () => set({ activeRegion: [], activeRegionPanel: null }),
// regions: completed annotation polygons
- // each: { id, points [[x,y],...], selectedCellIds [str,...], color [r,g,b] }
+ // each: { id, points [[x,y],...], selectedCellIds [str,...], color [r,g,b], panelIndex }
regions: [],
- commitRegion: (region) =>
- set((s) => ({ regions: [...s.regions, region], activeRegion: [] })),
+ commitRegion: (region, panelIndex = 0) =>
+ set((s) => ({
+ regions: [...s.regions, { ...region, panelIndex }],
+ activeRegion: [],
+ activeRegionPanel: null,
+ })),
removeRegion: (id) =>
set((s) => ({ regions: s.regions.filter((r) => r.id !== id) })),
- // measurements: [{id, p1:[x,y], p2:[x,y], distPx}]
+ // measurements: [{id, p1:[x,y], p2:[x,y], distPx, panelIndex}]
measurements: [],
- addMeasurement: (m) => set((s) => ({ measurements: [...s.measurements, m] })),
+ addMeasurement: (m, panelIndex = 0) =>
+ set((s) => ({ measurements: [...s.measurements, { ...m, panelIndex }] })),
removeMeasurement: (id) =>
set((s) => ({ measurements: s.measurements.filter((m) => m.id !== id) })),
- clearAnnotations: () =>
- set({ activeRegion: [], regions: [], measurements: [] }),
+ regionsForPanel: (i) =>
+ get().regions.filter((r) => (r.panelIndex ?? 0) === i),
+ measurementsForPanel: (i) =>
+ get().measurements.filter((m) => (m.panelIndex ?? 0) === i),
+
+ // Scoped to a panel, because the Clear button lives in each panel's own
+ // toolbar — clearing from one panel must not wipe the other's work. Omitting
+ // the index clears everything, which is what a dataset-level reset wants.
+ clearAnnotations: (panelIndex) =>
+ set((s) => (panelIndex === undefined
+ ? { activeRegion: [], activeRegionPanel: null, regions: [], measurements: [] }
+ : {
+ activeRegion: s.activeRegionPanel === panelIndex ? [] : s.activeRegion,
+ activeRegionPanel: s.activeRegionPanel === panelIndex ? null : s.activeRegionPanel,
+ regions: s.regions.filter((r) => (r.panelIndex ?? 0) !== panelIndex),
+ measurements: s.measurements.filter((m) => (m.panelIndex ?? 0) !== panelIndex),
+ })),
// ── Rendering / loading state ─────────────────────────────────────────────
// loadingKeys: Set of string keys currently in flight (one entry per panel).
@@ -368,17 +633,18 @@ export const useStore = create((set, get) => ({
// ── Transcript species filter ──────────────────────────────────────────────
// selectedGenes: null = no filter (show all); Set = allowlist (show only these).
// The selection is dataset-scoped and persists across pan/zoom.
- selectedGenes: null,
- setSelectedGenes: (genes) => set({ selectedGenes: genes }),
- toggleSelectedGene: (gene) =>
- set((s) => {
+ setSelectedGenes: (genes) => get().patchSettings({ selectedGenes: genes }),
+ toggleSelectedGene: (gene) => {
+ const s = get();
+ {
// The universe of genes is the union across the visible panels — the same
// list the picker renders.
const all = [...new Set(
s.panels.slice(0, s.panelCount).flatMap((p) => p.allGenes ?? [])
)];
+ const selectedGenes = s.getSetting("selectedGenes");
- if (s.selectedGenes === null) {
+ if (selectedGenes === null) {
// "Show all" renders EVERY checkbox ticked, so a click here means
// "uncheck this one" — exclude it and keep the rest.
//
@@ -387,18 +653,21 @@ export const useStore = create((set, get) => ({
// that silently narrowed the transcript layer from 200,000 dots to ~360,
// which reads as "transcripts are broken" rather than "you filtered to
// one gene". The checkbox said checked; the click has to mean uncheck.
- if (all.length === 0) return {}; // list not loaded yet — ignore
- return { selectedGenes: new Set(all.filter((g) => g !== gene)) };
+ if (all.length === 0) return; // list not loaded yet — ignore
+ return s.patchSettings({ selectedGenes: new Set(all.filter((g) => g !== gene)) });
}
- const next = new Set(s.selectedGenes);
+ const next = new Set(selectedGenes);
if (next.has(gene)) next.delete(gene); else next.add(gene);
// Back to everything selected is the same as no filter. Collapsing keeps
// the semantics single-valued and keeps hundreds of gene names out of the
// request URL.
- if (all.length > 0 && next.size === all.length) return { selectedGenes: null };
- return { selectedGenes: next };
- }),
+ if (all.length > 0 && next.size === all.length) {
+ return s.patchSettings({ selectedGenes: null });
+ }
+ return s.patchSettings({ selectedGenes: next });
+ }
+ },
}));
// Dev-only handle for debugging from the browser console.
diff --git a/frontend/src/store.settings.test.js b/frontend/src/store.settings.test.js
new file mode 100644
index 0000000..3eaa63e
--- /dev/null
+++ b/frontend/src/store.settings.test.js
@@ -0,0 +1,469 @@
+/**
+ * Display settings live in `panels[i].settings` (Phase 2a).
+ *
+ * The contract this stage promises is *behaviour-preserving*: settings moved
+ * out of the top level of the store, but every write still lands on every
+ * panel, so one sidebar drives both exactly as before. These tests pin that
+ * down, and pin down the two things easiest to get wrong while moving them.
+ *
+ * When Phase 2b adds `linkSettings`, the "writes reach every panel" tests here
+ * become the *linked* case and gain unlinked counterparts. Deleting them
+ * instead would be the tell that 2b broke the default.
+ *
+ * See docs/split_screen_phase2.md.
+ */
+import { describe, it, expect, beforeEach } from "vitest";
+import { useStore, makePanel } from "./store";
+
+const S = () => useStore.getState();
+const settings = (i) => S().panels[i].settings;
+
+// Built through the store's own factory so these tests cannot drift from the
+// real defaults. activePanel and linkSettings are reset too — they are what the
+// write-targeting rule reads, so leaving them set by a previous test silently
+// redirects the next one's writes to the wrong panel.
+beforeEach(() => {
+ useStore.setState({
+ panels: [makePanel(), makePanel()],
+ panelCount: 2,
+ activePanel: 0,
+ linkSettings: true,
+ });
+});
+
+describe("settings live on the panel, not the store root", () => {
+ it("is not readable at the top level any more", () => {
+ // Guards the migration: a stale `s.edgeWidth` read elsewhere would silently
+ // be undefined rather than throwing, so assert the root really is clear.
+ for (const key of ["layers", "edgeWidth", "colorBy", "hiddenLrms", "selectedGenes",
+ "cellFilter", "edgeFilter", "cellColorClamp", "edgeDensity"]) {
+ expect(S()[key], `store root should not carry "${key}"`).toBeUndefined();
+ }
+ });
+
+ it("gives every panel its own settings object", () => {
+ // makeSettings() is a factory, not a shared constant: a panel created now
+ // must not alias the containers of one created earlier, or editing the
+ // layer map in one panel would edit it in both.
+ expect(settings(0)).not.toBe(settings(1));
+ expect(settings(0).layers).not.toBe(settings(1).layers);
+ expect(settings(0).hiddenLrms).not.toBe(settings(1).hiddenLrms);
+ });
+});
+
+describe("2a keeps every write in lockstep across panels", () => {
+ it("propagates a scalar setter to both panels", () => {
+ S().setEdgeWidth(7);
+ expect(settings(0).edgeWidth).toBe(7);
+ expect(settings(1).edgeWidth).toBe(7);
+ });
+
+ it("propagates a nested layer edit to both panels", () => {
+ S().setLayerProp("transcripts", "visible", true);
+ expect(settings(0).layers.transcripts.visible).toBe(true);
+ expect(settings(1).layers.transcripts.visible).toBe(true);
+ // and leaves its siblings alone
+ expect(settings(0).layers.cellSegments.visible).toBe(true);
+ expect(settings(0).layers.transcripts.opacity).toBe(0.8);
+ });
+
+ it("propagates a Set-valued setter", () => {
+ S().toggleLrm("A|B");
+ expect([...settings(0).hiddenLrms]).toEqual(["A|B"]);
+ expect([...settings(1).hiddenLrms]).toEqual(["A|B"]);
+ });
+
+ it("a write to one panel cannot leak into the other", () => {
+ // Panels may share a reference to a settings *value* — every setter builds a
+ // new container rather than mutating, so sharing an immutable value is
+ // correct and cheaper than cloning per panel. What must hold is that a
+ // single-panel write leaves the other panel alone; that is the invariant
+ // 2b's unlinked mode depends on, so it is pinned here rather than there.
+ S().toggleLrm("A|B"); // both panels now hold it
+ S().patchSettings({ hiddenLrms: new Set(["X|Y"]) }, 1);
+ expect([...settings(0).hiddenLrms]).toEqual(["A|B"]);
+ expect([...settings(1).hiddenLrms]).toEqual(["X|Y"]);
+ });
+
+ it("propagates a keyed override map", () => {
+ S().setCategoricalOverride("cell", "cluster", true);
+ expect(settings(0).categoricalOverrides).toEqual({ "cell::cluster": true });
+ expect(settings(1).categoricalOverrides).toEqual({ "cell::cluster": true });
+ });
+});
+
+describe("patchSettings can target one panel", () => {
+ it("writes only where told", () => {
+ // Not reachable through the UI in 2a, but it is the mechanism 2b's link
+ // toggle switches on, so it is worth having pinned before then.
+ S().patchSettings({ edgeWidth: 9 }, 1);
+ expect(settings(0).edgeWidth).toBe(2);
+ expect(settings(1).edgeWidth).toBe(9);
+ });
+});
+
+describe("a dataset change resets only the name-bound settings", () => {
+ beforeEach(() => {
+ S().setEdgeWidth(9);
+ S().setCellColorPalette("plasma");
+ S().setLayerProp("transcripts", "visible", true);
+ S().toggleLrm("A|B");
+ S().setColorBy("metadata", "seurat_clusters");
+ S().setCellFilter({ field: "cluster", values: ["4"] });
+ S().setSelectedGenes(new Set(["Gapdh"]));
+ });
+
+ it("clears filters, colour-by and mechanism selection", () => {
+ S().setPanelDataset(0, "other-dataset");
+ expect(settings(0).cellFilter).toBeNull();
+ expect(settings(0).selectedGenes).toBeNull();
+ expect(settings(0).colorBy).toEqual({ mode: "off", field: null });
+ expect([...settings(0).hiddenLrms]).toEqual([]);
+ });
+
+ it("keeps geometry, palette and layer visibility", () => {
+ // These were never reset by a dataset change. Rebuilding the panel from
+ // makePanel() would have quietly wiped them — the one real regression risk
+ // in moving settings onto the panel object.
+ S().setPanelDataset(0, "other-dataset");
+ expect(settings(0).edgeWidth).toBe(9);
+ expect(settings(0).cellColorPalette).toBe("plasma");
+ expect(settings(0).layers.transcripts.visible).toBe(true);
+ });
+
+ it("resets the other panel too while the panels are linked", () => {
+ // Linked panels share one set of values, so leaving the other alone would
+ // strand a filter naming a column the new dataset lacks.
+ expect(S().linkSettings).toBe(true);
+ S().setPanelDataset(0, "other-dataset");
+ expect(settings(1).cellFilter).toBeNull();
+ expect(settings(1).colorBy).toEqual({ mode: "off", field: null });
+ });
+
+ it("leaves the other panel alone once unlinked (2d)", () => {
+ // The toggle says "editing panel 1 only"; an action on panel 2 must not
+ // destroy panel 1's work. This was the cost recorded in CLAUDE.md, and the
+ // link toggle is what makes it fixable.
+ S().setLinkSettings(false);
+ S().setActivePanel(0);
+ S().setCellFilter({ field: "cluster", values: ["4"] });
+ S().setColorBy("metadata", "cluster");
+
+ S().setPanelDataset(1, "other-dataset");
+ expect(settings(0).cellFilter).toEqual({ field: "cluster", values: ["4"] });
+ expect(settings(0).colorBy).toEqual({ mode: "metadata", field: "cluster" });
+ // ...while the panel that actually changed is still cleared.
+ expect(settings(1).cellFilter).toBeNull();
+ expect(settings(1).colorBy).toEqual({ mode: "off", field: null });
+ });
+});
+
+describe("gene toggle reads and writes through settings", () => {
+ beforeEach(() => {
+ useStore.setState({
+ panels: S().panels.map((p) => ({ ...p, allGenes: ["A", "B", "C"] })),
+ });
+ });
+
+ it("first click on the all-selected state excludes that gene", () => {
+ expect(settings(0).selectedGenes).toBeNull();
+ S().toggleSelectedGene("B");
+ expect([...settings(0).selectedGenes].sort()).toEqual(["A", "C"]);
+ expect([...settings(1).selectedGenes].sort()).toEqual(["A", "C"]);
+ });
+
+ it("collapses back to no filter when everything is re-selected", () => {
+ S().toggleSelectedGene("B");
+ S().toggleSelectedGene("B");
+ expect(settings(0).selectedGenes).toBeNull();
+ });
+});
+
+describe("2b — the link toggle decides where a write lands", () => {
+ it("linked (the default) still writes to every panel", () => {
+ expect(S().linkSettings).toBe(true);
+ S().setEdgeWidth(7);
+ expect(settings(0).edgeWidth).toBe(7);
+ expect(settings(1).edgeWidth).toBe(7);
+ });
+
+ it("unlinked writes only to the active panel", () => {
+ S().setLinkSettings(false);
+ S().setActivePanel(1);
+ S().setEdgeWidth(7);
+ expect(settings(0).edgeWidth).toBe(2);
+ expect(settings(1).edgeWidth).toBe(7);
+ });
+
+ it("unlinked, a nested layer edit stays in its panel", () => {
+ S().setLinkSettings(false);
+ S().setActivePanel(1);
+ S().setLayerProp("transcripts", "visible", true);
+ expect(settings(0).layers.transcripts.visible).toBe(false);
+ expect(settings(1).layers.transcripts.visible).toBe(true);
+ });
+
+ it("unlinked, a read-modify-write setter reads the panel it writes", () => {
+ // toggleLrm builds the next Set from the current one. Reading panel 0 while
+ // writing panel 1 would drop whatever panel 1 already had hidden.
+ S().setLinkSettings(false);
+ S().setActivePanel(1);
+ S().toggleLrm("A|B");
+ S().toggleLrm("C|D");
+ expect([...settings(1).hiddenLrms].sort()).toEqual(["A|B", "C|D"]);
+ expect([...settings(0).hiddenLrms]).toEqual([]);
+ });
+
+ it("switching tabs does not itself change anything", () => {
+ S().setLinkSettings(false);
+ S().setEdgeWidth(5); // panel 0
+ S().setActivePanel(1);
+ expect(settings(0).edgeWidth).toBe(5);
+ expect(settings(1).edgeWidth).toBe(2);
+ });
+});
+
+describe("2b — re-linking adopts the active panel's settings", () => {
+ beforeEach(() => {
+ S().setLinkSettings(false);
+ S().setActivePanel(0);
+ S().setEdgeWidth(3);
+ S().setActivePanel(1);
+ S().setEdgeWidth(9);
+ S().setCellColorPalette("plasma");
+ });
+
+ it("copies the tab you are on onto the others", () => {
+ S().setActivePanel(1);
+ S().setLinkSettings(true);
+ expect(settings(0).edgeWidth).toBe(9);
+ expect(settings(1).edgeWidth).toBe(9);
+ expect(settings(0).cellColorPalette).toBe("plasma");
+ });
+
+ it("the other tab wins if that is the one you are on", () => {
+ S().setActivePanel(0);
+ S().setLinkSettings(true);
+ expect(settings(0).edgeWidth).toBe(3);
+ expect(settings(1).edgeWidth).toBe(3);
+ });
+
+ it("does not leave the panels sharing containers", () => {
+ // A shallow copy on re-link would alias the layer maps and Sets, so the
+ // next unlink-and-edit would write through to both panels.
+ S().setActivePanel(1);
+ S().setLinkSettings(true);
+ expect(settings(0).layers).not.toBe(settings(1).layers);
+ expect(settings(0).hiddenLrms).not.toBe(settings(1).hiddenLrms);
+
+ S().setLinkSettings(false);
+ S().setActivePanel(1);
+ S().setLayerProp("edges", "visible", true);
+ expect(settings(0).layers.edges.visible).toBe(false);
+ });
+
+ it("unlinking on its own changes no values", () => {
+ S().setActivePanel(1);
+ S().setLinkSettings(true);
+ const before = settings(0).edgeWidth;
+ S().setLinkSettings(false);
+ expect(settings(0).edgeWidth).toBe(before);
+ expect(settings(1).edgeWidth).toBe(before);
+ });
+});
+
+describe("2c — pushSettings copies one panel onto the other", () => {
+ beforeEach(() => {
+ S().setLinkSettings(false);
+ S().setActivePanel(0);
+ S().setEdgeWidth(8);
+ S().setCellColorPalette("magma");
+ S().setLayerProp("edges", "visible", true);
+ });
+
+ it("copies dataset-independent settings verbatim", () => {
+ S().pushSettings(0, 1);
+ expect(settings(1).edgeWidth).toBe(8);
+ expect(settings(1).cellColorPalette).toBe("magma");
+ expect(settings(1).layers.edges.visible).toBe(true);
+ });
+
+ it("leaves the source untouched and the panels independent", () => {
+ S().pushSettings(0, 1);
+ S().setActivePanel(1);
+ S().setEdgeWidth(1);
+ expect(settings(0).edgeWidth).toBe(8);
+ // A shallow copy would alias the containers and write through.
+ S().setLayerProp("edges", "visible", false);
+ expect(settings(0).layers.edges.visible).toBe(true);
+ });
+
+ it("is a no-op onto itself", () => {
+ S().pushSettings(0, 0);
+ expect(settings(0).edgeWidth).toBe(8);
+ });
+});
+
+describe("2c — the guard drops what the target cannot honour", () => {
+ const allowed = {
+ cellFields: new Set(["cluster"]),
+ edgeFields: new Set(["confidence"]),
+ genes: new Set(["A", "B"]),
+ lrms: new Set(["L1|R1"]),
+ };
+
+ beforeEach(() => {
+ S().setLinkSettings(false);
+ S().setActivePanel(0);
+ // Give panel 1 a different dataset so the clamp rule engages too.
+ useStore.setState({
+ panels: S().panels.map((p, i) => ({ ...p, dataset: i === 0 ? "src" : "dst" })),
+ });
+ });
+
+ it("drops a cell filter naming a missing column", () => {
+ // The dangerous one: the backend 400s on every viewport change, so the
+ // panel renders nothing and the UI gives no clue why.
+ S().setCellFilter({ field: "absent_column", values: ["x"] });
+ S().pushSettings(0, 1, allowed);
+ expect(settings(1).cellFilter).toBeNull();
+ });
+
+ it("keeps a cell filter the target does have", () => {
+ S().setCellFilter({ field: "cluster", values: ["4"] });
+ S().pushSettings(0, 1, allowed);
+ expect(settings(1).cellFilter).toEqual({ field: "cluster", values: ["4"] });
+ });
+
+ it("turns colour-by off when it names a missing column", () => {
+ S().setColorBy("metadata", "absent_column");
+ S().pushSettings(0, 1, allowed);
+ expect(settings(1).colorBy).toEqual({ mode: "off", field: null });
+ });
+
+ it("leaves a non-metadata colour-by alone", () => {
+ S().setColorBy("gene_set", null);
+ S().pushSettings(0, 1, allowed);
+ expect(settings(1).colorBy).toEqual({ mode: "gene_set", field: null });
+ });
+
+ it("drops edge filters and edge colour-by naming missing columns", () => {
+ S().setEdgeFilters([{ field: "confidence", values: ["high"] },
+ { field: "nope", values: ["1"] }]);
+ S().setEdgeColorBy("metadata", "nope");
+ S().pushSettings(0, 1, allowed);
+ // Every filter in the list is checked, not just the first.
+ expect(settings(1).edgeFilters).toEqual([{ field: "confidence", values: ["high"] }]);
+ expect(settings(1).edgeColorBy).toEqual({ mode: "lrm_set", field: null });
+ });
+
+ it("validates endpoint filters against CELL columns, not edge columns", () => {
+ // sendingFilter/receivingFilter name cell metadata even though they filter
+ // edges. Checking them against edgeFields would drop every one of them
+ // whenever the target's edge table happens to have different columns.
+ S().setSendingFilter({ field: "cluster", values: ["4"] });
+ S().setReceivingFilter({ field: "absent_column", values: ["x"] });
+ S().pushSettings(0, 1, allowed);
+ expect(settings(1).sendingFilter).toEqual({ field: "cluster", values: ["4"] });
+ expect(settings(1).receivingFilter).toBeNull();
+ });
+
+ it("intersects the gene allowlist", () => {
+ S().setSelectedGenes(new Set(["A", "ZZZ"]));
+ S().pushSettings(0, 1, allowed);
+ expect([...settings(1).selectedGenes]).toEqual(["A"]);
+ });
+
+ it("falls back to no filter when no selected gene exists in the target", () => {
+ // An empty Set would mean "show no species", a stranger thing to inherit
+ // than "no filter" — which is also what a dataset change leaves behind.
+ S().setSelectedGenes(new Set(["ZZZ"]));
+ S().pushSettings(0, 1, allowed);
+ expect(settings(1).selectedGenes).toBeNull();
+ });
+
+ it("intersects hidden mechanisms", () => {
+ S().toggleLrm("L1|R1");
+ S().toggleLrm("L9|R9");
+ S().pushSettings(0, 1, allowed);
+ expect([...settings(1).hiddenLrms]).toEqual(["L1|R1"]);
+ });
+
+ it("drops keyed overrides whose column or gene is absent", () => {
+ S().setCategoricalOverride("cell", "cluster", true);
+ S().setCategoricalOverride("cell", "absent_column", true);
+ S().setCategoryColorOverride("cluster", "4", [1, 2, 3, 255]);
+ S().setCategoryColorOverride("absent_column", "x", [1, 2, 3, 255]);
+ S().setTranscriptColorOverride("A", [1, 2, 3, 255]);
+ S().setTranscriptColorOverride("ZZZ", [1, 2, 3, 255]);
+ S().pushSettings(0, 1, allowed);
+ expect(Object.keys(settings(1).categoricalOverrides)).toEqual(["cell::cluster"]);
+ expect(Object.keys(settings(1).categoryColorOverrides)).toEqual(["cluster::4"]);
+ expect(Object.keys(settings(1).transcriptColorOverrides)).toEqual(["A"]);
+ });
+
+ it("resets colour clamps across different datasets, keeps them within one", () => {
+ // A clamp is a range in the source data's units. [0,4000] onto a dataset
+ // topping out at 70 paints everything the bottom colour, which reads as a
+ // broken render rather than a copied setting.
+ S().setCellColorClamp(0, 4000);
+ S().pushSettings(0, 1, allowed);
+ expect(settings(1).cellColorClamp).toEqual({ low: null, high: null });
+
+ useStore.setState({ panels: S().panels.map((p) => ({ ...p, dataset: "same" })) });
+ S().setActivePanel(0);
+ S().setCellColorClamp(0, 4000);
+ S().pushSettings(0, 1, allowed);
+ expect(settings(1).cellColorClamp).toEqual({ low: 0, high: 4000 });
+ });
+});
+
+describe("#59 — edge filtering is independent of cell filtering", () => {
+ it("cellFilter and the endpoint filters are separate values", () => {
+ S().setCellFilter({ field: "region", values: ["crypt"] });
+ expect(settings(0).sendingFilter).toBeNull();
+ expect(settings(0).receivingFilter).toBeNull();
+
+ S().setSendingFilter({ field: "region", values: ["villus"] });
+ // Setting one must not disturb the other, in either direction.
+ expect(settings(0).cellFilter).toEqual({ field: "region", values: ["crypt"] });
+ });
+
+ it("edgeFilters is a list, and-ed", () => {
+ S().setEdgeFilters([{ field: "a", values: ["1"] }, { field: "b", values: ["2"] }]);
+ expect(settings(0).edgeFilters).toHaveLength(2);
+ S().setEdgeFilterAt(0, null); // remove the first
+ expect(settings(0).edgeFilters).toEqual([{ field: "b", values: ["2"] }]);
+ });
+
+ it("a dataset change clears every filter that names a column", () => {
+ S().setCellFilter({ field: "region", values: ["crypt"] });
+ S().setSendingFilter({ field: "cluster", values: ["4"] });
+ S().setReceivingFilter({ field: "region", values: ["mid"] });
+ S().setEdgeFilters([{ field: "confidence", values: ["high"] }]);
+
+ S().setPanelDataset(0, "other-dataset");
+ expect(settings(0).cellFilter).toBeNull();
+ expect(settings(0).sendingFilter).toBeNull();
+ expect(settings(0).receivingFilter).toBeNull();
+ expect(settings(0).edgeFilters).toEqual([]);
+ // ...but not the density, which names nothing.
+ expect(settings(0).edgeDensity).toBe(0.1);
+ });
+
+ it("an edge-file change clears edge-table filters but not endpoint filters", () => {
+ // Edge-table column names are specific to one file; cell metadata is not.
+ S().setSendingFilter({ field: "cluster", values: ["4"] });
+ S().setEdgeFilters([{ field: "confidence", values: ["high"] }]);
+ S().setPanelEdgeFile(0, "edges/other.parquet");
+ expect(settings(0).edgeFilters).toEqual([]);
+ expect(settings(0).sendingFilter).toEqual({ field: "cluster", values: ["4"] });
+ });
+
+ it("cloneSettings does not alias the filter list across panels", () => {
+ S().setEdgeFilters([{ field: "a", values: ["1"] }]);
+ S().setActivePanel(0);
+ S().setLinkSettings(true);
+ expect(settings(0).edgeFilters).not.toBe(settings(1).edgeFilters);
+ });
+});
|