Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions frontend/src/components/ui/BrowsePage/FileBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -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';

Expand Down Expand Up @@ -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<HTMLDivElement>,
Expand Down Expand Up @@ -186,6 +194,27 @@ export default function FileBrowser({
/>
) : null}

{/* N5 Preview */}
{isN5Dir && n5MetadataQuery.isPending ? (
<div className="flex shadow-sm rounded-md w-full min-h-96 bg-surface animate-appear animate-pulse animate-delay-150 opacity-0">
<Typography className="place-self-center text-center w-full">
Loading N5 metadata...
</Typography>
</div>
) : n5MetadataQuery.isError ? (
<div className="flex shadow-sm rounded-md w-full min-h-96 bg-primary-light/30">
<Typography className="place-self-center text-center w-full text-warning">
Error loading N5 metadata
</Typography>
</div>
) : n5MetadataQuery.data ? (
<N5Preview
mainPanelWidth={mainPanelWidth}
n5MetadataQuery={n5MetadataQuery}
openWithToolUrls={n5OpenWithToolUrls}
/>
) : null}

{/* Loading state */}
{fileQuery.isPending ? (
<div className="min-w-full bg-background select-none">
Expand Down
163 changes: 163 additions & 0 deletions frontend/src/components/ui/BrowsePage/N5MetadataTable.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<N5MetadataTable metadata={metadata} />);

// 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(<N5MetadataTable metadata={metadata} />);

// 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(<N5MetadataTable metadata={metadata} />);

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(<N5MetadataTable metadata={metadata} />);

// 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(<N5MetadataTable metadata={metadata} />);

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(<N5MetadataTable metadata={metadata} />);

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(<N5MetadataTable metadata={metadata} />);

expect(screen.getByText('Multiscale Levels')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.queryByText('4')).not.toBeInTheDocument();
});
});
142 changes: 142 additions & 0 deletions frontend/src/components/ui/BrowsePage/N5MetadataTable.tsx
Original file line number Diff line number Diff line change
@@ -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 */}
<table className="bg-background/90 h-fit">
<tbody className="text-sm">
<tr className="h-11 border-y border-surface-dark">
<td className="px-3 py-2 font-semibold" colSpan={2}>
N5 Dataset Metadata
</td>
</tr>
<tr className="h-11 border-y border-surface-dark">
<td className="px-3 py-2 font-semibold">N5 Version</td>
<td className="px-3 py-2">{rootAttrs.n5}</td>
</tr>
<tr className="h-11 border-b border-surface-dark">
<td className="px-3 py-2 font-semibold">Data Type</td>
<td className="px-3 py-2">{s0Attrs.dataType}</td>
</tr>
<tr className="h-11 border-b border-surface-dark">
<td className="px-3 py-2 font-semibold">Dimensions</td>
<td className="px-3 py-2">
{formatDimensions(s0Attrs.dimensions)}
</td>
</tr>
<tr className="h-11 border-b border-surface-dark">
<td className="px-3 py-2 font-semibold">Block Size</td>
<td className="px-3 py-2">{formatBlockSize(s0Attrs.blockSize)}</td>
</tr>
{rootAttrs.downsamplingFactors || rootAttrs.scales ? (
<tr className="h-11 border-b border-surface-dark">
<td className="px-3 py-2 font-semibold">Multiscale Levels</td>
<td className="px-3 py-2">
{rootAttrs.downsamplingFactors?.length ??
rootAttrs.scales?.length}
</td>
</tr>
) : null}
<tr className="h-11 border-b border-surface-dark">
<td className="px-3 py-2 font-semibold">Compression</td>
<td className="px-3 py-2">
{formatCompression(s0Attrs.compression)}
</td>
</tr>
</tbody>
</table>

{/* Second table - Axis-specific metadata */}
{axisData.length > 0 ? (
<table className="bg-background/90 h-fit">
<thead className="text-sm">
<tr className="h-11 border-y border-surface-dark">
<th className="px-3 py-2 font-semibold text-left">Axis</th>
<th className="px-3 py-2 font-semibold text-left">Shape</th>
<th className="px-3 py-2 font-semibold text-left">Resolution</th>
<th className="px-3 py-2 font-semibold text-left">Unit</th>
</tr>
</thead>
<tbody className="text-sm">
{axisData.map(axis => (
<tr className="h-11 border-b border-surface-dark" key={axis.name}>
<td className="px-3 py-2 text-center">{axis.name}</td>
<td className="px-3 py-2 text-right">{axis.shape}</td>
<td className="px-3 py-2 text-right">{axis.resolution}</td>
<td className="px-3 py-2 text-left">{axis.unit}</td>
</tr>
))}
</tbody>
</table>
) : null}
</>
);
}
Loading