| 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 |
|
|