+ {shouldShowTomogramDropdown && (
+
+
+ {tomograms.map((tomogram) => {
+ return (
+ {
+ if (selectedTomogram(tomogram)) return
+ voxelSpacing.current = tomogram.voxelSpacing
+ alignmentId.current = tomogram.alignment?.id || 0
+ updateState((state) => {
+ return {
+ ...state,
+ neuroglancer: tomogram.neuroglancerConfig
+ ? replaceOnlyTomogram(
+ state.neuroglancer,
+ JSON.parse(
+ tomogram.neuroglancerConfig,
+ ) as NeuroglancerState,
+ )
+ : replaceOnlyTomogramSource(
+ state.neuroglancer,
+ toZarr(tomogram.httpsMrcFile)!,
+ ),
+ }
+ })
+ }}
+ title={getTomogramName(tomogram)}
+ subtitle={[
+ `${IdPrefix.Tomogram}-${tomogram.id}`,
+ `${t('unitAngstrom', { value: tomogram.voxelSpacing })} (${tomogram.sizeX}, ${tomogram.sizeY}, ${tomogram.sizeZ}) px`,
+ tomogram.alignment?.id != null &&
+ `${IdPrefix.Alignment}-${tomogram.alignment.id}`,
+ ]
+ .filter(Boolean)
+ .join(' ยท ')}
+ />
+ )
+ })}
+
+
+ )}
{shouldShowAnnotationDropdown && (
toggleAllDepositions()}
- >
- {t('allDepositions')}
-
+ title={t('allDepositions')}
+ />
{Object.entries(depositionConfigs).map(
([depositionId, depositions]) => {
const layersOfInterest = depositions.map((c) => c.name)
@@ -366,15 +451,12 @@ export function ViewerPage({
onSelect={() => {
toggleDepositions(layersOfInterest)
}}
- >
-
- {depositions?.[0].annotation?.deposition?.title ||
- 'Deposition'}
-
-
- {IdPrefix.Deposition}-{depositionId}
-
-
+ title={
+ depositions?.[0].annotation?.deposition?.title ||
+ 'Deposition'
+ }
+ subtitle={`${IdPrefix.Deposition}-${depositionId}`}
+ />
)
},
)}
@@ -388,53 +470,45 @@ export function ViewerPage({
isCurrentLayout('4panel') || isCurrentLayout('4panel-alt')
}
onSelect={() => setCurrentLayout('4panel')}
- >
- {t('4panels')}
-
+ title={t('4panels')}
+ />
setCurrentLayout('xy')}
- >
- XY
-
+ title="XY"
+ />
setCurrentLayout('xz')}
- >
- XZ
-
+ title="XZ"
+ />
setCurrentLayout('yz')}
- >
- YZ
-
+ title="YZ"
+ />
setCurrentLayout('3d')}
- >
- 3D
-
+ title="3D"
+ />
togglePanels()}
- >
- {t('hideUI')}
-
+ title={t('hideUI')}
+ />
toggleTopBar()}
- >
- {t('showTopLayerBar')}
-
+ title={t('showTopLayerBar')}
+ />
- {t('showPositionSelector')}
-
+ title={t('showPositionSelector')}
+ />
@@ -442,36 +516,36 @@ export function ViewerPage({
@@ -480,9 +554,9 @@ export function ViewerPage({
diff --git a/frontend/packages/data-portal/app/components/Viewer/state.ts b/frontend/packages/data-portal/app/components/Viewer/state.ts
index dfefa14f1..8f5be8d71 100644
--- a/frontend/packages/data-portal/app/components/Viewer/state.ts
+++ b/frontend/packages/data-portal/app/components/Viewer/state.ts
@@ -5,7 +5,10 @@ import {
currentNeuroglancer,
currentNeuroglancerState,
currentState,
+ DimensionValue,
+ getLayerSourceUrl,
NeuroglancerLayout,
+ NeuroglancerState,
ResolvedSuperState,
updateState,
} from 'neuroglancer'
@@ -301,6 +304,123 @@ export function toggleOrMakeDimensionPanel() {
else updateState(toggleDimensionPanelVisible)
}
+export function isTomogramActivatedFromConfig(
+ tomogramConfig: string | undefined | null,
+) {
+ if (!tomogramConfig) return false
+ const layers = currentNeuroglancerState().layers || []
+ const jsonConfig = JSON.parse(tomogramConfig) as NeuroglancerState
+ const newLayers = jsonConfig.layers || []
+ const tomogramLayer = newLayers.find((l) => l.type === 'image')
+ if (!tomogramLayer) return false
+ return layers.some(
+ (l) =>
+ l.type === 'image' &&
+ getLayerSourceUrl(l) === getLayerSourceUrl(tomogramLayer),
+ )
+}
+
+export function isTomogramActivated(tomogramPath: string | undefined | null) {
+ if (!tomogramPath) return false
+ const layers = currentNeuroglancerState().layers || []
+ return layers.some(
+ (l) => l.type === 'image' && getLayerSourceUrl(l) === tomogramPath,
+ )
+}
+
+function inferVoxelSpacingFromState(state: NeuroglancerState) {
+ const { dimensions } = state
+ if (dimensions === undefined) {
+ throw new Error('Cannot infer voxel spacing without dimensions')
+ }
+ // Get the average of all dims, usually isotropic but just in case
+ const dimensionValues = Object.values(dimensions)
+ const averageUnit =
+ dimensionValues.reduce((a: number, b: DimensionValue) => a + b[0], 0) /
+ dimensionValues.length
+ return averageUnit
+}
+
+export function replaceOnlyTomogramSource(
+ incomingState: NeuroglancerState,
+ newPath: string,
+) {
+ const newState = incomingState
+ const tomogramLayer = newState.layers?.find((l) => l.type === 'image')
+ if (tomogramLayer) {
+ // Replace either the source directly or the url inside the source object
+ if (typeof tomogramLayer.source === 'string') {
+ tomogramLayer.source = newPath
+ } else {
+ tomogramLayer.source.url = newPath
+ }
+ }
+ return newState
+}
+
+export function replaceOnlyTomogram(
+ incomingState: NeuroglancerState,
+ newState: NeuroglancerState,
+) {
+ // The first image layer is always the tomogram -- we can completely replace that layer
+ // For the other layers, we only need to adjust the "source" because they can have
+ // different transforms needed
+ if (!newState.layers) return incomingState
+ const incomingLayers = newState.layers
+ const newTomogramLayer = incomingLayers.find((l) => l.type === 'image')
+ if (!newTomogramLayer) return incomingState // No tomogram layer in the new state
+ newTomogramLayer.visible = true
+ newTomogramLayer.archived = false
+ const newLayers = incomingState.layers || []
+
+ // First, let's check for the tomogram layer in the current state
+ const tomogramLayerIndex = newLayers.findIndex((l) => l.type === 'image')
+ if (tomogramLayerIndex === -1) {
+ newLayers.unshift(newTomogramLayer)
+ } else {
+ const currentTomogram = newLayers[tomogramLayerIndex]
+ const openedTab = currentTomogram.tab
+ if (openedTab) {
+ newTomogramLayer.tab = openedTab
+ }
+ newLayers[tomogramLayerIndex] = newTomogramLayer
+ }
+
+ // For the other layers, we need to update their sources if they exist in both states
+ for (const newLayer of incomingLayers) {
+ if (newLayer.type === 'image') continue // Skip the tomogram layer
+ const matchingLayer = newLayers.find(
+ (l) => getLayerSourceUrl(l) === getLayerSourceUrl(newLayer),
+ )
+ if (matchingLayer) {
+ matchingLayer.source = newLayer.source
+ } else {
+ newLayers.push(newLayer)
+ }
+ }
+
+ // Adjust the zoom levels and position to keep view consistent when switching
+ const currentSpacing = inferVoxelSpacingFromState(incomingState)
+ const newSpacing = inferVoxelSpacingFromState(newState)
+ const voxelRatio = newSpacing / currentSpacing
+ const newCrossSectionScale = incomingState.crossSectionScale
+ ? incomingState.crossSectionScale * voxelRatio
+ : undefined
+ const newProjectionScale = incomingState.projectionScale
+ ? incomingState.projectionScale * voxelRatio
+ : undefined
+ const newPosition = incomingState.position
+ ? incomingState.position.map((x) => x * voxelRatio)
+ : undefined
+ return {
+ ...incomingState,
+ layers: newLayers,
+ crossSectionScale: newCrossSectionScale,
+ projectionScale: newProjectionScale,
+ position: newPosition,
+ }
+}
+
export function isDepositionActivated(
depositionEntries: (string | undefined)[],
) {
diff --git a/frontend/packages/data-portal/app/routes/view.runs.$id.tsx b/frontend/packages/data-portal/app/routes/view.runs.$id.tsx
index c0b8d9fa2..52f16c5ee 100644
--- a/frontend/packages/data-portal/app/routes/view.runs.$id.tsx
+++ b/frontend/packages/data-portal/app/routes/view.runs.$id.tsx
@@ -54,13 +54,17 @@ const ViewerPage = lazy(() =>
)
export default function RunByIdViewerPage() {
- const { run } = useRunById()
+ const { run, tomograms } = useRunById()
const [searchParams] = useSearchParams()
const shouldStartTour = searchParams.get(QueryParams.ShowTour) === 'true'
return (
Loading...}>
-
+
)
}
diff --git a/frontend/packages/neuroglancer/src/utils.ts b/frontend/packages/neuroglancer/src/utils.ts
index 36a3274c9..2134c58cb 100644
--- a/frontend/packages/neuroglancer/src/utils.ts
+++ b/frontend/packages/neuroglancer/src/utils.ts
@@ -18,8 +18,11 @@ export interface NeuroglancerState
selection?: PanelState
toolPalettes?: Record
layers?: LayerWithSource[]
+ dimensions?: { [key: string]: DimensionValue }
}
+export type DimensionValue = [number, string]
+
export interface SuperState extends Record {
neuroglancer: string
}
@@ -79,14 +82,27 @@ interface ToolPaletteState extends PanelState {
}
interface LayerWithSource extends LayerElement {
- source: string | { url?: string }
+ source:
+ | string
+ | {
+ url: string
+ transform?: { outputDimensions: unknown; inputDimensions: unknown }
+ }
archived?: boolean
+ tab: string
}
interface WatchableBoolean {
value: boolean
}
+export function getLayerSourceUrl(layer: LayerWithSource): string {
+ if (typeof layer.source === 'string') {
+ return layer.source
+ }
+ return layer.source.url
+}
+
const emptySuperState = (config: string): SuperState => {
return {
neuroglancer: config.length > 0 ? decompressHash(config) : '',