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
49 changes: 36 additions & 13 deletions src/features/brain-atlas-viewer/brain-region-mesh.tsx
Original file line number Diff line number Diff line change
@@ -1,49 +1,72 @@
import { useAtomValue, useSetAtom } from 'jotai';
import { useMemo, useLayoutEffect, useEffect } from 'react';
import { useThree } from '@react-three/fiber';
import { useMemo, useLayoutEffect } from 'react';
import { loadable } from 'jotai/utils';
import { useAtomValue } from 'jotai';
import get from 'lodash/get';

import { getAtlasMeshAsset } from '@/features/brain-atlas-viewer/context';
import { createMesh } from '@/features/brain-atlas-viewer/utils';
import { addMeshVisibilityAtom } from '@/features/brain-atlas-viewer/state';
import { useAppNotification } from '@/components/notification';
import { messages } from '@/i18n/en/atlas';

export default function BrainRegionMesh({
brainRegionId,
color,
dataKey,
regionName,
onLoadingChange,
}: {
brainRegionId: string;
color?: string;
dataKey: string;
regionName?: string;
onLoadingChange?: (type: 'mesh', loading: boolean) => void;
}) {
const addMeshVisibility = useSetAtom(addMeshVisibilityAtom);
const { scene } = useThree();
const notification = useAppNotification();

// Direct atom read - this will throw a promise if not resolved yet
const brainRegionMeshData = useAtomValue(
useMemo(() => getAtlasMeshAsset(brainRegionId), [brainRegionId])
const brainRegionMeshLoadable = useAtomValue(
useMemo(() => loadable(getAtlasMeshAsset(brainRegionId)), [brainRegionId])
);

useEffect(() => {
onLoadingChange?.('mesh', brainRegionMeshLoadable.state === 'loading');

if (brainRegionMeshLoadable.state === 'hasError') {
const error = brainRegionMeshLoadable.error as Error;
notification.warning({
message: <strong className="text-primary-9">{regionName ?? brainRegionId}</strong>,
description: `${get(messages, error.message, messages.default)} mesh.`,
placement: 'topRight',
key: `mesh-warning-${brainRegionId}`,
});
}
return () => {
onLoadingChange?.('mesh', false);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [brainRegionMeshLoadable.state]);

// Create mesh object only when data is available
const meshObject = useMemo(() => {
if (brainRegionMeshData?.data) {
const mesh = createMesh(brainRegionMeshData.data, color || '#FFF');
if (brainRegionMeshLoadable.state === 'hasData' && brainRegionMeshLoadable.data?.data) {
const mesh = createMesh(brainRegionMeshLoadable.data.data, color || '#FFF');
mesh.userData = { brainRegionId };
return mesh;
}
return null;
}, [brainRegionMeshData, color, brainRegionId]);
}, [brainRegionMeshLoadable, color, brainRegionId]);

// Add to scene and register visibility when mesh is created
// Add to scene when mesh is created
useLayoutEffect(() => {
if (meshObject) {
scene.add(meshObject);
addMeshVisibility(dataKey, brainRegionId, 'mesh', meshObject.uuid);

return () => {
scene.remove(meshObject);
};
}
}, [meshObject, scene, addMeshVisibility, dataKey, brainRegionId]);
}, [meshObject, scene, dataKey, brainRegionId]);

return null;
}
26 changes: 9 additions & 17 deletions src/features/brain-atlas-viewer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ import FullScreen from '@/features/brain-atlas-viewer/full-screen';
import Loader from '@/components/loader';
import { log } from '@/utils/logger';

import type { TSuspenseStatus } from '@/components/suspense-with-status';

/**
* CameraController synchronizes the camera's zoom level with external state and detects user-initiated zoom changes.
*
Expand Down Expand Up @@ -79,11 +77,8 @@ function CameraController({

export function AtlasViewer({ dataKey, children }: { dataKey: string; children?: ReactNode }) {
const threeDRef = useRef<HTMLDivElement>(null);
const [meshLoadingStatus, setMeshLoadingStatus] = useState<TSuspenseStatus>('pending');
const [isFullScreen, setIsFullScreen] = useState(false);

const [pointCloudLoadingStatus, setPointCloudLoadingStatus] =
useState<TSuspenseStatus>('pending');
const [isLoading, setIsLoading] = useState<boolean>(true);

// track user's manual zoom level
const [userBaseZoom, setUserBaseZoom] = useState<number>(1.3);
Expand Down Expand Up @@ -179,19 +174,17 @@ export function AtlasViewer({ dataKey, children }: { dataKey: string; children?:
};
}, [isFullScreen, updateSize]);

const onMeshLoadingStatusChange = useCallback((status: TSuspenseStatus) => {
setMeshLoadingStatus(status);
}, []);

const onPointCloudLoadingStatusChange = useCallback((status: TSuspenseStatus) => {
setPointCloudLoadingStatus(status);
// After first render, let loading overlay be controlled by subcomponents via global atoms
useEffect(() => {
const t = setTimeout(() => setIsLoading(false), 0);
return () => clearTimeout(t);
}, []);

const handleFullScreenToggle = () => {
setIsFullScreen((prev) => !prev);
};

const isLoading = meshLoadingStatus === 'pending' || pointCloudLoadingStatus === 'pending';
// kept as local fast guard for initial layout while Jotai loadables spin up

const handleUserZoomChange = useCallback((newZoom: number) => {
setUserBaseZoom(newZoom);
Expand Down Expand Up @@ -309,16 +302,15 @@ export function AtlasViewer({ dataKey, children }: { dataKey: string; children?:
/>
<ViewerComposer
dataKey={dataKey}
onMeshLoadingStatusChange={onMeshLoadingStatusChange}
onPointCloudLoadingStatusChange={onPointCloudLoadingStatusChange}
onLoadingChange={(_type, loading) => {
setIsLoading(loading);
}}
/>
</Canvas>
</>
),
[
dataKey,
onMeshLoadingStatusChange,
onPointCloudLoadingStatusChange,
containerSize.width,
containerSize.height,
finalZoom,
Expand Down
22 changes: 0 additions & 22 deletions src/features/brain-atlas-viewer/loading-handler.tsx

This file was deleted.

50 changes: 39 additions & 11 deletions src/features/brain-atlas-viewer/point-cloud.tsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,85 @@
import { useAtomValue, useSetAtom } from 'jotai';
import { useMemo, useLayoutEffect, useRef, useEffect } from 'react';
import { useThree } from '@react-three/fiber';
import { useMemo, useLayoutEffect, useRef } from 'react';
import { loadable } from 'jotai/utils';
import { useAtomValue } from 'jotai';
import get from 'lodash/get';

import { createPointCloud } from '@/features/brain-atlas-viewer/utils';
import { addMeshVisibilityAtom, getPointCloudAtom } from '@/features/brain-atlas-viewer/state';
import { getPointCloudAtom } from '@/features/brain-atlas-viewer/state';
import { useAppNotification } from '@/components/notification';
import { messages } from '@/i18n/en/atlas';

type PointCloudMeshProps = {
brainRegionId: string;
brainRegionAnnotationValue: number;
dataKey: string;
color?: string;
onLoadingChange?: (type: 'pointCloud', loading: boolean) => void;
regionName?: string;
};

export default function PointCloudMesh({
brainRegionAnnotationValue,
brainRegionId,
dataKey,
color,
onLoadingChange,
regionName,
}: PointCloudMeshProps) {
const { scene } = useThree();
const addMeshVisibility = useSetAtom(addMeshVisibilityAtom);
const hasAddedVisibility = useRef(false);
const notification = useAppNotification();

// Direct atom read - this will throw a promise if not resolved yet
const pointCloudData = useAtomValue(
useMemo(() => getPointCloudAtom(brainRegionAnnotationValue), [brainRegionAnnotationValue])
const pointCloudLoadable = useAtomValue(
useMemo(
() => loadable(getPointCloudAtom(brainRegionAnnotationValue)),
[brainRegionAnnotationValue]
)
);

useEffect(() => {
onLoadingChange?.('pointCloud', pointCloudLoadable.state === 'loading');

if (pointCloudLoadable.state === 'hasError') {
const error = pointCloudLoadable.error as Error;
notification.warning({
message: <strong className="text-primary-9">{regionName ?? brainRegionId}</strong>,
description: `${get(messages, error.message, messages.default)} point cloud.`,
placement: 'topRight',
key: `point-cloud-warning-${brainRegionId}`,
});
}
return () => {
onLoadingChange?.('pointCloud', false);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pointCloudLoadable.state]);

// Create point cloud object only when data is available
const pointCloud3DObject = useMemo(() => {
if (pointCloudData) {
const pointCloudObj = createPointCloud(pointCloudData, color || '#FFF');
if (pointCloudLoadable.state === 'hasData' && pointCloudLoadable.data) {
const pointCloudObj = createPointCloud(pointCloudLoadable.data, color || '#FFF');
pointCloudObj.userData = { brainRegionId };
// Reset visibility tracking when new object is created
hasAddedVisibility.current = false;
return pointCloudObj;
}
return null;
}, [pointCloudData, color, brainRegionId]);
}, [pointCloudLoadable, color, brainRegionId]);

// Add to scene when point cloud is created
useLayoutEffect(() => {
if (pointCloud3DObject && !hasAddedVisibility.current) {
scene.add(pointCloud3DObject);
addMeshVisibility(dataKey, brainRegionId, 'pointCloud', pointCloud3DObject.uuid);
hasAddedVisibility.current = true;

return () => {
scene.remove(pointCloud3DObject);
hasAddedVisibility.current = false;
};
}
}, [pointCloud3DObject, scene, addMeshVisibility, dataKey, brainRegionId]);
}, [pointCloud3DObject, scene, dataKey, brainRegionId]);

return null;
}
34 changes: 2 additions & 32 deletions src/features/brain-atlas-viewer/state.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { atomFamily, atomWithReset } from 'jotai/utils';
import { atom } from 'jotai';

import uniqBy from 'lodash/uniqBy';
import sessionAtom from '@/state/session';

import { LoadingState, MeshVisibility, VisibilityType } from '@/features/brain-atlas-viewer/types';
import { MeshVisibility, VisibilityType } from '@/features/brain-atlas-viewer/types';
import { fetchPointCloud } from '@/features/brain-atlas-viewer/api';
import { ApplicationSection } from '@/types/common';
import { cellSvcBaseUrl } from '@/config';
import { env } from '@/env';

Expand Down Expand Up @@ -44,33 +44,3 @@ export const getPointCloudAtom = atomFamily((brainRegionAnnotationValue: number)
return await fetchPointCloud(url, session.accessToken);
})
);

export const loadingAtom = atom<Record<ApplicationSection, LoadingState[]>>({
explore: [],
build: [],
simulate: [],
});

export const addLoadingAtom = atom(
null,
(get, set, section: ApplicationSection, brainRegionId: string, type: VisibilityType) => {
const loading = get(loadingAtom);
if (!loading[section].find((l) => l.id === brainRegionId && l.type === type)) {
set(loadingAtom, {
...loading,
[section]: [...loading[section], { id: brainRegionId, type }],
});
}
}
);

export const disableLoadingAtom = atom(
null,
(get, set, section: ApplicationSection, brainRegionId: string, type: VisibilityType) => {
const loading = get(loadingAtom);
set(loadingAtom, {
...loading,
[section]: [...loading[section].filter((l) => l.id !== brainRegionId || l.type !== type)],
});
}
);
Loading