From fc9dfe75b465638e544ddea2df5aa4ce3c4ca2c4 Mon Sep 17 00:00:00 2001 From: jerlendds Date: Sat, 2 Dec 2023 20:06:56 -0700 Subject: [PATCH] Fix force layout bug caused when many layout changes then edits nodes.css, graphSlice.ts, GraphDetails.tsx, BaseMiniNode.tsx, BaseNode.tsx, ContextMenu.tsx, EntityOptions.tsx, Graph.tsx, and index.tsx --- frontend/src/assets/styles/nodes.css | 4 +- frontend/src/features/graph/graphSlice.ts | 7 +- .../_components/graph/GraphDetails.tsx | 2 +- .../_components/BaseMiniNode.tsx | 4 +- .../graph-inquiry/_components/BaseNode.tsx | 30 +++-- .../graph-inquiry/_components/ContextMenu.tsx | 4 +- .../_components/EntityOptions.tsx | 38 +++--- .../src/routes/graph-inquiry/graph/Graph.tsx | 1 - frontend/src/routes/graph-inquiry/index.tsx | 118 +++++++++--------- 9 files changed, 114 insertions(+), 94 deletions(-) diff --git a/frontend/src/assets/styles/nodes.css b/frontend/src/assets/styles/nodes.css index b3360ac..c707a69 100755 --- a/frontend/src/assets/styles/nodes.css +++ b/frontend/src/assets/styles/nodes.css @@ -1,6 +1,6 @@ /* Start node */ .node.container { - @apply flex flex-col w-full max-w-sm justify-between rounded-md transition-all duration-150 ease-in-out ring-1 ring-mirage-400/30 backdrop-blur-lg hover:ring-mirage-400/70 to-mirage-700/70 from-slate-800/20 from-30% bg-gradient-to-br hover:shadow-md shadow-sm; + @apply flex flex-col w-full max-w-sm justify-between rounded-md transition-all duration-150 ease-in-out ring-1 ring-mirage-400/30 backdrop-blur-lg hover:ring-mirage-400/70 to-mirage-600/50 from-mirage-500/40 from-60% bg-gradient-to-br hover:shadow-md shadow-sm; } .node .side-header { @@ -97,7 +97,7 @@ p.node-label { /* End node context menu */ .elements { - @apply p-2 grid rounded-b-md; + @apply p-2 grid rounded-b-md gap-x-1; } .elements > div.dropdown-input:last-child { diff --git a/frontend/src/features/graph/graphSlice.ts b/frontend/src/features/graph/graphSlice.ts index eda3527..9759d0d 100755 --- a/frontend/src/features/graph/graphSlice.ts +++ b/frontend/src/features/graph/graphSlice.ts @@ -78,7 +78,7 @@ export const graph = createSlice({ state.editLabel = action.payload; }, - setEditId: (state, action: PayloadAction) => { + toggleEntityEdit: (state, action: PayloadAction) => { state.editId = action.payload; state.nodes = state.nodes.map((node) => node.id === action.payload ? { @@ -176,7 +176,10 @@ export const graph = createSlice({ }, deleteNode: (state, action: PayloadAction) => { + // state.editId = action.payload + // state.editLabel = 'deleteNode' state.nodes = state.nodes.filter((n) => n.id !== action.payload); + state.edges = state.edges.filter((e) => e.target !== action.payload && e.source !== action.payload) }, updateNodeFlow: (state, action: PayloadAction) => { @@ -259,7 +262,7 @@ export const { setEditLabel, setEditValue, saveUserEdits, - setEditId, + toggleEntityEdit, onEdgesChange, setEditState, setNodeSelected, diff --git a/frontend/src/routes/dashboard/_components/graph/GraphDetails.tsx b/frontend/src/routes/dashboard/_components/graph/GraphDetails.tsx index c9d9727..8c613fc 100644 --- a/frontend/src/routes/dashboard/_components/graph/GraphDetails.tsx +++ b/frontend/src/routes/dashboard/_components/graph/GraphDetails.tsx @@ -125,7 +125,7 @@ export default function GraphDetails() { isBounded: true }} key="u-edges-count" className={isEdgesLocked ? 'z-50' : '-z-10'}> - +
diff --git a/frontend/src/routes/graph-inquiry/_components/BaseMiniNode.tsx b/frontend/src/routes/graph-inquiry/_components/BaseMiniNode.tsx index f16e24f..4d1bc76 100755 --- a/frontend/src/routes/graph-inquiry/_components/BaseMiniNode.tsx +++ b/frontend/src/routes/graph-inquiry/_components/BaseMiniNode.tsx @@ -15,7 +15,7 @@ import { EditState, saveUserEdits, selectNodeValue, - setEditId, + toggleEntityEdit, } from '@/features/graph/graphSlice'; import { toast } from 'react-toastify'; @@ -61,7 +61,7 @@ export default function BaseMiniNode({
{ - dispatch(setEditId(ctx.id)); + dispatch(toggleEntityEdit(ctx.id)) }} data-label-type={node.label} className='node container !rounded-full' diff --git a/frontend/src/routes/graph-inquiry/_components/BaseNode.tsx b/frontend/src/routes/graph-inquiry/_components/BaseNode.tsx index a036de4..df52430 100755 --- a/frontend/src/routes/graph-inquiry/_components/BaseNode.tsx +++ b/frontend/src/routes/graph-inquiry/_components/BaseNode.tsx @@ -8,7 +8,7 @@ import { GripIcon, Icon } from '@/components/Icons'; import { toast } from 'react-toastify'; import { useAppDispatch, useAppSelector } from '@/app/hooks'; import { type ThunkDispatch } from 'redux-thunk'; -import { type Graph, EditState, saveUserEdits, selectNodeValue, setEditId, clearEditId } from '@/features/graph/graphSlice'; +import { type Graph, EditState, saveUserEdits, selectNodeValue, clearEditId } from '@/features/graph/graphSlice'; import { AnyAction } from '@reduxjs/toolkit'; var dropdownKey = 0; @@ -39,7 +39,7 @@ export default function BaseNode({ ctx, sendJsonMessage, closeRef }: JSONObject) const dispatch = useAppDispatch(); - const getNodeElement = (element: NodeInput, key: string | null = getNodeKey()) => { + const getNodeElement = (element: NodeInput, key: string | null = getNodeKey(), width: number = 1) => { switch (element.type) { case 'dropdown': return ( @@ -104,6 +104,9 @@ export default function BaseNode({ ctx, sendJsonMessage, closeRef }: JSONObject) return
; } }; + + const columnsCount = Math.max(0, ...node.elements.map(s => s.length)) + const gridRepeat = columnsCount === 0 ? 1 : columnsCount return ( <> @@ -135,23 +138,28 @@ export default function BaseNode({ ctx, sendJsonMessage, closeRef }: JSONObject) id={`${ctx.id}-form`} style={node.style} onSubmit={(event) => event.preventDefault()} - className='elements gap-x-1' + className='elements' + style={{ + gridTemplateColumns: '100%' + }} > {node.elements.map((element: NodeInput, i: number) => { - if (Array.isArray(element)) + if (Array.isArray(element)) { + console.log('columnsCount', columnsCount) return ( - +
{element.map((elm, i: number) => ( -
- {getNodeElement(elm, `${elm.label}-${elm.id}-${ctx.id}`)} -
+ + {getNodeElement(elm, `${elm.label}-${elm.id}-${ctx.id}`, 1)} + ))} - +
); + } return getNodeElement(element, `${element.label}-${element.id}-${ctx.id}`); })} -
+
); } @@ -318,7 +326,7 @@ export function DropdownInput({ options, label, nodeId, sendJsonMessage, dispatc return ( <> { diff --git a/frontend/src/routes/graph-inquiry/_components/ContextMenu.tsx b/frontend/src/routes/graph-inquiry/_components/ContextMenu.tsx index 1c2766f..13c7662 100755 --- a/frontend/src/routes/graph-inquiry/_components/ContextMenu.tsx +++ b/frontend/src/routes/graph-inquiry/_components/ContextMenu.tsx @@ -3,7 +3,7 @@ import ContextAction from './ContextAction'; import { MagnifyingGlassMinusIcon, MagnifyingGlassPlusIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline'; import classNames from 'classnames'; import { useAppDispatch } from '@/app/hooks'; -import { deleteNode, setEditId, setEditState } from '@/features/graph/graphSlice'; +import { deleteNode, setEditState } from '@/features/graph/graphSlice'; import { useState } from 'react'; import { useGetEntityTransformsQuery } from '@/app/api'; @@ -89,7 +89,7 @@ export default function ContextMenu({ node: { id: ctxSelection.id }, }); dispatch(deleteNode(ctxSelection.id)); - dispatch(setEditState({ editId: ctxSelection.id, editLabel: 'deleteNode' })) + // dispatch(setEditState({ editId: ctxSelection.id, editLabel: 'deleteNode' })) }} type='button' > diff --git a/frontend/src/routes/graph-inquiry/_components/EntityOptions.tsx b/frontend/src/routes/graph-inquiry/_components/EntityOptions.tsx index 883cca8..be6f91a 100755 --- a/frontend/src/routes/graph-inquiry/_components/EntityOptions.tsx +++ b/frontend/src/routes/graph-inquiry/_components/EntityOptions.tsx @@ -8,10 +8,10 @@ import { Link } from 'react-router-dom'; import 'react-grid-layout/css/styles.css'; import { Responsive, WidthProvider, Layout } from 'react-grid-layout'; import { useGetEntitiesQuery } from '@/app/api'; -import { selectPositionMode, selectViewMode, setAllEdges, setAllNodes, setNodeType, setPositionMode, setViewMode } from '@/features/graph/graphSlice'; +import { selectPositionMode, selectViewMode, setAllEdges, setAllNodes, setEditState, setNodeType, setPositionMode, setViewMode } from '@/features/graph/graphSlice'; import classNames from 'classnames'; -import { useAppDispatch, useAppSelector, useEffectOnce } from '@/app/hooks'; -import { ArrowsPointingInIcon, ArrowsPointingOutIcon, CubeTransparentIcon, HandRaisedIcon, RectangleStackIcon, Square2StackIcon, Squares2X2Icon } from '@heroicons/react/20/solid'; +import { useAppDispatch, } from '@/app/hooks'; +import { HandRaisedIcon } from '@heroicons/react/20/solid'; import { Icon } from '@/components/Icons'; type UseResizeProps = { @@ -112,7 +112,7 @@ const ResponsiveGridLayout = WidthProvider(Responsive); const MAX_GRAPH_LABEL_LENGTH = 22; -export default function EntityOptions({ allManualNodes, allManualEdges, positionMode, activeGraph, setElkLayout, toggleForceLayout, isForceActive, fitView }: JSONObject) { +export default function EntityOptions({ positionMode, activeGraph, setElkLayout, toggleForceLayout, fitView }: JSONObject) { const { data: entitiesData = { entities: [], count: 0, favorite_entities: [], favorite_count: 0 }, isLoading, @@ -168,7 +168,7 @@ export default function EntityOptions({ allManualNodes, allManualEdges, position const dispatch = useAppDispatch(); - + const [isForceActive, setIsForceActive] = useState(false); return ( diff --git a/frontend/src/routes/graph-inquiry/graph/Graph.tsx b/frontend/src/routes/graph-inquiry/graph/Graph.tsx index 0c358ab..6ac4845 100755 --- a/frontend/src/routes/graph-inquiry/graph/Graph.tsx +++ b/frontend/src/routes/graph-inquiry/graph/Graph.tsx @@ -32,7 +32,6 @@ export default function Graph({ onMultiSelectionCtxMenu, onPaneCtxMenu, onPaneClick, - addEdge, graphRef, nodes, edges, diff --git a/frontend/src/routes/graph-inquiry/index.tsx b/frontend/src/routes/graph-inquiry/index.tsx index 8d30524..5baf556 100755 --- a/frontend/src/routes/graph-inquiry/index.tsx +++ b/frontend/src/routes/graph-inquiry/index.tsx @@ -24,6 +24,7 @@ import { selectViewMode, setAllEdges, setAllNodes, + setEditState, setPositionMode, } from '@/features/graph/graphSlice'; import { WS_URL } from '@/app/baseApi'; @@ -109,11 +110,11 @@ export default function GraphInquiry({ }: GraphInquiryProps) { function addEdge( source: string, target: string, + id: any = getEdgeId(), sourceHandle: string = 'r1', targetHandle: string = 'l2', type: string = 'float', label: string = '', - id: any = getEdgeId() ): void { dispatch( createEdge({ @@ -165,7 +166,6 @@ export default function GraphInquiry({ }: GraphInquiryProps) { return updatedNode; }; - // websocket updates happen here useEffect(() => { if (lastJsonMessage) { setMessageHistory((prev) => prev.concat(lastJsonMessage)); @@ -178,7 +178,6 @@ export default function GraphInquiry({ }: GraphInquiryProps) { } else { lastJsonMessage.map((node, idx) => { if (node?.action === 'addNode') { - console.log('adding node') const isOdd = idx % 2 === 0; const pos = node.position; const x = isOdd ? pos.x + 560 : pos.x + 970; @@ -188,9 +187,11 @@ export default function GraphInquiry({ }: GraphInquiryProps) { y, }; addNodeAction(node); + dispatch(setEditState({ editId: node.id, editLabel: 'addNode' })) sendJsonMessage({ action: 'update:node', node: { id: node.id, x, y } }); } }); + if (lastJsonMessage.length > 0) { toast.success(`Found ${lastJsonMessage.length} results`); } else { @@ -262,19 +263,14 @@ export default function GraphInquiry({ }: GraphInquiryProps) { setNodesBeforeLayout(initialNodes) setEdgesBeforeLayout(initialEdges) } - }, [initialNodes, activeEditState]) + }, [initialNodes, initialEdges]) // , activeEditState useEffect(() => { - if (activeEditState.label === 'deleteNode') { - setNodesBeforeLayout(nodesBeforeLayout.filter((node) => node.id !== activeEditState.id)) + if (positionMode === 'manual') { + fitView && fitView({ padding: 0.25 }) + dispatch(setAllNodes(nodesBeforeLayout)) + dispatch(setAllEdges(edgesBeforeLayout)) } - if (activeEditState.label === 'addNode') { - setNodesBeforeLayout([...nodesBeforeLayout, initialNodes.find((node) => node.id === activeEditState.id) as Node]) - } - }, [activeEditState]) - - useEffect(() => { - if (positionMode === 'manual') fitView && fitView({ padding: 0.25 }) }, [positionMode]) // TODO: Also implement d3-hierarchy, entitree-flex, dagre, webcola, and graphology layout modes // Once implemented measure performance and deprecate whatever performs worse @@ -306,8 +302,7 @@ export default function GraphInquiry({ }: GraphInquiryProps) { fitView && fitView({ padding: 0.25 }); }); }); - - }, [nodesBeforeLayout]); + }, [nodesBeforeLayout, activeEditState]); return { setElkLayout }; }; @@ -325,56 +320,71 @@ export default function GraphInquiry({ }: GraphInquiryProps) { .alphaTarget(0.01) .stop(); - // console.log('insideForce allLayoutNodes', allLayoutNodes) let forceNodes = initialNodes.map((node: any) => ({ ...node, x: node.position.x, y: node.position.y })); let forceEdges = initialEdges.map((edge: any) => ({ ...edge })); + const forceSimOff = [false, { toggleForceLayout: (setForce?: boolean) => null } as any] // if no width or height or no nodes in the flow, can't run the simulation! - if (!nodesInitialized || forceNodes.length === 0) return [false, { toggleForceLayout: (setForce?: boolean) => null } as any]; + if (!nodesInitialized || forceNodes.length === 0) return forceSimOff; let running = false; + try { + simulation.nodes(forceNodes).force( + 'link', + forceLink(forceEdges) + .id((d: any) => d.id) + .strength(0.05) + .distance(42) + ); - simulation.nodes(forceNodes).force( - 'link', - forceLink(forceEdges) - .id((d: any) => d.id) - .strength(0.05) - .distance(42) - ); + // The tick function is called every animation frame while the simulation is + // running and progresses the simulation one step forward each time. + const tick = () => { + fitView && fitView({ padding: 0.25 }) + forceNodes.forEach((node: any, i: number) => { + const activeNode = document.querySelector(`[data-id="${node.id}"].dragging`) + const dragging = Boolean(activeNode); + forceNodes[i].fx = dragging ? node.x : null; + forceNodes[i].fy = dragging ? node.y : null; + }); + simulation.tick(); + dispatch(setAllNodes(forceNodes.map((node: any) => ({ ...node, position: { x: node.x, y: node.y } }))) as any); - // The tick function is called every animation frame while the simulation is - // running and progresses the simulation one step forward each time. - const tick = () => { - fitView && fitView({ padding: 0.25 }) - forceNodes.forEach((node: any, i: number) => { - const activeNode = document.querySelector(`[data-id="${node.id}"].dragging`) - const dragging = Boolean(activeNode); - forceNodes[i].fx = dragging ? node.x : null; - forceNodes[i].fy = dragging ? node.y : null; - }); - simulation.tick(); - dispatch(setAllNodes(forceNodes.map((node: any) => ({ ...node, position: { x: node.x, y: node.y } }))) as any); + window.requestAnimationFrame(() => { + if (running) { + tick() + }; + }); + }; - window.requestAnimationFrame(() => { - if (running) { - tick() - }; - }); - }; - - const toggleForceLayout = (setForce?: boolean) => { - if (typeof setForce === 'boolean') { - running = setForce - } else { - running = !running - } - running && window.requestAnimationFrame(tick); - }; - return [true, { toggleForceLayout, isForceRunning: running }]; + const toggleForceLayout = (setForce?: boolean) => { + if (typeof setForce === 'boolean') { + running = setForce + } else { + running = !running + } + running && window.requestAnimationFrame(tick); + }; + return [true, { toggleForceLayout, isForceRunning: running }]; + } catch (e) { console.warn(e) } + return forceSimOff }, [nodesBeforeLayout, activeEditState]); } + + const [forceInitialized, { toggleForceLayout, isForceRunning }] = useForceLayoutElements(); + + + useEffect(() => { + if (activeEditState.label === 'deleteNode') { + setNodesBeforeLayout(nodesBeforeLayout.filter((node) => node.id !== activeEditState.id)) + } + if (activeEditState.label === 'addNode') { + setNodesBeforeLayout([...nodesBeforeLayout, initialNodes.find((node) => node.id === activeEditState.id) as Node]) + } + }, [activeEditState]) + // Prevents layout bugs from occurring on navigate away and returning to a graph // https://reactrouter.com/en/main/hooks/use-blocker useBlocker(useCallback( @@ -398,10 +408,7 @@ export default function GraphInquiry({ }: GraphInquiryProps) {
@@ -411,7 +418,6 @@ export default function GraphInquiry({ }: GraphInquiryProps) { onMultiSelectionCtxMenu={onMultiSelectionCtxMenu} onPaneCtxMenu={onPaneCtxMenu} onPaneClick={onPaneClick} - addEdge={addEdge} graphRef={graphRef} nodes={initialNodes} edges={initialEdges}