F: Implement AST visualization using reactflow

This commit is contained in:
Ivan 2024-11-21 21:38:32 +03:00
parent 4172e387c2
commit 71659b8c15
15 changed files with 225 additions and 68 deletions

View File

@ -0,0 +1,74 @@
'use client';
import { useLayoutEffect } from 'react';
import { Edge, MarkerType, Node, ReactFlow, useEdgesState, useNodesState, useReactFlow } from 'reactflow';
import { SyntaxTree } from '@/models/rslang';
import { ASTEdgeTypes } from './graph/ASTEdgeTypes';
import { applyLayout } from './graph/ASTLayout';
import { ASTNodeTypes } from './graph/ASTNodeTypes';
interface ASTFlowProps {
data: SyntaxTree;
onNodeEnter: (node: Node) => void;
onNodeLeave: (node: Node) => void;
}
function ASTFlow({ data, onNodeEnter, onNodeLeave }: ASTFlowProps) {
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges] = useEdgesState([]);
const flow = useReactFlow();
useLayoutEffect(() => {
const newNodes = data.map(node => ({
id: String(node.uid),
data: node,
position: { x: 0, y: 0 },
type: 'token'
}));
const newEdges: Edge[] = [];
data.forEach(node => {
if (node.parent !== node.uid) {
newEdges.push({
id: String(node.uid),
source: String(node.parent),
target: String(node.uid),
type: 'dynamic',
focusable: false,
markerEnd: {
type: MarkerType.ArrowClosed,
width: 20,
height: 20
}
});
}
});
applyLayout(newNodes, newEdges);
setNodes(newNodes);
setEdges(newEdges);
}, [data, setNodes, setEdges, flow]);
return (
<ReactFlow
nodes={nodes}
edges={edges}
edgesFocusable={false}
nodesFocusable={false}
onNodeMouseEnter={(_, node) => onNodeEnter(node)}
onNodeMouseLeave={(_, node) => onNodeLeave(node)}
onNodesChange={onNodesChange}
nodeTypes={ASTNodeTypes}
edgeTypes={ASTEdgeTypes}
fitView
maxZoom={2}
minZoom={0.5}
nodesConnectable={false}
/>
);
}
export default ASTFlow;

View File

@ -1,17 +1,16 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { ReactFlowProvider } from 'reactflow';
import { Node } from 'reactflow';
import GraphUI, { GraphEdge, GraphNode } from '@/components/ui/GraphUI';
import Modal, { ModalProps } from '@/components/ui/Modal';
import Overlay from '@/components/ui/Overlay';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { HelpTopic } from '@/models/miscellaneous';
import { SyntaxTree } from '@/models/rslang';
import { graphDarkT, graphLightT } from '@/styling/color';
import { colorBgSyntaxTree } from '@/styling/color';
import { resources } from '@/utils/constants';
import { labelSyntaxTree } from '@/utils/labels';
import ASTFlow from './ASTFlow';
interface DlgShowASTProps extends Pick<ModalProps, 'hideWindow'> {
syntaxTree: SyntaxTree;
@ -19,36 +18,11 @@ interface DlgShowASTProps extends Pick<ModalProps, 'hideWindow'> {
}
function DlgShowAST({ hideWindow, syntaxTree, expression }: DlgShowASTProps) {
const { darkMode, colors } = useConceptOptions();
const { colors } = useConceptOptions();
const [hoverID, setHoverID] = useState<number | undefined>(undefined);
const hoverNode = useMemo(() => syntaxTree.find(node => node.uid === hoverID), [hoverID, syntaxTree]);
const nodes: GraphNode[] = useMemo(
() =>
syntaxTree.map(node => ({
id: String(syntaxTree.length - node.uid), // invert order of IDs to force correct ordering in graph layout
label: labelSyntaxTree(node),
fill: colorBgSyntaxTree(node, colors)
})),
[syntaxTree, colors]
);
const edges: GraphEdge[] = useMemo(() => {
const result: GraphEdge[] = [];
syntaxTree.forEach(node => {
if (node.parent !== node.uid) {
result.push({
id: String(node.uid),
source: String(syntaxTree.length - node.parent),
target: String(syntaxTree.length - node.uid)
});
}
});
return result;
}, [syntaxTree]);
const handleHoverIn = useCallback((node: GraphNode) => setHoverID(syntaxTree.length - Number(node.id)), [syntaxTree]);
const handleHoverIn = useCallback((node: Node) => setHoverID(Number(node.id)), []);
const handleHoverOut = useCallback(() => setHoverID(undefined), []);
return (
@ -72,18 +46,9 @@ function DlgShowAST({ hideWindow, syntaxTree, expression }: DlgShowASTProps) {
</div>
) : null}
</Overlay>
<div className='flex-grow relative'>
<GraphUI
animated={false}
nodes={nodes}
edges={edges}
layoutType='hierarchicalTd'
labelFontUrl={resources.graph_font}
theme={darkMode ? graphDarkT : graphLightT}
onNodePointerOver={handleHoverIn}
onNodePointerOut={handleHoverOut}
/>
</div>
<ReactFlowProvider>
<ASTFlow data={syntaxTree} onNodeEnter={handleHoverIn} onNodeLeave={handleHoverOut} />
</ReactFlowProvider>
</Modal>
);
}

View File

@ -0,0 +1,28 @@
import { EdgeProps, getStraightPath } from 'reactflow';
const NODE_RADIUS = 20;
const EDGE_RADIUS = 25;
function ASTEdge({ id, markerEnd, style, ...props }: EdgeProps) {
const scale =
EDGE_RADIUS /
Math.sqrt(
Math.pow(props.sourceX - props.targetX, 2) +
Math.pow(Math.abs(props.sourceY - props.targetY) + 2 * NODE_RADIUS, 2)
);
const [path] = getStraightPath({
sourceX: props.sourceX - (props.sourceX - props.targetX) * scale,
sourceY: props.sourceY - (props.sourceY - props.targetY - 2 * NODE_RADIUS) * scale - NODE_RADIUS,
targetX: props.targetX + (props.sourceX - props.targetX) * scale,
targetY: props.targetY + (props.sourceY - props.targetY - 2 * NODE_RADIUS) * scale + NODE_RADIUS
});
return (
<>
<path id={id} className='react-flow__edge-path' d={path} markerEnd={markerEnd} style={style} />
</>
);
}
export default ASTEdge;

View File

@ -0,0 +1,7 @@
import { EdgeTypes } from 'reactflow';
import ASTEdge from './ASTEdge';
export const ASTEdgeTypes: EdgeTypes = {
dynamic: ASTEdge
};

View File

@ -0,0 +1,35 @@
import dagre from '@dagrejs/dagre';
import { Edge, Node } from 'reactflow';
import { ISyntaxTreeNode } from '@/models/rslang';
const NODE_WIDTH = 44;
const NODE_HEIGHT = 44;
const HOR_SEPARATION = 40;
const VERT_SEPARATION = 40;
export function applyLayout(nodes: Node<ISyntaxTreeNode>[], edges: Edge[]) {
const dagreGraph = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
dagreGraph.setGraph({
rankdir: 'TB',
ranksep: VERT_SEPARATION,
nodesep: HOR_SEPARATION,
ranker: 'network-simplex',
align: undefined
});
nodes.forEach(node => {
dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
});
edges.forEach(edge => {
dagreGraph.setEdge(edge.source, edge.target);
});
dagre.layout(dagreGraph);
nodes.forEach(node => {
const nodeWithPosition = dagreGraph.node(node.id);
node.position.x = nodeWithPosition.x - NODE_WIDTH / 2;
node.position.y = nodeWithPosition.y - NODE_HEIGHT / 2;
});
}

View File

@ -0,0 +1,44 @@
'use client';
import { useMemo } from 'react';
import { Handle, Position } from 'reactflow';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { ISyntaxTreeNode } from '@/models/rslang';
import { colorBgSyntaxTree } from '@/styling/color';
import { labelSyntaxTree } from '@/utils/labels';
/**
* Represents graph AST node internal data.
*/
interface ASTNodeInternal {
id: string;
data: ISyntaxTreeNode;
dragging: boolean;
xPos: number;
yPos: number;
}
function ASTNode(node: ASTNodeInternal) {
const { colors } = useConceptOptions();
const label = useMemo(() => labelSyntaxTree(node.data), [node.data]);
return (
<>
<Handle type='target' position={Position.Top} style={{ opacity: 0 }} />
<div
className='w-full h-full cursor-default flex items-center justify-center rounded-full'
style={{ backgroundColor: colorBgSyntaxTree(node.data, colors) }}
/>
<Handle type='source' position={Position.Bottom} style={{ opacity: 0 }} />
<div
className='font-math mt-1 w-fit px-1 text-center translate-x-[calc(-50%+20px)]'
style={{ backgroundColor: colors.bgDefault, fontSize: label.length > 3 ? 12 : 14 }}
>
{label}
</div>
</>
);
}
export default ASTNode;

View File

@ -0,0 +1,7 @@
import { NodeTypes } from 'reactflow';
import ASTNode from './ASTNode';
export const ASTNodeTypes: NodeTypes = {
token: ASTNode
};

View File

@ -0,0 +1 @@
export { default } from './DlgShowAST';

View File

@ -4,7 +4,6 @@ import { useLayoutEffect } from 'react';
import { Edge, ReactFlow, useEdgesState, useNodesState, useReactFlow } from 'reactflow';
import { TMGraph } from '@/models/TMGraph';
import { PARAMETER } from '@/utils/constants';
import { TMGraphEdgeTypes } from './graph/MGraphEdgeTypes';
import { applyLayout } from './graph/MGraphLayout';
@ -53,7 +52,6 @@ function MGraphFlow({ data }: MGraphFlowProps) {
setNodes(newNodes);
setEdges(newEdges);
flow.fitView({ duration: PARAMETER.zoomDuration });
}, [data, setNodes, setEdges, flow]);
return (
@ -69,8 +67,6 @@ function MGraphFlow({ data }: MGraphFlowProps) {
maxZoom={2}
minZoom={0.5}
nodesConnectable={false}
snapToGrid={true}
snapGrid={[PARAMETER.ossGridSize, PARAMETER.ossGridSize]}
/>
);
}

View File

@ -4,10 +4,21 @@ import { useMemo } from 'react';
import { Handle, Position } from 'reactflow';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { MGraphNodeInternal } from '@/models/miscellaneous';
import { TMGraphNode } from '@/models/TMGraph';
import { colorBgTMGraphNode } from '@/styling/color';
import { globals } from '@/utils/constants';
/**
* Represents graph TMGraph node internal data.
*/
interface MGraphNodeInternal {
id: string;
data: TMGraphNode;
dragging: boolean;
xPos: number;
yPos: number;
}
function MGraphNode(node: MGraphNodeInternal) {
const { colors } = useConceptOptions();

View File

@ -6,7 +6,6 @@ import { EdgeProps, Node } from 'reactflow';
import { LibraryItemType, LocationHead } from './library';
import { IOperation } from './oss';
import { TMGraphNode } from './TMGraph';
import { UserID } from './user';
/**
@ -46,17 +45,6 @@ export interface OssNodeInternal {
yPos: number;
}
/**
* Represents graph TMGraph node internal data.
*/
export interface MGraphNodeInternal {
id: string;
data: TMGraphNode;
dragging: boolean;
xPos: number;
yPos: number;
}
/**
* Represents graph TMGraph edge internal data.
*/

View File

@ -28,7 +28,7 @@ function HelpFormulaTree() {
<span style={{ backgroundColor: colors.bgRed }}>присвоение и итерация</span>
</li>
<li>
<span style={{ backgroundColor: '#7ca0ab' }}>составные выражения</span>
<span style={{ backgroundColor: colors.bgDisabled }}>составные выражения</span>
</li>
</div>
);

View File

@ -371,7 +371,7 @@ export function colorBgSyntaxTree(node: ISyntaxTreeNode, colors: IColorTheme): s
case TokenID.NT_FUNC_CALL:
case TokenID.NT_ARGUMENTS:
case TokenID.NT_RECURSIVE_SHORT:
return '';
return colors.bgDisabled;
case TokenID.ASSIGN:
case TokenID.ITERATE:

View File

@ -126,7 +126,8 @@
height: 40px;
}
.react-flow__node-step {
.react-flow__node-step,
.react-flow__node-token {
cursor: default;
border-radius: 100%;

View File

@ -581,16 +581,16 @@ export function labelSyntaxTree(node: ISyntaxTreeNode): string {
case TokenID.NT_TUPLE: return 'TUPLE';
case TokenID.NT_ENUMERATION: return 'ENUM';
case TokenID.NT_ENUM_DECL: return 'ENUM_DECLARATION';
case TokenID.NT_TUPLE_DECL: return 'TUPLE_DECLARATION';
case TokenID.NT_ENUM_DECL: return 'ENUM_DECLARE';
case TokenID.NT_TUPLE_DECL: return 'TUPLE_DECLARE';
case TokenID.PUNCTUATION_DEFINE: return 'DEFINITION';
case TokenID.PUNCTUATION_STRUCT: return 'STRUCTURE_DEFINITION';
case TokenID.PUNCTUATION_STRUCT: return 'STRUCTURE_DEFINE';
case TokenID.NT_ARG_DECL: return 'ARG';
case TokenID.NT_FUNC_CALL: return 'CALL';
case TokenID.NT_ARGUMENTS: return 'ARGS';
case TokenID.NT_FUNC_DEFINITION: return 'FUNCTION_DEFINITION';
case TokenID.NT_FUNC_DEFINITION: return 'FUNCTION_DEFINE';
case TokenID.NT_RECURSIVE_SHORT: return labelToken(TokenID.NT_RECURSIVE_FULL);