diff --git a/src/app/app/v2/[virtualLabId]/[projectId]/explore/page.tsx b/src/app/app/v2/[virtualLabId]/[projectId]/explore/page.tsx index 60611179d..0400c3d7b 100644 --- a/src/app/app/v2/[virtualLabId]/[projectId]/explore/page.tsx +++ b/src/app/app/v2/[virtualLabId]/[projectId]/explore/page.tsx @@ -1,12 +1,10 @@ -import { Suspense } from 'react'; +import { ExploreLayout, ExploreInnerLayout } from '@/ui/layouts/explore-layout'; -import { TreeSkeleton } from '@/features/brain-region-hierarchy/brain-region-skeleton'; -import { BrainRegionHierarchy } from '@/features/brain-region-hierarchy'; - -import { AtlasViewer } from '@/features/brain-atlas-viewer'; -import { ExploreLayout } from '@/ui/layouts/explore-layout'; +import { ExploreMenu } from '@/ui/segments/explore/left-menu'; import { ExploreHeader } from '@/ui/segments/explore/header'; + import { resolveDataKey } from '@/utils/key-builder'; +import { Atlas } from '@/ui/segments/explore/atlas'; import { Card } from '@/ui/molecules/card'; import type { ServerSideComponentProp, WorkspaceContext } from '@/types/common'; @@ -20,18 +18,20 @@ export default async function Page({ return ( -
- - }> - - - -
-
-
- + +
+ + + +
+
+
-
+ ); } diff --git a/src/components/GenericErrorFallback/index.tsx b/src/components/GenericErrorFallback/index.tsx index 35c5a0cf2..246dcfe26 100644 --- a/src/components/GenericErrorFallback/index.tsx +++ b/src/components/GenericErrorFallback/index.tsx @@ -4,7 +4,7 @@ import { WarningOutlined } from '@ant-design/icons'; import { ReactNode } from 'react'; import Link from 'next/link'; -import { classNames } from '@/util/utils'; +import { cn } from '@/utils/css-class'; interface Props { error?: Error & { cause?: unknown }; @@ -33,7 +33,7 @@ export function ErrorComponent({ }: Props) { return (
) { ); } + +export function HierarchySquare(props: React.SVGProps) { + return ( + + {/* Icon from Huge Icons by Hugeicons - undefined */} + + + + + + ); +} diff --git a/src/components/tree/elements/helpers.ts b/src/components/tree/elements/helpers.ts index 6ec2ac8a7..5de6bf64d 100644 --- a/src/components/tree/elements/helpers.ts +++ b/src/components/tree/elements/helpers.ts @@ -117,14 +117,59 @@ export function scrollToNode( if (!node || typeof document === 'undefined') return; const element = document.querySelector(`[data-node-id="${node.id}"]`); - if (element) { - setTimeout(() => { - element.scrollIntoView({ - behavior: 'smooth', - block, - }); - }, 50); - } + if (!element) return; + + const findScrollableAncestor = (el: Element | null): HTMLElement | null => { + let current: HTMLElement | null = el as HTMLElement | null; + while (current && current.parentElement) { + const parent = current.parentElement as HTMLElement; + const style = window.getComputedStyle(parent); + const overflowY = style.overflowY || style.overflow; + const isScrollable = /auto|scroll/i.test(overflowY); + if (isScrollable && parent.scrollHeight > parent.clientHeight) { + return parent; + } + current = parent; + } + // fallback to document scrolling element + return (document.scrollingElement || document.documentElement) as HTMLElement; + }; + + // delay to allow DOM (expansions) to settle + window.setTimeout(() => { + const container = findScrollableAncestor(element); + if (!container) return; + + const containerRect = container.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + + // current scroll position of the container + const currentTop = container.scrollTop; + const offsetTop = elementRect.top - containerRect.top + currentTop; + + let targetScrollTop = offsetTop; + if (block === 'center') { + targetScrollTop = offsetTop - container.clientHeight / 2 + elementRect.height / 2; + } else if (block === 'start') { + targetScrollTop = offsetTop; + } else if (block === 'end') { + targetScrollTop = offsetTop - container.clientHeight + elementRect.height; + } else if (block === 'nearest') { + // of already in view, do nothing; otherwise choose the closer edge + const inView = + elementRect.top >= containerRect.top && elementRect.bottom <= containerRect.bottom; + if (!inView) { + const distanceToTop = Math.abs(elementRect.top - containerRect.top); + const distanceToBottom = Math.abs(elementRect.bottom - containerRect.bottom); + if (distanceToTop < distanceToBottom) targetScrollTop = offsetTop; + else targetScrollTop = offsetTop - container.clientHeight + elementRect.height; + } else { + return; + } + } + + container.scrollTo({ top: Math.max(0, targetScrollTop), behavior: 'smooth' }); + }, 50); } /** @@ -134,6 +179,7 @@ export function scrollToNode( * @param obj - The object to process. If `null`, the function returns `null`. * @param field - The key name to be renamed throughout the object. * @param newKey - The new key name to replace the old key. + * @param keepOriginal - Whether to keep the original key alongside the new key (default: false). * @returns A new object with the specified key renamed at all levels, or `null` if the input is `null`. * * @example @@ -141,26 +187,44 @@ export function scrollToNode( * const obj = { a: 1, b: { a: 2 }, c: [{ a: 3 }] }; * const result = renameKeyDeep(obj, 'a', 'x'); * // result: { x: 1, b: { x: 2 }, c: [{ x: 3 }] } + * + * const resultWithOriginal = renameKeyDeep(obj, 'a', 'x', true); + * // result: { a: 1, x: 1, b: { a: 2, x: 2 }, c: [{ a: 3, x: 3 }] } * ``` */ export function renameKeyDeep>( obj: T | null, field: string, - newKey: string + newKey: string, + keepOriginal: boolean = false ): T | null { if (!obj) return null; return transform(obj, (result, value, key) => { if (!result) return; - const sanitizedField = key === field ? newKey : key; const dic = result as Record; + + // Process the value recursively if it's an object or array + let processedValue: any; if (isArray(value)) { - dic[sanitizedField] = value.map((item) => renameKeyDeep(item, field, newKey)); + processedValue = value.map((item) => renameKeyDeep(item, field, newKey, keepOriginal)); } else if (isObject(value)) { - dic[sanitizedField] = renameKeyDeep(value, field, newKey); + processedValue = renameKeyDeep(value, field, newKey, keepOriginal); + } else { + processedValue = value; + } + + if (key === field) { + // set the new key + dic[newKey] = processedValue; + // optionally keep the original key + if (keepOriginal) { + dic[key] = processedValue; + } } else { - dic[sanitizedField] = value; + // key doesn't match, set it as-is + dic[key] = processedValue; } }); } diff --git a/src/components/tree/index.tsx b/src/components/tree/index.tsx index db78b7f74..dbd96cdc2 100644 --- a/src/components/tree/index.tsx +++ b/src/components/tree/index.tsx @@ -44,7 +44,7 @@ function Container({ }) { return (
{children} diff --git a/src/features/brain-atlas-viewer/full-screen.tsx b/src/features/brain-atlas-viewer/full-screen.tsx index ebe3ea520..2e0640951 100644 --- a/src/features/brain-atlas-viewer/full-screen.tsx +++ b/src/features/brain-atlas-viewer/full-screen.tsx @@ -1,38 +1,17 @@ -import { RefObject, useEffect, useReducer } from 'react'; import { FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons'; -export default function FullScreen({ - elementRef, -}: { - elementRef?: RefObject | null; -}) { - const [isFullScreen, toggleFullScreen] = useReducer((value) => !value, false); - - const onFullScreenEnter = () => { - if (elementRef?.current && !document.fullscreenElement) { - elementRef.current.requestFullscreen(); - } - }; - const onFullScreenExit = () => { - if (elementRef?.current && document.fullscreenElement && document.exitFullscreen) { - document.exitFullscreen(); - } - }; - - useEffect(() => { - document.addEventListener('fullscreenchange', toggleFullScreen); - - return () => { - document.removeEventListener('fullscreenchange', toggleFullScreen); - }; - }, []); +interface FullScreenProps { + isFullScreen: boolean; + onToggle: () => void; +} +export default function FullScreen({ isFullScreen, onToggle }: FullScreenProps) { return (
{isFullScreen ? ( - + ) : ( - + )}
); diff --git a/src/features/brain-atlas-viewer/index.tsx b/src/features/brain-atlas-viewer/index.tsx index d122fb1fb..952143911 100644 --- a/src/features/brain-atlas-viewer/index.tsx +++ b/src/features/brain-atlas-viewer/index.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { OrbitControls } from '@react-three/drei'; import { Canvas } from '@react-three/fiber'; +import { match } from 'ts-pattern'; import { Vector3 } from 'three'; import ViewerComposer from '@/features/brain-atlas-viewer/viewer-composer'; @@ -14,10 +15,37 @@ import type { TSuspenseStatus } from '@/components/suspense-with-status'; export function AtlasViewer({ dataKey }: { dataKey: string }) { const threeDRef = useRef(null); const [meshLoadingStatus, setMeshLoadingStatus] = useState('pending'); + const [isFullScreen, setIsFullScreen] = useState(false); const [pointCloudLoadingStatus, setPointCloudLoadingStatus] = useState('pending'); + // Track container size using ResizeObserver to ensure the Canvas always fits its parent + const [containerSize, setContainerSize] = useState<{ width: number; height: number }>({ + width: 0, + height: 0, + }); + + useEffect(() => { + const element = threeDRef.current; + if (!element) return; + + // Initialize with current size + const rect = element.getBoundingClientRect(); + setContainerSize({ width: Math.floor(rect.width) - 10, height: Math.floor(rect.height) }); + + // Observe size changes + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + const { width, height } = entry.contentRect; + setContainerSize({ width: Math.floor(width) - 10, height: Math.floor(height) }); + }); + + observer.observe(element); + return () => observer.disconnect(); + }, [isFullScreen]); + const onMeshLoadingStatusChange = useCallback((status: TSuspenseStatus) => { setMeshLoadingStatus(status); }, []); @@ -26,36 +54,74 @@ export function AtlasViewer({ dataKey }: { dataKey: string }) { setPointCloudLoadingStatus(status); }, []); + const handleFullScreenToggle = () => { + setIsFullScreen((prev) => !prev); + }; + const isLoading = meshLoadingStatus === 'pending' || pointCloudLoadingStatus === 'pending'; - return ( -
- {isLoading && ( -
- -
- )} - - - - - -
+ const renderViewer = useMemo( + () => ( + <> + + + + + + ), + [ + dataKey, + onMeshLoadingStatusChange, + onPointCloudLoadingStatusChange, + containerSize.width, + containerSize.height, + ] ); + + return match(isFullScreen) + .with(true, () => { + return ( +
+ + {isLoading && ( +
+ +
+ )} +
+ {renderViewer} +
+
+ ); + }) + .otherwise(() => { + return ( +
+ + {isLoading && ( +
+ +
+ )} + {renderViewer} +
+ ); + }); } export default AtlasViewer; diff --git a/src/features/brain-region-hierarchy/context.tsx b/src/features/brain-region-hierarchy/context.tsx index 4e3daf136..76c151d1f 100644 --- a/src/features/brain-region-hierarchy/context.tsx +++ b/src/features/brain-region-hierarchy/context.tsx @@ -2,7 +2,7 @@ import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs'; import { useEffect, useRef } from 'react'; -import { atom, useAtomValue, useSetAtom } from 'jotai'; +import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'; import lowerCase from 'lodash/lowerCase'; import find from 'lodash/find'; import omit from 'lodash/omit'; @@ -109,14 +109,18 @@ export const brainRegionBasicCellGroupsRegionsHierarchyAtom = atom( } let options: Array = []; let leaves: Map = new Map(); - const nodes = renameKeyDeep(root, 'color_hex_triplet', 'color'); + const nodes = renameKeyDeep(root, 'color_hex_triplet', 'color', true); - if (nodes) { + if (root) { options = flattenTreeAsObject(root).map((region) => ({ av: region.annotation_value, value: region.id, label: `${region.name}`, - data: region, + data: { + ...region, + color_hex_triplet: region.color_hex_triplet, + color: region.color_hex_triplet, + }, })); leaves = getLeavesForEachRegion(root); } @@ -142,8 +146,9 @@ export const brainRegionBasicCellGroupsRegionsHierarchyAtom = atom( */ export const useBrainRegionHierarchy = ({ dataKey }: Props) => { const key = getSectionFromDataKey(dataKey); - const updateSelectedBrainRegion = useSetAtom(selectedBrainRegionAtom); + const [selectedBrainRegion, updateSelectedBrainRegion] = useAtom(selectedBrainRegionAtom); const brainRegions = useUnwrappedValue(brainRegionBasicCellGroupsRegionsHierarchyAtom); + const defaultSelectedBrainRegion = brainRegions?.options.find( (o) => lowerCase(o.label).trim() === lowerCase(DEFAULT_SELECTED_BRAIN_REGION_NAME).trim() ); @@ -174,11 +179,16 @@ export const useBrainRegionHierarchy = ({ dataKey }: Props) => { useEffect(() => { if (isInitializedRef.current || !brainRegions) return; - const hasURLParams = !!id && !!annotation_value; if (hasURLParams) { isInitializedRef.current = true; + if (selectedBrainRegion?.id !== id) { + const foundNode = find(brainRegions?.options, (o) => o.data.id === id)?.data; + if (foundNode) { + updateSelectedBrainRegion(omit(foundNode, 'children')); + } + } return; } @@ -186,7 +196,9 @@ export const useBrainRegionHierarchy = ({ dataKey }: Props) => { if (id !== stored.id || annotation_value !== stored.annotation_value) { setHierarchyConfig(stored); const foundNode = find(brainRegions?.options, (o) => o.data.id === stored.id)?.data; - if (foundNode) updateSelectedBrainRegion(omit(foundNode, 'children')); + if (foundNode) { + updateSelectedBrainRegion(omit(foundNode, 'children')); + } } isInitializedRef.current = true; return; diff --git a/src/features/brain-region-hierarchy/index.tsx b/src/features/brain-region-hierarchy/index.tsx index bf008ee91..b59a58179 100644 --- a/src/features/brain-region-hierarchy/index.tsx +++ b/src/features/brain-region-hierarchy/index.tsx @@ -62,15 +62,15 @@ export function BrainRegionHierarchy({ dataKey }: { dataKey: string }) { return (
-
-
+
+
diff --git a/src/features/brain-region-hierarchy/region-banner.tsx b/src/features/brain-region-hierarchy/region-banner.tsx new file mode 100644 index 000000000..c8f6dfe47 --- /dev/null +++ b/src/features/brain-region-hierarchy/region-banner.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { CloseOutlined } from '@ant-design/icons'; + +import { useGetSelectedBrainRegion } from '@/features/brain-region-hierarchy/context'; +// import { SpeciesSwitcher } from '@/features/brain-region-hierarchy/species-switcher'; +import { HierarchySquare } from '@/components/icons/buttons'; +import { Button } from '@/ui/molecules/button'; +import { cn } from '@/utils/css-class'; + +// import type { ISpecies } from '@/api/entitycore/types/shared/global'; + +export const ExploreLeftMenuContext = { + BrainRegionHierarchy: 'brain-region-hierarchy', + DataType: 'data-type', +} as const; +export type TExploreLeftMenuContext = + (typeof ExploreLeftMenuContext)[keyof typeof ExploreLeftMenuContext]; + +type Props = { + view: TExploreLeftMenuContext; + onSwitchView: (_view: TExploreLeftMenuContext) => void; +}; + +export function RegionBanner({ view, onSwitchView }: Props) { + const { selectedBrainRegion } = useGetSelectedBrainRegion(); + // const [currentSpecies, setCurrentSpecies] = useState(null); + // const speciesOptions = [ + // { value: 'rodent', label: 'Rodent', data: null }, + // { value: 'human', label: 'Human', data: null }, + // { value: 'primate', label: 'Primate', data: null }, + // ]; + + // const handleSpeciesChange = (species: ISpecies | null) => { + // setCurrentSpecies(species); + // }; + + return ( +
+
+
+ {/* {currentSpecies?.name && ( +
+ Species + {currentSpecies.name} +
+ )} */} + {selectedBrainRegion && ( +
+ Region: +
+
+ {selectedBrainRegion.name} +
+
+ )} +
+ +
+ {/*
+
+ Species + +
+
*/} +
+ ); +} diff --git a/src/features/brain-region-hierarchy/species-switcher.tsx b/src/features/brain-region-hierarchy/species-switcher.tsx new file mode 100644 index 000000000..597a24202 --- /dev/null +++ b/src/features/brain-region-hierarchy/species-switcher.tsx @@ -0,0 +1,63 @@ +import { useCallback, useState } from 'react'; +import { ConfigProvider, Select } from 'antd'; + +import filterAndSortBasedOnPosition from '@/util/filterAndSortBasedOnPosition'; +import { cn } from '@/utils/css-class'; + +import type { ISpecies } from '@/api/entitycore/types/shared/global'; + +interface Props { + options: Array<{ value: string; label: string; data: ISpecies | null }>; + onSelect?: (species: ISpecies | null) => void; +} + +export function SpeciesSwitcher({ options, onSelect }: Props) { + const [searchValue, setSearchValue] = useState(undefined); + const handleSelect = useCallback( + (value: string) => { + const selectedOption = options.find((option) => option.value === value); + if (selectedOption) { + onSelect?.(selectedOption.data); + } + }, + [options, onSelect] + ); + + return ( +
+ +