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
34 changes: 17 additions & 17 deletions src/app/app/v2/[virtualLabId]/[projectId]/explore/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -20,18 +18,20 @@ export default async function Page({
return (
<ExploreLayout>
<ExploreHeader />
<div className="h-full max-h-[calc(100vh-10.7rem)] w-full px-3 [grid-area:aside]">
<Card borderless className="h-full w-full py-0">
<Suspense fallback={<TreeSkeleton />}>
<BrainRegionHierarchy dataKey={dataKey} />
</Suspense>
</Card>
</div>
<div className="h-full w-full [grid-area:main]">
<div id="3d-area" className="3d bg-primary-9 relative h-full w-full rounded-2xl p-1">
<AtlasViewer dataKey={dataKey} />
<ExploreInnerLayout>
<div
id="explore-left-menu"
data-testid="explore-left-menu"
className="h-full max-h-[calc(100vh-11.8rem)] min-h-0 w-full overflow-hidden [grid-area:aside]"
>
<Card borderless className="h-full w-full gap-0 bg-white py-0 shadow-lg">
<ExploreMenu dataKey={dataKey} />
</Card>
</div>
<div className="h-full max-h-[calc(100vh-11.8rem)] w-full rounded-2xl [grid-area:body]">
<Atlas dataKey={dataKey} />
</div>
</div>
</ExploreInnerLayout>
</ExploreLayout>
);
}
4 changes: 2 additions & 2 deletions src/components/GenericErrorFallback/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -33,7 +33,7 @@ export function ErrorComponent({
}: Props) {
return (
<div
className={classNames(
className={cn(
'bg-primary-9 flex h-screen w-full flex-col items-center justify-center p-6 text-white',
cls.container
)}
Expand Down
19 changes: 19 additions & 0 deletions src/components/icons/buttons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,22 @@ export function PeopleCommunity(props: React.SVGProps<SVGSVGElement>) {
</svg>
);
}

export function HierarchySquare(props: React.SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props}>
{/* Icon from Huge Icons by Hugeicons - undefined */}
<g
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
color="currentColor"
>
<path d="M2 20c0-.943 0-1.414.293-1.707S3.057 18 4 18h1c.943 0 1.414 0 1.707.293S7 19.057 7 20s0 1.414-.293 1.707S5.943 22 5 22H4c-.943 0-1.414 0-1.707-.293S2 20.943 2 20m15 0c0-.943 0-1.414.293-1.707S18.057 18 19 18h1c.943 0 1.414 0 1.707.293S22 19.057 22 20s0 1.414-.293 1.707S20.943 22 20 22h-1c-.943 0-1.414 0-1.707-.293S17 20.943 17 20m2.5-2.5c0-3.31-.648-4-3.75-4H14.5m-10 4c0-3.31.648-4 3.75-4H9.5M12 7v4m-2-9h4c1.815 0 2 .925 2 2.5S15.815 7 14 7h-4c-1.815 0-2-.925-2-2.5S8.185 2 10 2" />
<path d="M14.5 13.5a2.5 2.5 0 1 1-5 0a2.5 2.5 0 0 1 5 0" />
</g>
</svg>
);
}
90 changes: 77 additions & 13 deletions src/components/tree/elements/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,59 @@ export function scrollToNode<TNode extends TTreeNode>(
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);
}

/**
Expand All @@ -134,33 +179,52 @@ export function scrollToNode<TNode extends TTreeNode>(
* @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
* ```typescript
* 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<T extends Record<string, any>>(
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<string, any>;

// 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;
}
});
}
2 changes: 1 addition & 1 deletion src/components/tree/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ function Container({
}) {
return (
<div
className={classNames('no-scrollbar w-full overflow-y-auto', className)}
className={classNames('no-scrollbar h-full min-h-0 w-full overflow-y-auto', className)}
style={{ height }}
>
{children}
Expand Down
35 changes: 7 additions & 28 deletions src/features/brain-atlas-viewer/full-screen.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,17 @@
import { RefObject, useEffect, useReducer } from 'react';
import { FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons';

export default function FullScreen({
elementRef,
}: {
elementRef?: RefObject<HTMLDivElement | null> | 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 (
<div className="absolute top-4 left-4 z-50 cursor-pointer text-white">
{isFullScreen ? (
<FullscreenExitOutlined className="h-5 w-5 text-xl" onClick={onFullScreenExit} />
<FullscreenExitOutlined className="h-5 w-5 text-xl" onClick={onToggle} />
) : (
<FullscreenOutlined className="h-5 w-5 text-xl" onClick={onFullScreenEnter} />
<FullscreenOutlined className="h-5 w-5 text-xl" onClick={onToggle} />
)}
</div>
);
Expand Down
Loading