|
import { useSigma } from '@react-sigma/core' |
|
import { animateNodes } from 'sigma/utils' |
|
import { useLayoutCirclepack } from '@react-sigma/layout-circlepack' |
|
import { useLayoutCircular } from '@react-sigma/layout-circular' |
|
import { LayoutHook, LayoutWorkerHook, WorkerLayoutControlProps } from '@react-sigma/layout-core' |
|
import { useLayoutForce, useWorkerLayoutForce } from '@react-sigma/layout-force' |
|
import { useLayoutForceAtlas2, useWorkerLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2' |
|
import { useLayoutNoverlap, useWorkerLayoutNoverlap } from '@react-sigma/layout-noverlap' |
|
import { useLayoutRandom } from '@react-sigma/layout-random' |
|
import { useCallback, useMemo, useState, useEffect, useRef } from 'react' |
|
|
|
import Button from '@/components/ui/Button' |
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover' |
|
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/Command' |
|
import { controlButtonVariant } from '@/lib/constants' |
|
import { useSettingsStore } from '@/stores/settings' |
|
|
|
import { GripIcon, PlayIcon, PauseIcon } from 'lucide-react' |
|
import { useTranslation } from 'react-i18next' |
|
|
|
type LayoutName = |
|
| 'Circular' |
|
| 'Circlepack' |
|
| 'Random' |
|
| 'Noverlaps' |
|
| 'Force Directed' |
|
| 'Force Atlas' |
|
|
|
|
|
interface ExtendedWorkerLayoutControlProps extends WorkerLayoutControlProps { |
|
mainLayout: LayoutHook; |
|
} |
|
|
|
const WorkerLayoutControl = ({ layout, autoRunFor, mainLayout }: ExtendedWorkerLayoutControlProps) => { |
|
const sigma = useSigma() |
|
|
|
const [isRunning, setIsRunning] = useState(false) |
|
|
|
const animationTimerRef = useRef<number | null>(null) |
|
const { t } = useTranslation() |
|
|
|
|
|
const updatePositions = useCallback(() => { |
|
if (!sigma) return |
|
|
|
try { |
|
const graph = sigma.getGraph() |
|
if (!graph || graph.order === 0) return |
|
|
|
|
|
|
|
const positions = mainLayout.positions() |
|
|
|
|
|
|
|
animateNodes(graph, positions, { duration: 300 }) |
|
} catch (error) { |
|
console.error('Error updating positions:', error) |
|
|
|
if (animationTimerRef.current) { |
|
window.clearInterval(animationTimerRef.current) |
|
animationTimerRef.current = null |
|
setIsRunning(false) |
|
} |
|
} |
|
}, [sigma, mainLayout]) |
|
|
|
|
|
const handleClick = useCallback(() => { |
|
if (isRunning) { |
|
|
|
console.log('Stopping layout animation') |
|
if (animationTimerRef.current) { |
|
window.clearInterval(animationTimerRef.current) |
|
animationTimerRef.current = null |
|
} |
|
|
|
|
|
try { |
|
if (typeof layout.kill === 'function') { |
|
layout.kill() |
|
console.log('Layout algorithm killed') |
|
} else if (typeof layout.stop === 'function') { |
|
layout.stop() |
|
console.log('Layout algorithm stopped') |
|
} |
|
} catch (error) { |
|
console.error('Error stopping layout algorithm:', error) |
|
} |
|
|
|
setIsRunning(false) |
|
} else { |
|
|
|
console.log('Starting layout animation') |
|
|
|
|
|
updatePositions() |
|
|
|
|
|
animationTimerRef.current = window.setInterval(() => { |
|
updatePositions() |
|
}, 200) |
|
|
|
setIsRunning(true) |
|
|
|
|
|
setTimeout(() => { |
|
if (animationTimerRef.current) { |
|
console.log('Auto-stopping layout animation after 3 seconds') |
|
window.clearInterval(animationTimerRef.current) |
|
animationTimerRef.current = null |
|
setIsRunning(false) |
|
|
|
|
|
try { |
|
if (typeof layout.kill === 'function') { |
|
layout.kill() |
|
} else if (typeof layout.stop === 'function') { |
|
layout.stop() |
|
} |
|
} catch (error) { |
|
console.error('Error stopping layout algorithm:', error) |
|
} |
|
} |
|
}, 3000) |
|
} |
|
}, [isRunning, layout, updatePositions]) |
|
|
|
|
|
|
|
|
|
useEffect(() => { |
|
if (!sigma) { |
|
console.log('No sigma instance available') |
|
return |
|
} |
|
|
|
|
|
let timeout: number | null = null |
|
if (autoRunFor !== undefined && autoRunFor > -1 && sigma.getGraph().order > 0) { |
|
console.log('Auto-starting layout animation') |
|
|
|
|
|
updatePositions() |
|
|
|
|
|
animationTimerRef.current = window.setInterval(() => { |
|
updatePositions() |
|
}, 200) |
|
|
|
setIsRunning(true) |
|
|
|
|
|
if (autoRunFor > 0) { |
|
timeout = window.setTimeout(() => { |
|
console.log('Auto-stopping layout animation after timeout') |
|
if (animationTimerRef.current) { |
|
window.clearInterval(animationTimerRef.current) |
|
animationTimerRef.current = null |
|
} |
|
setIsRunning(false) |
|
}, autoRunFor) |
|
} |
|
} |
|
|
|
|
|
return () => { |
|
|
|
if (animationTimerRef.current) { |
|
window.clearInterval(animationTimerRef.current) |
|
animationTimerRef.current = null |
|
} |
|
if (timeout) { |
|
window.clearTimeout(timeout) |
|
} |
|
setIsRunning(false) |
|
} |
|
}, [autoRunFor, sigma, updatePositions]) |
|
|
|
return ( |
|
<Button |
|
size="icon" |
|
onClick={handleClick} |
|
tooltip={isRunning ? t('graphPanel.sideBar.layoutsControl.stopAnimation') : t('graphPanel.sideBar.layoutsControl.startAnimation')} |
|
variant={controlButtonVariant} |
|
> |
|
{isRunning ? <PauseIcon /> : <PlayIcon />} |
|
</Button> |
|
) |
|
} |
|
|
|
|
|
|
|
|
|
const LayoutsControl = () => { |
|
const sigma = useSigma() |
|
const { t } = useTranslation() |
|
const [layout, setLayout] = useState<LayoutName>('Circular') |
|
const [opened, setOpened] = useState<boolean>(false) |
|
|
|
const maxIterations = useSettingsStore.use.graphLayoutMaxIterations() |
|
|
|
const layoutCircular = useLayoutCircular() |
|
const layoutCirclepack = useLayoutCirclepack() |
|
const layoutRandom = useLayoutRandom() |
|
const layoutNoverlap = useLayoutNoverlap({ |
|
maxIterations: maxIterations, |
|
settings: { |
|
margin: 5, |
|
expansion: 1.1, |
|
gridSize: 1, |
|
ratio: 1, |
|
speed: 3, |
|
} |
|
}) |
|
|
|
const layoutForce = useLayoutForce({ |
|
maxIterations: maxIterations, |
|
settings: { |
|
attraction: 0.0003, |
|
repulsion: 0.02, |
|
gravity: 0.02, |
|
inertia: 0.4, |
|
maxMove: 100 |
|
} |
|
}) |
|
const layoutForceAtlas2 = useLayoutForceAtlas2({ iterations: maxIterations }) |
|
const workerNoverlap = useWorkerLayoutNoverlap() |
|
const workerForce = useWorkerLayoutForce() |
|
const workerForceAtlas2 = useWorkerLayoutForceAtlas2() |
|
|
|
const layouts = useMemo(() => { |
|
return { |
|
Circular: { |
|
layout: layoutCircular |
|
}, |
|
Circlepack: { |
|
layout: layoutCirclepack |
|
}, |
|
Random: { |
|
layout: layoutRandom |
|
}, |
|
Noverlaps: { |
|
layout: layoutNoverlap, |
|
worker: workerNoverlap |
|
}, |
|
'Force Directed': { |
|
layout: layoutForce, |
|
worker: workerForce |
|
}, |
|
'Force Atlas': { |
|
layout: layoutForceAtlas2, |
|
worker: workerForceAtlas2 |
|
} |
|
} as { [key: string]: { layout: LayoutHook; worker?: LayoutWorkerHook } } |
|
}, [ |
|
layoutCirclepack, |
|
layoutCircular, |
|
layoutForce, |
|
layoutForceAtlas2, |
|
layoutNoverlap, |
|
layoutRandom, |
|
workerForce, |
|
workerNoverlap, |
|
workerForceAtlas2 |
|
]) |
|
|
|
const runLayout = useCallback( |
|
(newLayout: LayoutName) => { |
|
console.debug('Running layout:', newLayout) |
|
const { positions } = layouts[newLayout].layout |
|
|
|
try { |
|
const graph = sigma.getGraph() |
|
if (!graph) { |
|
console.error('No graph available') |
|
return |
|
} |
|
|
|
const pos = positions() |
|
console.log('Positions calculated, animating nodes') |
|
animateNodes(graph, pos, { duration: 400 }) |
|
setLayout(newLayout) |
|
} catch (error) { |
|
console.error('Error running layout:', error) |
|
} |
|
}, |
|
[layouts, sigma] |
|
) |
|
|
|
return ( |
|
<div> |
|
<div> |
|
{layouts[layout] && 'worker' in layouts[layout] && ( |
|
<WorkerLayoutControl |
|
layout={layouts[layout].worker!} |
|
mainLayout={layouts[layout].layout} |
|
/> |
|
)} |
|
</div> |
|
<div> |
|
<Popover open={opened} onOpenChange={setOpened}> |
|
<PopoverTrigger asChild> |
|
<Button |
|
size="icon" |
|
variant={controlButtonVariant} |
|
onClick={() => setOpened((e: boolean) => !e)} |
|
tooltip={t('graphPanel.sideBar.layoutsControl.layoutGraph')} |
|
> |
|
<GripIcon /> |
|
</Button> |
|
</PopoverTrigger> |
|
<PopoverContent |
|
side="right" |
|
align="start" |
|
sideOffset={8} |
|
collisionPadding={5} |
|
sticky="always" |
|
className="p-1 min-w-auto" |
|
> |
|
<Command> |
|
<CommandList> |
|
<CommandGroup> |
|
{Object.keys(layouts).map((name) => ( |
|
<CommandItem |
|
onSelect={() => { |
|
runLayout(name as LayoutName) |
|
}} |
|
key={name} |
|
className="cursor-pointer text-xs" |
|
> |
|
{t(`graphPanel.sideBar.layoutsControl.layouts.${name}`)} |
|
</CommandItem> |
|
))} |
|
</CommandGroup> |
|
</CommandList> |
|
</Command> |
|
</PopoverContent> |
|
</Popover> |
|
</div> |
|
</div> |
|
) |
|
} |
|
|
|
export default LayoutsControl |
|
|