mirror of
https://github.com/IRBorisov/ConceptPortal.git
synced 2025-06-26 04:50:36 +03:00
F: Implement AST visualization using reactflow
This commit is contained in:
parent
c1f50d6f50
commit
5eace56968
74
rsconcept/frontend/src/dialogs/DlgShowAST/ASTFlow.tsx
Normal file
74
rsconcept/frontend/src/dialogs/DlgShowAST/ASTFlow.tsx
Normal 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;
|
|
@ -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>
|
||||
);
|
||||
}
|
28
rsconcept/frontend/src/dialogs/DlgShowAST/graph/ASTEdge.tsx
Normal file
28
rsconcept/frontend/src/dialogs/DlgShowAST/graph/ASTEdge.tsx
Normal 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;
|
|
@ -0,0 +1,7 @@
|
|||
import { EdgeTypes } from 'reactflow';
|
||||
|
||||
import ASTEdge from './ASTEdge';
|
||||
|
||||
export const ASTEdgeTypes: EdgeTypes = {
|
||||
dynamic: ASTEdge
|
||||
};
|
35
rsconcept/frontend/src/dialogs/DlgShowAST/graph/ASTLayout.ts
Normal file
35
rsconcept/frontend/src/dialogs/DlgShowAST/graph/ASTLayout.ts
Normal 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;
|
||||
});
|
||||
}
|
44
rsconcept/frontend/src/dialogs/DlgShowAST/graph/ASTNode.tsx
Normal file
44
rsconcept/frontend/src/dialogs/DlgShowAST/graph/ASTNode.tsx
Normal 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;
|
|
@ -0,0 +1,7 @@
|
|||
import { NodeTypes } from 'reactflow';
|
||||
|
||||
import ASTNode from './ASTNode';
|
||||
|
||||
export const ASTNodeTypes: NodeTypes = {
|
||||
token: ASTNode
|
||||
};
|
1
rsconcept/frontend/src/dialogs/DlgShowAST/index.tsx
Normal file
1
rsconcept/frontend/src/dialogs/DlgShowAST/index.tsx
Normal file
|
@ -0,0 +1 @@
|
|||
export { default } from './DlgShowAST';
|
|
@ -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]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -126,7 +126,8 @@
|
|||
height: 40px;
|
||||
}
|
||||
|
||||
.react-flow__node-step {
|
||||
.react-flow__node-step,
|
||||
.react-flow__node-token {
|
||||
cursor: default;
|
||||
|
||||
border-radius: 100%;
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user