diff --git a/src/__test__/components/data-exploration/CellInfo.test.jsx b/src/__test__/components/data-exploration/CellInfo.test.jsx index 4f12502a9f..628d58a5b5 100644 --- a/src/__test__/components/data-exploration/CellInfo.test.jsx +++ b/src/__test__/components/data-exploration/CellInfo.test.jsx @@ -9,22 +9,45 @@ const cellInfo = { cellSets: ['Louvain : cluster1', 'anotherRootCluster : cluster2'], }; -const coordinates = { - current: { - x: 100, - y: 200, - width: 500, - height: 500, - }, -}; +const coordinates = { x: 100, y: 200 }; + +// each tooltip line renders as " value", so the label and value are +// separate text nodes — match them individually +const lineWithLabelAndValue = (label, value) => ( + (content, node) => node?.textContent === `${label} ${value}` +); describe('CellInfo', () => { it('renders cell info card with properties', () => { - render(); - expect(screen.getByText(`Gene name: ${cellInfo.geneName}`)).toBeInTheDocument(); - expect(screen.getByText(`Cell id: ${cellInfo.cellId}`)).toBeInTheDocument(); - expect(screen.getByText(`Expression Level: ${cellInfo.expression}`)).toBeInTheDocument(); - expect(screen.getByText(cellInfo.cellSets[0])).toBeInTheDocument(); - expect(screen.getByText(cellInfo.cellSets[1])).toBeInTheDocument(); + render( + , + ); + expect(screen.getByText(lineWithLabelAndValue('Cell id:', cellInfo.cellId))).toBeInTheDocument(); + expect(screen.getByText(lineWithLabelAndValue('Gene name:', cellInfo.geneName))).toBeInTheDocument(); + expect( + screen.getByText(lineWithLabelAndValue('Expression Level:', cellInfo.expression)), + ).toBeInTheDocument(); + // cell-set entries split into a bold "Parent :" label + value + expect(screen.getByText('cluster1')).toBeInTheDocument(); + expect(screen.getByText('cluster2')).toBeInTheDocument(); + }); + + it('renders category labels in bold', () => { + render( + , + ); + const label = screen.getByText('Cell id:'); + expect(label.tagName).toBe('SPAN'); + expect(label).toHaveStyle({ fontWeight: 600 }); }); }); diff --git a/src/__test__/components/data-exploration/embedding/SpatialViewer.test.jsx b/src/__test__/components/data-exploration/embedding/SpatialViewer.test.jsx index 71718005d7..14023bd55b 100644 --- a/src/__test__/components/data-exploration/embedding/SpatialViewer.test.jsx +++ b/src/__test__/components/data-exploration/embedding/SpatialViewer.test.jsx @@ -165,10 +165,15 @@ describe('SpatialViewer', () => { ); // a zip store + zarr root per returned url, for both the image and segmentation grids + // (roots are memoised by url, so regrouping rebuilds the grid without re-opening stores) expect(zarrRoot).toHaveBeenCalledTimes(4); - // loads a grid for the image and a grid for the segmentations - expect(loadOmeZarrGrid).toHaveBeenCalledTimes(2); + // loads a grid for the image (emptyFill undefined) and one for the segmentations + // (emptyFill 0). The grid may be rebuilt when the sample grouping resolves, so + // assert both grids load rather than an exact call count. + const emptyFillArgs = loadOmeZarrGrid.mock.calls.map((call) => call[2]); + expect(emptyFillArgs).toContain(undefined); // image grid + expect(emptyFillArgs).toContain(0); // segmentation grid }); it('renders correctly with initial data', () => { diff --git a/src/__test__/components/data-exploration/heatmap/HeatmapPlot.test.jsx b/src/__test__/components/data-exploration/heatmap/HeatmapPlot.test.jsx index f6d9c62816..7ede0f9b61 100644 --- a/src/__test__/components/data-exploration/heatmap/HeatmapPlot.test.jsx +++ b/src/__test__/components/data-exploration/heatmap/HeatmapPlot.test.jsx @@ -397,9 +397,10 @@ describe('HeatmapPlot', () => { await vitesscePropsSpy.setGeneHighlight(highlightedGene); }); - // It shows the cell info tooltip - expect(screen.getByText(/Cell id: 2/i)).toBeInTheDocument(); - expect(screen.getByText(/Gene name: S100a4/i)).toBeInTheDocument(); + // It shows the cell info tooltip (label + value are separate nodes now, so + // match the whole line's text content) + expect(screen.getByText((c, node) => node?.textContent === 'Cell id: 2')).toBeInTheDocument(); + expect(screen.getByText((c, node) => node?.textContent === 'Gene name: S100a4')).toBeInTheDocument(); // On hovering outside await act(async () => { @@ -417,8 +418,8 @@ describe('HeatmapPlot', () => { }); // It shows the track cell info tooltip - expect(screen.getByText(/Cell id: 4/i)).toBeInTheDocument(); - expect(screen.getByText(/Group name: Cluster 0/i)).toBeInTheDocument(); + expect(screen.getByText((c, node) => node?.textContent === 'Cell id: 4')).toBeInTheDocument(); + expect(screen.getByText((c, node) => node?.textContent === 'Group name: Cluster 0')).toBeInTheDocument(); // On hovering outside heatmap tracks await act(async () => { diff --git a/src/__test__/redux/reducers/__snapshots__/cellInfoReducer.test.js.snap b/src/__test__/redux/reducers/__snapshots__/cellInfoReducer.test.js.snap index b216fcdc71..59a312d317 100644 --- a/src/__test__/redux/reducers/__snapshots__/cellInfoReducer.test.js.snap +++ b/src/__test__/redux/reducers/__snapshots__/cellInfoReducer.test.js.snap @@ -34,6 +34,7 @@ exports[`cellInfoReducer Updates cell info state on update action 1`] = ` "store": "cellSets", }, "groupedTrack": "louvain", + "hoverSource": undefined, "selectedTracks": [ "louvain", ], diff --git a/src/__test__/utils/data-exploration/getCellInfoCoordinates.test.js b/src/__test__/utils/data-exploration/getCellInfoCoordinates.test.js new file mode 100644 index 0000000000..70337c0fea --- /dev/null +++ b/src/__test__/utils/data-exploration/getCellInfoCoordinates.test.js @@ -0,0 +1,64 @@ +import getCellInfoCoordinates from 'utils/data-exploration/getCellInfoCoordinates'; + +const PADDING = 16; +const panel = { boundingX: 500, boundingY: 500 }; +const popup = { width: 120, height: 80 }; + +const coords = (x, y) => getCellInfoCoordinates( + { x, y }, popup, panel.boundingX, panel.boundingY, +); + +const withinPanel = ({ left, top }) => ( + left >= 0 + && top >= 0 + && left + popup.width <= panel.boundingX + && top + popup.height <= panel.boundingY +); + +describe('getCellInfoCoordinates', () => { + it('places the tooltip below-right of the cursor when there is room', () => { + const { left, top } = coords(100, 200); + expect(left).toBe(100 + PADDING); + expect(top).toBe(200 + PADDING); + }); + + it('flips to the left of the cursor when it would overflow the right edge', () => { + const { left } = coords(450, 200); // 450 + 16 + 120 > 500 + expect(left).toBe(450 - PADDING - popup.width); + }); + + it('flips above the cursor when it would overflow the bottom edge', () => { + const { top } = coords(100, 480); // 480 + 16 + 80 > 500 + expect(top).toBe(480 - PADDING - popup.height); + }); + + it('never falls outside the panel near the top-left edge', () => { + // cursor in the corner: flipping left/up alone would go negative + const result = coords(2, 2); + expect(withinPanel(result)).toBe(true); + expect(result.left).toBeGreaterThanOrEqual(0); + expect(result.top).toBeGreaterThanOrEqual(0); + }); + + it('never falls outside the panel near the bottom-right edge', () => { + const result = coords(498, 498); + expect(withinPanel(result)).toBe(true); + }); + + it('stays inside the panel when a tall tooltip flips above the cursor', () => { + // tall tooltip, cursor low in the panel — the flip used to push `top` + // negative; it must land fully inside instead + const tall = { width: 120, height: 220 }; + const { left, top } = getCellInfoCoordinates({ x: 40, y: 300 }, tall, 300, 400); + expect(top).toBeGreaterThanOrEqual(PADDING); + expect(top + tall.height).toBeLessThanOrEqual(400 - PADDING); + expect(left).toBeGreaterThanOrEqual(PADDING); + }); + + it('pins to the top-left corner when the tooltip is larger than the panel', () => { + const huge = { width: 600, height: 600 }; + const { left, top } = getCellInfoCoordinates({ x: 100, y: 100 }, huge, 400, 400); + expect(left).toBe(PADDING); + expect(top).toBe(PADDING); + }); +}); diff --git a/src/__test__/utils/plotSpecs/generateSpatialFeatureSpec.test.js b/src/__test__/utils/plotSpecs/generateSpatialFeatureSpec.test.js index c9ced8375c..498856596b 100644 --- a/src/__test__/utils/plotSpecs/generateSpatialFeatureSpec.test.js +++ b/src/__test__/utils/plotSpecs/generateSpatialFeatureSpec.test.js @@ -186,4 +186,42 @@ describe('filterCells / generateData', () => { // only even cell ids survive result.forEach((datum) => expect(datum.x % 2).toBe(0)); }); + + describe('viewport extent (padded vs original dims)', () => { + const signalValue = (spec, name) => spec.signals.find((s) => s.name === name).value; + + it('uses the full padded extent when originalSize is absent', () => { + const spec = generateSpec(baseConfig(), 'mock', imageData, []); + // imageData = { imageWidth: 1000, imageHeight: 500 } + expect(signalValue(spec, 'initXdom')).toEqual([0, 1000]); + expect(signalValue(spec, 'initYdom')).toEqual([0, 500]); + expect(signalValue(spec, 'boundsX')).toEqual([0, 1000]); + expect(signalValue(spec, 'boundsY')).toEqual([0, 500]); + }); + + it('caps the viewport to the original tissue extent when padded', () => { + // padded canvas 1000x500, tissue only 600x300 (padding right + bottom) + const padded = { + imageWidth: 1000, imageHeight: 500, origWidth: 600, origHeight: 300, + }; + const spec = generateSpec(baseConfig(), 'mock', padded, []); + + // x is not flipped: content is on the left + expect(signalValue(spec, 'initXdom')).toEqual([0, 600]); + // y IS flipped (imageHeight - y), so tissue sits at the TOP of the range + expect(signalValue(spec, 'initYdom')).toEqual([500 - 300, 500]); + // max zoom-out (bounds) matches the tissue extent — no blank padding + expect(signalValue(spec, 'boundsX')).toEqual([0, 600]); + expect(signalValue(spec, 'boundsY')).toEqual([200, 500]); + }); + + it('keeps the y-flip anchored to the padded height', () => { + const padded = { + imageWidth: 1000, imageHeight: 500, origWidth: 600, origHeight: 300, + }; + const spec = generateSpec(padded && baseConfig(), 'mock', padded, []); + const flip = JSON.stringify(spec).includes('500 - datum.y'); + expect(flip).toBe(true); + }); + }); }); diff --git a/src/__test__/utils/plotUtils.test.js b/src/__test__/utils/plotUtils.test.js index 80dc16ff98..aecac6a2e9 100644 --- a/src/__test__/utils/plotUtils.test.js +++ b/src/__test__/utils/plotUtils.test.js @@ -53,6 +53,24 @@ describe('offsetCentroids', () => { expect(offset[2]).toEqual([205, 5]); }); + it('places samples by the provided sampleRowCol (grouped rows)', () => { + const results = []; + results[0] = [10, 20]; // sample-0 + results[2] = [5, 5]; // sample-1 + + // group layout: sample-0 in row 0, sample-1 in row 1 (one per row) + const sampleRowCol = [{ row: 0, col: 0 }, { row: 1, col: 0 }]; + const groupedGrid = [2, 1]; // 2 rows, 1 column + + const offset = offsetCentroids( + results, properties, sampleIds, perImageShape, groupedGrid, sampleRowCol, + ); + + // sample-0: no offset; sample-1: shifted DOWN by one image height (row 1) + expect(offset[0]).toEqual([10, 20]); + expect(offset[2]).toEqual([5, 105]); + }); + it('leaves null/undefined (filtered) cells as holes, producing a sparse array', () => { const results = []; results[0] = [10, 20]; diff --git a/src/__test__/utils/spatial/buildSpatialGridLayout.test.js b/src/__test__/utils/spatial/buildSpatialGridLayout.test.js new file mode 100644 index 0000000000..898ff65c6a --- /dev/null +++ b/src/__test__/utils/spatial/buildSpatialGridLayout.test.js @@ -0,0 +1,79 @@ +import buildSpatialGridLayout from 'utils/spatial/buildSpatialGridLayout'; + +describe('buildSpatialGridLayout', () => { + describe('ungrouped (dense row-major, max 4 columns)', () => { + it('lays out 3 samples in a single row', () => { + const { gridShape, slotToSampleIndex, sampleRowCol } = buildSpatialGridLayout(3); + expect(gridShape).toEqual([1, 3]); + expect(slotToSampleIndex).toEqual([0, 1, 2]); + expect(sampleRowCol).toEqual([ + { row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }, + ]); + }); + + it('wraps past 4 columns and fills the tail with nulls', () => { + const { gridShape, slotToSampleIndex } = buildSpatialGridLayout(5); + expect(gridShape).toEqual([2, 4]); + // 5 samples in a 2x4 grid → 3 trailing filler cells + expect(slotToSampleIndex).toEqual([0, 1, 2, 3, 4, null, null, null]); + }); + + it('handles zero samples without crashing', () => { + const { gridShape, slotToSampleIndex } = buildSpatialGridLayout(0); + expect(gridShape).toEqual([1, 1]); + expect(slotToSampleIndex).toEqual([null]); + }); + + it('places samples in the provided order (Cell sets tile order)', () => { + // 3 samples displayed in reverse of their natural index order + const { slotToSampleIndex, sampleRowCol } = buildSpatialGridLayout(3, null, [2, 0, 1]); + expect(slotToSampleIndex).toEqual([2, 0, 1]); + expect(sampleRowCol[2]).toEqual({ row: 0, col: 0 }); + expect(sampleRowCol[0]).toEqual({ row: 0, col: 1 }); + expect(sampleRowCol[1]).toEqual({ row: 0, col: 2 }); + }); + }); + + describe('grouped (one row per group)', () => { + // 5 samples: group A = [0, 1, 2], group B = [3, 4] + const groups = [ + { groupKey: 'A', sampleIndices: [0, 1, 2] }, + { groupKey: 'B', sampleIndices: [3, 4] }, + ]; + + it('puts each group in its own row, width = largest group', () => { + const { gridShape } = buildSpatialGridLayout(5, groups); + expect(gridShape).toEqual([2, 3]); // 2 groups, largest has 3 + }); + + it('leaves filler cells at the end of the smaller group row', () => { + const { slotToSampleIndex } = buildSpatialGridLayout(5, groups); + // row 0: A → [0,1,2]; row 1: B → [3,4,null] + expect(slotToSampleIndex).toEqual([0, 1, 2, 3, 4, null]); + }); + + it('maps each sample to its group row and column', () => { + const { sampleRowCol } = buildSpatialGridLayout(5, groups); + expect(sampleRowCol[0]).toEqual({ row: 0, col: 0 }); + expect(sampleRowCol[2]).toEqual({ row: 0, col: 2 }); + expect(sampleRowCol[3]).toEqual({ row: 1, col: 0 }); + expect(sampleRowCol[4]).toEqual({ row: 1, col: 1 }); + }); + + it('preserves group order as the row order', () => { + const reordered = [ + { groupKey: 'B', sampleIndices: [3, 4] }, + { groupKey: 'A', sampleIndices: [0, 1, 2] }, + ]; + const { sampleRowCol } = buildSpatialGridLayout(5, reordered); + // B is now row 0, A is row 1 + expect(sampleRowCol[3].row).toBe(0); + expect(sampleRowCol[0].row).toBe(1); + }); + + it('falls back to dense layout when groups is empty', () => { + const { gridShape } = buildSpatialGridLayout(3, []); + expect(gridShape).toEqual([1, 3]); + }); + }); +}); diff --git a/src/components/data-exploration/CellInfo.jsx b/src/components/data-exploration/CellInfo.jsx index afdcf361a5..4e45a568ee 100644 --- a/src/components/data-exploration/CellInfo.jsx +++ b/src/components/data-exploration/CellInfo.jsx @@ -1,25 +1,48 @@ -import React, { useCallback, useState } from 'react'; +import React, { useLayoutEffect, useRef, useState } from 'react'; import { Card } from 'antd'; import PropTypes from 'prop-types'; import getCellInfoCoordinates from 'utils/data-exploration/getCellInfoCoordinates'; const cellInfoStyle = { fontSize: '0.75rem' }; +const labelStyle = { fontWeight: 600 }; + +// One tooltip line: a bold category label ("Cell id:", "Samples:", …) followed +// by its value. +const InfoRow = ({ label, value }) => ( +
+ {label} + {value !== undefined && value !== null && value !== '' ? ` ${value}` : ''} +
+); + +InfoRow.propTypes = { + label: PropTypes.string.isRequired, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), +}; +InfoRow.defaultProps = { value: undefined }; const CellInfo = (props) => { const { containerWidth, containerHeight, coordinates, cellInfo, } = props; + const wrapperRef = useRef(null); const [tooltipDimensions, setTooltipDimensions] = useState({ width: 0, height: 0 }); - const getTooltipElement = useCallback((el) => { - if (!el) return; - - setTooltipDimensions({ - width: el.firstChild.offsetWidth, - height: el.firstChild.offsetHeight, - }); - }, []); + // Re-measure on every render (the card resizes as its content changes between + // cells), before paint so the position never flashes. Only set state when the + // size actually changed, otherwise this would loop. Using a one-shot ref + // callback instead left the dimensions stale, so a bigger tooltip was clamped + // against the previous cell's smaller size and got cut off. + useLayoutEffect(() => { + const card = wrapperRef.current?.firstChild; + if (!card) return; + const width = card.offsetWidth; + const height = card.offsetHeight; + setTooltipDimensions((prev) => ( + prev.width === width && prev.height === height ? prev : { width, height } + )); + }); const { left, top } = getCellInfoCoordinates( coordinates, @@ -31,7 +54,7 @@ const CellInfo = (props) => { return ( // We have to wrap the in a
because Antd does not correctly set the ref // https://github.com/ant-design/ant-design/issues/28582 -
+
{ position: 'absolute', left, top, + // keep each line on one row — near a panel edge the card would + // otherwise wrap mid-label and grow tall; we reposition it to fit + // (getCellInfoCoordinates) rather than reflow it + whiteSpace: 'nowrap', + // partially translucent so the plot underneath stays visible + opacity: 0.8, pointerEvents: 'none', }} > -
- {`Cell id: ${cellInfo.cellId}`} -
+ {cellInfo.geneName ? ( -
- {`Gene name: ${cellInfo.geneName}`} -
+ ) : <>} {cellInfo.expression !== undefined ? ( -
- Expression Level:  - {parseFloat(cellInfo.expression.toFixed(3))} -
+ ) : <>} - {cellInfo.cellSets?.length > 0 ? cellInfo.cellSets.map((cellSetName) => ( -
- {cellSetName} -
- )) : <>} + {cellInfo.cellSets?.length > 0 ? cellInfo.cellSets.map((cellSetName) => { + // cell-set entries are "Parent: value" (e.g. "Samples: sample-1") — + // bold the parent label, leave the value plain + const colonIndex = cellSetName.indexOf(':'); + const label = colonIndex >= 0 ? cellSetName.slice(0, colonIndex + 1) : cellSetName; + const value = colonIndex >= 0 ? cellSetName.slice(colonIndex + 1).trim() : ''; + return ; + }) : <>}
); diff --git a/src/components/data-exploration/cell-sets-tool/CellSetsTool.jsx b/src/components/data-exploration/cell-sets-tool/CellSetsTool.jsx index 8187967085..1253fa07f7 100644 --- a/src/components/data-exploration/cell-sets-tool/CellSetsTool.jsx +++ b/src/components/data-exploration/cell-sets-tool/CellSetsTool.jsx @@ -90,12 +90,22 @@ const CellSetsTool = (props) => { }, [hierarchy]); useEffect(() => { - const selectedCells = union(selectedCellSetKeys, properties); - - const numSelectedFiltered = new Set([...selectedCells] - .filter((cellIndex) => filteredCellIds.current.has(cellIndex))); - - setSelectedCellsCount(numSelectedFiltered.size); + // Count distinct selected cells that survive filtering in a single pass over + // the selected sets' cellIds. The previous approach built a union Set, spread + // it to an array, filtered, then built another Set — several full-size + // allocations that blocked the UI ("N cells selected" lagged) when selecting + // all clusters in large experiments. + const seen = new Set(); + const filtered = filteredCellIds.current; + selectedCellSetKeys.forEach((key) => { + const cellIds = properties[key]?.cellIds; + if (!cellIds) return; + cellIds.forEach((cellId) => { + if (filtered.has(cellId)) seen.add(cellId); + }); + }); + + setSelectedCellsCount(seen.size); }, [selectedCellSetKeys, properties]); const onNodeUpdate = useCallback((key, data) => { diff --git a/src/components/data-exploration/embedding/Embedding.jsx b/src/components/data-exploration/embedding/Embedding.jsx index c03b3c230a..e870bd22f0 100644 --- a/src/components/data-exploration/embedding/Embedding.jsx +++ b/src/components/data-exploration/embedding/Embedding.jsx @@ -22,6 +22,7 @@ import calculateInitialViewState from 'components/data-exploration/embedding/cal import { buildCellsQuadTree, selectCellsInPolygon } from 'components/data-exploration/embedding/lassoUtils'; import parseColor from 'components/data-exploration/parseColor'; import useFocusCellColors from 'components/data-exploration/useFocusCellColors'; +import HOVER_SOURCE from 'utils/data-exploration/cellInfoHoverSource'; import { loadEmbedding } from 'redux/actions/embedding'; import { getCellSetsHierarchyByType, getCellSets } from 'redux/selectors'; @@ -102,6 +103,7 @@ const Embedding = (props) => { } = cellSets; const selectedCell = useSelector((state) => state.cellInfo.cellId); + const hoverSource = useSelector((state) => state.cellInfo.hoverSource); const expressionLoading = useSelector((state) => state.genes.expression.full.loading); const expressionMatrix = useSelector((state) => state.genes.expression.full.matrix); @@ -255,7 +257,7 @@ const Embedding = (props) => { // Keep last shown tooltip if (!cell) return; - dispatch(updateCellInfo({ cellId: cell })); + dispatch(updateCellInfo({ cellId: cell, hoverSource: HOVER_SOURCE.embedding })); }, []); const clearCellHighlight = useCallback(() => { @@ -543,13 +545,17 @@ const Embedding = (props) => { ) : ( (cellInfoVisible && cellInfoTooltip && activeTool !== 'polygon') ? (
- + {/* only show the tooltip when the hover happened in this plot; + linked plots still draw the crosshair below */} + {hoverSource === HOVER_SOURCE.embedding && ( + + )} { + const values = Array.isArray(expression) ? expression : [expression]; + return values + .map((value) => (typeof value === 'number' ? parseFloat(value.toFixed(3)) : value)) + .join(', '); +}; const HeatmapCellInfo = (props) => { const { @@ -12,16 +22,21 @@ const HeatmapCellInfo = (props) => { geneExpression, coordinates, } = props; + const wrapperRef = useRef(null); const [tooltipDimensions, setTooltipDimensions] = useState({ width: 0, height: 0 }); - const getTooltipElement = useCallback((el) => { - if (!el) return; - - setTooltipDimensions({ - width: el.firstChild.offsetWidth, - height: el.firstChild.offsetHeight, - }); - }, []); + // Re-measure on every render (the card resizes with its content) before paint, + // only setting state on a real change; a one-shot ref left dimensions stale and + // a bigger tooltip got clamped against the previous cell's size and cut off. + useLayoutEffect(() => { + const card = wrapperRef.current?.firstChild; + if (!card) return; + const width = card.offsetWidth; + const height = card.offsetHeight; + setTooltipDimensions((prev) => ( + prev.width === width && prev.height === height ? prev : { width, height } + )); + }); const { left, top } = getCellInfoCoordinates( coordinates, @@ -33,7 +48,7 @@ const HeatmapCellInfo = (props) => { const renderCellInfo = () => ( // We have to wrap the in a
because Antd does not correctly set the ref // https://github.com/ant-design/ant-design/issues/28582 -
+
{ position: 'absolute', left, top, + // keep each line on one row instead of wrapping mid-label near an edge + whiteSpace: 'nowrap', + // partially translucent so the heatmap underneath stays visible + opacity: 0.85, pointerEvents: 'none', }} > {cellId ? (
- {`Cell id: ${cellId}`} + Cell id: + {` ${cellId}`}
) : <>} {geneName ? (
- {`Gene name: ${geneName}`} + Gene name: + {` ${geneName}`}
) : <>} {geneExpression !== undefined ? (
- Expression:  - {geneExpression} + Expression: +   + {formatExpression(geneExpression)}
) : <>}
@@ -79,7 +101,7 @@ HeatmapCellInfo.propTypes = { geneName: PropTypes.string.isRequired, geneExpression: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.number), - PropTypes.oneOf([undefined, null]) + PropTypes.oneOf([undefined, null]), ]), coordinates: PropTypes.object.isRequired, }; diff --git a/src/components/data-exploration/heatmap/HeatmapPlot.jsx b/src/components/data-exploration/heatmap/HeatmapPlot.jsx index 44a6e53086..785f5d2750 100644 --- a/src/components/data-exploration/heatmap/HeatmapPlot.jsx +++ b/src/components/data-exploration/heatmap/HeatmapPlot.jsx @@ -24,6 +24,7 @@ import PlatformError from 'components/PlatformError'; import HeatmapCellInfo from 'components/data-exploration/heatmap/HeatmapCellInfo'; import HeatmapTracksCellInfo from 'components/data-exploration/heatmap/HeatmapTracksCellInfo'; +import HOVER_SOURCE from 'utils/data-exploration/cellInfoHoverSource'; import getContainingCellSetsProperties from 'utils/cellSets/getContainingCellSetsProperties'; import useConditionalEffect from 'utils/customHooks/useConditionalEffect'; @@ -374,7 +375,7 @@ const HeatmapPlot = (props) => { useEffect(() => { if (cellHighlight) { - dispatch(updateCellInfo({ cellId: cellHighlight })); + dispatch(updateCellInfo({ cellId: cellHighlight, hoverSource: HOVER_SOURCE.heatmap })); } }, [cellHighlight]); @@ -462,7 +463,7 @@ const HeatmapPlot = (props) => { setHighlightedTrackData(null); return; } - dispatch(updateCellInfo({ cellId: info[0] })); + dispatch(updateCellInfo({ cellId: info[0], hoverSource: HOVER_SOURCE.heatmap })); const [cellIndexStr, trackIndex, mouseX, mouseY] = info; diff --git a/src/components/data-exploration/heatmap/HeatmapTracksCellInfo.jsx b/src/components/data-exploration/heatmap/HeatmapTracksCellInfo.jsx index 68fc7d5386..77bb2082d3 100644 --- a/src/components/data-exploration/heatmap/HeatmapTracksCellInfo.jsx +++ b/src/components/data-exploration/heatmap/HeatmapTracksCellInfo.jsx @@ -1,25 +1,31 @@ -import React, { useState, useCallback } from 'react'; +import React, { useLayoutEffect, useRef, useState } from 'react'; import { Card } from 'antd'; import PropTypes from 'prop-types'; import getCellInfoCoordinates from 'utils/data-exploration/getCellInfoCoordinates'; const cellInfoStyle = { fontSize: '0.75rem' }; +const labelStyle = { fontWeight: 600 }; const HeatmapTracksCellInfo = (props) => { const { containerWidth, containerHeight, cellId, trackName, coordinates, } = props; + const wrapperRef = useRef(null); const [tooltipDimensions, setTooltipDimensions] = useState({ width: 0, height: 0 }); - const getTooltipElement = useCallback((el) => { - if (!el) return; - - setTooltipDimensions({ - width: el.firstChild.offsetWidth, - height: el.firstChild.offsetHeight, - }); - }, []); + // Re-measure on every render (the card resizes with its content) before paint, + // only setting state on a real change; a one-shot ref left dimensions stale and + // a bigger tooltip got clamped against the previous cell's size and cut off. + useLayoutEffect(() => { + const card = wrapperRef.current?.firstChild; + if (!card) return; + const width = card.offsetWidth; + const height = card.offsetHeight; + setTooltipDimensions((prev) => ( + prev.width === width && prev.height === height ? prev : { width, height } + )); + }); const { left } = getCellInfoCoordinates( coordinates, @@ -31,7 +37,7 @@ const HeatmapTracksCellInfo = (props) => { const renderCellInfo = () => ( // We have to wrap the in a
because Antd does not correctly set the ref // https://github.com/ant-design/ant-design/issues/28582 -
+
{ position: 'absolute', left, top: '20px', + // keep each line on one row instead of wrapping mid-label near an edge + whiteSpace: 'nowrap', + // partially translucent so the heatmap underneath stays visible + opacity: 0.85, pointerEvents: 'none', }} > {cellId ? (
- {`Cell id: ${cellId}`} + Cell id: + {` ${cellId}`}
) : <>} {trackName ? (
- {`Group name: ${trackName}`} + Group name: + {` ${trackName}`}
) : <>}
diff --git a/src/components/data-exploration/spatial/SpatialGroupBySettings.jsx b/src/components/data-exploration/spatial/SpatialGroupBySettings.jsx index 4d02ca7b72..5c0f14e601 100644 --- a/src/components/data-exploration/spatial/SpatialGroupBySettings.jsx +++ b/src/components/data-exploration/spatial/SpatialGroupBySettings.jsx @@ -1,95 +1,66 @@ -import React, { useEffect, useState } from 'react'; -import _ from 'lodash'; +import React, { useMemo } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import PropTypes from 'prop-types'; -import { - Switch, Space, -} from 'antd'; +import { Switch, Space } from 'antd'; +import { ClipLoader } from 'react-spinners'; import { updatePlotConfig } from 'redux/actions/componentConfig'; import { getCellSets, getCellSetsHierarchyByType, getGroupSlidesBy } from 'redux/selectors'; -import { ClipLoader } from 'react-spinners'; import colors from 'utils/styling/colors'; -const convertToListData = (hierarchy, selectedKeys) => ( - hierarchy.map(({ key, name }) => ({ - key, - name, - selected: selectedKeys.includes(key), - })) -); +// Mutually-exclusive grouping picker, styled like the visible-layers toggles in +// the same settings dropdown: one "Name" row per available sample-level +// metadata option, in a single column. "Samples" (the 'sample' class) is the +// default flat view — samples in Cell sets & metadata tile order, ungrouped. +// Picking a metadata track instead lays samples out one group per row. +const SAMPLES_KEY = 'sample'; const SpatialGroupBySettings = (props) => { const dispatch = useDispatch(); const { componentType } = props; const { accessible: cellSetsAccessible } = useSelector(getCellSets()); - const hierarchy = useSelector(getCellSetsHierarchyByType('metadataCategorical')); + // include 'sample' ("Samples") — it's the default flat/ungrouped option + const tracks = useSelector(getCellSetsHierarchyByType('metadataCategorical')); const groupSlidesBy = useSelector(getGroupSlidesBy(componentType)); - const [listData, setListData] = useState([]); - - useEffect(() => { - if (!hierarchy || _.isEmpty(hierarchy)) return; - - // Initialize listData based on hierarchy order - setListData(convertToListData(hierarchy, groupSlidesBy)); - }, [hierarchy, groupSlidesBy]); - - const setGroupSlidesBy = (selectedKey) => { - const newListData = listData.map((item) => ({ - ...item, - selected: item.key === selectedKey, - })); + // The active option: the stored selection if still available, else "Samples" + // (the default). Keep this in sync with SpatialViewer. + const selectedKey = useMemo(() => { + const available = tracks.map(({ key }) => key); + return groupSlidesBy.find((key) => available.includes(key)) ?? SAMPLES_KEY; + }, [groupSlidesBy, tracks]); - setListData(newListData); - - // Update only with the newly selected key (or empty array if none) - dispatch( - updatePlotConfig(componentType, { - groupSlidesBy: selectedKey ? [selectedKey] : [], - }), - ); + const setGroupSlidesBy = (key) => { + dispatch(updatePlotConfig(componentType, { groupSlidesBy: [key] })); }; - const leftItem = (listDataItem) => ( - { - setGroupSlidesBy(listDataItem.key); - }} - /> - ); + if (!cellSetsAccessible) { + return
; + } - const rightItem = (listDataItem) => ( - - {listDataItem.name} - - ); + if (!tracks.length) { + return
No sample metadata to group by.
; + } - const composeItem = (itemData) => ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events -
e.stopPropagation()} - > - {leftItem(itemData)} - {rightItem(itemData)} -
- ); + const stopPropagationEvent = (e) => e.stopPropagation(); return (
- {cellSetsAccessible ? ( - - {listData.map((itemData) => composeItem(itemData))} - - ) : ( -
- -
- )} + + {tracks.map(({ key, name }) => ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events +
+ checked && setGroupSlidesBy(key)} + /> + {name} +
+ ))} +
); }; diff --git a/src/components/data-exploration/spatial/SpatialViewer.jsx b/src/components/data-exploration/spatial/SpatialViewer.jsx index f4fd151de3..fe4f14f521 100644 --- a/src/components/data-exploration/spatial/SpatialViewer.jsx +++ b/src/components/data-exploration/spatial/SpatialViewer.jsx @@ -23,10 +23,12 @@ import { getSampleFileUrls } from 'utils/data-management/downloadSampleFile'; import { loadComponentConfig } from 'redux/actions/componentConfig'; import { loadEmbedding } from 'redux/actions/embedding'; import { loadProcessingSettings } from 'redux/actions/experimentSettings'; -import { getCellSetsHierarchyByType, getCellSets } from 'redux/selectors'; +import { getCellSetsHierarchyByType, getCellSets, getGroupSlidesBy } from 'redux/selectors'; +import buildSpatialGridLayout from 'utils/spatial/buildSpatialGridLayout'; import { createCellSet } from 'redux/actions/cellSets'; import { updateCellInfo } from 'redux/actions/cellInfo'; import { union } from 'utils/cellSetOperations'; +import HOVER_SOURCE from 'utils/data-exploration/cellInfoHoverSource'; import { imagelessTechs } from 'utils/constants'; import _ from 'lodash'; @@ -118,6 +120,7 @@ const SpatialViewer = (props) => { }, [cellSetsHierarchyNodes, cellSetProperties]); const selectedCell = useSelector((state) => state.cellInfo.cellId); + const hoverSource = useSelector((state) => state.cellInfo.hoverSource); const expressionLoading = useSelector((state) => state.genes.expression.full.loading); const expressionMatrix = useSelector((state) => state.genes.expression.full.matrix); @@ -161,11 +164,92 @@ const SpatialViewer = (props) => { const [segmentationsLoader, setSegmentationsLoader] = useState(null); const [offsetData, setOffsetData] = useState(); const [perImageShape, setPerImageShape] = useState(); - const [gridShape, setGridShape] = useState(); const [viewState, setViewState] = useState(null); const viewStateRef = useRef(null); const hoveredByPointerRef = useRef(null); + // ── Group samples into rows by a sample-level metadata track ──────────────── + // 'sample' is excluded (grouping by sample = one slide per row = ungrouped). + const groupSlidesBy = useSelector(getGroupSlidesBy(COMPONENT_TYPE)); + const metadataTracks = useSelector( + getCellSetsHierarchyByType('metadataCategorical', ['sample']), + ); + + // The metadata track to group rows by, or null for the default flat + // "Samples" view (ungrouped). Only a real non-sample track groups; anything + // else ('sample' / empty / invalid) stays ungrouped. + const groupByKey = useMemo(() => { + const available = metadataTracks.map(({ key }) => key); + return groupSlidesBy.find((key) => available.includes(key)) ?? null; + }, [groupSlidesBy, metadataTracks]); + + // Sample display order = the 'sample' cell class's child order in the Cell + // sets & metadata tile. The grid follows this order and re-lays out when the + // user reorders samples there (reordering mutates cellSetHierarchy). + const sampleRank = useMemo(() => { + const sampleNode = cellSetHierarchy?.find(({ key }) => key === 'sample'); + const rank = new Map(); + sampleNode?.children?.forEach(({ key }, i) => rank.set(key, i)); + return rank; + }, [cellSetHierarchy]); + + // Compare two sample indices by tile order; ties (e.g. samples absent from + // the tile) keep their original relative order. + const compareByRank = useCallback((a, b) => { + const ra = sampleRank.get(omeZarrSampleIds[a]) ?? Infinity; + const rb = sampleRank.get(omeZarrSampleIds[b]) ?? Infinity; + return ra === rb ? a - b : ra - rb; + }, [sampleRank, omeZarrSampleIds]); + + // Sample indices (into omeZarrSampleIds) in tile order — drives the ungrouped + // layout sequence. + const orderedSampleIndices = useMemo( + () => omeZarrSampleIds.map((_id, i) => i).sort(compareByRank), + [omeZarrSampleIds, compareByRank], + ); + + // Ordered groups of sample indices (into omeZarrSampleIds) for the chosen + // track; null when ungrouped. A sample belongs to the group whose cellIds + // contain a representative cell of the sample (sample-level metadata assigns + // all of a sample's cells to one group). Any unmatched samples land in a + // trailing "Other" row so no sample disappears. + const sampleGroups = useMemo(() => { + if (!groupByKey || !omeZarrSampleIds.length || !cellSetProperties) return null; + const track = metadataTracks.find(({ key }) => key === groupByKey); + if (!track?.children?.length) return null; + + const groups = track.children.map(({ key, name }) => ({ + groupKey: key, name, sampleIndices: [], + })); + const other = { groupKey: '$other', name: 'Other', sampleIndices: [] }; + + omeZarrSampleIds.forEach((sampleId, sampleIndex) => { + const sampleCellIds = cellSetProperties[sampleId]?.cellIds; + const probe = sampleCellIds?.values().next().value; + const group = probe === undefined + ? undefined + : groups.find(({ groupKey }) => cellSetProperties[groupKey]?.cellIds?.has(probe)); + (group ?? other).sampleIndices.push(sampleIndex); + }); + + const nonEmpty = groups.filter(({ sampleIndices }) => sampleIndices.length > 0); + if (other.sampleIndices.length > 0) nonEmpty.push(other); + // order samples within each row by the Cell sets tile order + nonEmpty.forEach((group) => group.sampleIndices.sort(compareByRank)); + return nonEmpty.length ? nonEmpty : null; + }, [groupByKey, omeZarrSampleIds, cellSetProperties, metadataTracks, compareByRank]); + + // Shared grid layout (shape + slot map + per-sample row/col) that the image + // grid, segmentation grid, centroids and molecules all key off, so they stay + // aligned. Image-driven techs size by image count, imageless by sample count. + // The default (ungrouped) view follows the Cell sets & metadata tile order. + const gridLayout = useMemo(() => { + const numItems = isImageless ? omeZarrSampleIds.length : omeZarrUrls.length; + return buildSpatialGridLayout(numItems, sampleGroups, orderedSampleIndices); + }, [isImageless, omeZarrSampleIds.length, omeZarrUrls.length, sampleGroups, orderedSampleIndices]); + + const gridShape = gridLayout.gridShape; + const deckglView = useMemo(() => new OrthographicView({ id: 'spatial', controller: true }), []); // ── Keep viewStateRef in sync ───────────────────────────────────────────── @@ -183,8 +267,10 @@ const SpatialViewer = (props) => { useEffect(() => { if (!data || !omeZarrSampleIds.length || !cellSetProperties || !perImageShape || !gridShape) return; if (omeZarrSampleIds.some((id) => !cellSetProperties[id])) return; - setOffsetData(offsetCentroids(data, cellSetProperties, omeZarrSampleIds, perImageShape, gridShape)); - }, [data, omeZarrSampleIds, cellSetProperties, perImageShape, gridShape]); + setOffsetData(offsetCentroids( + data, cellSetProperties, omeZarrSampleIds, perImageShape, gridShape, gridLayout.sampleRowCol, + )); + }, [data, omeZarrSampleIds, cellSetProperties, perImageShape, gridShape, gridLayout]); // ── URL fetching ────────────────────────────────────────────────────────── useEffect(() => { @@ -240,25 +326,24 @@ const SpatialViewer = (props) => { })(); }, [sampleIdsForFileUrls, experimentId, isObj2s, isImageless]); - // ── Grid shape ──────────────────────────────────────────────────────────── - // Image-driven techs key the grid off the number of image OME-Zarrs; imageless - // techs (e.g. Xenium) have no images, so key it off the number of samples. - useEffect(() => { - const numItems = isImageless ? omeZarrSampleIds.length : omeZarrUrls.length; - if (!numItems) return; - const numColumns = Math.min(numItems, 4); - const numRows = Math.ceil(numItems / numColumns); - setGridShape((prev) => ( - prev && prev[0] === numRows && prev[1] === numColumns ? prev : [numRows, numColumns] - )); - }, [omeZarrUrls, omeZarrSampleIds, isImageless]); + // Zarr roots are keyed only by URL, so regrouping (which changes the grid + // layout but not the underlying per-sample data) doesn't re-open the stores — + // only the grid adapter is rebuilt below. + const imageRoots = useMemo( + () => omeZarrUrls.map((url) => zarrRoot(ZipFileStore.fromUrl(url))), + [omeZarrUrls], + ); + const segmentationRoots = useMemo( + () => segmentationsOmeZarrUrls.map((url) => zarrRoot(ZipFileStore.fromUrl(url))), + [segmentationsOmeZarrUrls], + ); // ── Image loader ────────────────────────────────────────────────────────── useEffect(() => { - if (!omeZarrUrls.length || !gridShape) return; - const roots = omeZarrUrls.map((url) => zarrRoot(ZipFileStore.fromUrl(url))); - loadOmeZarrGrid(roots, gridShape).then(setLoader); - }, [omeZarrUrls, gridShape]); + if (!imageRoots.length) return; + loadOmeZarrGrid(imageRoots, gridShape, undefined, gridLayout.slotToSampleIndex) + .then(setLoader); + }, [imageRoots, gridLayout]); // ── Segmentations bitmask loader ────────────────────────────────────────── // The segmentation OME-Zarr holds the real cell-boundary polygons rasterised @@ -267,12 +352,15 @@ const SpatialViewer = (props) => { // micron frame as the centroids — load and render it just like Visium HD // rather than falling back to approximate diamonds. useEffect(() => { - if (!segmentationsOmeZarrUrls.length || !gridShape) return; - const roots = segmentationsOmeZarrUrls.map((url) => zarrRoot(ZipFileStore.fromUrl(url))); - loadOmeZarrGrid(roots, gridShape) + if (!segmentationRoots.length) return; + // Fill empty grid cells with 0 (background) so the label bitmask treats + // filler tiles as background (excludeBackground → transparent), instead of + // a bogus cell id that the colour LUT would paint (brown/pink). The light + // image-grid filler (#f5f7f9) then shows through in those cells. + loadOmeZarrGrid(segmentationRoots, gridShape, 0, gridLayout.slotToSampleIndex) .then(setSegmentationsLoader) .catch((e) => console.error('[SpatialViewer] Segmentations loader error:', e)); - }, [segmentationsOmeZarrUrls, gridShape]); + }, [segmentationRoots, gridLayout]); // ── Molecule artifact stores + meta ──────────────────────────────────────── // One ZipFileStore per sample (null where the sample has no artifact). The @@ -509,7 +597,7 @@ const SpatialViewer = (props) => { // ── Callbacks ───────────────────────────────────────────────────────────── const setCellHighlight = useCallback((cell) => { if (!cell) return; - dispatch(updateCellInfo({ cellId: cell })); + dispatch(updateCellInfo({ cellId: cell, hoverSource: HOVER_SOURCE.spatial })); }, []); const clearCellHighlight = useCallback(() => { @@ -610,11 +698,12 @@ const SpatialViewer = (props) => { const [imageHeight, imageWidth] = perImageShape; const numColumns = gridShape[1]; return omeZarrSampleIds.map((_id, sampleIndex) => { - const row = Math.floor(sampleIndex / numColumns); - const column = sampleIndex % numColumns; + const rowCol = gridLayout.sampleRowCol?.[sampleIndex]; + const row = rowCol ? rowCol.row : Math.floor(sampleIndex / numColumns); + const column = rowCol ? rowCol.col : sampleIndex % numColumns; return [column * imageWidth, row * imageHeight]; }); - }, [omeZarrSampleIds, perImageShape, gridShape]); + }, [omeZarrSampleIds, perImageShape, gridShape, gridLayout]); const showMolecules = spatialSettings.showMolecules === true; @@ -724,7 +813,11 @@ const SpatialViewer = (props) => { contrastLimits: [[0, 255], [0, 255], [0, 255]], colors: [[255, 0, 0], [0, 255, 0], [0, 0, 255]], channelsVisible: [true, true, true], - opacity: spatialSettings.showImages !== false ? 1 : 0, + // visible (not opacity) so deck.gl skips fetching/compositing the image + // tiles entirely when hidden (the default) — this is the expensive work, + // and avoiding it makes regrouping/layout changes much faster. + visible: spatialSettings.showImages !== false, + opacity: 1, colormap: null, pickable: false, }); @@ -925,13 +1018,17 @@ const SpatialViewer = (props) => { ) : ( (cellInfoVisible && cellInfoTooltip && activeTool !== 'polygon') ? (
- + {/* only show the tooltip when the hover happened in this plot; + linked plots still draw the crosshair below */} + {hoverSource === HOVER_SOURCE.spatial && ( + + )}
) : <> diff --git a/src/components/data-exploration/spatial/bitmaskLayers.js b/src/components/data-exploration/spatial/bitmaskLayers.js index 091de90ec7..28254c9115 100644 --- a/src/components/data-exploration/spatial/bitmaskLayers.js +++ b/src/components/data-exploration/spatial/bitmaskLayers.js @@ -59,6 +59,20 @@ export const buildCellColorLUT = (offsetData, cellColors, hiddenCellIds, cellsIn const hasCellColors = Object.keys(cellColors).length > 0; + // Cells in the same cluster share one colour string, but parseColor runs a + // regex + parseInt per call. Cache by colour value so each distinct colour is + // parsed once instead of once per cell (this LUT is rebuilt on every hide/ + // unhide and colour change, over every cell). + const parsedColorCache = new Map(); + const parseColorCached = (colorValue) => { + let rgb = parsedColorCache.get(colorValue); + if (!rgb) { + rgb = parseColor(colorValue); + parsedColorCache.set(colorValue, rgb); + } + return rgb; + }; + offsetData.forEach((_, cellIdKey) => { if (hiddenCellIds.has(cellIdKey)) return; if (!cellsInAnyCluster.has(cellIdKey)) return; @@ -70,7 +84,7 @@ export const buildCellColorLUT = (offsetData, cellColors, hiddenCellIds, cellsIn if (hasCellColors) { const colorValue = cellColors[String(cellIdKey)]; if (colorValue) { - [r, g, b] = parseColor(colorValue); + [r, g, b] = parseColorCached(colorValue); } else { r = DEFAULT_CELL_GREY; g = DEFAULT_CELL_GREY; b = DEFAULT_CELL_GREY; } diff --git a/src/components/data-exploration/spatial/createZarrArrayAdapter.js b/src/components/data-exploration/spatial/createZarrArrayAdapter.js index 3715df335d..cc0628f567 100644 --- a/src/components/data-exploration/spatial/createZarrArrayAdapter.js +++ b/src/components/data-exploration/spatial/createZarrArrayAdapter.js @@ -2,8 +2,9 @@ import { slice, get } from 'zarrita'; -// use background color of tile for transparent data -const SPATIAL_BACKGROUND_COLOR = { 0: 246, 1: 247, 2: 249 }; +// Light neutral canvas (#f5f7f9) used to fill the RGB image grid's empty cells +// (the "filler" tiles that complete the last row of a multi-sample grid). +const SPATIAL_BACKGROUND_COLOR = { 0: 245, 1: 247, 2: 249 }; function getV2DataType(dtype) { const mapping = { @@ -56,7 +57,19 @@ export function createZarrArrayAdapter(arr) { }); } -export function createZarrArrayAdapterGrid(arrs, [numRows, numCols]) { +// emptyFill controls the value written into empty grid cells (filler tiles that +// complete the last row). For the RGB image grid it's a per-channel object +// (SPATIAL_BACKGROUND_COLOR → #f5f7f9). For the segmentation label grid pass 0 +// so the filler reads as background (excludeBackground makes it transparent, +// revealing the light image filler beneath) instead of a bogus cell id. +// slotToArrIndex maps each grid cell (row * numCols + col) to an index into +// `arrs`, or null for an empty filler cell. When omitted, cells are filled +// densely row-major (arrs[0], arrs[1], …) — the ungrouped default. A map lets +// samples be grouped into rows with filler cells mid-grid (see +// buildSpatialGridLayout). +export function createZarrArrayAdapterGrid( + arrs, [numRows, numCols], emptyFill = SPATIAL_BACKGROUND_COLOR, slotToArrIndex = null, +) { const firstArray = arrs[0]; return new Proxy(firstArray, { @@ -107,12 +120,16 @@ export function createZarrArrayAdapterGrid(arrs, [numRows, numCols]) { } }); - const imageIndex = row * numCols + col; - // e.g. if 3 images but grid is 2 x 2 then 1 empty image - const hasImage = imageIndex < arrs.length; + const slot = row * numCols + col; + // Resolve the grid cell to a backing image. With a slot map, + // cells can be empty mid-grid (grouped rows); without one, cells + // fill densely and are empty only past the sample count. + const arrIndex = slotToArrIndex ? slotToArrIndex[slot] : slot; + const hasImage = arrIndex !== null && arrIndex !== undefined + && arrIndex < arrs.length; return { - imageIndex, + imageIndex: arrIndex, row, col, adjustedSelection, @@ -120,6 +137,14 @@ export function createZarrArrayAdapterGrid(arrs, [numRows, numCols]) { }; })).filter(Boolean); // Remove any null results + // The requested region can fall entirely outside the grid — e.g. a + // stale tile request after the grid shape shrank when the sample + // grouping changed. Return a background tile of the requested size + // rather than letting combineGridData build from an empty list. + if (dataSelections.length === 0) { + return generateEmptyImageData(normalizedSelection, 0, 0, emptyFill).data; + } + const dataPerImage = await Promise.all( dataSelections.map(({ imageIndex, adjustedSelection, hasImage, row, col, @@ -132,7 +157,7 @@ export function createZarrArrayAdapterGrid(arrs, [numRows, numCols]) { } // Generate image data if the image doesn't exist - return generateEmptyImageData(adjustedSelection, row, col); + return generateEmptyImageData(adjustedSelection, row, col, emptyFill); }), ); @@ -157,16 +182,22 @@ export function createZarrArrayAdapterGrid(arrs, [numRows, numCols]) { }); } -function generateEmptyImageData(adjustedSelection, row, col) { +function generateEmptyImageData(adjustedSelection, row, col, emptyFill = SPATIAL_BACKGROUND_COLOR) { // Extract the channel and slices for y and x dimensions const [channel, ySlice, xSlice] = adjustedSelection; - // Calculate the dimensions for the empty image data - const height = ySlice.stop - ySlice.start; - const width = xSlice.stop - xSlice.start; - - // Create empty data using background color of spatial tile - const backgroundChannelValue = SPATIAL_BACKGROUND_COLOR[channel]; + // Calculate the dimensions for the empty image data. Clamp to >= 0: a stale + // out-of-bounds tile request (e.g. right after the grid shape shrinks when the + // sample grouping changes) can arrive with stop < start, which would make + // `new Int32Array(negative)` throw "Invalid typed array length". + const height = Math.max(0, ySlice.stop - ySlice.start); + const width = Math.max(0, xSlice.stop - xSlice.start); + + // Fill value for the empty cell: a per-channel object (RGB image background) + // or a scalar (e.g. 0 for the segmentation label grid). + const backgroundChannelValue = typeof emptyFill === 'object' + ? (emptyFill[channel] ?? 0) + : emptyFill; const data = new Int32Array(height * width).fill(backgroundChannelValue); // Return an object structured like Zarr array data @@ -198,6 +229,12 @@ function calculateRanges(dimSelection, sizePerImage, imagesPerDimension) { } function combineGridData(dataArrays) { + if (dataArrays.length === 0) { + // No tiles intersect the request; nothing to composite. Return an empty + // tile instead of computing a negative grid size (Array(-Infinity)). + return { data: new Int32Array(0), shape: [0, 0], stride: [0, 1] }; + } + if (dataArrays.length === 1) { return dataArrays[0].data; // Directly return if only one image } diff --git a/src/components/data-exploration/spatial/loadOmeZarr.js b/src/components/data-exploration/spatial/loadOmeZarr.js index eb871f2875..3ec750701d 100644 --- a/src/components/data-exploration/spatial/loadOmeZarr.js +++ b/src/components/data-exploration/spatial/loadOmeZarr.js @@ -31,7 +31,22 @@ function isAxis(axisOrLabel) { return typeof axisOrLabel[0] !== 'string'; } +// Opening a root's multiscale arrays (reading .zattrs/.zarray for every pyramid +// level) depends only on the root, not on the grid layout. Cache it per root so +// that re-laying out the grid (e.g. toggling the sample grouping, which rebuilds +// the loaders) reuses the already-opened arrays instead of re-reading metadata +// for every sample. Keyed on the (memoised) root object; GC'd with it. +const multiscalesCache = new WeakMap(); + async function loadMultiscales(root) { + if (multiscalesCache.has(root)) return multiscalesCache.get(root); + const promise = loadMultiscalesUncached(root); + multiscalesCache.set(root, promise); + promise.catch(() => multiscalesCache.delete(root)); // don't cache failures + return promise; +} + +async function loadMultiscalesUncached(root) { const rootAttrs = (await zarrOpen(root)).attrs; let paths = ['0']; @@ -90,7 +105,7 @@ export async function loadOmeZarr(root) { }; } -export async function loadOmeZarrGrid(roots, gridSize) { +export async function loadOmeZarrGrid(roots, gridSize, emptyFill, slotToArrIndex) { const dataArrays = await Promise.all(roots.map((root) => loadMultiscales(root))); const dataGroups = dataArrays.map(({ data }) => data); @@ -106,7 +121,7 @@ export async function loadOmeZarrGrid(roots, gridSize) { const pyramid = dataGroups[0].map((_, resolution) => { const arrs = dataGroups.map((group) => group[resolution]); return new ZarritaPixelSource( - createZarrArrayAdapterGrid(arrs, gridSize), + createZarrArrayAdapterGrid(arrs, gridSize, emptyFill, slotToArrIndex), labels, tileSize, ); diff --git a/src/components/plots/useSpatialStream.js b/src/components/plots/useSpatialStream.js index 6e827df936..63abf164d4 100644 --- a/src/components/plots/useSpatialStream.js +++ b/src/components/plots/useSpatialStream.js @@ -361,7 +361,12 @@ const useSpatialStream = ({ openOmePyramid(omeZarrUrl).then((p) => { if (cancelled || !p) return; histPyramidRef.current = { key: myKey, pyramid: p }; - const dims = { imageWidth: p.fullW, imageHeight: p.fullH }; + const dims = { + imageWidth: p.fullW, + imageHeight: p.fullH, + origWidth: p.origW, + origHeight: p.origH, + }; dimsRef.current = dims; setImageDims(dims); if (!viewportRef.current) { @@ -383,7 +388,12 @@ const useSpatialStream = ({ if (cancelled || !p) return; segPyramidRef.current = { key: myKey, pyramid: p }; if (!dimsRef.current) { - const dims = { imageWidth: p.fullW, imageHeight: p.fullH }; + const dims = { + imageWidth: p.fullW, + imageHeight: p.fullH, + origWidth: p.origW, + origHeight: p.origH, + }; dimsRef.current = dims; setImageDims(dims); } diff --git a/src/components/plots/zarrPyramid.js b/src/components/plots/zarrPyramid.js index e62418ad29..ffa361342c 100644 --- a/src/components/plots/zarrPyramid.js +++ b/src/components/plots/zarrPyramid.js @@ -92,11 +92,15 @@ export const openOmePyramid = (omeZarrUrl) => { let datasets = [{ path: '0' }]; let axesMetadata = null; + let originalSize = null; try { const rootGroup = await open(rootNode, { kind: 'group' }); const rootAttrs = await Promise.resolve(rootGroup.attrs); datasets = rootAttrs?.multiscales?.[0]?.datasets || datasets; axesMetadata = rootAttrs?.multiscales?.[0]?.axes || null; + // pipeline-written pre-pad tissue extent { width, height }; absent for + // un-padded / older images + originalSize = rootAttrs?.originalSize || null; } catch (_e) { // multiscale metadata is optional — fall back to the default dataset path } @@ -112,6 +116,10 @@ export const openOmePyramid = (omeZarrUrl) => { const fullW = shape[shape.length - 1]; const fullH = shape[shape.length - 2]; + // original (pre-pad) tissue extent; falls back to the padded full extent + const origW = originalSize?.width ?? fullW; + const origH = originalSize?.height ?? fullH; + attachLevelTransforms(levels); return { @@ -119,6 +127,8 @@ export const openOmePyramid = (omeZarrUrl) => { axesMetadata, fullW, fullH, + origW, + origH, }; })(); promise.catch(() => pyramidCache.delete(omeZarrUrl)); diff --git a/src/redux/reducers/cellInfo/updateCellInfo.js b/src/redux/reducers/cellInfo/updateCellInfo.js index 0749b1fc0e..568c118747 100644 --- a/src/redux/reducers/cellInfo/updateCellInfo.js +++ b/src/redux/reducers/cellInfo/updateCellInfo.js @@ -4,8 +4,10 @@ import produce from 'immer'; import initialState from './initialState'; const updateCellInfo = produce((draft, action) => { - const { cellId } = action.payload; + const { cellId, hoverSource } = action.payload; draft.cellId = cellId; + // which plot the hover came from, so only that plot shows the tooltip + draft.hoverSource = hoverSource; }, initialState); export default updateCellInfo; diff --git a/src/redux/reducers/componentConfig/initialState.js b/src/redux/reducers/componentConfig/initialState.js index 85f3602be9..0dadd634c4 100644 --- a/src/redux/reducers/componentConfig/initialState.js +++ b/src/redux/reducers/componentConfig/initialState.js @@ -709,7 +709,9 @@ const interactiveHeatmapInitialConfig = { }; const interactiveSpatialInitialConfig = { - showImages: true, + // tissue image hidden by default in Data Exploration — the segmentation + // overlay is the primary layer; users can enable images in the settings + showImages: false, showSegmentations: true, showSegmentationOutlines: true, groupSlidesBy: ['sample'], diff --git a/src/redux/selectors/cellSets/getCellSetsHierarchy.js b/src/redux/selectors/cellSets/getCellSetsHierarchy.js index 8b4c56d446..47718ecb89 100644 --- a/src/redux/selectors/cellSets/getCellSetsHierarchy.js +++ b/src/redux/selectors/cellSets/getCellSetsHierarchy.js @@ -1,6 +1,6 @@ import createMemoizedSelector from 'redux/selectors/createMemoizedSelector'; -import getCellSets from 'redux/selectors/cellSets/getCellSets'; +import initialState from '../../reducers/cellSets/initialState'; const getCellSetsData = (children, properties) => ( children.map(({ key }) => { @@ -9,26 +9,38 @@ const getCellSetsData = (children, properties) => ( }) ); -const getCellSetsHierarchy = () => (state) => { - if (!state || !state.accessible) { - return []; - } +// Depend only on `hierarchy` + `properties` (and the accessible flag), NOT on +// the whole cellSets slice. `hidden`/`selected` change on every eye-toggle and +// checkbox tick, but immer keeps `hierarchy`/`properties` referentially stable +// across those. Previously this chained off getCellSets(), which returns a new +// object on every cellSets change, so a hide or select recomputed this — and, +// via getCellSetsHierarchyByType, every downstream consumer (SpatialViewer's +// cellsInAnyCluster O(all cells) rebuild, the colour LUTs, the embedding, …). +const getCellSetsHierarchy = () => (accessible, hierarchy, properties) => { + if (!accessible) return []; - const hierarchy = state.hierarchy.map( - (cellClass) => ( - { - key: cellClass.key, - name: state.properties[cellClass.key]?.name, - type: state.properties[cellClass.key]?.type, - children: getCellSetsData(cellClass?.children ?? [], state.properties), - } - ), - ); + return hierarchy.map((cellClass) => ({ + key: cellClass.key, + name: properties[cellClass.key]?.name, + type: properties[cellClass.key]?.type, + children: getCellSetsData(cellClass?.children ?? [], properties), + })); +}; + +const withFallback = (state) => (Object.keys(state).length ? state : initialState); - return hierarchy; +const selectAccessible = (state) => { + const s = withFallback(state); + return !s.loading && !s.initialLoadPending && !s.updatingClustering && !s.error; }; export default createMemoizedSelector( getCellSetsHierarchy, - { inputSelectors: getCellSets() }, + { + inputSelectors: [ + selectAccessible, + (state) => withFallback(state).hierarchy, + (state) => withFallback(state).properties, + ], + }, ); diff --git a/src/redux/selectors/componentConfig/getGroupSlidesBy.js b/src/redux/selectors/componentConfig/getGroupSlidesBy.js index e8e56d66b9..fc30c70ad5 100644 --- a/src/redux/selectors/componentConfig/getGroupSlidesBy.js +++ b/src/redux/selectors/componentConfig/getGroupSlidesBy.js @@ -4,7 +4,7 @@ import getCellSets from '../cellSets/getCellSets'; const getGroupSlidesBy = (plotUuid) => (cellSets, componentConfig) => { if (!cellSets.accessible) return []; - const groupSlidesBy = componentConfig[plotUuid]?.config.groupSlidesBy; + const groupSlidesBy = componentConfig[plotUuid]?.config?.groupSlidesBy; if (!groupSlidesBy?.length) return []; const { properties } = cellSets; diff --git a/src/utils/cellSetOperations.js b/src/utils/cellSetOperations.js index 51869afc1e..1bbfaa74c8 100644 --- a/src/utils/cellSetOperations.js +++ b/src/utils/cellSetOperations.js @@ -20,11 +20,15 @@ const union = (listOfSets, properties) => { return new Set(); } - const sets = listOfSets.map((key) => properties[key]?.cellIds || []); - // flatten and transform list of Sets to list of lists - const unionSet = new Set( - sets.flatMap(set => [...set]), - ); + // Add each set's cellIds straight into one Set. Avoids spreading every set + // into an array and flattening into one giant array first (which allocated + // ~2x the total cell count on every call) — this runs on every checkbox tick + // and hide/unhide, so it must stay cheap for large (100k+ cell) experiments. + const unionSet = new Set(); + listOfSets.forEach((key) => { + const cellIds = properties[key]?.cellIds; + if (cellIds) cellIds.forEach((cellId) => unionSet.add(cellId)); + }); return unionSet; }; diff --git a/src/utils/data-exploration/cellInfoHoverSource.js b/src/utils/data-exploration/cellInfoHoverSource.js new file mode 100644 index 0000000000..c1e82a4939 --- /dev/null +++ b/src/utils/data-exploration/cellInfoHoverSource.js @@ -0,0 +1,12 @@ +// Identifies which Data Exploration plot the current cell hover originated from, +// stored alongside the shared cellInfo state. The hovered plot shows the full +// cell-info tooltip; linked plots still react to the shared cellInfo (embedding +// and spatial keep drawing the crosshair on the matching cell) but suppress +// their own tooltip, so it doesn't pop up over a plot the cursor isn't on. +const HOVER_SOURCE = { + embedding: 'embedding', + spatial: 'spatial', + heatmap: 'heatmap', +}; + +export default HOVER_SOURCE; diff --git a/src/utils/data-exploration/getCellInfoCoordinates.js b/src/utils/data-exploration/getCellInfoCoordinates.js index b0c7e1347a..d532d369e5 100644 --- a/src/utils/data-exploration/getCellInfoCoordinates.js +++ b/src/utils/data-exploration/getCellInfoCoordinates.js @@ -1,9 +1,12 @@ -const PDDG_TOP = 16; // px -const PDDG_BOTTOM = 16; // px -const PDDG_RIGHT = 16; // px - -const CUTOFF = 3 / 4; - +const PADDING = 16; // px gap kept between the cursor/panel edge and the tooltip + +// Position the hover tooltip next to the cursor without letting it spill out of +// the plot panel. Preference is to the right of / below the cursor; each axis +// flips to the other side only when that placement would overflow the real +// panel edge. A final clamp keeps the whole card inside the panel even when the +// tooltip is larger than the space on either side of the cursor (small +// split-view panels, tall multi-cell-set tooltips) — the flip alone can't cover +// that, which is what let the card fall outside the panel before. const getCellInfoCoordinates = (coordinates, dimensions, boundingX, boundingY) => { const { width: popupWidth, @@ -15,14 +18,17 @@ const getCellInfoCoordinates = (coordinates, dimensions, boundingX, boundingY) = y: cursorY, } = coordinates; - const invertX = () => cursorX + popupWidth > boundingX * CUTOFF; + const overflowsRight = cursorX + PADDING + popupWidth > boundingX; + const overflowsBottom = cursorY + PADDING + popupHeight > boundingY; - // Padding bottom is removed from boundingY because the embedding - // has a padding at the top part of the embedding - const invertY = () => cursorY + popupHeight + PDDG_TOP + PDDG_BOTTOM > boundingY * CUTOFF; + let left = overflowsRight ? cursorX - PADDING - popupWidth : cursorX + PADDING; + let top = overflowsBottom ? cursorY - PADDING - popupHeight : cursorY + PADDING; - const left = invertX() ? cursorX - (popupWidth + PDDG_RIGHT) : cursorX + PDDG_RIGHT; - const top = invertY() ? cursorY - (popupHeight + PDDG_BOTTOM) : cursorY + PDDG_BOTTOM; + // Keep the tooltip within [PADDING, edge - size - PADDING]. When the tooltip + // is wider/taller than the panel this pins it to the top-left corner (the most + // useful part stays visible) rather than flying off-screen. + left = Math.max(PADDING, Math.min(left, boundingX - popupWidth - PADDING)); + top = Math.max(PADDING, Math.min(top, boundingY - popupHeight - PADDING)); return { left, top }; }; diff --git a/src/utils/plotSpecs/generateSpatialCategoricalSpec.js b/src/utils/plotSpecs/generateSpatialCategoricalSpec.js index 39c9ceb674..d3d5394b9b 100644 --- a/src/utils/plotSpecs/generateSpatialCategoricalSpec.js +++ b/src/utils/plotSpecs/generateSpatialCategoricalSpec.js @@ -29,13 +29,23 @@ const generateSpec = ( ) => { const { imageWidth, imageHeight } = imageData; + // Original (pre-pad) tissue extent. Images are padded (right + bottom) to a + // common size across samples for the multi-sample grid; here (single-sample) + // we cap the view to the tissue so the blank padding isn't shown. Falls back + // to the full padded extent when originalSize is absent (un-padded images) — + // then the domains below reduce to the previous [0, imageWidth/Height]. + // The y-flip (imageHeight - y) stays anchored to the padded imageHeight, so + // tissue (top-left in data space) maps to the TOP of the flipped y-range. + const contentWidth = imageData.origWidth ?? imageWidth; + const contentHeight = imageData.origHeight ?? imageHeight; + // Initial zoom/pan domains are ALWAYS the full image extent — the spec is // intentionally invariant to config.axesRanges so persisting a zoom never changes // the spec CONTENT (react-vega's VegaEmbed rebuilds on an expensive spec change), // which would flicker the slide. The persisted zoom (config.axesRanges) is // re-applied imperatively after (re)build via the plot's onNewView (restoreZoom). - const initXdom = [0, imageWidth]; - const initYdom = [0, imageHeight]; + const initXdom = [0, contentWidth]; + const initYdom = [imageHeight - contentHeight, imageHeight]; // Plot size always matches the containing box (config.dimensions) — the styling // panel for the full plot, the fixed mini-preview tile (MiniPlot) for previews. @@ -127,12 +137,16 @@ const generateSpec = ( // 2a. Segmentation overlay — data-driven (image supplied via the `data` prop) // so recolouring is an in-place dataset update, not a view rebuild. + // smooth:false → nearest-neighbour scaling (no bilinear interpolation), so + // zoomed-in cells render as solid pixel blocks, matching the Data Exploration + // deck.gl BitmaskLayer (which samples the label texture with GL.NEAREST) + // instead of the fuzzy interpolation the canvas renderer applies by default. if (hasSegmentation) { marks.push({ type: 'image', clip: true, from: { data: 'segOverlayData' }, - encode: { update: tileEncode }, + encode: { update: { ...tileEncode, smooth: { value: false } } }, }); } else { // 2b. Centroid dots — permanent fallback when no segmentation zarr is available @@ -317,7 +331,7 @@ const generateSpec = ( ], // clamp zoom/pan to the full image extent so you can always zoom back out to it signals: spatialZoomSignals( - initXdom, initYdom, [0, imageWidth], [0, imageHeight], !config.miniPlot, + initXdom, initYdom, initXdom, initYdom, !config.miniPlot, ), scales: [ { diff --git a/src/utils/plotSpecs/generateSpatialFeatureSpec.js b/src/utils/plotSpecs/generateSpatialFeatureSpec.js index 354f8c95e3..5e0222dca5 100644 --- a/src/utils/plotSpecs/generateSpatialFeatureSpec.js +++ b/src/utils/plotSpecs/generateSpatialFeatureSpec.js @@ -21,14 +21,24 @@ const generateSpec = ( ) => { const { imageWidth, imageHeight } = imageData; + // Original (pre-pad) tissue extent. Images are padded (right + bottom) to a + // common size across samples for the multi-sample grid; here (single-sample) + // we cap the view to the tissue so the blank padding isn't shown. Falls back + // to the full padded extent when originalSize is absent (un-padded images) — + // then the domains below reduce to the previous [0, imageWidth/Height]. + // The y-flip (imageHeight - y) stays anchored to the padded imageHeight, so + // tissue (top-left in data space) maps to the TOP of the flipped y-range. + const contentWidth = imageData.origWidth ?? imageWidth; + const contentHeight = imageData.origHeight ?? imageHeight; + // Initial zoom/pan domains are ALWAYS the full image extent — the spec is // intentionally invariant to config.axesRanges so that persisting a zoom never // changes the spec CONTENT (react-vega's VegaEmbed rebuilds the view on an // expensive spec change), which would flicker the slide. The persisted zoom // (config.axesRanges) is re-applied imperatively after (re)build via the plot's // onNewView (restoreZoom), by setting the initXdom/initYdom signals. - const initXdom = [0, imageWidth]; - const initYdom = [0, imageHeight]; + const initXdom = [0, contentWidth]; + const initYdom = [imageHeight - contentHeight, imageHeight]; // Plot size always matches the containing box (config.dimensions) — the styling // panel for the full plot, the fixed mini-preview tile (MiniPlot) for previews. @@ -84,12 +94,16 @@ const generateSpec = ( // 2a. Segmentation overlay — data-driven (image supplied via the `data` prop) // so recolouring is an in-place dataset update, not a view rebuild. + // smooth:false → nearest-neighbour scaling (no bilinear interpolation), so + // zoomed-in cells render as solid pixel blocks, matching the Data Exploration + // deck.gl BitmaskLayer (which samples the label texture with GL.NEAREST) + // instead of the fuzzy interpolation the canvas renderer applies by default. if (hasSegmentation) { marks.push({ type: 'image', clip: true, from: { data: 'segOverlayData' }, - encode: { update: tileEncode }, + encode: { update: { ...tileEncode, smooth: { value: false } } }, }); } else { // 2b. Centroid dots — permanent fallback when no segmentation zarr available @@ -220,7 +234,7 @@ const generateSpec = ( ], // clamp zoom/pan to the full image extent so you can always zoom back out to it signals: spatialZoomSignals( - initXdom, initYdom, [0, imageWidth], [0, imageHeight], !config.miniPlot, + initXdom, initYdom, initXdom, initYdom, !config.miniPlot, ), scales: [ { diff --git a/src/utils/plotUtils.js b/src/utils/plotUtils.js index 33084729d7..b07581af7e 100644 --- a/src/utils/plotUtils.js +++ b/src/utils/plotUtils.js @@ -88,14 +88,19 @@ const convertCellsData = (results, hidden, properties) => { }; }; -const offsetCentroids = (results, properties, sampleIds, perImageShape, gridShape) => { +const offsetCentroids = ( + results, properties, sampleIds, perImageShape, gridShape, sampleRowCol = null, +) => { const [imageHeight, imageWidth] = perImageShape; const numColumns = gridShape[1]; - // Pre-calculate offsets for each sampleId + // Pre-calculate offsets for each sampleId. sampleRowCol (from + // buildSpatialGridLayout) places grouped samples; without it, fall back to + // dense row-major placement. const sampleOffsets = sampleIds.map((sampleId, sampleIndex) => { - const row = Math.floor(sampleIndex / numColumns); - const column = sampleIndex % numColumns; + const rowCol = sampleRowCol?.[sampleIndex]; + const row = rowCol ? rowCol.row : Math.floor(sampleIndex / numColumns); + const column = rowCol ? rowCol.col : sampleIndex % numColumns; return { xOffset: column * imageWidth, yOffset: row * imageHeight, diff --git a/src/utils/spatial/buildSpatialGridLayout.js b/src/utils/spatial/buildSpatialGridLayout.js new file mode 100644 index 0000000000..aa40a8f539 --- /dev/null +++ b/src/utils/spatial/buildSpatialGridLayout.js @@ -0,0 +1,59 @@ +// Grid placement for the multi-sample spatial view. +// +// The tissue images, segmentation bitmasks, cell centroids and molecule points +// are all laid out on a shared grid keyed by sample index (the order of +// omeZarrSampleIds). This helper produces the single source of truth all of +// those consumers use so they stay aligned: +// - gridShape: [numRows, numColumns] +// - slotToSampleIndex: length numRows*numColumns; slot -> sample index, or +// null for an empty "filler" cell +// - sampleRowCol: length numSamples; sample index -> { row, col } +// +// Ungrouped (default when no metadata grouping is selected): dense row-major +// with at most MAX_UNGROUPED_COLUMNS per row. Samples are placed in +// orderedSampleIndices order (the Cell sets & metadata tile's sample order); +// defaults to natural index order when not provided. +// +// Grouped: one row per group, in the given group order; a group's samples fill +// columns left-to-right (in the order given in sampleIndices); the grid is as +// wide as the largest group, so smaller groups leave empty filler cells at the +// end of their row. + +const MAX_UNGROUPED_COLUMNS = 4; + +const buildSpatialGridLayout = (numSamples, groups = null, orderedSampleIndices = null) => { + const sampleRowCol = new Array(numSamples).fill(null); + + if (!groups || groups.length === 0) { + const order = orderedSampleIndices + ?? Array.from({ length: numSamples }, (_, i) => i); + const numColumns = Math.min(Math.max(order.length, 1), MAX_UNGROUPED_COLUMNS); + const numRows = Math.max(1, Math.ceil(order.length / numColumns)); + const slotToSampleIndex = new Array(numRows * numColumns).fill(null); + + order.forEach((sampleIndex, slot) => { + const row = Math.floor(slot / numColumns); + const col = slot % numColumns; + slotToSampleIndex[slot] = sampleIndex; + sampleRowCol[sampleIndex] = { row, col }; + }); + + return { gridShape: [numRows, numColumns], slotToSampleIndex, sampleRowCol }; + } + + const numRows = groups.length; + const numColumns = Math.max(1, ...groups.map((group) => group.sampleIndices.length)); + const slotToSampleIndex = new Array(numRows * numColumns).fill(null); + + groups.forEach((group, row) => { + group.sampleIndices.forEach((sampleIndex, col) => { + slotToSampleIndex[row * numColumns + col] = sampleIndex; + sampleRowCol[sampleIndex] = { row, col }; + }); + }); + + return { gridShape: [numRows, numColumns], slotToSampleIndex, sampleRowCol }; +}; + +export default buildSpatialGridLayout; +export { MAX_UNGROUPED_COLUMNS };