edges.parquet now mirrors NICHESv2's $edge.data + $edge.meta:
| column | type | notes |
|---|---|---|
edge |
string | "SendingCell|ReceivingCell" — NICHESv2 edge ID |
sending_cell |
string | barcode |
receiving_cell |
string | barcode (== sending_cell for autocrine) |
is_autocrine |
bool | SendingCell == ReceivingCell |
lrm |
string | "ligand|receptor" — NICHESv2 LRM ID |
lrm_id |
int | legacy integer, kept for backward compat |
ligand |
string | |
receptor |
string | |
score |
float | raw LR product score |
score_norm |
float | score / sum(score within edge) |
x1, y1 |
float | sending cell centroid (µm) |
x2, y2 |
float | receiving cell centroid (µm), == x1,y1 for autocrine |
sending_type |
string | simulated cell type |
receiving_type |
string | simulated cell type |
Demo counts (mouse_ileum_tiny): 669 rows, 216 directed edges + 7 autocrine self-loops.
A1. Update lrm_catalogue()
Return string lrm field alongside existing lrm_id, ligand, receptor.
[{ lrm_id: 1, lrm: "Tgfb1|Tgfbr1", ligand: "Tgfb1", receptor: "Tgfbr1" }, ...]
A2. New POST /{dataset}/edge-color-values (analogous to /xenium/{dataset}/color-values)
Request body:
{ "mode": "lrm_set", "lrms": ["Tgfb1|Tgfbr1", "Il6|Il6ra"] }
{ "mode": "metadata", "field": "sending_type" }Response (lrm_set, continuous):
{ "type": "continuous", "values": {"edge_id": score, ...}, "min": 0, "max": 6.2 }Response (metadata, categorical):
{ "type": "categorical", "values": {"edge_id": "Epithelial", ...}, "categories": [...] }Implementation:
- lrm_set: group by
edge, filter rows wherelrmin requested list, sumscoreper edge → return edge→sum map + global min/max - metadata: group by
edge, take first value offieldper edge (all rows in a directed edge share the same metadata), auto-detect categorical vs continuous by same logic as cell color
A3. New GET /{dataset}/edge/{edge_id} — edge click info panel
Returns all rows for a specific edge value (all LRMs for that directed pair):
{
"edge": "cellA|cellB",
"sending_cell": "cellA", "receiving_cell": "cellB",
"sending_type": "Epithelial", "receiving_type": "Immune",
"is_autocrine": false,
"lrms": [
{ "lrm": "Tgfb1|Tgfbr1", "lrm_id": 1, "score": 3.18, "score_norm": 0.33 },
...
]
}Add to existing store:
// Directional/autocrine rendering
showAutocrine: true,
setShowAutocrine: (v) => set({ showAutocrine: v }),
edgeDirectional: true,
setEdgeDirectional: (v) => set({ edgeDirectional: v }),
// Edge color (parallel to cell color)
edgeColorPalette: "viridis",
setEdgeColorPalette: (p) => set({ edgeColorPalette: p }),
// Edge click info
selectedEdge: null,
setSelectedEdge: (edge) => set({ selectedEdge: edge }),Migrate hiddenLrms from Set<int> → Set<string> (string lrm IDs like "Tgfb1|Tgfbr1").
Update lrmCatalogue type accordingly (store lrm string alongside integer ID).
Change edgeColorBy.mode from "default"|"lrm_expression"|"metadata" → "default"|"lrm_set"|"metadata".
Mirrors useCellColors. Called in Viewer.jsx.
useEdgeColors(apiBase, dataset, edgeColorBy, hiddenLrms, lrmCatalogue, palette, enabled)
// Returns: { colorValues: Map<edge_id, [r,g,b,a]>, type, vmin, vmax, categories, categoryColors, loading }enabled=edgeColorBy.mode !== "default"- lrm_set mode: POST selected LRMs (all in
lrmCatalogueminushiddenLrms) → map edge→color - metadata mode: POST field → map edge→color
The EdgeLayer currently renders one line per row (one per edge×LRM). The new rendering pipeline:
Step 1 — client-side aggregation (useMemo in Viewer)
Group fetched edge rows by edge string:
const edgeFeatures = useMemo(() => {
// For each unique directed edge, compute:
// sourcePos, targetPos (in image pixel coords)
// totalScore = sum of score for visible LRMs
// is_autocrine, sending_cell, receiving_cell
// Filter out hidden LRMs but keep edge if any LRM is visible
...
}, [edgeData, hiddenLrms, edgeMinStrength]);Step 2 — perpendicular offset (when edgeDirectional = true)
For each directed edge A→B, compute a small perpendicular offset (+N px left-normal of the direction vector). This separates A→B visually from B→A since both exist in the data.
Offset magnitude: ~4 screen pixels, applied in the getSourcePosition/getTargetPosition accessors.
Step 3 — LineLayer for directed edges
new LineLayer({
data: edgeFeatures.filter(e => !e.is_autocrine),
getSourcePosition: e => e.sourcePos, // with perpendicular offset applied
getTargetPosition: e => e.targetPos,
getColor: e => colorValues?.get(e.edge) ?? DEFAULT_EDGE_COLOR,
getWidth: 1.5,
pickable: true,
onClick: ({ object }) => setSelectedEdge(object.edge),
})Step 4 — ScatterplotLayer for autocrine edges (when showAutocrine = true)
new ScatterplotLayer({
data: edgeFeatures.filter(e => e.is_autocrine),
getPosition: e => e.sourcePos,
getRadius: 12, // ring around the cell
filled: false,
stroked: true,
getLineColor: e => colorValues?.get(e.edge) ?? DEFAULT_EDGE_COLOR,
getLineWidth: 1.5,
pickable: true,
onClick: ({ object }) => setSelectedEdge(object.edge),
})Step 5 — direction arrow indicators (when edgeDirectional = true)
A separate ScatterplotLayer places small filled triangle markers at the midpoint of each directed edge, pointing toward the receiving cell. Rendered as small filled dots (5px radius) as a simple direction hint; a TextLayer rendering "▶" at midpoint is an alternative.
Current EdgeSection has: opacity slider, strength slider, LRM checklist with all/none, color-by picker.
New EdgeSection layout:
[✓] Edges [opacity slider]
[✓] Directional [✓] Show autocrine
── Edge Color ─────────────────
○ Default (gray)
○ LRM Set <N of M LRMs selected>
○ Metadata [column dropdown]
[palette picker] ← only for continuous
[continuous legend or categorical legend]
── LRM Filter ─────────────────
[All] [None]
[✓] Tgfb1|Tgfbr1
[✓] Il6|Il6ra
...
── Edge Filter ────────────────
Min strength: [slider]
ContinuousLegend and CategoricalLegend reuse the same components as the cell color section.
The LRM checklist switches from integer lrm_id to string lrm IDs. Each row shows ligand → receptor (formatted from the lrm string).
A floating panel anchored to the bottom-right of the viewport (or as a side panel). Appears when selectedEdge != null.
┌─ Edge: cellA → cellB ─────────────── [×] ┐
│ Sending: cellA (Epithelial) │
│ Receiving: cellB (Immune) │
│ │
│ LRM Scores │
│ ───────────────────────────────────── │
│ Tgfb1|Tgfbr1 3.18 (33.4%) │
│ Il6|Il6ra 3.11 (32.6%) │
│ Efnb1|EphB2 3.25 (34.1%) │
│ │
│ [Autocrine] ← badge if is_autocrine │
└───────────────────────────────────────────┘
Fetches from GET /edges/{dataset}/edge/{edge_id} on selectedEdge change.
Dismiss: click [×] or click elsewhere on the map.
- Backend A1–A3 — update lrm_catalogue, add edge-color-values endpoint, add edge detail endpoint
- Store B — add showAutocrine, edgeDirectional, selectedEdge, migrate hiddenLrms to string IDs
- useEdgeColors hook C — new hook parallel to useCellColors
- Viewer edge rendering D — aggregation, directional offset, LineLayer + autocrine ScatterplotLayer
- LayerPanel EdgeSection E — redesigned with new controls
- EdgeInfoPanel F — new click info component
Total estimated complexity: ~same as the cell color redesign. Each step is self-contained and testable.
- The edge bbox query (
GET /edges/{dataset}/query) — still fetches all rows within viewport, still returns long-format data. The frontend does client-side aggregation. - Existing
edgeMinStrengthfilter — still applied during aggregation step. - Layer visibility / opacity controls — unchanged.
- The broader annotation/region/measurement system — untouched.