- Neuroglancer links
+ Neuroglancer
diff --git a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx
index 5a5508d2a..638fdaadf 100644
--- a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx
+++ b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx
@@ -65,6 +65,7 @@ export default function DataToolLinks({
dataLinkUrl,
fspName,
onAddToCart,
+ onOpenNeuroglancer,
onToolClick,
path,
showCopiedTooltip,
@@ -75,6 +76,8 @@ export default function DataToolLinks({
readonly dataLinkUrl?: string;
readonly fspName?: string;
readonly onAddToCart?: () => void;
+ // Replaces the Neuroglancer link (e.g. to create a View instead).
+ readonly onOpenNeuroglancer?: () => void;
readonly onToolClick: (toolKey: PendingToolKey) => Promise;
readonly path?: string;
readonly showCopiedTooltip: boolean;
@@ -103,15 +106,20 @@ export default function DataToolLinks({
return null;
}
+ const opensView = viewer.key === 'neuroglancer' && onOpenNeuroglancer;
return (
onOpenNeuroglancer() : onToolClick
+ }
toolKey={viewer.key as PendingToolKey}
- tooltipLabel={viewer.label}
+ tooltipLabel={
+ opensView ? 'Create a View from this dataset' : viewer.label
+ }
url={url}
/>
);
diff --git a/frontend/src/components/ui/BrowsePage/ZarrPreview.tsx b/frontend/src/components/ui/BrowsePage/ZarrPreview.tsx
index 3ec904483..baa0856cd 100644
--- a/frontend/src/components/ui/BrowsePage/ZarrPreview.tsx
+++ b/frontend/src/components/ui/BrowsePage/ZarrPreview.tsx
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { Typography } from '@material-tailwind/react';
import toast from 'react-hot-toast';
-import { useOutletContext } from 'react-router';
+import { useNavigate, useOutletContext } from 'react-router';
import type { UseQueryResult } from '@tanstack/react-query';
import zarrLogo from '@/assets/zarr.jpg';
@@ -15,7 +15,9 @@ import type {
PendingToolKey
} from '@/hooks/useZarrMetadata';
import useDataToolLinks from '@/hooks/useDataToolLinks';
+import { useCreateViewFlow } from '@/hooks/useCreateViewFlow';
import { useCartContext } from '@/contexts/CartContext';
+import { usePreferencesContext } from '@/contexts/PreferencesContext';
import { Metadata, getDatasetWarnings } from '@/omezarr-helper';
import type { OutletContextType } from '@/layouts/BrowseLayout';
@@ -65,6 +67,9 @@ export default function ZarrPreview({
);
const { addToCart } = useCartContext();
+ const { neuroglancerButtonTarget } = usePreferencesContext();
+ const { startCreateView, dialog: createViewDialog } = useCreateViewFlow();
+ const navigate = useNavigate();
// ponytail: optional — component tests render without a router outlet.
const outlet = useOutletContext();
@@ -138,6 +143,16 @@ export default function ZarrPreview({
dataLinkUrl={openWithToolUrls.copy || undefined}
fspName={fspName}
onAddToCart={() => void handleAddToCart()}
+ onOpenNeuroglancer={
+ neuroglancerButtonTarget === 'views'
+ ? () =>
+ startCreateView(
+ [{ fsp_name: fspName, path, label: datasetLabel }],
+ datasetLabel,
+ view => navigate(`/view/${view.read_key}`)
+ )
+ : undefined
+ }
onToolClick={handleToolClick}
path={path}
showCopiedTooltip={showCopiedTooltip}
@@ -157,6 +172,7 @@ export default function ZarrPreview({
tools={true}
/>
) : null}
+ {createViewDialog}
{zarrMetadataQuery.data?.metadata &&
'arr' in zarrMetadataQuery.data.metadata ? (
diff --git a/frontend/src/components/ui/PreferencesPage/NeuroglancerOptions.tsx b/frontend/src/components/ui/PreferencesPage/NeuroglancerOptions.tsx
index ca26e3a80..bbc3877fd 100644
--- a/frontend/src/components/ui/PreferencesPage/NeuroglancerOptions.tsx
+++ b/frontend/src/components/ui/PreferencesPage/NeuroglancerOptions.tsx
@@ -1,10 +1,16 @@
import toast from 'react-hot-toast';
+import { Typography } from '@material-tailwind/react';
import FgSwitch from '@/components/designSystem/atoms/formElements/FgSwitch';
import FgRadio from '@/components/designSystem/atoms/formElements/FgRadio';
import FgFieldSet from '@/components/designSystem/molecules/FgFieldSet';
import { usePreferencesContext } from '@/contexts/PreferencesContext';
+import type {
+ NeuroglancerButtonTarget,
+ ViewerUrlSource
+} from '@/contexts/PreferencesContext';
import { useViewersContext } from '@/contexts/ViewersContext';
+import type { Result } from '@/shared.types';
/** Best-effort hostname for display; falls back to the raw template. */
function hostLabel(template: string): string {
@@ -15,6 +21,52 @@ function hostLabel(template: string): string {
}
}
+function BaseUrlChoice({
+ hosts,
+ idPrefix,
+ legend,
+ onChange,
+ source
+}: {
+ readonly hosts: { internal: string; public: string };
+ readonly idPrefix: string;
+ readonly legend: string;
+ readonly onChange: (source: 'configured' | 'manifest') => void;
+ readonly source: ViewerUrlSource | undefined;
+}) {
+ const current = source === 'manifest' ? 'manifest' : 'configured';
+ return (
+
+ onChange('configured')}
+ value="configured"
+ />
+ onChange('manifest')}
+ value="manifest"
+ />
+
+ );
+}
+
+function SubHeading({ children }: { readonly children: string }) {
+ return (
+
+ {children}
+
+ );
+}
+
export default function NeuroglancerOptions() {
const {
useLegacyMultichannelApproach,
@@ -24,7 +76,11 @@ export default function NeuroglancerOptions() {
disableHeuristicalLayerTypeDetection,
toggleDisableHeuristicalLayerTypeDetection,
viewerUrlSources,
- setViewerUrlSource
+ setViewerUrlSource,
+ neuroglancerButtonTarget,
+ setNeuroglancerButtonTarget,
+ viewsNeuroglancerUrlSource,
+ setViewsNeuroglancerUrlSource
} = usePreferencesContext();
const { validViewers } = useViewersContext();
@@ -33,103 +89,147 @@ export default function NeuroglancerOptions() {
// Only offer the URL choice when the deployment overrides the Neuroglancer URL
// (i.e. the configured template differs from the manifest default). Otherwise
// both options resolve to the same URL and the control is meaningless.
- const showUrlSourceChoice =
- !!neuroglancer &&
- !!neuroglancer.manifestTemplateUrl &&
- neuroglancer.manifestTemplateUrl !== neuroglancer.urlTemplate;
+ const hosts =
+ neuroglancer?.manifestTemplateUrl &&
+ neuroglancer.manifestTemplateUrl !== neuroglancer.urlTemplate
+ ? {
+ internal: hostLabel(neuroglancer.urlTemplate),
+ public: hostLabel(neuroglancer.manifestTemplateUrl)
+ }
+ : null;
- const currentSource =
- viewerUrlSources['neuroglancer'] === 'manifest' ? 'manifest' : 'configured';
-
- const handleUrlSourceChange = async (source: 'configured' | 'manifest') => {
- const result = await setViewerUrlSource('neuroglancer', source);
+ const report = (result: Result
, ok: string) => {
if (result.success) {
- toast.success('Neuroglancer URL preference updated');
+ toast.success(ok);
} else {
toast.error(result.error);
}
};
+ const handleButtonTargetChange = async (target: NeuroglancerButtonTarget) =>
+ report(
+ await setNeuroglancerButtonTarget(target),
+ 'Neuroglancer button preference updated'
+ );
+
return (
-
- {showUrlSourceChoice ? (
-
- handleUrlSourceChange('configured')}
- value="configured"
- />
- handleUrlSourceChange('manifest')}
- value="manifest"
- />
-
- ) : null}
-
- {
- const result = await toggleUseLegacyMultichannelApproach();
- if (result.success) {
- toast.success(
- useLegacyMultichannelApproach
- ? 'Disabled multichannel state generation for Neuroglancer'
- : 'Enabled multichannel state generation for Neuroglancer'
- );
- } else {
- toast.error(result.error);
- }
- }}
- showState
+
+
+ handleButtonTargetChange('views')}
+ value="views"
/>
- {
- const result = await toggleDisableNeuroglancerStateGeneration();
- if (result.success) {
- toast.success(
- disableNeuroglancerStateGeneration
- ? 'Neuroglancer state generation is now enabled'
- : 'Neuroglancer state generation is now disabled'
- );
- } else {
- toast.error(result.error);
- }
- }}
- showState
- />
- {
- const result = await toggleDisableHeuristicalLayerTypeDetection();
- if (result.success) {
- toast.success(
- disableHeuristicalLayerTypeDetection
- ? 'Heuristical layer type determination is now enabled'
- : 'Heuristical layer type determination is now disabled'
- );
- } else {
- toast.error(result.error);
- }
- }}
- showState
+ handleButtonTargetChange('standalone')}
+ value="standalone"
/>
+
+ {hosts ? (
+
+ Views
+
+ report(
+ await setViewsNeuroglancerUrlSource(source),
+ 'Views Neuroglancer URL preference updated'
+ )
+ }
+ source={viewsNeuroglancerUrlSource}
+ />
+
+ ) : null}
+
+
+ Standalone Neuroglancer
+
+ Used when the Neuroglancer button opens a standalone Neuroglancer, and
+ as the default for new Neuroglancer links.
+
+ {hosts ? (
+
+ report(
+ await setViewerUrlSource('neuroglancer', source),
+ 'Neuroglancer URL preference updated'
+ )
+ }
+ source={viewerUrlSources['neuroglancer']}
+ />
+ ) : null}
+
+ {
+ const result = await toggleUseLegacyMultichannelApproach();
+ if (result.success) {
+ toast.success(
+ useLegacyMultichannelApproach
+ ? 'Disabled multichannel state generation for Neuroglancer'
+ : 'Enabled multichannel state generation for Neuroglancer'
+ );
+ } else {
+ toast.error(result.error);
+ }
+ }}
+ showState
+ />
+ {
+ const result = await toggleDisableNeuroglancerStateGeneration();
+ if (result.success) {
+ toast.success(
+ disableNeuroglancerStateGeneration
+ ? 'Neuroglancer state generation is now enabled'
+ : 'Neuroglancer state generation is now disabled'
+ );
+ } else {
+ toast.error(result.error);
+ }
+ }}
+ showState
+ />
+ {
+ const result = await toggleDisableHeuristicalLayerTypeDetection();
+ if (result.success) {
+ toast.success(
+ disableHeuristicalLayerTypeDetection
+ ? 'Heuristical layer type determination is now enabled'
+ : 'Heuristical layer type determination is now disabled'
+ );
+ } else {
+ toast.error(result.error);
+ }
+ }}
+ showState
+ />
+
+
);
}
diff --git a/frontend/src/contexts/PreferencesContext.tsx b/frontend/src/contexts/PreferencesContext.tsx
index b868a7f69..848283ea3 100644
--- a/frontend/src/contexts/PreferencesContext.tsx
+++ b/frontend/src/contexts/PreferencesContext.tsx
@@ -51,6 +51,9 @@ export type FolderPreference = {
export type ViewerUrlSource = 'configured' | 'manifest' | { custom: string };
export type ViewerUrlSources = Record;
+/** Where the metadata panel's Neuroglancer button opens a dataset. */
+export type NeuroglancerButtonTarget = 'views' | 'standalone';
+
type PreferencesContextType = {
//Full query for accessing loading/error states
preferenceQuery: ReturnType;
@@ -65,6 +68,8 @@ type PreferencesContextType = {
disableHeuristicalLayerTypeDetection: boolean;
useLegacyMultichannelApproach: boolean;
viewerUrlSources: ViewerUrlSources;
+ neuroglancerButtonTarget: NeuroglancerButtonTarget;
+ viewsNeuroglancerUrlSource: ViewerUrlSource;
isFilteredByGroups: boolean;
showTutorial: boolean;
defaultExtraArgs: string;
@@ -101,6 +106,12 @@ type PreferencesContextType = {
viewerKey: string,
source: ViewerUrlSource
) => Promise>;
+ setNeuroglancerButtonTarget: (
+ target: NeuroglancerButtonTarget
+ ) => Promise>;
+ setViewsNeuroglancerUrlSource: (
+ source: ViewerUrlSource
+ ) => Promise>;
toggleFilterByGroups: () => Promise>;
toggleShowTutorial: () => Promise>;
updateDefaultExtraArgs: (args: string) => Promise>;
@@ -293,6 +304,34 @@ export const PreferencesProvider = ({
}
};
+ const setNeuroglancerButtonTarget = async (
+ target: NeuroglancerButtonTarget
+ ): Promise> => {
+ try {
+ await updatePreferenceMutation.mutateAsync({
+ key: 'neuroglancerButtonTarget',
+ value: target
+ });
+ return createSuccess(undefined);
+ } catch (error) {
+ return handleError(error);
+ }
+ };
+
+ const setViewsNeuroglancerUrlSource = async (
+ source: ViewerUrlSource
+ ): Promise> => {
+ try {
+ await updatePreferenceMutation.mutateAsync({
+ key: 'viewsNeuroglancerUrlSource',
+ value: source
+ });
+ return createSuccess(undefined);
+ } catch (error) {
+ return handleError(error);
+ }
+ };
+
const toggleShowTutorial = async (): Promise> => {
return togglePreference(
'showTutorial',
@@ -587,6 +626,10 @@ export const PreferencesProvider = ({
useLegacyMultichannelApproach:
preferencesQuery.data?.useLegacyMultichannelApproach || false,
viewerUrlSources: preferencesQuery.data?.viewerUrlSources || {},
+ neuroglancerButtonTarget:
+ preferencesQuery.data?.neuroglancerButtonTarget ?? 'views',
+ viewsNeuroglancerUrlSource:
+ preferencesQuery.data?.viewsNeuroglancerUrlSource ?? 'configured',
isFilteredByGroups: preferencesQuery.data?.isFilteredByGroups ?? true,
showTutorial: preferencesQuery.data?.showTutorial ?? true,
defaultExtraArgs: preferencesQuery.data?.defaultExtraArgs || '',
@@ -617,6 +660,8 @@ export const PreferencesProvider = ({
toggleDisableHeuristicalLayerTypeDetection,
toggleUseLegacyMultichannelApproach,
setViewerUrlSource,
+ setNeuroglancerButtonTarget,
+ setViewsNeuroglancerUrlSource,
toggleFilterByGroups,
toggleShowTutorial,
updateDefaultExtraArgs,
diff --git a/frontend/src/hooks/useDefaultNeuroglancerBaseUrl.ts b/frontend/src/hooks/useDefaultNeuroglancerBaseUrl.ts
index 32f7b24b2..da149bd18 100644
--- a/frontend/src/hooks/useDefaultNeuroglancerBaseUrl.ts
+++ b/frontend/src/hooks/useDefaultNeuroglancerBaseUrl.ts
@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { usePreferencesContext } from '@/contexts/PreferencesContext';
+import type { ViewerUrlSource } from '@/contexts/PreferencesContext';
import { useViewersContext } from '@/contexts/ViewersContext';
import { resolveViewerTemplate } from '@/utils/viewerUrl';
@@ -9,16 +10,13 @@ export const DEFAULT_NEUROGLANCER_BASE_URL =
'https://neuroglancer-demo.appspot.com/';
/**
- * The Neuroglancer base URL (the part before the '#!' state fragment) that new
- * short links should default to, honoring the deployment's configured URL and
- * the user's per-viewer URL preference.
+ * The Neuroglancer base URL (the part before the '#!' state fragment) for the
+ * given URL preference, honoring the deployment's configured URL.
*/
-export function useDefaultNeuroglancerBaseUrl(): string {
+function useNeuroglancerBaseUrl(source: ViewerUrlSource | undefined): string {
const { validViewers } = useViewersContext();
- const { viewerUrlSources } = usePreferencesContext();
return useMemo(() => {
- const source = viewerUrlSources['neuroglancer'];
const neuroglancer = validViewers.find(v => v.key === 'neuroglancer');
if (neuroglancer) {
const template = resolveViewerTemplate(neuroglancer, source);
@@ -33,7 +31,25 @@ export function useDefaultNeuroglancerBaseUrl(): string {
return source.custom.split('#!')[0] || DEFAULT_NEUROGLANCER_BASE_URL;
}
return DEFAULT_NEUROGLANCER_BASE_URL;
- }, [validViewers, viewerUrlSources]);
+ }, [validViewers, source]);
+}
+
+/**
+ * The base URL for standalone Neuroglancer: the metadata panel's button and
+ * new short links. Follows the user's standalone Neuroglancer URL preference.
+ */
+export function useDefaultNeuroglancerBaseUrl(): string {
+ const { viewerUrlSources } = usePreferencesContext();
+ return useNeuroglancerBaseUrl(viewerUrlSources['neuroglancer']);
+}
+
+/**
+ * The base URL for a View's "Open in Neuroglancer" link-out. Follows its own
+ * preference, separate from standalone Neuroglancer.
+ */
+export function useViewsNeuroglancerBaseUrl(): string {
+ const { viewsNeuroglancerUrlSource } = usePreferencesContext();
+ return useNeuroglancerBaseUrl(viewsNeuroglancerUrlSource);
}
/**
diff --git a/frontend/src/queries/preferencesQueries.ts b/frontend/src/queries/preferencesQueries.ts
index 149fcfed9..dd87efff8 100644
--- a/frontend/src/queries/preferencesQueries.ts
+++ b/frontend/src/queries/preferencesQueries.ts
@@ -18,6 +18,8 @@ import type {
FileSharePathPreference,
FolderPreference,
FolderFavorite,
+ NeuroglancerButtonTarget,
+ ViewerUrlSource,
ViewerUrlSources
} from '@/contexts/PreferencesContext';
import {
@@ -50,6 +52,8 @@ type PreferencesApiResponse = {
disableHeuristicalLayerTypeDetection?: { value: boolean };
useLegacyMultichannelApproach?: { value: boolean };
viewerUrlSources?: { value: ViewerUrlSources };
+ neuroglancerButtonTarget?: { value: NeuroglancerButtonTarget };
+ viewsNeuroglancerUrlSource?: { value: ViewerUrlSource };
isFilteredByGroups?: { value: boolean };
showTutorial?: { value: boolean };
defaultExtraArgs?: { value: string };
@@ -85,6 +89,8 @@ export type PreferencesQueryData = {
disableHeuristicalLayerTypeDetection: boolean;
useLegacyMultichannelApproach: boolean;
viewerUrlSources: ViewerUrlSources;
+ neuroglancerButtonTarget: NeuroglancerButtonTarget;
+ viewsNeuroglancerUrlSource: ViewerUrlSource;
isFilteredByGroups: boolean;
showTutorial: boolean;
defaultExtraArgs: string;
@@ -259,6 +265,10 @@ const createTransformPreferences = (
useLegacyMultichannelApproach:
rawData.useLegacyMultichannelApproach?.value || false,
viewerUrlSources: rawData.viewerUrlSources?.value || {},
+ neuroglancerButtonTarget:
+ rawData.neuroglancerButtonTarget?.value ?? 'views',
+ viewsNeuroglancerUrlSource:
+ rawData.viewsNeuroglancerUrlSource?.value ?? 'configured',
isFilteredByGroups: rawData.isFilteredByGroups?.value ?? true,
showTutorial: rawData.showTutorial?.value ?? true,
defaultExtraArgs: rawData.defaultExtraArgs?.value || '',
diff --git a/frontend/ui-tests/tests/data-link-operations.spec.ts b/frontend/ui-tests/tests/data-link-operations.spec.ts
index 7ecf90efe..981692c71 100644
--- a/frontend/ui-tests/tests/data-link-operations.spec.ts
+++ b/frontend/ui-tests/tests/data-link-operations.spec.ts
@@ -47,6 +47,14 @@ test.describe('Data Link Operations', () => {
test.beforeEach(
'Wait for Zarr directories to load',
async ({ fileglancerPage: page }) => {
+ // These tests cover the data link the standalone viewer needs; by
+ // default, the Neuroglancer button creates a View instead.
+ const response = await page.request.put(
+ '/api/preference/neuroglancerButtonTarget',
+ { data: { value: 'standalone' } }
+ );
+ expect(response.ok()).toBe(true);
+ await page.reload();
await expect(
page.getByRole('link', { name: ZARR_TEST_FILE_INFO.v3_ome.dirname })
).toBeVisible();