HyperView / frontend /src /components /DockviewWorkspace.tsx
morozovdd's picture
feat: add HyperView app for space
23680f2
"use client";
import React, {
useCallback,
useEffect,
useMemo,
useState,
createContext,
useContext,
type ReactNode,
} from "react";
import {
DockviewReact,
type DockviewApi,
type DockviewReadyEvent,
type IDockviewPanelProps,
type IDockviewPanelHeaderProps,
type IWatermarkPanelProps,
themeAbyss,
} from "dockview";
import { Circle, Disc, Grid3X3 } from "lucide-react";
import type { Geometry, Sample } from "@/types";
import { useStore } from "@/store/useStore";
import { findLayoutByGeometry } from "@/lib/layouts";
import { ImageGrid } from "./ImageGrid";
import { ScatterPanel } from "./ScatterPanel";
import { ExplorerPanel } from "./ExplorerPanel";
import { PlaceholderPanel } from "./PlaceholderPanel";
import { HyperViewLogo } from "./icons";
const LAYOUT_STORAGE_KEY = "hyperview:dockview-layout:v4";
// Panel IDs
const PANEL = {
EXPLORER: "explorer",
GRID: "grid",
SCATTER_EUCLIDEAN: "scatter-euclidean",
SCATTER_POINCARE: "scatter-poincare",
SCATTER_DEFAULT: "scatter-default",
RIGHT_PLACEHOLDER: "right-placeholder",
BOTTOM_PLACEHOLDER: "bottom-placeholder",
} as const;
const CENTER_PANEL_IDS = [
PANEL.GRID,
PANEL.SCATTER_EUCLIDEAN,
PANEL.SCATTER_POINCARE,
PANEL.SCATTER_DEFAULT,
] as const;
export const CENTER_PANEL_DEFS = [
{ id: PANEL.GRID, label: "Samples", icon: Grid3X3 },
{ id: PANEL.SCATTER_EUCLIDEAN, label: "Euclidean", icon: Circle },
{ id: PANEL.SCATTER_POINCARE, label: "Hyperbolic", icon: Disc },
] as const;
const NON_ANCHOR_PANEL_IDS = new Set<string>([
PANEL.EXPLORER,
PANEL.RIGHT_PLACEHOLDER,
PANEL.BOTTOM_PLACEHOLDER,
]);
const DRAG_LOCKED_PANEL_IDS = new Set<string>([PANEL.EXPLORER]);
const DEFAULT_CONTAINER_WIDTH = 1200;
const DEFAULT_CONTAINER_HEIGHT = 800;
const MIN_SIDE_PANEL_WIDTH = 120;
const MIN_BOTTOM_PANEL_HEIGHT = 150;
const getContainerWidth = (api?: DockviewApi | null) =>
api?.width ??
(typeof window === "undefined" ? DEFAULT_CONTAINER_WIDTH : window.innerWidth);
const getContainerHeight = (api?: DockviewApi | null) =>
api?.height ??
(typeof window === "undefined" ? DEFAULT_CONTAINER_HEIGHT : window.innerHeight);
const getDefaultLeftPanelWidth = (screenWidth: number) =>
Math.round(Math.min(0.35 * screenWidth, 200));
const getDefaultRightPanelWidth = (screenWidth: number) =>
Math.round(Math.min(0.45 * screenWidth, 300));
const getDefaultBottomPanelHeight = (containerHeight: number) =>
Math.round(
Math.min(Math.max(0.25 * containerHeight, MIN_BOTTOM_PANEL_HEIGHT), 250)
);
const getBottomPanelMaxHeight = (containerHeight: number) =>
Math.round(
Math.max(containerHeight - MIN_BOTTOM_PANEL_HEIGHT, MIN_BOTTOM_PANEL_HEIGHT)
);
function getCenterAnchorPanel(api: DockviewApi) {
for (const id of CENTER_PANEL_IDS) {
const panel = api.getPanel(id);
if (panel) return panel;
}
const fallback = api.panels.find((panel) => !NON_ANCHOR_PANEL_IDS.has(panel.id));
return fallback ?? api.activePanel;
}
function getZonePosition(zone: "left" | "right" | "bottom") {
return { direction: zone === "bottom" ? "below" : zone };
}
function getCenterTabPosition(api: DockviewApi) {
const anchor = getCenterAnchorPanel(api);
if (!anchor) return undefined;
return { referencePanel: anchor, direction: "within" as const };
}
// -----------------------------------------------------------------------------
// Context for sharing dockview API across components
// -----------------------------------------------------------------------------
interface DockviewContextValue {
api: DockviewApi | null;
setApi: (api: DockviewApi) => void;
samples: Sample[];
onLoadMore: () => void;
hasMore: boolean;
}
const DockviewContext = createContext<DockviewContextValue | null>(null);
function useDockviewContext() {
const ctx = useContext(DockviewContext);
if (!ctx) throw new Error("useDockviewContext must be used within DockviewProvider");
return ctx;
}
// Public hook for components like Header
export function useDockviewApi() {
const ctx = useContext(DockviewContext);
const datasetInfo = useStore((state) => state.datasetInfo);
const {
leftPanelOpen,
rightPanelOpen,
bottomPanelOpen,
setLeftPanelOpen,
setRightPanelOpen,
setBottomPanelOpen,
} = useStore();
const addPanel = useCallback(
(panelId: string) => {
if (!ctx?.api) return;
const api = ctx.api;
const position = getCenterTabPosition(api);
const baseOptions = position ? { position } : {};
const layouts = datasetInfo?.layouts ?? [];
const euclideanLayout = findLayoutByGeometry(layouts, "euclidean");
const poincareLayout = findLayoutByGeometry(layouts, "poincare");
// Don't add if already exists - just focus it
if (api.getPanel(panelId)) {
api.getPanel(panelId)?.focus();
return;
}
switch (panelId) {
case PANEL.GRID:
api.addPanel({
id: PANEL.GRID,
component: "grid",
title: "Samples",
tabComponent: "samplesTab",
renderer: "always",
...baseOptions,
});
break;
case PANEL.SCATTER_EUCLIDEAN:
api.addPanel({
id: PANEL.SCATTER_EUCLIDEAN,
component: "scatter",
title: "Euclidean",
tabComponent: "euclideanTab",
params: {
layoutKey: euclideanLayout?.layout_key,
geometry: "euclidean" as Geometry,
},
renderer: "always",
...baseOptions,
});
break;
case PANEL.SCATTER_POINCARE:
api.addPanel({
id: PANEL.SCATTER_POINCARE,
component: "scatter",
title: "Hyperbolic",
tabComponent: "hyperbolicTab",
params: {
layoutKey: poincareLayout?.layout_key,
geometry: "poincare" as Geometry,
},
renderer: "always",
...baseOptions,
});
break;
}
},
[ctx?.api, datasetInfo]
);
const resetLayout = useCallback(() => {
localStorage.removeItem(LAYOUT_STORAGE_KEY);
window.location.reload();
}, []);
// Toggle zone visibility
const toggleZone = useCallback(
(zone: "left" | "right" | "bottom") => {
if (!ctx?.api) return;
const api = ctx.api;
const panelId =
zone === "left"
? PANEL.EXPLORER
: zone === "right"
? PANEL.RIGHT_PLACEHOLDER
: PANEL.BOTTOM_PLACEHOLDER;
const setOpen =
zone === "left"
? setLeftPanelOpen
: zone === "right"
? setRightPanelOpen
: setBottomPanelOpen;
const isOpen =
zone === "left"
? leftPanelOpen
: zone === "right"
? rightPanelOpen
: bottomPanelOpen;
const existingPanel = api.getPanel(panelId);
if (isOpen && existingPanel) {
existingPanel.api.close();
setOpen(false);
return;
}
if (isOpen) return;
const containerWidth = getContainerWidth(api);
const containerHeight = getContainerHeight(api);
const position = getZonePosition(zone);
let newPanel;
if (zone === "left") {
const targetWidth = getDefaultLeftPanelWidth(containerWidth);
newPanel = api.addPanel({
id: panelId,
component: "explorer",
title: "Explorer",
position,
initialWidth: targetWidth,
minimumWidth: MIN_SIDE_PANEL_WIDTH,
maximumWidth: targetWidth,
});
if (newPanel) {
newPanel.group.locked = true;
newPanel.group.header.hidden = true;
// Explicitly set the width to ensure it's applied
newPanel.api.setSize({ width: targetWidth });
}
} else if (zone === "right") {
newPanel = api.addPanel({
id: panelId,
component: "placeholder",
title: "Blank",
position,
initialWidth: getDefaultRightPanelWidth(containerWidth),
minimumWidth: MIN_SIDE_PANEL_WIDTH,
maximumWidth: Math.round(containerWidth * 0.65),
});
} else {
newPanel = api.addPanel({
id: panelId,
component: "placeholder",
title: "Blank",
position,
initialHeight: getDefaultBottomPanelHeight(containerHeight),
minimumHeight: MIN_BOTTOM_PANEL_HEIGHT,
maximumHeight: getBottomPanelMaxHeight(containerHeight),
});
}
if (newPanel) {
setOpen(true);
// Activate the panel so its content renders immediately
newPanel.api.setActive();
}
},
[
ctx?.api,
leftPanelOpen,
rightPanelOpen,
bottomPanelOpen,
setLeftPanelOpen,
setRightPanelOpen,
setBottomPanelOpen,
]
);
if (!ctx) return null;
return {
api: ctx.api,
addPanel,
resetLayout,
toggleZone,
};
}
// -----------------------------------------------------------------------------
// Panel Components - stable references defined outside component
// -----------------------------------------------------------------------------
type ScatterPanelParams = {
layoutKey?: string;
geometry?: Geometry;
};
const ScatterDockPanel = React.memo(function ScatterDockPanel(
props: IDockviewPanelProps<ScatterPanelParams>
) {
const params = props.params ?? {};
return (
<ScatterPanel
className="h-full"
layoutKey={params.layoutKey}
geometry={params.geometry}
/>
);
});
// Custom tab component with icon (like Rerun's "Image and segmentation mask" tab)
type TabWithIconProps = IDockviewPanelHeaderProps & {
icon: React.ReactNode;
};
const TabWithIcon = React.memo(function TabWithIcon({ api, icon }: TabWithIconProps) {
return (
<div className="flex items-center gap-1 text-[12px] leading-[16px] font-medium tracking-[-0.15px]">
<span className="flex-shrink-0">{icon}</span>
<span className="truncate">{api.title}</span>
</div>
);
});
// Tab components for different panel types
const EuclideanTab = React.memo(function EuclideanTab(props: IDockviewPanelHeaderProps) {
return <TabWithIcon {...props} icon={<Circle className="h-3.5 w-3.5" />} />;
});
const HyperbolicTab = React.memo(function HyperbolicTab(props: IDockviewPanelHeaderProps) {
return <TabWithIcon {...props} icon={<Disc className="h-3.5 w-3.5" />} />;
});
const SamplesTab = React.memo(function SamplesTab(props: IDockviewPanelHeaderProps) {
return <TabWithIcon {...props} icon={<Grid3X3 className="h-3.5 w-3.5" />} />;
});
// Grid panel uses context to get samples
const GridDockPanel = React.memo(function GridDockPanel() {
const ctx = useDockviewContext();
return (
<ImageGrid
samples={ctx.samples}
onLoadMore={ctx.onLoadMore}
hasMore={ctx.hasMore}
/>
);
});
// Explorer panel for left zone
const ExplorerDockPanel = React.memo(function ExplorerDockPanel() {
return <ExplorerPanel />;
});
// Placeholder panel for right/bottom zones
const PlaceholderDockPanel = React.memo(function PlaceholderDockPanel(
props: IDockviewPanelProps
) {
const handleClose = React.useCallback(() => {
props.api.close();
}, [props.api]);
return <PlaceholderPanel onClose={handleClose} />;
});
// Watermark shown when dock is empty - just the logo, no text
const Watermark = React.memo(function Watermark(_props: IWatermarkPanelProps) {
return (
<div className="flex items-center justify-center h-full w-full">
<div className="text-muted-foreground/20">
<HyperViewLogo className="w-16 h-16" />
</div>
</div>
);
});
// Stable components object - never changes
const COMPONENTS = {
grid: GridDockPanel,
scatter: ScatterDockPanel,
explorer: ExplorerDockPanel,
placeholder: PlaceholderDockPanel,
};
// Tab components with icons
const TAB_COMPONENTS = {
euclideanTab: EuclideanTab,
hyperbolicTab: HyperbolicTab,
samplesTab: SamplesTab,
};
// -----------------------------------------------------------------------------
// Provider Component
// -----------------------------------------------------------------------------
interface DockviewProviderProps {
children: ReactNode;
samples: Sample[];
onLoadMore: () => void;
hasMore: boolean;
}
export function DockviewProvider({
children,
samples,
onLoadMore,
hasMore,
}: DockviewProviderProps) {
const [api, setApi] = useState<DockviewApi | null>(null);
const contextValue = useMemo(
() => ({
api,
setApi,
samples,
onLoadMore,
hasMore,
}),
[api, samples, onLoadMore, hasMore]
);
return (
<DockviewContext.Provider value={contextValue}>
{children}
</DockviewContext.Provider>
);
}
function applyZonePolicies(api: DockviewApi) {
const explorer = api.getPanel(PANEL.EXPLORER);
if (explorer) {
explorer.group.locked = true;
explorer.group.header.hidden = true;
}
// Hide tab headers for placeholder panels
const rightPlaceholder = api.getPanel(PANEL.RIGHT_PLACEHOLDER);
if (rightPlaceholder) {
rightPlaceholder.group.header.hidden = true;
}
const bottomPlaceholder = api.getPanel(PANEL.BOTTOM_PLACEHOLDER);
if (bottomPlaceholder) {
bottomPlaceholder.group.header.hidden = true;
}
}
// -----------------------------------------------------------------------------
// Workspace Component - the actual dockview renderer
// -----------------------------------------------------------------------------
export function DockviewWorkspace() {
const ctx = useDockviewContext();
const datasetInfo = useStore((state) => state.datasetInfo);
const { setLeftPanelOpen, setRightPanelOpen, setBottomPanelOpen } = useStore();
const buildDefaultLayout = useCallback(
(api: DockviewApi) => {
const layouts = datasetInfo?.layouts ?? [];
const euclideanLayout = findLayoutByGeometry(layouts, "euclidean");
const poincareLayout = findLayoutByGeometry(layouts, "poincare");
const fallbackLayout = !euclideanLayout && !poincareLayout ? layouts[0] : null;
const hasLayouts = layouts.length > 0;
// Create the grid panel first (center zone)
const gridPanel =
api.getPanel(PANEL.GRID) ??
api.addPanel({
id: PANEL.GRID,
component: "grid",
title: "Samples",
tabComponent: "samplesTab",
renderer: "always",
});
let scatterPanel: typeof gridPanel | null = null;
if (hasLayouts && euclideanLayout) {
scatterPanel =
api.getPanel(PANEL.SCATTER_EUCLIDEAN) ??
api.addPanel({
id: PANEL.SCATTER_EUCLIDEAN,
component: "scatter",
title: "Euclidean",
tabComponent: "euclideanTab",
params: {
layoutKey: euclideanLayout.layout_key,
geometry: "euclidean" as Geometry,
},
position: {
referencePanel: gridPanel.id,
direction: "right",
},
renderer: "always",
});
}
if (hasLayouts && poincareLayout) {
const position = scatterPanel
? { referencePanel: scatterPanel.id, direction: "within" as const }
: { referencePanel: gridPanel.id, direction: "right" as const };
const poincarePanel =
api.getPanel(PANEL.SCATTER_POINCARE) ??
api.addPanel({
id: PANEL.SCATTER_POINCARE,
component: "scatter",
title: "Hyperbolic",
tabComponent: "hyperbolicTab",
params: {
layoutKey: poincareLayout.layout_key,
geometry: "poincare" as Geometry,
},
position,
renderer: "always",
});
if (!scatterPanel) {
scatterPanel = poincarePanel;
}
}
if (!hasLayouts) {
const euclideanPanel =
api.getPanel(PANEL.SCATTER_EUCLIDEAN) ??
api.addPanel({
id: PANEL.SCATTER_EUCLIDEAN,
component: "scatter",
title: "Euclidean",
tabComponent: "euclideanTab",
params: {
geometry: "euclidean" as Geometry,
},
position: {
referencePanel: gridPanel.id,
direction: "right",
},
renderer: "always",
});
api.getPanel(PANEL.SCATTER_POINCARE) ??
api.addPanel({
id: PANEL.SCATTER_POINCARE,
component: "scatter",
title: "Hyperbolic",
tabComponent: "hyperbolicTab",
params: {
geometry: "poincare" as Geometry,
},
position: {
referencePanel: euclideanPanel.id,
direction: "within" as const,
},
renderer: "always",
});
scatterPanel = euclideanPanel;
}
if (fallbackLayout && !scatterPanel) {
api.getPanel(PANEL.SCATTER_DEFAULT) ??
api.addPanel({
id: PANEL.SCATTER_DEFAULT,
component: "scatter",
title: "Embeddings",
params: {
layoutKey: fallbackLayout.layout_key,
},
position: {
referencePanel: gridPanel.id,
direction: "right",
},
renderer: "always",
});
}
const containerWidth = getContainerWidth(api);
const explorerPanel =
api.getPanel(PANEL.EXPLORER) ??
api.addPanel({
id: PANEL.EXPLORER,
component: "explorer",
title: "Explorer",
position: getZonePosition("left"),
initialWidth: getDefaultLeftPanelWidth(containerWidth),
minimumWidth: MIN_SIDE_PANEL_WIDTH,
maximumWidth: getDefaultLeftPanelWidth(containerWidth),
inactive: true,
});
if (explorerPanel) {
explorerPanel.group.locked = true;
explorerPanel.group.header.hidden = true;
}
setLeftPanelOpen(!!explorerPanel);
setRightPanelOpen(false);
setBottomPanelOpen(false);
},
[datasetInfo, setLeftPanelOpen, setRightPanelOpen, setBottomPanelOpen]
);
const onReady = useCallback(
(event: DockviewReadyEvent) => {
ctx.setApi(event.api);
const stored = localStorage.getItem(LAYOUT_STORAGE_KEY);
if (stored) {
try {
event.api.fromJSON(JSON.parse(stored));
if (event.api.totalPanels === 0) {
localStorage.removeItem(LAYOUT_STORAGE_KEY);
buildDefaultLayout(event.api);
}
// Re-apply side-zone policies after restore (header hidden, no-drop targets, etc)
applyZonePolicies(event.api);
// Sync store state with restored layout
setLeftPanelOpen(!!event.api.getPanel(PANEL.EXPLORER));
setRightPanelOpen(!!event.api.getPanel(PANEL.RIGHT_PLACEHOLDER));
setBottomPanelOpen(!!event.api.getPanel(PANEL.BOTTOM_PLACEHOLDER));
return;
} catch (err) {
console.warn("Failed to restore dock layout, resetting.", err);
localStorage.removeItem(LAYOUT_STORAGE_KEY);
}
}
if (event.api.totalPanels === 0) {
buildDefaultLayout(event.api);
}
},
[buildDefaultLayout, ctx, setLeftPanelOpen, setRightPanelOpen, setBottomPanelOpen]
);
// Save layout on changes
useEffect(() => {
const api = ctx.api;
if (!api) return;
const disposable = api.onDidLayoutChange(() => {
if (api.totalPanels === 0) return;
const layout = api.toJSON();
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(layout));
});
return () => disposable.dispose();
}, [ctx.api]);
// Sync panel state when panels are closed
useEffect(() => {
const api = ctx.api;
if (!api) return;
const disposable = api.onDidRemovePanel((e) => {
if (e.id === PANEL.EXPLORER) setLeftPanelOpen(false);
if (e.id === PANEL.RIGHT_PLACEHOLDER) setRightPanelOpen(false);
if (e.id === PANEL.BOTTOM_PLACEHOLDER) setBottomPanelOpen(false);
});
return () => disposable.dispose();
}, [ctx.api, setLeftPanelOpen, setRightPanelOpen, setBottomPanelOpen]);
// When a real panel is dropped into a placeholder group, close the placeholder
useEffect(() => {
const api = ctx.api;
if (!api) return;
const disposable = api.onDidAddPanel((e) => {
// Skip if the added panel is a placeholder itself
if (e.id === PANEL.RIGHT_PLACEHOLDER || e.id === PANEL.BOTTOM_PLACEHOLDER) {
return;
}
// Check if this panel was added to the same group as a placeholder
const group = e.group;
if (!group) return;
// Find and close any placeholder panels in the same group
const rightPlaceholder = api.getPanel(PANEL.RIGHT_PLACEHOLDER);
const bottomPlaceholder = api.getPanel(PANEL.BOTTOM_PLACEHOLDER);
if (rightPlaceholder && rightPlaceholder.group?.id === group.id) {
rightPlaceholder.api.close();
}
if (bottomPlaceholder && bottomPlaceholder.group?.id === group.id) {
bottomPlaceholder.api.close();
}
});
return () => disposable.dispose();
}, [ctx.api]);
// Prevent dragging locked panels (explorer only)
useEffect(() => {
const api = ctx.api;
if (!api) return;
const disposable = api.onWillDragPanel((event) => {
if (DRAG_LOCKED_PANEL_IDS.has(event.panel.id)) {
event.nativeEvent.preventDefault();
}
});
return () => disposable.dispose();
}, [ctx.api]);
// Rebuild layout when dataset info changes
useEffect(() => {
if (!ctx.api) return;
if (!datasetInfo) return;
const hasScatter =
ctx.api.getPanel(PANEL.SCATTER_EUCLIDEAN) ||
ctx.api.getPanel(PANEL.SCATTER_POINCARE) ||
ctx.api.getPanel(PANEL.SCATTER_DEFAULT);
if (!hasScatter) {
buildDefaultLayout(ctx.api);
}
}, [buildDefaultLayout, datasetInfo, ctx.api]);
return (
<div className="h-full w-full">
<DockviewReact
className="dockview-theme-abyss hyperview-dockview"
components={COMPONENTS}
tabComponents={TAB_COMPONENTS}
onReady={onReady}
theme={themeAbyss}
defaultRenderer="always"
scrollbars="native"
watermarkComponent={Watermark}
/>
</div>
);
}