diff --git a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx index 4fab2cd5..8e34c838 100644 --- a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx +++ b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx @@ -4,6 +4,7 @@ import toast from 'react-hot-toast'; import Crumbs from './Crumbs'; import ZarrPreview from './ZarrPreview'; +import N5Preview from './N5Preview'; import Table from './FileTable'; import FileViewer from './FileViewer'; import ContextMenu, { @@ -12,11 +13,13 @@ import ContextMenu, { import { FileRowSkeleton } from '@/components/ui/widgets/Loaders'; import useContextMenu from '@/hooks/useContextMenu'; import useZarrMetadata from '@/hooks/useZarrMetadata'; +import useN5Metadata from '@/hooks/useN5Metadata'; import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; import { usePreferencesContext } from '@/contexts/PreferencesContext'; import useHideDotFiles from '@/hooks/useHideDotFiles'; import { useHandleDownload } from '@/hooks/useHandleDownload'; import { detectZarrVersions } from '@/queries/zarrQueries'; +import { detectN5 } from '@/queries/n5Queries'; import { makeMapKey } from '@/utils'; import type { FileOrFolder } from '@/shared.types'; @@ -72,9 +75,14 @@ export default function FileBrowser({ availableVersions } = useZarrMetadata(); + const { n5MetadataQuery, openWithToolUrls: n5OpenWithToolUrls } = + useN5Metadata(); + const isZarrDir = detectZarrVersions(fileQuery.data?.files as FileOrFolder[]).length > 0; + const isN5Dir = detectN5(fileQuery.data?.files as FileOrFolder[]); + // Handle right-click on file - FileBrowser-specific logic const handleFileContextMenu = ( e: MouseEvent, @@ -186,6 +194,27 @@ export default function FileBrowser({ /> ) : null} + {/* N5 Preview */} + {isN5Dir && n5MetadataQuery.isPending ? ( +
+ + Loading N5 metadata... + +
+ ) : n5MetadataQuery.isError ? ( +
+ + Error loading N5 metadata + +
+ ) : n5MetadataQuery.data ? ( + + ) : null} + {/* Loading state */} {fileQuery.isPending ? (
diff --git a/frontend/src/components/ui/BrowsePage/N5MetadataTable.test.tsx b/frontend/src/components/ui/BrowsePage/N5MetadataTable.test.tsx new file mode 100644 index 00000000..f3dbb39d --- /dev/null +++ b/frontend/src/components/ui/BrowsePage/N5MetadataTable.test.tsx @@ -0,0 +1,163 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import N5MetadataTable from './N5MetadataTable'; +import type { N5Metadata } from '@/queries/n5Queries'; + +const mockS0Attrs = { + dataType: 'uint16', + compression: { type: 'gzip' }, + blockSize: [64, 64, 64], + dimensions: [100, 200, 300] +}; + +describe('N5MetadataTable', () => { + it('should use explicit units and resolution when present (Standard N5)', () => { + const metadata: N5Metadata = { + rootAttrs: { + n5: '2.0.0', + units: ['nm', 'um', 'mm'], + resolution: [10, 20, 30] + }, + s0Attrs: mockS0Attrs, + dataUrl: 'mock-url' + }; + + render(); + + // Check table headers + expect(screen.getByText('Axis')).toBeInTheDocument(); + + // X Axis + expect(screen.getByText('X')).toBeInTheDocument(); + expect(screen.getByText('10')).toBeInTheDocument(); // Resolution + expect(screen.getByText('nm')).toBeInTheDocument(); // Unit + + // Y Axis + expect(screen.getByText('Y')).toBeInTheDocument(); + expect(screen.getByText('20')).toBeInTheDocument(); // Resolution + expect(screen.getByText('um')).toBeInTheDocument(); // Unit + + // Z Axis + expect(screen.getByText('Z')).toBeInTheDocument(); + expect(screen.getByText('30')).toBeInTheDocument(); // Resolution + expect(screen.getByText('mm')).toBeInTheDocument(); // Unit + }); + + it('should use pixelResolution when units/resolution are missing (Cellmap N5)', () => { + const metadata: N5Metadata = { + rootAttrs: { + n5: '2.0.0', + pixelResolution: { + unit: 'nm', + dimensions: [5, 5, 5] + } + }, + s0Attrs: mockS0Attrs, + dataUrl: 'mock-url' + }; + + render(); + + // Check X Axis using more specific approach if needed, but 5 is unique here + expect(screen.getByText('X')).toBeInTheDocument(); + expect(screen.getAllByText('5')).toHaveLength(3); // One for each resolution cell + expect(screen.getAllByText('nm')).toHaveLength(3); // One for each unit cell + }); + + it('should prioritize units over pixelResolution.unit', () => { + const metadata: N5Metadata = { + rootAttrs: { + n5: '2.0.0', + units: ['km', 'km', 'km'], // Priority + pixelResolution: { + unit: 'nm', // Should be ignored + dimensions: [1, 1, 1] + } + }, + s0Attrs: mockS0Attrs, + dataUrl: 'mock-url' + }; + + render(); + + expect(screen.getAllByText('km')).toHaveLength(3); + expect(screen.queryByText('nm')).not.toBeInTheDocument(); + }); + + it('should prioritize resolution over pixelResolution.dimensions', () => { + const metadata: N5Metadata = { + rootAttrs: { + n5: '2.0.0', + resolution: [100, 100, 100], // Priority + pixelResolution: { + unit: 'nm', + dimensions: [1, 1, 1] // Should be ignored + } + }, + s0Attrs: mockS0Attrs, // dimensions: [100, 200, 300] + dataUrl: 'mock-url' + }; + + render(); + + // Total '100' instances: + // 1 in Axis X Shape column + // 3 in Axis table Resolution column + // (Note: '100' in Dimensions string is part of "100, 200, 300" and not matched exactly) + expect(screen.getAllByText('100')).toHaveLength(4); + expect(screen.queryByText('1')).not.toBeInTheDocument(); + }); + + it('should default to micrometers ("um") if no units specified', () => { + const metadata: N5Metadata = { + rootAttrs: { + n5: '2.0.0', + resolution: [1, 1, 1] + }, + s0Attrs: mockS0Attrs, + dataUrl: 'mock-url' + }; + + render(); + + expect(screen.getAllByText('um')).toHaveLength(3); + }); + + it('should use scales when downsamplingFactors is missing', () => { + const metadata: N5Metadata = { + rootAttrs: { + n5: '2.0.0', + scales: [ + [1, 1, 1], + [2, 2, 2], + [4, 4, 4] + ] // Length 3 + }, + s0Attrs: mockS0Attrs, + dataUrl: 'mock-url' + }; + + render(); + + expect(screen.getByText('Multiscale Levels')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('should prioritize downsamplingFactors over scales', () => { + const metadata: N5Metadata = { + rootAttrs: { + n5: '2.0.0', + downsamplingFactors: [[1], [2]], // Length 2 (Priority) + scales: [[1], [2], [3], [4]] // Length 4 + }, + s0Attrs: mockS0Attrs, + dataUrl: 'mock-url' + }; + + render(); + + expect(screen.getByText('Multiscale Levels')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.queryByText('4')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ui/BrowsePage/N5MetadataTable.tsx b/frontend/src/components/ui/BrowsePage/N5MetadataTable.tsx new file mode 100644 index 00000000..32f61dc2 --- /dev/null +++ b/frontend/src/components/ui/BrowsePage/N5MetadataTable.tsx @@ -0,0 +1,142 @@ +import type { N5Metadata } from '@/queries/n5Queries'; +import { translateUnitToNeuroglancer } from '@/omezarr-helper'; + +type N5MetadataTableProps = { + readonly metadata: N5Metadata; +}; + +function formatDimensions(dimensions: number[]): string { + return dimensions.join(', '); +} + +function formatBlockSize(blockSize: number[]): string { + return blockSize.join(', '); +} + +function formatCompression(compression: { + type: string; + level?: number; +}): string { + if (compression.level !== undefined) { + return `${compression.type} (level ${compression.level})`; + } + return compression.type; +} + +/** + * Get axis-specific metadata for the second table + */ +function getAxisData(metadata: N5Metadata) { + const { resolution, units, pixelResolution } = metadata.rootAttrs; + const { dimensions } = metadata.s0Attrs; + + if (!dimensions) { + return []; + } + + // Assume X, Y, Z axis order for N5 (common convention) + const axisNames = ['X', 'Y', 'Z']; + + return dimensions.map((dim, index) => { + const axisName = axisNames[index] || `Axis ${index}`; + + // Priority: resolution -> pixelResolution.dimensions + const res = resolution?.[index] ?? pixelResolution?.dimensions?.[index]; + + // Determine unit for this specific axis + // Priority: units[index] -> pixelResolution.unit -> "um" + let axisUnit = 'um'; + if (units && units[index]) { + axisUnit = units[index]; + } else if (pixelResolution?.unit) { + axisUnit = pixelResolution.unit; + } + + const displayUnit = translateUnitToNeuroglancer(axisUnit); + const shape = dim; + + return { + name: axisName, + shape, + resolution: res !== undefined ? res.toString() : 'Unknown', + unit: displayUnit + }; + }); +} + +export default function N5MetadataTable({ metadata }: N5MetadataTableProps) { + const { rootAttrs, s0Attrs } = metadata; + const axisData = getAxisData(metadata); + + return ( + <> + {/* First table - General metadata */} + + + + + + + + + + + + + + + + + + + + + + {rootAttrs.downsamplingFactors || rootAttrs.scales ? ( + + + + + ) : null} + + + + + +
+ N5 Dataset Metadata +
N5 Version{rootAttrs.n5}
Data Type{s0Attrs.dataType}
Dimensions + {formatDimensions(s0Attrs.dimensions)} +
Block Size{formatBlockSize(s0Attrs.blockSize)}
Multiscale Levels + {rootAttrs.downsamplingFactors?.length ?? + rootAttrs.scales?.length} +
Compression + {formatCompression(s0Attrs.compression)} +
+ + {/* Second table - Axis-specific metadata */} + {axisData.length > 0 ? ( + + + + + + + + + + + {axisData.map(axis => ( + + + + + + + ))} + +
AxisShapeResolutionUnit
{axis.name}{axis.shape}{axis.resolution}{axis.unit}
+ ) : null} + + ); +} diff --git a/frontend/src/components/ui/BrowsePage/N5Preview.tsx b/frontend/src/components/ui/BrowsePage/N5Preview.tsx new file mode 100644 index 00000000..ec1f5c55 --- /dev/null +++ b/frontend/src/components/ui/BrowsePage/N5Preview.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; +import type { UseQueryResult } from '@tanstack/react-query'; + +import N5MetadataTable from '@/components/ui/BrowsePage/N5MetadataTable'; +import DataLinkDialog from '@/components/ui/Dialogs/DataLink'; +import DataToolLinks from './DataToolLinks'; +import type { N5Metadata, N5OpenWithToolUrls } from '@/queries/n5Queries'; +import useDataToolLinks from '@/hooks/useDataToolLinks'; +import type { OpenWithToolUrls, PendingToolKey } from '@/hooks/useZarrMetadata'; + +type N5PreviewProps = { + readonly mainPanelWidth: number; + readonly openWithToolUrls: N5OpenWithToolUrls | null; + readonly n5MetadataQuery: UseQueryResult; +}; + +/** + * N5 Logo placeholder component + */ +function N5Logo() { + return ( +
+
+ N5 +
+
+ ); +} + +export default function N5Preview({ + mainPanelWidth, + openWithToolUrls, + n5MetadataQuery +}: N5PreviewProps) { + const [showDataLinkDialog, setShowDataLinkDialog] = useState(false); + const [pendingToolKey, setPendingToolKey] = useState(null); + + const { + handleToolClick, + handleDialogConfirm, + handleDialogCancel, + showCopiedTooltip + } = useDataToolLinks( + setShowDataLinkDialog, + openWithToolUrls as OpenWithToolUrls | null, + pendingToolKey, + setPendingToolKey + ); + + return ( +
+
+
+ + + {openWithToolUrls ? ( + + ) : null} + + {showDataLinkDialog ? ( + + ) : null} +
+ {n5MetadataQuery.data ? ( +
1000 ? 'gap-6' : 'flex-col gap-4'} h-fit`} + > + +
+ ) : null} +
+
+ ); +} diff --git a/frontend/src/hooks/useN5Metadata.ts b/frontend/src/hooks/useN5Metadata.ts new file mode 100644 index 00000000..f432c1ce --- /dev/null +++ b/frontend/src/hooks/useN5Metadata.ts @@ -0,0 +1,99 @@ +import { useMemo } from 'react'; +import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; +import { useProxiedPathContext } from '@/contexts/ProxiedPathContext'; +import { useExternalBucketContext } from '@/contexts/ExternalBucketContext'; +import { useN5MetadataQuery } from '@/queries/n5Queries'; +import type { N5Metadata, N5OpenWithToolUrls } from '@/queries/n5Queries'; + +export type { N5Metadata, N5OpenWithToolUrls }; + +/** + * Get the Neuroglancer source URL for N5 format + */ +function getNeuroglancerSourceN5(dataUrl: string): string { + // Neuroglancer expects a trailing slash + if (!dataUrl.endsWith('/')) { + dataUrl = dataUrl + '/'; + } + return dataUrl + '|n5:'; +} + +/** + * Get the layer name for a given URL (same as Neuroglancer does it) + */ +function getLayerName(dataUrl: string): string { + return dataUrl.split('/').filter(Boolean).pop() || 'Default'; +} + +/** + * Generate a Neuroglancer state for N5 data + */ +function generateNeuroglancerStateForN5(dataUrl: string): string { + const layer = { + name: getLayerName(dataUrl), + source: getNeuroglancerSourceN5(dataUrl), + type: 'image' // Default to image for N5 + }; + + const state = { + layers: [layer], + selectedLayer: { + visible: true, + layer: layer.name + }, + layout: '4panel-alt' + }; + + return encodeURIComponent(JSON.stringify(state)); +} + +export default function useN5Metadata() { + const { fileQuery } = useFileBrowserContext(); + const { proxiedPathByFspAndPathQuery } = useProxiedPathContext(); + const { externalDataUrlQuery } = useExternalBucketContext(); + + // Fetch N5 metadata + const n5MetadataQuery = useN5MetadataQuery({ + fspName: fileQuery.data?.currentFileSharePath?.name, + currentFileOrFolder: fileQuery.data?.currentFileOrFolder, + files: fileQuery.data?.files + }); + + const metadata = n5MetadataQuery.data || null; + + const openWithToolUrls = useMemo(() => { + if (!metadata) { + return null; + } + + const neuroglancerBaseUrl = 'https://neuroglancer-demo.appspot.com/#!'; + + const url = + externalDataUrlQuery.data || proxiedPathByFspAndPathQuery.data?.url; + + const toolUrls: N5OpenWithToolUrls = { + copy: url || '', + neuroglancer: '', + validator: null, + vole: null, + avivator: null + }; + + if (url) { + // Generate Neuroglancer URL with state + toolUrls.neuroglancer = + neuroglancerBaseUrl + generateNeuroglancerStateForN5(url); + } + + return toolUrls; + }, [ + metadata, + proxiedPathByFspAndPathQuery.data?.url, + externalDataUrlQuery.data + ]); + + return { + n5MetadataQuery, + openWithToolUrls + }; +} diff --git a/frontend/src/queries/n5Queries.ts b/frontend/src/queries/n5Queries.ts new file mode 100644 index 00000000..3f15ba6a --- /dev/null +++ b/frontend/src/queries/n5Queries.ts @@ -0,0 +1,160 @@ +import { useQuery } from '@tanstack/react-query'; +import type { UseQueryResult } from '@tanstack/react-query'; +import { default as log } from '@/logger'; +import { getFileURL } from '@/utils'; +import { fetchFileAsJson } from './queryUtils'; +import type { FileOrFolder } from '@/shared.types'; + +/** + * N5 root attributes.json structure + */ +export type N5RootAttributes = { + n5: string; // e.g., "4.0.0" + downsamplingFactors?: number[][]; // e.g., [[1,1,1], [2,2,1], ...] + scales?: number[][]; // Alternative to downsamplingFactors + resolution?: number[]; // e.g., [157, 157, 628] + pixelResolution?: { + unit?: string; + dimensions: number[]; + }; // Alternative to resolution + units + units?: string[]; // e.g., ["nm", "nm", "nm"] + multiScale?: boolean; // e.g., true +}; + +/** + * N5 s0/attributes.json structure (scale 0 attributes) + */ +export type N5S0Attributes = { + dataType: string; // e.g., "uint16" + compression: { + type: string; // e.g., "zstd" + level?: number; // e.g., 3 + }; + blockSize: number[]; // e.g., [128, 128, 128] + dimensions: number[]; // e.g., [51911, 83910, 3618] +}; + +/** + * Combined N5 metadata from both attributes files + */ +export type N5Metadata = { + rootAttrs: N5RootAttributes; + s0Attrs: N5S0Attributes; + dataUrl: string; +}; + +/** + * N5 tool URLs - compatible with Zarr's OpenWithToolUrls but with null for unsupported tools + */ +export type N5OpenWithToolUrls = { + copy: string; + neuroglancer: string; + validator: null; + vole: null; + avivator: null; +}; + +type N5MetadataQueryParams = { + fspName: string | undefined; + currentFileOrFolder: FileOrFolder | undefined | null; + files: FileOrFolder[] | undefined; +}; + +/** + * Detects if the current directory is an N5 dataset. + * N5 is detected when: + * 1. attributes.json exists in the current directory + * 2. A child directory named "s0" exists + */ +export function detectN5(files: FileOrFolder[]): boolean { + if (!files || files.length === 0) { + return false; + } + + const hasAttributesJson = files.some( + f => f.name === 'attributes.json' && !f.is_dir + ); + const hasS0Folder = files.some(f => f.name === 's0' && f.is_dir); + + return hasAttributesJson && hasS0Folder; +} + +/** + * Fetches N5 metadata from attributes.json and s0/attributes.json + */ +async function fetchN5Metadata({ + fspName, + currentFileOrFolder, + files +}: N5MetadataQueryParams): Promise { + if (!fspName || !currentFileOrFolder || !files) { + log.warn('Missing required parameters for N5 metadata fetch'); + return null; + } + + const dataUrl = getFileURL(fspName, currentFileOrFolder.path); + + // Find the attributes.json file + const attributesFile = files.find( + f => f.name === 'attributes.json' && !f.is_dir + ); + if (!attributesFile) { + log.warn('No attributes.json file found'); + return null; + } + + try { + // Fetch root attributes.json + log.info('Fetching N5 root attributes from', attributesFile.path); + const rootAttrs = (await fetchFileAsJson( + fspName, + attributesFile.path + )) as N5RootAttributes; + + // Construct path to s0/attributes.json + const s0AttributesPath = currentFileOrFolder.path + '/s0/attributes.json'; + + // Fetch s0/attributes.json + log.info('Fetching N5 s0 attributes from', s0AttributesPath); + const s0Attrs = (await fetchFileAsJson( + fspName, + s0AttributesPath + )) as N5S0Attributes; + + return { + rootAttrs, + s0Attrs, + dataUrl + }; + } catch (error) { + log.error('Error fetching N5 metadata:', error); + throw error; + } +} + +/** + * Hook to fetch N5 metadata for the current file/folder + */ +export function useN5MetadataQuery( + params: N5MetadataQueryParams +): UseQueryResult { + const { fspName, currentFileOrFolder, files } = params; + + return useQuery({ + queryKey: [ + 'n5', + 'metadata', + fspName || '', + currentFileOrFolder?.path || '' + ], + queryFn: async () => await fetchN5Metadata(params), + enabled: + !!fspName && + !!currentFileOrFolder && + !!files && + files.length > 0 && + detectN5(files), + staleTime: 5 * 60 * 1000, // 5 minutes - N5 metadata doesn't change often + retry: false + }); +}