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 ` 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. +
+
{label}
+ +
+ ); + + 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 ( + + ); + }; + + return ( +
+
+ {tab(0)} + {tab(1)} +
+ + {/* 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 ( + + ); +} + function LinkColorScaleRow() { - const { linkColorScale, setLinkColorScale } = useStore(); + const { linkColorScale, setLinkColorScale } = usePanelSettings(); return (