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
This commit is contained in:
jerlendds
2023-12-02 20:06:56 -07:00
parent 937b475239
commit fc9dfe75b4
9 changed files with 114 additions and 94 deletions

View File

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

View File

@@ -78,7 +78,7 @@ export const graph = createSlice({
state.editLabel = action.payload;
},
setEditId: (state, action: PayloadAction<string>) => {
toggleEntityEdit: (state, action: PayloadAction<string>) => {
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<Node['id']>) => {
// 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<NodeChange[]>) => {
@@ -259,7 +262,7 @@ export const {
setEditLabel,
setEditValue,
saveUserEdits,
setEditId,
toggleEntityEdit,
onEdgesChange,
setEditState,
setNodeSelected,

View File

@@ -125,7 +125,7 @@ export default function GraphDetails() {
isBounded: true
}} key="u-edges-count" className={isEdgesLocked ? 'z-50' : '-z-10'}>
<GridPanel setIsLocked={setIsEdgesLocked} isLocked={isEdgesLocked} icon='graph' label="Outgoing Relations Count by Entity">
<GridPanel setIsLocked={setIsEdgesLocked} isLocked={isEdgesLocked} icon='graph' label="Outgoing Edges Count by Entity">
<div className="w-full mb-0 bar-hz" ref={uniqueOutEdgesChartRef} />
</GridPanel>
</div>

View File

@@ -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({
<Handle position={Position.Left} id='l2' key='l2' type='target' style={handleStyle} />
<div
onDoubleClick={() => {
dispatch(setEditId(ctx.id));
dispatch(toggleEntityEdit(ctx.id))
}}
data-label-type={node.label}
className='node container !rounded-full'

View File

@@ -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 <div className='hidden' />;
}
};
const columnsCount = Math.max(0, ...node.elements.map(s => s.length))
const gridRepeat = columnsCount === 0 ? 1 : columnsCount
return (
<>
<Handle position={Position.Right} id='r1' key='r1' type='source' style={handleStyle} />
@@ -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 (
<Fragment key={i.toString()}>
<div style={{ display: 'grid', columnGap: '0.5rem', gridTemplateColumns: `repeat(${gridRepeat}, minmax(0, 1fr))` }} key={i.toString()}>
{element.map((elm, i: number) => (
<div key={i.toString()} className='flex flex-col mr-2 last:mr-0'>
{getNodeElement(elm, `${elm.label}-${elm.id}-${ctx.id}`)}
</div>
<Fragment key={i.toString()}>
{getNodeElement(elm, `${elm.label}-${elm.id}-${ctx.id}`, 1)}
</Fragment>
))}
</Fragment>
</div>
);
}
return getNodeElement(element, `${element.label}-${element.id}-${ctx.id}`);
})}
</form>
</div>
</div >
</>
);
}
@@ -318,7 +326,7 @@ export function DropdownInput({ options, label, nodeId, sendJsonMessage, dispatc
return (
<>
<Combobox
className=' w-full z-[999] dropdown-input'
className=' w-full z-[999] dropdown-input col-span-1'
as='div'
value={activeOption}
onChange={(option) => {

View File

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

View File

@@ -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 (
<ResponsiveGridLayout
@@ -279,10 +279,9 @@ export default function EntityOptions({ allManualNodes, allManualEdges, position
<ul className='isolate inline-flex shadow-sm pt-2.5 w-full'>
<button
onClick={() => {
isForceActive !== undefined && !isForceActive && toggleForceLayout(false)
setIsForceActive(false)
toggleForceLayout && toggleForceLayout(false)
dispatch(setPositionMode('manual'))
dispatch(setAllNodes(allManualNodes))
dispatch(setAllEdges(allManualEdges))
}}
type='button'
className={classNames(
@@ -299,7 +298,13 @@ export default function EntityOptions({ allManualNodes, allManualEdges, position
<button
onClick={() => {
dispatch(setPositionMode('force'))
toggleForceLayout && toggleForceLayout()
console.log('setting to isForceActive', !isForceActive)
toggleForceLayout && toggleForceLayout(!isForceActive)
setIsForceActive(!isForceActive)
// dispatch(setEditState({ editId: '', editLabel: 'force' }))
}}
type='button'
className={classNames(
@@ -308,17 +313,18 @@ export default function EntityOptions({ allManualNodes, allManualEdges, position
)}
>
<span className='sr-only'>List view</span>
<CubeTransparentIcon
className={classNames('h-5 w-5 ', positionMode === 'force' && 'text-primary-300')}
aria-hidden='true'
<Icon
icon={isForceActive !== undefined && isForceActive ? "3d-cube-sphere" : "3d-cube-sphere-off"}
className={classNames('h-5 w-5 text-inherit', positionMode === 'force' && 'text-primary-300')}
/>
</button>
<button
onClick={() => {
isForceActive !== undefined && !isForceActive && toggleForceLayout(false)
toggleForceLayout && toggleForceLayout(false)
// setElkLayout({ 'elk.algorithm': 'org.eclipse.elk.radial', })
// setElkLayout({ 'elk.algorithm': 'layered', 'elk.direction': 'DOWN' })
// setElkLayout({ 'elk.algorithm': 'layered', 'elk.direction': 'RIGHT' })
setIsForceActive(false)
dispatch(setPositionMode('right tree'))
setElkLayout({
'elk.algorithm': 'layered',
@@ -335,13 +341,12 @@ export default function EntityOptions({ allManualNodes, allManualEdges, position
<Icon
icon="binary-tree-2"
className={classNames('h-5 w-5 -rotate-90 origin-center text-inherit', positionMode === 'right tree' && 'text-primary-300')}
aria-hidden='true'
/>
</button>
<button
onClick={() => {
isForceActive !== undefined && !isForceActive && toggleForceLayout(false)
setIsForceActive(false)
toggleForceLayout && toggleForceLayout(false)
dispatch(setPositionMode('tree'))
setElkLayout({
'elk.algorithm': 'layered',
@@ -358,7 +363,6 @@ export default function EntityOptions({ allManualNodes, allManualEdges, position
<Icon
icon="binary-tree"
className={classNames('h-5 w-5 text-inherit', positionMode === 'tree' && 'text-primary-300')}
aria-hidden='true'
/>
</button>
</ul>

View File

@@ -32,7 +32,6 @@ export default function Graph({
onMultiSelectionCtxMenu,
onPaneCtxMenu,
onPaneClick,
addEdge,
graphRef,
nodes,
edges,

View File

@@ -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) {
<EntityOptions
positionMode={positionMode}
toggleForceLayout={toggleForceLayout}
isForceActive={isForceRunning}
activeGraph={activeGraph}
allManualNodes={nodesBeforeLayout}
allManualEdges={edgesBeforeLayout}
setElkLayout={setElkLayout}
/>
<div className='h-full w-full justify-between bg-mirage-600/95'>
@@ -411,7 +418,6 @@ export default function GraphInquiry({ }: GraphInquiryProps) {
onMultiSelectionCtxMenu={onMultiSelectionCtxMenu}
onPaneCtxMenu={onPaneCtxMenu}
onPaneClick={onPaneClick}
addEdge={addEdge}
graphRef={graphRef}
nodes={initialNodes}
edges={initialEdges}