From cb0becc98cfd35623a4660179f8a9de0f85aa1f6 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 21 Oct 2026 13:00:25 -0400 Subject: [PATCH 1/5] fix(views): key cart channels by index, not name Datasets can repeat a channel label, so name-keyed entries made one checkbox toggle every channel that shared the label. --- .../componentTests/CartContext.test.tsx | 55 ++++++++++++++++++- .../__tests__/componentTests/CartTab.test.tsx | 5 +- .../components/ui/Views/CartDatasetRow.tsx | 15 ++--- frontend/src/contexts/CartContext.tsx | 20 ++++--- 4 files changed, 76 insertions(+), 19 deletions(-) diff --git a/frontend/src/__tests__/componentTests/CartContext.test.tsx b/frontend/src/__tests__/componentTests/CartContext.test.tsx index 58c56a8d9..d412b562b 100644 --- a/frontend/src/__tests__/componentTests/CartContext.test.tsx +++ b/frontend/src/__tests__/componentTests/CartContext.test.tsx @@ -43,11 +43,34 @@ function Probe() { + + ); @@ -87,7 +110,13 @@ describe('CartContext', () => { it('removeManyFromCart filters all removals in a single persist call', async () => { cartData = [ { fsp_name: 'fsp', path: '/a', label: 'a' }, - { fsp_name: 'fsp', path: '/b', channel: 'DAPI', label: 'DAPI' }, + { + fsp_name: 'fsp', + path: '/b', + channel: 'DAPI', + channelIndex: 0, + label: 'DAPI' + }, { fsp_name: 'fsp', path: '/c', label: 'c' } ]; const user = userEvent.setup(); @@ -104,6 +133,28 @@ describe('CartContext', () => { }); }); + it('keys channels by index, so repeated channel names stay distinct', async () => { + const user = userEvent.setup(); + const { rerender } = render( + + + + ); + await user.click(screen.getByText('addRepeated')); + expect(mutateAsync.mock.calls[0][0].value).toHaveLength(2); + + cartData = mutateAsync.mock.calls[0][0].value; + rerender( + + + + ); + await user.click(screen.getByText('removeRepeated')); + expect(mutateAsync.mock.calls[1][0].value).toEqual([ + expect.objectContaining({ channelIndex: 1 }) + ]); + }); + it('clearCart persists an empty array', async () => { const user = userEvent.setup(); render( diff --git a/frontend/src/__tests__/componentTests/CartTab.test.tsx b/frontend/src/__tests__/componentTests/CartTab.test.tsx index 643b41141..66d4c9d33 100644 --- a/frontend/src/__tests__/componentTests/CartTab.test.tsx +++ b/frontend/src/__tests__/componentTests/CartTab.test.tsx @@ -32,6 +32,7 @@ const cartAGfp: CartItem = { fsp_name: 'fsp1', path: '/a', channel: 'GFP', + channelIndex: 1, label: 'GFP' }; const cartB: CartItem = { fsp_name: 'fsp2', path: '/b', label: 'Dataset B' }; @@ -267,8 +268,8 @@ describe('Layer Cart tab', () => { expect(removeManyFromCart).toHaveBeenCalledTimes(1); }); expect(removeManyFromCart).toHaveBeenCalledWith([ - { path: '/a', channel: undefined }, - { path: '/a', channel: 'GFP' } + { path: '/a', channelIndex: undefined }, + { path: '/a', channelIndex: 1 } ]); // The bug being regression-tested: no per-item loop calling single-remove. expect(removeFromCart).not.toHaveBeenCalled(); diff --git a/frontend/src/components/ui/Views/CartDatasetRow.tsx b/frontend/src/components/ui/Views/CartDatasetRow.tsx index a47d4402c..87c250146 100644 --- a/frontend/src/components/ui/Views/CartDatasetRow.tsx +++ b/frontend/src/components/ui/Views/CartDatasetRow.tsx @@ -31,14 +31,15 @@ export default function CartDatasetRow({ }: CartDatasetRowProps) { const { addToCart, removeFromCart, removeManyFromCart } = useCartContext(); - const checkedChannels = new Set( - items.map(item => item.channel).filter((c): c is string => Boolean(c)) - ); + const checkedIndices = new Set(items.map(item => item.channelIndex)); const handleRemoveDataset = async () => { try { await removeManyFromCart( - items.map(item => ({ path: item.path, channel: item.channel })) + items.map(item => ({ + path: item.path, + channelIndex: item.channelIndex + })) ); } catch (error) { toast.error( @@ -58,7 +59,7 @@ export default function CartDatasetRow({ { fsp_name, path, channel, label: channel, channelIndex: index } ]); } else { - await removeFromCart(path, channel); + await removeFromCart(path, index); } } catch (error) { toast.error( @@ -84,8 +85,8 @@ export default function CartDatasetRow({ {channels.map((channel, index) => ( void handleToggleChannel(channel, index, e.target.checked) diff --git a/frontend/src/contexts/CartContext.tsx b/frontend/src/contexts/CartContext.tsx index cc30da2b2..3769ba000 100644 --- a/frontend/src/contexts/CartContext.tsx +++ b/frontend/src/contexts/CartContext.tsx @@ -7,13 +7,13 @@ import type { CartItem } from '@/queries/preferencesQueries'; export type { CartItem }; -type CartRemoval = { path: string; channel?: string }; +type CartRemoval = { path: string; channelIndex?: number }; type CartContextType = { cart: CartItem[]; cartCount: number; addToCart: (items: CartItem[]) => Promise; - removeFromCart: (path: string, channel?: string) => Promise; + removeFromCart: (path: string, channelIndex?: number) => Promise; removeManyFromCart: (removals: CartRemoval[]) => Promise; clearCart: () => Promise; }; @@ -28,8 +28,10 @@ export const useCartContext = () => { return context; }; -// Identity of a cart entry: fsp + path + channel (channel-per-layer). -const itemKey = (i: CartItem) => `${i.fsp_name}::${i.path}::${i.channel ?? ''}`; +// Identity of a cart entry: fsp + path + channel index (channel-per-layer). +// The index, not the name: a dataset can repeat a channel name. +const itemKey = (i: CartItem) => + `${i.fsp_name}::${i.path}::${i.channelIndex ?? ''}`; export const CartProvider = ({ children @@ -74,9 +76,9 @@ export const CartProvider = ({ ); const removeFromCart = useCallback( - async (path: string, channel?: string) => { + async (path: string, channelIndex?: number) => { await persist( - cart.filter(i => !(i.path === path && i.channel === channel)) + cart.filter(i => !(i.path === path && i.channelIndex === channelIndex)) ); }, [cart, persist] @@ -87,9 +89,11 @@ export const CartProvider = ({ // same pre-loop `cart`/`persist` and overwrite the previous removal). const removeManyFromCart = useCallback( async (removals: CartRemoval[]) => { - const keys = new Set(removals.map(r => `${r.path}::${r.channel ?? ''}`)); + const keys = new Set( + removals.map(r => `${r.path}::${r.channelIndex ?? ''}`) + ); await persist( - cart.filter(i => !keys.has(`${i.path}::${i.channel ?? ''}`)) + cart.filter(i => !keys.has(`${i.path}::${i.channelIndex ?? ''}`)) ); }, [cart, persist] From f83f8fe3aa0db60df9882220415f8949fdff3fcf Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 21 Oct 2026 13:00:26 -0400 Subject: [PATCH 2/5] fix(viewer): rename repeated channel layers Neuroglancer needs unique layer names, so channels that shared a label loaded as a single layer. --- .../__tests__/unitTests/omezarrHelper.test.ts | 42 ++++++++++++++++++- frontend/src/omezarr-helper.ts | 9 +++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/frontend/src/__tests__/unitTests/omezarrHelper.test.ts b/frontend/src/__tests__/unitTests/omezarrHelper.test.ts index e469e5e59..007f2ddf2 100644 --- a/frontend/src/__tests__/unitTests/omezarrHelper.test.ts +++ b/frontend/src/__tests__/unitTests/omezarrHelper.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from 'vitest'; -import { getNeuroglancerSource } from '@/omezarr-helper'; +import { + generateNeuroglancerStateForOmeZarr, + getNeuroglancerSource +} from '@/omezarr-helper'; describe('getNeuroglancerSource with version selection', () => { it('should generate v3 source URL when v3 is selected', () => { @@ -35,3 +38,40 @@ describe('getNeuroglancerSource with version selection', () => { ); }); }); + +describe('generateNeuroglancerStateForOmeZarr per-channel layers', () => { + it('gives repeated channel labels unique layer names', () => { + const multiscale = { + axes: [ + { name: 'c', type: 'channel' }, + { name: 'y', type: 'space' }, + { name: 'x', type: 'space' } + ], + datasets: [ + { + path: '0', + coordinateTransformations: [{ type: 'scale', scale: [1, 1, 1] }] + } + ] + }; + const omero = { + channels: [{ label: 'Gpb5' }, { label: 'Pale' }, { label: 'Gpb5' }] + }; + const encoded = generateNeuroglancerStateForOmeZarr( + 'https://example.com/data.zarr', + 3, + 'image', + multiscale as any, + { shape: [3, 10, 10], dtype: 'uint16' } as any, + undefined, + omero as any, + true + ); + const state = JSON.parse(decodeURIComponent(encoded!)); + expect(state.layers.map((l: any) => l.name)).toEqual([ + 'Gpb5', + 'Pale', + 'Gpb5 (2)' + ]); + }); +}); diff --git a/frontend/src/omezarr-helper.ts b/frontend/src/omezarr-helper.ts index a74f92dcb..a642bc23d 100644 --- a/frontend/src/omezarr-helper.ts +++ b/frontend/src/omezarr-helper.ts @@ -608,8 +608,13 @@ function generateFullNeuroglancerStateForOmeZarr( channels[0].color = 'white'; } - // Add layers for each channel + // Add layers for each channel. NG layer names must be unique, and some + // datasets repeat a channel label, so repeats become `name (2)`, ... + const nameCounts = new Map(); channels.forEach((channel, i) => { + const count = (nameCounts.get(channel.name) ?? 0) + 1; + nameCounts.set(channel.name, count); + // Format color let color = channel.color; if (/^[\dA-F]{6}$/.test(color)) { @@ -622,7 +627,7 @@ function generateFullNeuroglancerStateForOmeZarr( const transform = { outputDimensions: localDimensions }; const layer: Record = { - name: channel.name, + name: count > 1 ? `${channel.name} (${count})` : channel.name, type: layerType, source: { url: sourceUrl, From cf044b612bdd8e5577b606ea988808820c218c98 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 21 Oct 2026 13:00:27 -0400 Subject: [PATCH 3/5] fix(views): dedupe layer names across datasets Two datasets in one View can share a layer name, such as DAPI, and Neuroglancer drops one of them. --- .../__tests__/unitTests/viewCheckout.test.ts | 18 ++++++++++++++++++ frontend/src/utils/viewCheckout.ts | 6 +++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/frontend/src/__tests__/unitTests/viewCheckout.test.ts b/frontend/src/__tests__/unitTests/viewCheckout.test.ts index 095ea7dca..528c09e75 100644 --- a/frontend/src/__tests__/unitTests/viewCheckout.test.ts +++ b/frontend/src/__tests__/unitTests/viewCheckout.test.ts @@ -285,6 +285,24 @@ describe('buildViewState', () => { expect(layers[0].localPosition).toEqual([0]); expect(layers[1].localPosition).toEqual([2]); }); + it('renames layers that would share a name across datasets', async () => { + (generateNeuroglancerStateForOmeZarr as any).mockReturnValue( + encoded({ layers: [{ name: 'DAPI' }] }) + ); + const ds = (path: string) => ({ + url: path, + sharing_key: path, + url_prefix: path, + fsp_name: 'f', + path, + label: path + }); + const { ng_state } = await buildViewState([ds('/a'), ds('/b')]); + expect((ng_state as any).layers.map((l: any) => l.name)).toEqual([ + 'DAPI', + 'DAPI (2)' + ]); + }); }); describe('probeDataset', () => { diff --git a/frontend/src/utils/viewCheckout.ts b/frontend/src/utils/viewCheckout.ts index 7cf82b48c..bbd37871a 100644 --- a/frontend/src/utils/viewCheckout.ts +++ b/frontend/src/utils/viewCheckout.ts @@ -124,6 +124,7 @@ export async function buildViewState( const viewLayers: ViewLayerInput[] = []; const unsupported: ResolvedCheckoutDataset[] = []; let base: NgState | null = null; + const taken = new Set(); for (const ds of datasets) { const state = await generateStateForDataset(ds); @@ -136,7 +137,10 @@ export async function buildViewState( } for (const layer of selectLayers(state, ds.channelIndex)) { const layer_index = combinedLayers.length; - combinedLayers.push({ ...layer, archived: layer_index >= 4 }); + // Datasets can share layer names (e.g. two with a "DAPI" channel). + const name = uniqueName(layer.name ?? 'layer', taken); + taken.add(name); + combinedLayers.push({ ...layer, name, archived: layer_index >= 4 }); viewLayers.push({ sharing_key: ds.sharing_key, layer_index, From e3c195a8f5bbdcad3400fa6c5a1b3ff40ce1b65e Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 21 Oct 2026 14:12:04 -0400 Subject: [PATCH 4/5] feat(views): wrap cart channel lists into columns Datasets with dozens of channels made the Layer Cart a single long column. --- .../components/ui/Views/CartDatasetRow.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/ui/Views/CartDatasetRow.tsx b/frontend/src/components/ui/Views/CartDatasetRow.tsx index 87c250146..a13504289 100644 --- a/frontend/src/components/ui/Views/CartDatasetRow.tsx +++ b/frontend/src/components/ui/Views/CartDatasetRow.tsx @@ -83,16 +83,19 @@ export default function CartDatasetRow({ Optional: select channels to create per-channel layers - {channels.map((channel, index) => ( - - void handleToggleChannel(channel, index, e.target.checked) - } - /> - ))} + {/* Column-major, so channels still read in c-axis order. */} +
+ {channels.map((channel, index) => ( + + void handleToggleChannel(channel, index, e.target.checked) + } + /> + ))} +
)} /> From b6e4f742241a1d748228ebe6942debfeb3c1bee8 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 21 Oct 2026 14:12:06 -0400 Subject: [PATCH 5/5] feat(views): list every dataset channel in Sources Sources listed only the channels already in the View, so adding another channel from the same dataset meant a trip back to the Add data tab. --- .../componentTests/useAddToView.test.tsx | 18 +++++ .../unitTests/useViewSources.test.tsx | 38 ++++++++++- .../__tests__/unitTests/viewCheckout.test.ts | 9 +++ .../src/components/ui/Views/SourcesList.tsx | 66 +++++++++++++------ .../src/components/ui/Views/ViewerSidebar.tsx | 43 ++++++++++++ frontend/src/hooks/useAddToView.ts | 17 +++-- frontend/src/hooks/useViewSources.ts | 48 ++++++++++++++ frontend/src/utils/viewCheckout.ts | 10 ++- 8 files changed, 219 insertions(+), 30 deletions(-) diff --git a/frontend/src/__tests__/componentTests/useAddToView.test.tsx b/frontend/src/__tests__/componentTests/useAddToView.test.tsx index 003e9b24b..7e5b05f17 100644 --- a/frontend/src/__tests__/componentTests/useAddToView.test.tsx +++ b/frontend/src/__tests__/componentTests/useAddToView.test.tsx @@ -1,6 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook } from '@testing-library/react'; +import toast from 'react-hot-toast'; + import { makeFakeBridge } from '@/__tests__/mocks/fakeNeuroglancer'; import type { CartItem } from '@/contexts/CartContext'; import type { ViewLayer } from '@/queries/viewQueries'; @@ -62,6 +64,22 @@ describe('useAddToView', () => { expect(onAdded).toHaveBeenCalledWith([resolvedOf(ok, 'k1')]); }); + it('counts channels, not datasets, when adding channels', async () => { + const gpb5 = { ...ok, channel: 'Gpb5', channelIndex: 4 }; + resolveCartDatasets.mockResolvedValue([resolvedOf(gpb5, 'k1')]); + buildViewState.mockResolvedValue({ + ng_state: { layers: [{ name: 'Gpb5' }] }, + layers: [ + { sharing_key: 'k1', layer_index: 0, channel: 'Gpb5', opts: null } + ] + }); + const success = vi.spyOn(toast, 'success'); + const { addToView } = setup(); + await addToView([gpb5]); + expect(success).toHaveBeenCalledWith('Added 1 channel. Save to keep it.'); + success.mockRestore(); + }); + it('records unsupported datasets as sources', async () => { resolveCartDatasets.mockResolvedValue([ resolvedOf(ok, 'k1'), diff --git a/frontend/src/__tests__/unitTests/useViewSources.test.tsx b/frontend/src/__tests__/unitTests/useViewSources.test.tsx index 5a4082fe6..9b5df75a9 100644 --- a/frontend/src/__tests__/unitTests/useViewSources.test.tsx +++ b/frontend/src/__tests__/unitTests/useViewSources.test.tsx @@ -2,7 +2,11 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { act, renderHook } from '@testing-library/react'; import { makeFakeBridge } from '@/__tests__/mocks/fakeNeuroglancer'; -import { groupLayersBySource, useViewSources } from '@/hooks/useViewSources'; +import { + channelRows, + groupLayersBySource, + useViewSources +} from '@/hooks/useViewSources'; import type { ViewLayer } from '@/queries/viewQueries'; import type { ResolvedCheckoutDataset } from '@/utils/viewCheckout'; @@ -34,6 +38,38 @@ const row = ( ...extra }); +describe('channelRows', () => { + const channelLayer = (name: string, index: number) => ({ + ...layer(name, 'K1', 'a.zarr'), + localPosition: [index] + }); + + it('lists every channel of a per-channel dataset, linked to its layer', () => { + const [source] = groupLayersBySource( + [channelLayer('Gpb5 (2)', 2), channelLayer('DAPI', 0)], + [ds('K1', 'data/a.zarr')], + [] + ); + expect(channelRows(source, ['DAPI', 'Gpb5', 'Gpb5'])).toEqual([ + { key: 'channel:0', label: 'DAPI', layerName: 'DAPI' }, + { key: 'channel:1', label: 'Gpb5', layerName: undefined }, + { key: 'channel:2', label: 'Gpb5', layerName: 'Gpb5 (2)' } + ]); + }); + + it('falls back to the layer names when a layer is not one channel', () => { + const [source] = groupLayersBySource( + [layer('a DAPI', 'K1', 'a.zarr'), layer('a GFP', 'K1', 'a.zarr')], + [ds('K1', 'data/a.zarr')], + [] + ); + expect(channelRows(source, ['DAPI', 'GFP'])?.map(r => r.label)).toEqual([ + 'a DAPI', + 'a GFP' + ]); + }); +}); + describe('groupLayersBySource', () => { it('groups per-channel layers into their dataset, in first-layer order', () => { const sources = groupLayersBySource( diff --git a/frontend/src/__tests__/unitTests/viewCheckout.test.ts b/frontend/src/__tests__/unitTests/viewCheckout.test.ts index 528c09e75..5b2e0cb91 100644 --- a/frontend/src/__tests__/unitTests/viewCheckout.test.ts +++ b/frontend/src/__tests__/unitTests/viewCheckout.test.ts @@ -368,6 +368,15 @@ describe('appendLayers', () => { expect(merged.layers?.[2].name).toBe('img.zarr (3)'); }); + it('keeps added layers shown past index 4 when asked', () => { + const merged = appendLayers( + { layers: [{ name: 'a' }, { name: 'b' }, { name: 'c' }, { name: 'd' }] }, + [{ name: 'e' }], + true + ); + expect(merged.layers?.[4].archived).toBe(false); + }); + it('archives added layers from index 4 on and leaves existing layers alone', () => { const merged = appendLayers( { diff --git a/frontend/src/components/ui/Views/SourcesList.tsx b/frontend/src/components/ui/Views/SourcesList.tsx index 5d09dfe4a..07598cdbb 100644 --- a/frontend/src/components/ui/Views/SourcesList.tsx +++ b/frontend/src/components/ui/Views/SourcesList.tsx @@ -9,6 +9,7 @@ import { useCartDimensionCheck } from '@/hooks/useCartDimensionCheck'; import { useExpandAll } from '@/hooks/useExpandAll'; import { datasetKey } from '@/utils/pathHandling'; import type { CartItem } from '@/contexts/CartContext'; +import { channelRows } from '@/hooks/useViewSources'; import type { LiveSource } from '@/hooks/useViewSources'; type SourcesListProps = { @@ -16,13 +17,23 @@ type SourcesListProps = { readonly onRemove: (source: LiveSource) => void; /** Archives a layer (shown false) or brings it back. */ readonly onToggleLayer: (layerName: string, shown: boolean) => void; + /** Channels being added, as `${source.key}::${index}`. */ + readonly addingChannels: Set; + /** Adds a channel of the dataset that isn't in the View yet. */ + readonly onAddChannel: ( + source: LiveSource, + channel: string, + index: number + ) => void; }; /** The datasets in the viewer, each with its metadata and a remove button. */ export default function SourcesList({ sources, onRemove, - onToggleLayer + onToggleLayer, + addingChannels, + onAddChannel }: SourcesListProps) { // Probes are cached per dataset, so these share the Add data tab's. const datasets = useMemo( @@ -67,26 +78,43 @@ export default function SourcesList({ )} onRemove={() => onRemove(source)} path={source.path} - renderChannels={ - // NG splits a multichannel dataset into a layer per channel. - source.layerNames.length > 1 - ? () => ( -
- - Uncheck a channel to remove it; check it to add it back - - {source.layerNames.map(name => ( + renderChannels={channels => { + const rows = channelRows(source, channels); + return rows ? ( +
+ + Check a channel to show it; uncheck it to hide it + + {/* Column-major, so channels still read in c-axis order. */} +
+ {rows.map((row, index) => { + const adding = addingChannels.has( + `${source.key}::${index}` + ); + return ( onToggleLayer(name, e.target.checked)} + checked={ + row.layerName === undefined + ? adding + : !source.archivedNames.includes(row.layerName) + } + disabled={adding} + key={row.key} + label={row.label} + onChange={e => { + if (row.layerName !== undefined) { + onToggleLayer(row.layerName, e.target.checked); + } else if (e.target.checked) { + onAddChannel(source, row.label, index); + } + }} /> - ))} -
- ) - : undefined - } + ); + })} +
+
+ ) : null; + }} tense="present" /> ) : ( diff --git a/frontend/src/components/ui/Views/ViewerSidebar.tsx b/frontend/src/components/ui/Views/ViewerSidebar.tsx index d99bc9413..25edbd58b 100644 --- a/frontend/src/components/ui/Views/ViewerSidebar.tsx +++ b/frontend/src/components/ui/Views/ViewerSidebar.tsx @@ -77,6 +77,47 @@ export default function ViewerSidebar({ toast.success(`Removed ${source.label}. Save to keep the change.`); }; + // Shown at once, even past the 4th layer: the owner asked to see it. + // Channels being added, as `${source.key}::${index}`: a second click + // before the first add lands must not add the channel twice. + const [addingChannels, setAddingChannels] = useState>( + () => new Set() + ); + const addChannel = (source: LiveSource, channel: string, index: number) => { + const key = `${source.key}::${index}`; + if (source.fsp_name === null || source.path === null) { + return; + } + if (addingChannels.has(key)) { + return; + } + setAddingChannels(prev => new Set(prev).add(key)); + addToView( + [ + { + fsp_name: source.fsp_name, + path: source.path, + channel, + label: channel, + channelIndex: index + } + ], + { shown: true } + ) + .catch(error => { + toast.error( + error instanceof Error ? error.message : 'Failed to add channel' + ); + }) + .finally(() => { + setAddingChannels(prev => { + const next = new Set(prev); + next.delete(key); + return next; + }); + }); + }; + // Archived layers stay in the state, so checking one again restores it // with its settings. const toggleLayer = (layerName: string, shown: boolean) => { @@ -137,6 +178,8 @@ export default function ViewerSidebar({ role="tabpanel" > void; readonly onAdded: (datasets: ResolvedCheckoutDataset[]) => void; } -): (items: CartItem[]) => Promise { +): (items: CartItem[], options?: { shown?: boolean }) => Promise { const { resolveCartDatasets } = useCartCheckout(); // The sidebar unmounts when NG reloads (the bridge leaves `ready`). An add // still in flight then holds the dead viewer, so it must stop. @@ -50,7 +50,7 @@ export function useAddToView( }, []); return useCallback( - async (items: CartItem[]) => { + async (items: CartItem[], options?: { shown?: boolean }) => { // Snapshot before resolving: creating a Data Link relinks this View's // broken layers on that dataset server-side and refetches the rows. const broken = viewLayers.filter(isRelinkableLayer); @@ -97,21 +97,24 @@ export function useAddToView( } onAdded(resolved); if (added.length > 0 || relinked) { - bridge.setState(appendLayers(current, added)); + bridge.setState(appendLayers(current, added, options?.shown)); onEdited(); } if (unsupportedKeys.length > 0) { onAddSources(unsupportedKeys); } - const count = new Set( - resolved.map(ds => datasetKey(ds.fsp_name, ds.path)) - ).size; + // Picking channels of a dataset in the View adds channels, not datasets. + const channelsOnly = resolved.every(ds => ds.channel !== undefined); + const noun = channelsOnly ? 'channel' : 'dataset'; + const count = channelsOnly + ? resolved.length + : new Set(resolved.map(ds => datasetKey(ds.fsp_name, ds.path))).size; const skipped = unsupportedKeys.length ? ` ${unsupportedKeys.length} can't display in Neuroglancer and will be listed as ${unsupportedKeys.length === 1 ? 'a source' : 'sources'} only.` : ''; toast.success( - `Added ${count} dataset${count === 1 ? '' : 's'}. Save to keep ${count === 1 ? 'it' : 'them'}.${skipped}` + `Added ${count} ${noun}${count === 1 ? '' : 's'}. Save to keep ${count === 1 ? 'it' : 'them'}.${skipped}` ); }, [resolveCartDatasets, bridge, viewLayers, onEdited, onAddSources, onAdded] diff --git a/frontend/src/hooks/useViewSources.ts b/frontend/src/hooks/useViewSources.ts index 93b5c09c0..63a939553 100644 --- a/frontend/src/hooks/useViewSources.ts +++ b/frontend/src/hooks/useViewSources.ts @@ -29,11 +29,21 @@ export type LiveSource = { layerNames: string[]; /** Those of layerNames that are archived: kept in the state, not shown. */ archivedNames: string[]; + /** Per layerNames entry: its channel index, or null if not one channel. */ + layerChannels: (number | null)[]; sharing_key: string | null; /** Recorded as a source only: it can't load as a Neuroglancer layer. */ unsupported: boolean; }; +// A per-channel layer pins its one local dimension, the channel axis. +function channelOf(layer: NgLayer): number | null { + const pos = layer.localPosition; + return Array.isArray(pos) && pos.length === 1 && typeof pos[0] === 'number' + ? pos[0] + : null; +} + const basename = (path: string) => path.replace(/\/+$/, '').split('/').pop() || path; @@ -63,6 +73,7 @@ export function groupLayersBySource( label: name, layerNames: [name], archivedNames: layer.archived ? [name] : [], + layerChannels: [channelOf(layer)], sharing_key: null, unsupported: false }); @@ -72,6 +83,7 @@ export function groupLayersBySource( const row = rows.get(key); if (row) { row.layerNames.push(name); + row.layerChannels.push(channelOf(layer)); if (layer.archived) { row.archivedNames.push(name); } @@ -83,6 +95,7 @@ export function groupLayersBySource( label: basename(ds.path), layerNames: [name], archivedNames: layer.archived ? [name] : [], + layerChannels: [channelOf(layer)], sharing_key: ds.sharing_key, unsupported: false }); @@ -98,6 +111,7 @@ export function groupLayersBySource( label: basename(ds.path), layerNames: [], archivedNames: [], + layerChannels: [], sharing_key: ds.sharing_key, unsupported: true }); @@ -106,6 +120,40 @@ export function groupLayersBySource( return [...rows.values()]; } +/** A checkbox in a Sources row; no layerName if not in the View yet. */ +export type ChannelRow = { key: string; label: string; layerName?: string }; + +// When every layer of the dataset is one channel, list all of its channels, +// so channels never added to the View can be added here. Otherwise list the +// layers NG split the dataset into. +export function channelRows( + source: LiveSource, + channels: string[] +): ChannelRow[] | null { + const perChannel = + channels.length > 0 && + source.layerChannels.length > 0 && + source.layerChannels.every(c => c !== null); + if (perChannel) { + return channels.map((label, index) => { + const i = source.layerChannels.indexOf(index); + return { + key: `channel:${index}`, + label, + layerName: i >= 0 ? source.layerNames[i] : undefined + }; + }); + } + if (source.layerNames.length > 1) { + return source.layerNames.map(name => ({ + key: name, + label: name, + layerName: name + })); + } + return null; +} + // NG fires `changed` on every camera frame. const THROTTLE_MS = 300; diff --git a/frontend/src/utils/viewCheckout.ts b/frontend/src/utils/viewCheckout.ts index bbd37871a..97cc30db0 100644 --- a/frontend/src/utils/viewCheckout.ts +++ b/frontend/src/utils/viewCheckout.ts @@ -191,8 +191,12 @@ function uniqueName(name: string, taken: Set): string { // Adds layers to an existing View's state. The View keeps its dimensions, // layout and selected layer. Added layers from index 4 on start archived, -// the same rule as checkout. -export function appendLayers(current: NgState, added: NgLayer[]): NgState { +// the same rule as checkout, unless `shown` asks for them to stay visible. +export function appendLayers( + current: NgState, + added: NgLayer[], + shown = false +): NgState { const layers = [...(current.layers ?? [])]; const taken = new Set( layers.map(l => l.name).filter((n): n is string => typeof n === 'string') @@ -200,7 +204,7 @@ export function appendLayers(current: NgState, added: NgLayer[]): NgState { for (const layer of added) { const name = uniqueName(layer.name ?? 'layer', taken); taken.add(name); - layers.push({ ...layer, name, archived: layers.length >= 4 }); + layers.push({ ...layer, name, archived: !shown && layers.length >= 4 }); } return { ...current, layers }; }