Add more layout modes and fix layout bugs

graphSlice.ts, BaseMiniNode.tsx, BaseNode.tsx, EntityOptions.tsx, Graph.tsx, and index.tsx
This commit is contained in:
jerlendds
2023-12-01 11:04:10 -07:00
parent 028e3489e4
commit 52e4c64306
6 changed files with 88 additions and 73 deletions

View File

@@ -105,8 +105,21 @@ export const graph = createSlice({
state.nodes = action.payload
},
setAllEdges: (state, action) => {
state.edges = action.payload
},
state.edges = action.payload.map((edge: Edge) => {
return {
...edge,
style: {
strokeWidth: 2.5,
},
markerEnd: {
type: MarkerType.ArrowClosed,
width: 19,
height: 20,
color: '#334155',
},
}
})
},
saveUserEdits: (state, action) => {
const { value, nodeId, label } = action.payload;
const nodeToUpdate = state.nodes.find((n) => n.id === nodeId);
@@ -221,7 +234,6 @@ export const graph = createSlice({
},
updateEdgeEvent: (state, action: PayloadAction<UpdateEdgeEvent>) => {
console.log(updateEdge(action.payload.oldEdge, action.payload.newConnection, state.edges))
state.edges = updateEdge(action.payload.oldEdge, action.payload.newConnection, state.edges);
},
addNodeUpdate: (state, action: PayloadAction<any>) => {

View File

@@ -48,7 +48,6 @@ export default function BaseMiniNode({
dispatch
}: JSONObject) {
const node = ctx.data;
return (
<>
<Handle position={Position.Right} id='r1' key='r1' type='source' style={handleStyle} />
@@ -66,11 +65,10 @@ export default function BaseMiniNode({
}}
data-label-type={node.label}
className='node container !rounded-full'
style={node.style}
>
<div
// 99 === 0.6 opacity
style={{ backgroundColor: node?.color?.length === 7 ? `${node.color}99` : node?.color ?? '#145070' }}
style={{ backgroundColor: node?.color?.length === 7 ? `${node.color}99` : node?.color }}
className='header !rounded-full !p-4'
>
<Icon icon={node.icon} className='!h-20 !w-20 cursor-grab focus:cursor-grabbing' />

View File

@@ -104,7 +104,6 @@ export default function BaseNode({ ctx, sendJsonMessage, closeRef }: JSONObject)
return <div className='hidden' />;
}
};
return (
<>
<Handle position={Position.Right} id='r1' key='r1' type='source' style={handleStyle} />
@@ -119,7 +118,7 @@ export default function BaseNode({ ctx, sendJsonMessage, closeRef }: JSONObject)
<div onDoubleClick={() => dispatch(clearEditId(ctx.id))} data-label-type={node.label} className=' node container' style={node.style}>
<div
// 99 === 0.6 opacity
style={{ backgroundColor: node?.color?.length === 7 ? `${node.color}99` : node?.color ?? '#145070' }}
style={{ backgroundColor: node?.color?.length === 7 ? `${node.color}99` : node?.color }}
className='header '
>
<GripIcon />

View File

@@ -299,7 +299,7 @@ export default function EntityOptions({ allManualNodes, allManualEdges, position
<button
onClick={() => {
dispatch(setPositionMode('force'))
isForceActive !== undefined && toggleForceLayout()
toggleForceLayout && toggleForceLayout()
}}
type='button'
className={classNames(
@@ -340,7 +340,7 @@ export default function EntityOptions({ allManualNodes, allManualEdges, position
</button>
<button
onClick={() => {
isForceActive && toggleForceLayout(false)
isForceActive !== undefined && !isForceActive && toggleForceLayout(false)
dispatch(setPositionMode('tree'))
setElkLayout({

View File

@@ -1,4 +1,4 @@
import { useCallback, useState, useMemo, DragEventHandler, useEffect } from 'react';
import { useCallback, useState, useMemo, DragEventHandler, useEffect, MouseEvent } from 'react';
import ReactFlow, {
Edge,
Background,
@@ -6,6 +6,7 @@ import ReactFlow, {
FitViewOptions,
NodeDragHandler,
Connection,
Node,
} from 'reactflow';
import BaseNode from '../_components/BaseNode';
import { addNodeUpdate, createEdge, onEdgesChange, updateEdgeEvent, updateNodeFlow } from '@/features/graph/graphSlice';
@@ -16,6 +17,7 @@ import { CreateGraphEntityApiResponse, useCreateGraphEntityMutation, useRefreshP
import { useParams } from 'react-router-dom';
import NewConnectionLine from './ConnectionLine';
import SimpleFloatingEdge from './SimpleFloatingEdge';
import { SendJsonMessage } from 'react-use-websocket/dist/lib/types';
const viewOptions: FitViewOptions = {
padding: 50,
@@ -37,7 +39,8 @@ export default function Graph({
graphInstance,
setGraphInstance,
sendJsonMessage,
fitView
fitView,
positionMode,
}: ProjectGraphProps) {
const dispatch = useAppDispatch();
const onEdgeUpdate = useCallback(
@@ -90,6 +93,7 @@ export default function Graph({
<BaseNode
ctx={data}
dispatch={dispatch}
sendJsonMessage={sendJsonMessage}
/>),
mini: (data: JSONObject) => (
<BaseMiniNode
@@ -109,15 +113,17 @@ export default function Graph({
);
const onNodeDragStop: NodeDragHandler = (_, node) => {
sendJsonMessage({ action: 'update:node', node: { id: node.id, x: node.position.x } });
sendJsonMessage({ action: 'update:node', node: { id: node.id, y: node.position.y } });
if (positionMode === 'manual') {
sendJsonMessage({ action: 'update:node', node: { id: node.id, x: node.position.x } });
sendJsonMessage({ action: 'update:node', node: { id: node.id, y: node.position.y } });
}
};
useEffect(() => {
fitView && fitView()
}, [fitView])
return (
<ReactFlow
minZoom={0.05}
@@ -142,9 +148,8 @@ export default function Graph({
onNodeContextMenu={onSelectionCtxMenu}
onSelectionContextMenu={onMultiSelectionCtxMenu}
connectionLineComponent={NewConnectionLine}
elevateNodesOnSelect={true}
>
<Background variant={BackgroundVariant.Dots} className='bg-transparent' color='#394778' />
{/* <Controls /> */}
</ReactFlow>

View File

@@ -49,6 +49,13 @@ interface UseWebsocket {
interface GraphInquiryProps {
}
let edgeId = 0;
const getEdgeId = () => {
edgeId += 1
return `e-tmp-${edgeId}`
}
export default function GraphInquiry({ }: GraphInquiryProps) {
const dispatch = useAppDispatch();
const { hid } = useParams()
@@ -65,11 +72,10 @@ export default function GraphInquiry({ }: GraphInquiryProps) {
const WS_GRAPH_INQUIRE = `ws://${WS_URL}/node/graph/${hid}`
const { data: activeGraph, isSuccess, isLoading, isError } = useGetGraphQuery({ hid: hid as string })
const initialNodes = useAppSelector((state) => graphNodes(state));
const initialEdges = useAppSelector((state) => graphEdges(state));
const [nodesBeforeLayout, setNodesBeforeLayout] = useState(initialNodes)
const [edgesBeforeLayout, setEdgesBeforeLayout] = useState(initialEdges)
useEffect(() => {
dispatch(setPositionMode('manual'))
}, [activeGraph?.id])
const graphRef = useRef<HTMLDivElement>(null);
const [graphInstance, setGraphInstance] = useState<ReactFlowInstance>();
@@ -110,32 +116,19 @@ export default function GraphInquiry({ }: GraphInquiryProps) {
targetHandle: string = 'l2',
type: string = 'float',
label: string = '',
id: any = null
id: any = getEdgeId()
): void {
if (id) {
dispatch(
createEdge({
source,
target,
sourceHandle,
targetHandle,
type,
label,
id
})
);
} else {
dispatch(
createEdge({
source,
target,
sourceHandle,
targetHandle,
type,
label
})
);
}
dispatch(
createEdge({
source,
target,
sourceHandle,
targetHandle,
type,
label,
id
})
);
}
const togglePalette = () => setShowCommandPalette(!showCommandPalette);
@@ -200,8 +193,8 @@ export default function GraphInquiry({ }: GraphInquiryProps) {
if (lastJsonMessage && lastJsonMessage.action === 'error') toast.error(`${lastJsonMessage.detail}`);
if (!Array.isArray(lastJsonMessage)) {
if (lastJsonMessage.action === 'addInitialLoad') {
lastJsonMessage.nodes.forEach((node: JSONObject, idx: JSONObject) => addNode(node.id.toString(), node.data, node.position, 'mini'));
lastJsonMessage.edges.forEach((edge: JSONObject) => addEdge(`${edge.source}`, `${edge.target}`, 'r1', 'l2', 'float', '', `${edge.id}`));
dispatch(setAllNodes(lastJsonMessage.nodes))
dispatch(setAllEdges(lastJsonMessage.edges))
}
if (lastJsonMessage.action === 'addNode') {
lastJsonMessage.position.x += 560;
@@ -238,13 +231,19 @@ export default function GraphInquiry({ }: GraphInquiryProps) {
}
}, [lastJsonMessage, setMessageHistory]);
const initialNodes = useAppSelector((state) => graphNodes(state));
const initialEdges = useAppSelector((state) => graphEdges(state));
const [nodesBeforeLayout, setNodesBeforeLayout] = useState(initialNodes)
const [edgesBeforeLayout, setEdgesBeforeLayout] = useState(initialEdges)
const [ctxPosition, setCtxPosition] = useState<XYPosition>({ x: 0, y: 0 });
const [ctxSelection, setCtxSelection] = useState<JSONObject | null>(null);
const [showMenu, setShowMenu] = useState(false);
const [activeTransformLabel, setActiveTransformLabel] = useState<string | null>(null)
// @todo implement support for multi-select transforms -
// hm, actually, how will the transforms work if different plugin types/nodes are in the selection?
// just delete?
// just delete/save position on drag?
const onMultiSelectionCtxMenu = (event: MouseEvent, nodes: Node[]) => {
event.preventDefault();
};
@@ -270,18 +269,27 @@ export default function GraphInquiry({ }: GraphInquiryProps) {
});
};
const onPaneClick = () => {
setShowMenu(false);
setCtxSelection(null);
};
const positionMode = useAppSelector((state) => selectPositionMode(state))
useEffect
let fitView: any;
if (graphInstance) {
fitView = graphInstance.fitView
}
useEffect(() => {
if (positionMode === 'manual') {
setNodesBeforeLayout(initialNodes)
setEdgesBeforeLayout(initialEdges)
}
}, [initialNodes])
const allNodes = [...useAppSelector(state => selectAllNodes(state))]
const allEdges = [...useAppSelector(state => selectAllEdges(state))]
// TODO: Also implement d3-hierarchy, entitree-flex, dagre, webcola, and graphology layout modes
// Once implemented measure performance and deprecate whatever performs worse
@@ -292,9 +300,12 @@ export default function GraphInquiry({ }: GraphInquiryProps) {
'elk.layered.spacing.nodeNodeBetweenLayers': 420,
'elk.spacing.nodeNode': 180,
};
const allNodes = [...useAppSelector(state => selectAllNodes(state))]
const allEdges = [...useAppSelector(state => selectAllEdges(state))]
const setElkLayout = useCallback((options: any) => {
if (positionMode === 'manual') {
setNodesBeforeLayout(allNodes)
setEdgesBeforeLayout(allEdges)
}
const layoutOptions = { ...defaultOptions, ...options };
const graph = {
id: 'root',
@@ -306,7 +317,6 @@ export default function GraphInquiry({ }: GraphInquiryProps) {
children.forEach((node: any) => {
node.position = { x: node.x, y: node.y };
});
console.log('children', children)
dispatch(resetGraph())
dispatch(setAllNodes(children));
dispatch(setAllEdges(edges))
@@ -320,11 +330,9 @@ export default function GraphInquiry({ }: GraphInquiryProps) {
};
const { setElkLayout } = useElkLayoutElements();
const [forceDragPosition, setForceDragPosition] = useState<any>();
const useForceLayoutElements = () => {
const allNodes = [...useAppSelector(state => selectAllNodes(state))]
const allEdges = [...useAppSelector(state => selectAllEdges(state))]
const nodesInitialized = initialNodes.every((node: any) => node.width && node.height)
@@ -333,15 +341,13 @@ export default function GraphInquiry({ }: GraphInquiryProps) {
.force('charge', forceManyBody().strength(-4000))
.force('x', forceX().x(0).strength(0.05))
.force('y', forceY().y(0).strength(0.05))
.alphaTarget(0.05)
.alphaTarget(0.01)
.stop();
setNodesBeforeLayout(allNodes)
setEdgesBeforeLayout(allEdges)
let forceNodes = allNodes.map((node: any) => ({ ...node, x: node.position.x, y: node.position.y }));
let forceEdges = allEdges.map((edge: any) => ({ ...edge }));
// 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];
let running = false;
@@ -350,20 +356,17 @@ export default function GraphInquiry({ }: GraphInquiryProps) {
forceLink(forceEdges)
.id((d: any) => d.id)
.strength(0.05)
.distance(100)
.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 = () => {
forceNodes.forEach((node: any, i: number) => {
const dragging = Boolean(document.querySelector(`[data-id="${node.id}"].dragging`));
// Setting the fx/fy properties of a node tells the simulation to "fix"
// the node at that position and ignore any forces that would normally
// cause it to move.
forceNodes[i].fx = dragging ? node.position.x : null;
forceNodes[i].fy = dragging ? node.position.y : null;
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();
@@ -393,17 +396,14 @@ export default function GraphInquiry({ }: GraphInquiryProps) {
const [forceInitialized, { toggleForceLayout, isForceRunning }] = useForceLayoutElements();
// Prevents bugs from occurring on navigate away and returning to a graph
// Prevents layout bugs from occurring on navigate away and returning to a graph
// https://reactrouter.com/en/main/hooks/use-blocker
useBlocker(useCallback(
(tx: any) => tx.historyAction && toggleForceLayout(false),
[toggleForceLayout]
));
useEffect(() => {
dispatch(setPositionMode('manual'))
}, [activeGraph?.id])
return (
<>
@@ -440,6 +440,7 @@ export default function GraphInquiry({ }: GraphInquiryProps) {
setGraphInstance={setGraphInstance}
sendJsonMessage={sendJsonMessage}
fitView={fitView}
positionMode={positionMode}
/>
</div>
</div>