Compare commits

...

11 Commits

Author SHA1 Message Date
Ivan
a2615a9236 M: Minor UI fix for small screens
Some checks failed
Frontend CI / build (22.x) (push) Has been cancelled
2024-12-03 19:35:28 +03:00
Ivan
fc9d21d425 R: Small UI refactoring and improvement 2024-12-03 12:46:25 +03:00
Ivan
4012b8f995 fix 2024-12-02 21:00:11 +03:00
Ivan
a63af2b5cf M: Update GraphUI 2024-12-02 20:46:54 +03:00
Ivan
82731be327 M: UI fixes 2024-12-02 16:07:40 +03:00
Ivan
eb97dc5974 npm update 2024-12-02 16:01:38 +03:00
Ivan
f6bb76f5e1 F: Term graph rework pt1 2024-12-02 15:58:18 +03:00
Ivan
80f34d90c4 M: Hide modal on backdrop click 2024-11-28 14:04:33 +03:00
Ivan
39c972eeea R: Upgrade to react-router v7 2024-11-25 19:52:57 +03:00
Ivan
01b1ade339 M: Remove unused dependencies 2024-11-22 11:44:29 +03:00
Ivan
34212a5c07 npm update 2024-11-22 11:09:19 +03:00
48 changed files with 1322 additions and 2313 deletions

View File

@ -31,7 +31,7 @@ This readme file is used mostly to document project dependencies and conventions
- axios - axios
- clsx - clsx
- react-icons - react-icons
- react-router-dom - react-router
- react-toastify - react-toastify
- react-loader-spinner - react-loader-spinner
- react-tabs - react-tabs
@ -44,7 +44,6 @@ This readme file is used mostly to document project dependencies and conventions
- js-file-download - js-file-download
- use-debounce - use-debounce
- framer-motion - framer-motion
- reagraph
- html-to-image - html-to-image
- @tanstack/react-table - @tanstack/react-table
- @uiw/react-codemirror - @uiw/react-codemirror

File diff suppressed because it is too large Load Diff

View File

@ -17,49 +17,48 @@
"@tanstack/react-table": "^8.20.5", "@tanstack/react-table": "^8.20.5",
"@uiw/codemirror-themes": "^4.23.6", "@uiw/codemirror-themes": "^4.23.6",
"@uiw/react-codemirror": "^4.23.6", "@uiw/react-codemirror": "^4.23.6",
"axios": "^1.7.7", "axios": "^1.7.8",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
"framer-motion": "^11.11.17", "framer-motion": "^11.12.0",
"html-to-image": "^1.11.11", "html-to-image": "^1.11.11",
"js-file-download": "^0.4.12", "js-file-download": "^0.4.12",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-error-boundary": "^4.1.2", "react-error-boundary": "^4.1.2",
"react-icons": "^5.3.0", "react-icons": "^5.3.0",
"react-intl": "^6.8.9", "react-intl": "^7.0.1",
"react-loader-spinner": "^6.1.6", "react-loader-spinner": "^6.1.6",
"react-router-dom": "^6.28.0", "react-router": "^7.0.1",
"react-select": "^5.8.3", "react-select": "^5.8.3",
"react-tabs": "^6.0.2", "react-tabs": "^6.0.2",
"react-toastify": "^10.0.6", "react-toastify": "^10.0.6",
"react-tooltip": "^5.28.0", "react-tooltip": "^5.28.0",
"react-zoom-pan-pinch": "^3.6.1", "react-zoom-pan-pinch": "^3.6.1",
"reactflow": "^11.11.4", "reactflow": "^11.11.4",
"reagraph": "^4.20.1",
"use-debounce": "^10.0.4" "use-debounce": "^10.0.4"
}, },
"devDependencies": { "devDependencies": {
"@lezer/generator": "^1.7.1", "@lezer/generator": "^1.7.1",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.9.1", "@types/node": "^22.10.1",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^8.0.1", "@typescript-eslint/eslint-plugin": "^8.0.1",
"@typescript-eslint/parser": "^8.0.1", "@typescript-eslint/parser": "^8.0.1",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"eslint-plugin-react": "^7.37.2", "eslint-plugin-react": "^7.37.2",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"globals": "^15.12.0", "globals": "^15.13.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"tailwindcss": "^3.4.15", "tailwindcss": "^3.4.15",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
"typescript": "^5.6.3", "typescript": "^5.7.2",
"typescript-eslint": "^8.15.0", "typescript-eslint": "^8.16.0",
"vite": "^5.4.11" "vite": "^6.0.2"
}, },
"jest": { "jest": {
"preset": "ts-jest", "preset": "ts-jest",

View File

@ -1,4 +1,4 @@
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router';
import ConceptToaster from '@/app/ConceptToaster'; import ConceptToaster from '@/app/ConceptToaster';
import Footer from '@/app/Footer'; import Footer from '@/app/Footer';

View File

@ -1,4 +1,4 @@
import { createBrowserRouter } from 'react-router-dom'; import { createBrowserRouter } from 'react-router';
import CreateItemPage from '@/pages/CreateItemPage'; import CreateItemPage from '@/pages/CreateItemPage';
import DatabaseSchemaPage from '@/pages/DatabaseSchemaPage'; import DatabaseSchemaPage from '@/pages/DatabaseSchemaPage';

View File

@ -1,4 +1,4 @@
import { RouterProvider } from 'react-router-dom'; import { RouterProvider } from 'react-router';
import { Router } from './Router'; import { Router } from './Router';

View File

@ -139,6 +139,7 @@ function PickMultiConstituenta({
graph={foldedGraph} graph={foldedGraph}
isCore={cstID => isBasicConcept(schema.cstByID.get(cstID)?.cst_type)} isCore={cstID => isBasicConcept(schema.cstByID.get(cstID)?.cst_type)}
isOwned={cstID => !schema.cstByID.get(cstID)?.is_inherited} isOwned={cstID => !schema.cstByID.get(cstID)?.is_inherited}
selected={selected}
setSelected={setSelected} setSelected={setSelected}
emptySelection={selected.length === 0} emptySelection={selected.length === 0}
className='w-fit' className='w-fit'

View File

@ -19,15 +19,17 @@ import MiniButton from '../ui/MiniButton';
interface ToolbarGraphSelectionProps extends CProps.Styling { interface ToolbarGraphSelectionProps extends CProps.Styling {
graph: Graph; graph: Graph;
selected: number[];
isCore: (item: number) => boolean; isCore: (item: number) => boolean;
isOwned: (item: number) => boolean; isOwned?: (item: number) => boolean;
setSelected: React.Dispatch<React.SetStateAction<number[]>>; setSelected: (newSelection: number[]) => void;
emptySelection?: boolean; emptySelection?: boolean;
} }
function ToolbarGraphSelection({ function ToolbarGraphSelection({
className, className,
graph, graph,
selected,
isCore, isCore,
isOwned, isOwned,
setSelected, setSelected,
@ -40,13 +42,13 @@ function ToolbarGraphSelection({
}, [setSelected, graph, isCore]); }, [setSelected, graph, isCore]);
const handleSelectOwned = useCallback( const handleSelectOwned = useCallback(
() => setSelected([...graph.nodes.keys()].filter(isOwned)), () => (isOwned ? setSelected([...graph.nodes.keys()].filter(isOwned)) : undefined),
[setSelected, graph, isOwned] [setSelected, graph, isOwned]
); );
const handleInvertSelection = useCallback( const handleInvertSelection = useCallback(
() => setSelected(prev => [...graph.nodes.keys()].filter(item => !prev.includes(item))), () => setSelected([...graph.nodes.keys()].filter(item => !selected.includes(item))),
[setSelected, graph] [setSelected, selected, graph]
); );
return ( return (
@ -60,31 +62,31 @@ function ToolbarGraphSelection({
<MiniButton <MiniButton
titleHtml='Выделить все влияющие' titleHtml='Выделить все влияющие'
icon={<IconGraphCollapse size='1.25rem' className='icon-primary' />} icon={<IconGraphCollapse size='1.25rem' className='icon-primary' />}
onClick={() => setSelected(prev => [...prev, ...graph.expandAllInputs(prev)])} onClick={() => setSelected([...selected, ...graph.expandAllInputs(selected)])}
disabled={emptySelection} disabled={emptySelection}
/> />
<MiniButton <MiniButton
titleHtml='Выделить все зависимые' titleHtml='Выделить все зависимые'
icon={<IconGraphExpand size='1.25rem' className='icon-primary' />} icon={<IconGraphExpand size='1.25rem' className='icon-primary' />}
onClick={() => setSelected(prev => [...prev, ...graph.expandAllOutputs(prev)])} onClick={() => setSelected([...selected, ...graph.expandAllOutputs(selected)])}
disabled={emptySelection} disabled={emptySelection}
/> />
<MiniButton <MiniButton
titleHtml='<b>Максимизация</b> <br/>дополнение выделения конституентами, <br/>зависимыми только от выделенных' titleHtml='<b>Максимизация</b> <br/>дополнение выделения конституентами, <br/>зависимыми только от выделенных'
icon={<IconGraphMaximize size='1.25rem' className='icon-primary' />} icon={<IconGraphMaximize size='1.25rem' className='icon-primary' />}
onClick={() => setSelected(prev => graph.maximizePart(prev))} onClick={() => setSelected(graph.maximizePart(selected))}
disabled={emptySelection} disabled={emptySelection}
/> />
<MiniButton <MiniButton
titleHtml='Выделить поставщиков' titleHtml='Выделить поставщиков'
icon={<IconGraphInputs size='1.25rem' className='icon-primary' />} icon={<IconGraphInputs size='1.25rem' className='icon-primary' />}
onClick={() => setSelected(prev => [...prev, ...graph.expandInputs(prev)])} onClick={() => setSelected([...selected, ...graph.expandInputs(selected)])}
disabled={emptySelection} disabled={emptySelection}
/> />
<MiniButton <MiniButton
titleHtml='Выделить потребителей' titleHtml='Выделить потребителей'
icon={<IconGraphOutputs size='1.25rem' className='icon-primary' />} icon={<IconGraphOutputs size='1.25rem' className='icon-primary' />}
onClick={() => setSelected(prev => [...prev, ...graph.expandOutputs(prev)])} onClick={() => setSelected([...selected, ...graph.expandOutputs(selected)])}
disabled={emptySelection} disabled={emptySelection}
/> />
<MiniButton <MiniButton
@ -97,11 +99,13 @@ function ToolbarGraphSelection({
icon={<IconGraphCore size='1.25rem' className='icon-primary' />} icon={<IconGraphCore size='1.25rem' className='icon-primary' />}
onClick={handleSelectCore} onClick={handleSelectCore}
/> />
{isOwned ? (
<MiniButton <MiniButton
titleHtml='Выделить собственные' titleHtml='Выделить собственные'
icon={<IconPredecessor size='1.25rem' className='icon-primary' />} icon={<IconPredecessor size='1.25rem' className='icon-primary' />}
onClick={handleSelectOwned} onClick={handleSelectOwned}
/> />
) : null}
</div> </div>
); );
} }

View File

@ -0,0 +1,36 @@
import { EdgeProps, getStraightPath } from 'reactflow';
import { PARAMETER } from '@/utils/constants';
const RADIUS = PARAMETER.graphNodeRadius + PARAMETER.graphNodePadding;
function DynamicEdge({ id, markerEnd, style, ...props }: EdgeProps) {
const sourceY = props.sourceY - PARAMETER.graphNodeRadius - PARAMETER.graphHandleSize;
const targetY = props.targetY + PARAMETER.graphNodeRadius + PARAMETER.graphHandleSize;
const dx = props.targetX - props.sourceX;
const dy = targetY - sourceY;
const distance = Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));
if (distance <= 2 * RADIUS) {
return null;
}
const ux = dx / distance;
const uy = dy / distance;
const [path] = getStraightPath({
sourceX: props.sourceX + ux * RADIUS,
sourceY: sourceY + uy * RADIUS,
targetX: props.targetX - ux * RADIUS,
targetY: targetY - uy * RADIUS
});
return (
<>
<path id={id} className='react-flow__edge-path' d={path} markerEnd={markerEnd} style={style} />
</>
);
}
export default DynamicEdge;

View File

@ -1,21 +0,0 @@
// Reexporting necessary reagraph types.
'use client';
import { GraphCanvas as GraphUI } from 'reagraph';
export {
type CollapseProps,
type GraphCanvasRef,
type GraphEdge,
type GraphNode,
Sphere,
useSelection
} from 'reagraph';
export { type LayoutTypes as GraphLayout } from 'reagraph';
import { ThreeEvent } from '@react-three/fiber';
export type GraphMouseEvent = ThreeEvent<MouseEvent>;
export type GraphPointerEvent = ThreeEvent<PointerEvent>;
export default GraphUI;

View File

@ -98,7 +98,10 @@ function Modal({
return ( return (
<div className='fixed top-0 left-0 w-full h-full z-modal cursor-default'> <div className='fixed top-0 left-0 w-full h-full z-modal cursor-default'>
<div className={clsx('z-navigation', 'fixed top-0 left-0', 'w-full h-full', 'cc-modal-blur')} /> <div className={clsx('z-navigation', 'fixed top-0 left-0', 'w-full h-full', 'cc-modal-blur')} />
<div className={clsx('z-navigation', 'fixed top-0 left-0', 'w-full h-full', 'cc-modal-backdrop')} /> <div
className={clsx('z-navigation', 'fixed top-0 left-0', 'w-full h-full', 'cc-modal-backdrop')}
onClick={hideWindow}
/>
<motion.div <motion.div
ref={ref} ref={ref}
className={clsx( className={clsx(

View File

@ -1,4 +1,4 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router';
interface TextURLProps { interface TextURLProps {
/** Text to display. */ /** Text to display. */

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { createContext, useCallback, useContext, useEffect, useState } from 'react'; import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router';
import { globals } from '@/utils/constants'; import { globals } from '@/utils/constants';
import { contextOutsideScope } from '@/utils/labels'; import { contextOutsideScope } from '@/utils/labels';
@ -54,7 +54,7 @@ export const NavigationState = ({ children }: React.PropsWithChildren) => {
} }
if (validate()) { if (validate()) {
scrollTop(); scrollTop();
router(path); Promise.resolve(router(path)).catch(console.log);
setIsBlocked(false); setIsBlocked(false);
} }
}, },
@ -65,7 +65,7 @@ export const NavigationState = ({ children }: React.PropsWithChildren) => {
(path: string) => { (path: string) => {
if (validate()) { if (validate()) {
scrollTop(); scrollTop();
router(path, { replace: true }); Promise.resolve(router(path, { replace: true })).catch(console.log);
setIsBlocked(false); setIsBlocked(false);
} }
}, },
@ -75,7 +75,7 @@ export const NavigationState = ({ children }: React.PropsWithChildren) => {
const back = useCallback(() => { const back = useCallback(() => {
if (validate()) { if (validate()) {
scrollTop(); scrollTop();
router(-1); Promise.resolve(router(-1)).catch(console.log);
setIsBlocked(false); setIsBlocked(false);
} }
}, [router, validate, scrollTop]); }, [router, validate, scrollTop]);
@ -83,7 +83,7 @@ export const NavigationState = ({ children }: React.PropsWithChildren) => {
const forward = useCallback(() => { const forward = useCallback(() => {
if (validate()) { if (validate()) {
scrollTop(); scrollTop();
router(1); Promise.resolve(router(1)).catch(console.log);
setIsBlocked(false); setIsBlocked(false);
} }
}, [router, validate, scrollTop]); }, [router, validate, scrollTop]);

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useLayoutEffect } from 'react'; import { useLayoutEffect } from 'react';
import { Edge, MarkerType, Node, ReactFlow, useEdgesState, useNodesState, useReactFlow } from 'reactflow'; import { Edge, MarkerType, Node, ReactFlow, useEdgesState, useNodesState } from 'reactflow';
import { SyntaxTree } from '@/models/rslang'; import { SyntaxTree } from '@/models/rslang';
@ -13,12 +13,12 @@ interface ASTFlowProps {
data: SyntaxTree; data: SyntaxTree;
onNodeEnter: (node: Node) => void; onNodeEnter: (node: Node) => void;
onNodeLeave: (node: Node) => void; onNodeLeave: (node: Node) => void;
onChangeDragging: (value: boolean) => void;
} }
function ASTFlow({ data, onNodeEnter, onNodeLeave }: ASTFlowProps) { function ASTFlow({ data, onNodeEnter, onNodeLeave, onChangeDragging }: ASTFlowProps) {
const [nodes, setNodes, onNodesChange] = useNodesState([]); const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges] = useEdgesState([]); const [edges, setEdges] = useEdgesState([]);
const flow = useReactFlow();
useLayoutEffect(() => { useLayoutEffect(() => {
const newNodes = data.map(node => ({ const newNodes = data.map(node => ({
@ -50,7 +50,7 @@ function ASTFlow({ data, onNodeEnter, onNodeLeave }: ASTFlowProps) {
setNodes(newNodes); setNodes(newNodes);
setEdges(newEdges); setEdges(newEdges);
}, [data, setNodes, setEdges, flow]); }, [data, setNodes, setEdges]);
return ( return (
<ReactFlow <ReactFlow
@ -60,6 +60,8 @@ function ASTFlow({ data, onNodeEnter, onNodeLeave }: ASTFlowProps) {
nodesFocusable={false} nodesFocusable={false}
onNodeMouseEnter={(_, node) => onNodeEnter(node)} onNodeMouseEnter={(_, node) => onNodeEnter(node)}
onNodeMouseLeave={(_, node) => onNodeLeave(node)} onNodeMouseLeave={(_, node) => onNodeLeave(node)}
onNodeDragStart={() => onChangeDragging(true)}
onNodeDragStop={() => onChangeDragging(false)}
onNodesChange={onNodesChange} onNodesChange={onNodesChange}
nodeTypes={ASTNodeTypes} nodeTypes={ASTNodeTypes}
edgeTypes={ASTEdgeTypes} edgeTypes={ASTEdgeTypes}

View File

@ -25,6 +25,8 @@ function DlgShowAST({ hideWindow, syntaxTree, expression }: DlgShowASTProps) {
const handleHoverIn = useCallback((node: Node) => setHoverID(Number(node.id)), []); const handleHoverIn = useCallback((node: Node) => setHoverID(Number(node.id)), []);
const handleHoverOut = useCallback(() => setHoverID(undefined), []); const handleHoverOut = useCallback(() => setHoverID(undefined), []);
const [isDragging, setIsDragging] = useState(false);
return ( return (
<Modal <Modal
readonly readonly
@ -37,8 +39,8 @@ function DlgShowAST({ hideWindow, syntaxTree, expression }: DlgShowASTProps) {
className='px-2 py-1 rounded-2xl cc-blur max-w-[60ch] text-lg text-center' className='px-2 py-1 rounded-2xl cc-blur max-w-[60ch] text-lg text-center'
style={{ backgroundColor: colors.bgBlur }} style={{ backgroundColor: colors.bgBlur }}
> >
{!hoverNode ? expression : null} {!hoverNode || isDragging ? expression : null}
{hoverNode ? ( {!isDragging && hoverNode ? (
<div> <div>
<span>{expression.slice(0, hoverNode.start)}</span> <span>{expression.slice(0, hoverNode.start)}</span>
<span className='clr-selected'>{expression.slice(hoverNode.start, hoverNode.finish)}</span> <span className='clr-selected'>{expression.slice(hoverNode.start, hoverNode.finish)}</span>
@ -47,7 +49,12 @@ function DlgShowAST({ hideWindow, syntaxTree, expression }: DlgShowASTProps) {
) : null} ) : null}
</Overlay> </Overlay>
<ReactFlowProvider> <ReactFlowProvider>
<ASTFlow data={syntaxTree} onNodeEnter={handleHoverIn} onNodeLeave={handleHoverOut} /> <ASTFlow
data={syntaxTree}
onNodeEnter={handleHoverIn}
onNodeLeave={handleHoverOut}
onChangeDragging={setIsDragging}
/>
</ReactFlowProvider> </ReactFlowProvider>
</Modal> </Modal>
); );

View File

@ -1,28 +0,0 @@
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

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

View File

@ -2,23 +2,19 @@ import dagre from '@dagrejs/dagre';
import { Edge, Node } from 'reactflow'; import { Edge, Node } from 'reactflow';
import { ISyntaxTreeNode } from '@/models/rslang'; import { ISyntaxTreeNode } from '@/models/rslang';
import { PARAMETER } from '@/utils/constants';
const NODE_WIDTH = 44;
const NODE_HEIGHT = 44;
const HOR_SEPARATION = 40;
const VERT_SEPARATION = 40;
export function applyLayout(nodes: Node<ISyntaxTreeNode>[], edges: Edge[]) { export function applyLayout(nodes: Node<ISyntaxTreeNode>[], edges: Edge[]) {
const dagreGraph = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); const dagreGraph = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
dagreGraph.setGraph({ dagreGraph.setGraph({
rankdir: 'TB', rankdir: 'TB',
ranksep: VERT_SEPARATION, ranksep: 40,
nodesep: HOR_SEPARATION, nodesep: 40,
ranker: 'network-simplex', ranker: 'network-simplex',
align: undefined align: undefined
}); });
nodes.forEach(node => { nodes.forEach(node => {
dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }); dagreGraph.setNode(node.id, { width: 2 * PARAMETER.graphNodeRadius, height: 2 * PARAMETER.graphNodeRadius });
}); });
edges.forEach(edge => { edges.forEach(edge => {
@ -29,7 +25,7 @@ export function applyLayout(nodes: Node<ISyntaxTreeNode>[], edges: Edge[]) {
nodes.forEach(node => { nodes.forEach(node => {
const nodeWithPosition = dagreGraph.node(node.id); const nodeWithPosition = dagreGraph.node(node.id);
node.position.x = nodeWithPosition.x - NODE_WIDTH / 2; node.position.x = nodeWithPosition.x - PARAMETER.graphNodeRadius;
node.position.y = nodeWithPosition.y - NODE_HEIGHT / 2; node.position.y = nodeWithPosition.y - PARAMETER.graphNodeRadius;
}); });
} }

View File

@ -8,6 +8,11 @@ import { ISyntaxTreeNode } from '@/models/rslang';
import { colorBgSyntaxTree } from '@/styling/color'; import { colorBgSyntaxTree } from '@/styling/color';
import { labelSyntaxTree } from '@/utils/labels'; import { labelSyntaxTree } from '@/utils/labels';
const FONT_SIZE_MAX = 14;
const FONT_SIZE_MED = 12;
const LABEL_THRESHOLD = 3;
/** /**
* Represents graph AST node internal data. * Represents graph AST node internal data.
*/ */
@ -15,6 +20,7 @@ interface ASTNodeInternal {
id: string; id: string;
data: ISyntaxTreeNode; data: ISyntaxTreeNode;
dragging: boolean; dragging: boolean;
selected: boolean;
xPos: number; xPos: number;
yPos: number; yPos: number;
} }
@ -32,11 +38,20 @@ function ASTNode(node: ASTNodeInternal) {
/> />
<Handle type='source' position={Position.Bottom} style={{ opacity: 0 }} /> <Handle type='source' position={Position.Bottom} style={{ opacity: 0 }} />
<div <div
className='font-math mt-1 w-fit px-1 text-center translate-x-[calc(-50%+20px)]' className='font-math mt-1 w-fit text-center translate-x-[calc(-50%+20px)]'
style={{ backgroundColor: colors.bgDefault, fontSize: label.length > 3 ? 12 : 14 }} style={{ fontSize: label.length > LABEL_THRESHOLD ? FONT_SIZE_MED : FONT_SIZE_MAX }}
>
<div className='absolute top-0 left-0 text-center w-full'>{label}</div>
<div
aria-hidden='true'
style={{
WebkitTextStrokeWidth: 2,
WebkitTextStrokeColor: colors.bgDefault
}}
> >
{label} {label}
</div> </div>
</div>
</> </>
); );
} }

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useLayoutEffect } from 'react'; import { useLayoutEffect } from 'react';
import { Edge, ReactFlow, useEdgesState, useNodesState, useReactFlow } from 'reactflow'; import { Edge, ReactFlow, useEdgesState, useNodesState } from 'reactflow';
import { TMGraph } from '@/models/TMGraph'; import { TMGraph } from '@/models/TMGraph';
@ -9,6 +9,9 @@ import { TMGraphEdgeTypes } from './graph/MGraphEdgeTypes';
import { applyLayout } from './graph/MGraphLayout'; import { applyLayout } from './graph/MGraphLayout';
import { TMGraphNodeTypes } from './graph/MGraphNodeTypes'; import { TMGraphNodeTypes } from './graph/MGraphNodeTypes';
const ZOOM_MAX = 2;
const ZOOM_MIN = 0.5;
interface MGraphFlowProps { interface MGraphFlowProps {
data: TMGraph; data: TMGraph;
} }
@ -16,7 +19,6 @@ interface MGraphFlowProps {
function MGraphFlow({ data }: MGraphFlowProps) { function MGraphFlow({ data }: MGraphFlowProps) {
const [nodes, setNodes, onNodesChange] = useNodesState([]); const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges] = useEdgesState([]); const [edges, setEdges] = useEdgesState([]);
const flow = useReactFlow();
useLayoutEffect(() => { useLayoutEffect(() => {
const newNodes = data.nodes.map(node => ({ const newNodes = data.nodes.map(node => ({
@ -52,7 +54,7 @@ function MGraphFlow({ data }: MGraphFlowProps) {
setNodes(newNodes); setNodes(newNodes);
setEdges(newEdges); setEdges(newEdges);
}, [data, setNodes, setEdges, flow]); }, [data, setNodes, setEdges]);
return ( return (
<ReactFlow <ReactFlow
@ -64,8 +66,8 @@ function MGraphFlow({ data }: MGraphFlowProps) {
nodeTypes={TMGraphNodeTypes} nodeTypes={TMGraphNodeTypes}
edgeTypes={TMGraphEdgeTypes} edgeTypes={TMGraphEdgeTypes}
fitView fitView
maxZoom={2} maxZoom={ZOOM_MAX}
minZoom={0.5} minZoom={ZOOM_MIN}
nodesConnectable={false} nodesConnectable={false}
/> />
); );

View File

@ -36,7 +36,12 @@ function MGraphNode(node: MGraphNodeInternal) {
className='w-full h-full cursor-default flex items-center justify-center rounded-full' className='w-full h-full cursor-default flex items-center justify-center rounded-full'
data-tooltip-id={globals.tooltip} data-tooltip-id={globals.tooltip}
data-tooltip-html={tooltipText} data-tooltip-html={tooltipText}
style={{ backgroundColor: colorBgTMGraphNode(node.data, colors) }} style={{
backgroundColor: colorBgTMGraphNode(node.data, colors),
fontWeight: 600,
WebkitTextStrokeWidth: '0.6px',
WebkitTextStrokeColor: colors.bgDefault
}}
> >
{node.data.rank === 0 ? node.data.text : node.data.annotations.length > 0 ? node.data.annotations.length : ''} {node.data.rank === 0 ? node.data.text : node.data.annotations.length > 0 ? node.data.annotations.length : ''}
</div> </div>

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router';
function useQueryStrings() { function useQueryStrings() {
const search = useLocation().search; const search = useLocation().search;

View File

@ -57,11 +57,6 @@ export interface MGraphEdgeInternal extends EdgeProps {
*/ */
export type GraphColoring = 'none' | 'status' | 'type' | 'schemas'; export type GraphColoring = 'none' | 'status' | 'type' | 'schemas';
/**
* Represents graph node sizing scheme.
*/
export type GraphSizing = 'none' | 'complex' | 'derived';
/** /**
* Represents manuals topic. * Represents manuals topic.
*/ */

View File

@ -3,7 +3,7 @@
*/ */
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
import { DependencyMode, GraphSizing, Position2D } from './miscellaneous'; import { DependencyMode, Position2D } from './miscellaneous';
import { IOperationPosition, IOperationSchema, OperationID, OperationType } from './oss'; import { IOperationPosition, IOperationSchema, OperationID, OperationType } from './oss';
import { IConstituenta, IRSForm } from './rsform'; import { IConstituenta, IRSForm } from './rsform';
@ -38,19 +38,6 @@ export function applyGraphFilter(target: IRSForm, start: number, mode: Dependenc
} }
} }
/**
* Apply {@link GraphSizing} to a given {@link IConstituenta}.
*/
export function applyNodeSizing(target: IConstituenta, sizing: GraphSizing): number | undefined {
if (sizing === 'none') {
return undefined;
} else if (sizing === 'complex') {
return target.is_simple_expression ? 1 : 2;
} else {
return target.spawner ? 1 : 2;
}
}
/** /**
* Calculate insert position for a new {@link IOperation} * Calculate insert position for a new {@link IOperation}
*/ */

View File

@ -13,6 +13,7 @@ import {
IconImage, IconImage,
IconNewItem, IconNewItem,
IconOSS, IconOSS,
IconPredecessor,
IconReset, IconReset,
IconRotate3D, IconRotate3D,
IconText, IconText,
@ -32,8 +33,6 @@ function HelpRSGraphTerm() {
<div className='sm:w-[14rem]'> <div className='sm:w-[14rem]'>
<h1>Настройка графа</h1> <h1>Настройка графа</h1>
<li>Цвет покраска узлов</li> <li>Цвет покраска узлов</li>
<li>Граф расположение</li>
<li>Размер размер узлов</li>
<li> <li>
<IconText className='inline-icon' /> Отображение текста <IconText className='inline-icon' /> Отображение текста
</li> </li>
@ -51,7 +50,7 @@ function HelpRSGraphTerm() {
<h1>Изменение узлов</h1> <h1>Изменение узлов</h1>
<li>Клик на конституенту выделение</li> <li>Клик на конституенту выделение</li>
<li> <li>
Ctrl + клик выбор <span style={{ color: colors.fgPurple }}>фокус-конституенты</span> Alt + клик выбор <span style={{ color: colors.fgPurple }}>фокус-конституенты</span>
</li> </li>
<li> <li>
<IconReset className='inline-icon' /> Esc сбросить выделение <IconReset className='inline-icon' /> Esc сбросить выделение
@ -89,9 +88,6 @@ function HelpRSGraphTerm() {
<li> <li>
<IconImage className='inline-icon' /> Сохранить в формат PNG <IconImage className='inline-icon' /> Сохранить в формат PNG
</li> </li>
<li>
* <LinkTopic text='наследованные' topic={HelpTopic.CC_PROPAGATION} /> в ОСС
</li>
</div> </div>
<Divider vertical margins='mx-3' className='hidden sm:block' /> <Divider vertical margins='mx-3' className='hidden sm:block' />
@ -116,6 +112,10 @@ function HelpRSGraphTerm() {
<li> <li>
<IconGraphCore className='inline-icon' /> выделить <LinkTopic text='Ядро' topic={HelpTopic.CC_SYSTEM} /> <IconGraphCore className='inline-icon' /> выделить <LinkTopic text='Ядро' topic={HelpTopic.CC_SYSTEM} />
</li> </li>
<li>
<IconPredecessor className='inline-icon' /> выделить{' '}
<LinkTopic text='собственные' topic={HelpTopic.CC_PROPAGATION} />
</li>
</div> </div>
</div> </div>
</div> </div>

View File

@ -32,6 +32,9 @@ import { OssNodeTypes } from './graph/OssNodeTypes';
import NodeContextMenu, { ContextMenuData } from './NodeContextMenu'; import NodeContextMenu, { ContextMenuData } from './NodeContextMenu';
import ToolbarOssGraph from './ToolbarOssGraph'; import ToolbarOssGraph from './ToolbarOssGraph';
const ZOOM_MAX = 2;
const ZOOM_MIN = 0.5;
interface OssFlowProps { interface OssFlowProps {
isModified: boolean; isModified: boolean;
setIsModified: (newValue: boolean) => void; setIsModified: (newValue: boolean) => void;
@ -223,7 +226,7 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
const imageWidth = PARAMETER.ossImageWidth; const imageWidth = PARAMETER.ossImageWidth;
const imageHeight = PARAMETER.ossImageHeight; const imageHeight = PARAMETER.ossImageHeight;
const nodesBounds = getNodesBounds(nodes); const nodesBounds = getNodesBounds(nodes);
const viewport = getViewportForBounds(nodesBounds, imageWidth, imageHeight, 0.5, 2); const viewport = getViewportForBounds(nodesBounds, imageWidth, imageHeight, ZOOM_MIN, ZOOM_MAX);
toPng(canvas, { toPng(canvas, {
backgroundColor: colors.bgDefault, backgroundColor: colors.bgDefault,
width: imageWidth, width: imageWidth,
@ -266,7 +269,7 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
setMenuProps(undefined); setMenuProps(undefined);
}, [controller]); }, [controller]);
const handleClickCanvas = useCallback(() => { const handleCanvasClick = useCallback(() => {
handleContextMenuHide(); handleContextMenuHide();
}, [handleContextMenuHide]); }, [handleContextMenuHide]);
@ -322,13 +325,13 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
nodesFocusable={false} nodesFocusable={false}
fitView fitView
nodeTypes={OssNodeTypes} nodeTypes={OssNodeTypes}
maxZoom={2} maxZoom={ZOOM_MAX}
minZoom={0.5} minZoom={ZOOM_MIN}
nodesConnectable={false} nodesConnectable={false}
snapToGrid={true} snapToGrid={true}
snapGrid={[PARAMETER.ossGridSize, PARAMETER.ossGridSize]} snapGrid={[PARAMETER.ossGridSize, PARAMETER.ossGridSize]}
onNodeContextMenu={handleContextMenu} onNodeContextMenu={handleContextMenu}
onClick={handleClickCanvas} onClick={handleCanvasClick}
> >
{showGrid ? <Background gap={PARAMETER.ossGridSize} /> : null} {showGrid ? <Background gap={PARAMETER.ossGridSize} /> : null}
</ReactFlow> </ReactFlow>
@ -338,7 +341,7 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
edges, edges,
handleNodesChange, handleNodesChange,
handleContextMenu, handleContextMenu,
handleClickCanvas, handleCanvasClick,
onEdgesChange, onEdgesChange,
handleNodeDoubleClick, handleNodeDoubleClick,
showGrid showGrid

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router';
import { AccessModeState } from '@/context/AccessModeContext'; import { AccessModeState } from '@/context/AccessModeContext';
import { OssState } from '@/context/OssContext'; import { OssState } from '@/context/OssContext';

View File

@ -51,7 +51,7 @@ function ToolbarConstituenta({
return ( return (
<Overlay <Overlay
position='cc-tab-tools right-1/2 translate-x-1/2 xs:right-4 xs:translate-x-0 md:right-1/2 md:translate-x-1/2' position='cc-tab-tools right-1/2 translate-x-1/2 xs:right-4 xs:translate-x-0 md:right-1/2 md:translate-x-1/2'
className='cc-icons outline-none transition-all duration-500' className='cc-icons outline-none transition-all duration-500 cc-blur px-1 rounded-b-2xl'
> >
{controller.schema && controller.schema?.oss.length > 0 ? ( {controller.schema && controller.schema?.oss.length > 0 ? (
<MiniSelectorOSS <MiniSelectorOSS

View File

@ -1,390 +1,18 @@
'use client'; import { ReactFlowProvider } from 'reactflow';
import clsx from 'clsx'; import { ConstituentaID } from '@/models/rsform';
import { AnimatePresence } from 'framer-motion';
import fileDownload from 'js-file-download';
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useDebounce } from 'use-debounce';
import InfoConstituenta from '@/components/info/InfoConstituenta'; import TGFlow from './TGFlow';
import SelectedCounter from '@/components/info/SelectedCounter';
import ToolbarGraphSelection from '@/components/select/ToolbarGraphSelection';
import { GraphCanvasRef, GraphEdge, GraphLayout, GraphNode } from '@/components/ui/GraphUI';
import Overlay from '@/components/ui/Overlay';
import AnimateFade from '@/components/wrap/AnimateFade';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import DlgGraphParams from '@/dialogs/DlgGraphParams';
import useLocalStorage from '@/hooks/useLocalStorage';
import { GraphColoring, GraphFilterParams, GraphSizing } from '@/models/miscellaneous';
import { applyNodeSizing } from '@/models/miscellaneousAPI';
import { ConstituentaID, CstType, IConstituenta } from '@/models/rsform';
import { isBasicConcept } from '@/models/rsformAPI';
import { colorBgGraphNode } from '@/styling/color';
import { PARAMETER, storage } from '@/utils/constants';
import { convertBase64ToBlob } from '@/utils/utils';
import { useRSEdit } from '../RSEditContext';
import GraphSelectors from './GraphSelectors';
import TermGraph from './TermGraph';
import ToolbarFocusedCst from './ToolbarFocusedCst';
import ToolbarTermGraph from './ToolbarTermGraph';
import useGraphFilter from './useGraphFilter';
import ViewHidden from './ViewHidden';
interface EditorTermGraphProps { interface EditorTermGraphProps {
onOpenEdit: (cstID: ConstituentaID) => void; onOpenEdit: (cstID: ConstituentaID) => void;
} }
function EditorTermGraph({ onOpenEdit }: EditorTermGraphProps) { function EditorTermGraph({ onOpenEdit }: EditorTermGraphProps) {
const controller = useRSEdit();
const { colors } = useConceptOptions();
const [filterParams, setFilterParams] = useLocalStorage<GraphFilterParams>(storage.rsgraphFilter, {
noHermits: true,
noTemplates: false,
noTransitive: true,
noText: false,
foldDerived: false,
focusShowInputs: true,
focusShowOutputs: true,
allowBase: true,
allowStruct: true,
allowTerm: true,
allowAxiom: true,
allowFunction: true,
allowPredicate: true,
allowConstant: true,
allowTheorem: true
});
const [showParamsDialog, setShowParamsDialog] = useState(false);
const [focusCst, setFocusCst] = useState<IConstituenta | undefined>(undefined);
const filtered = useGraphFilter(controller.schema, filterParams, focusCst);
const graphRef = useRef<GraphCanvasRef | null>(null);
const [hidden, setHidden] = useState<ConstituentaID[]>([]);
const [layout, setLayout] = useLocalStorage<GraphLayout>(storage.rsgraphLayout, 'treeTd2d');
const [coloring, setColoring] = useLocalStorage<GraphColoring>(storage.rsgraphColoring, 'type');
const [sizing, setSizing] = useLocalStorage<GraphSizing>(storage.rsgraphSizing, 'derived');
const [orbit, setOrbit] = useState(false);
const is3D = useMemo(() => layout.includes('3d'), [layout]);
const [hoverID, setHoverID] = useState<ConstituentaID | undefined>(undefined);
const hoverCst = useMemo(() => {
return hoverID && controller.schema?.cstByID.get(hoverID);
}, [controller.schema?.cstByID, hoverID]);
const [hoverCstDebounced] = useDebounce(hoverCst, PARAMETER.graphPopupDelay);
const [hoverLeft, setHoverLeft] = useState(true);
const [toggleResetView, setToggleResetView] = useState(false);
useLayoutEffect(() => {
if (!controller.schema) {
return;
}
const newDismissed: ConstituentaID[] = [];
controller.schema.items.forEach(cst => {
if (!filtered.nodes.has(cst.id)) {
newDismissed.push(cst.id);
}
});
setHidden(newDismissed);
setHoverID(undefined);
}, [controller.schema, filtered]);
const nodes: GraphNode[] = useMemo(() => {
const result: GraphNode[] = [];
if (!controller.schema) {
return result;
}
filtered.nodes.forEach(node => {
const cst = controller.schema!.cstByID.get(node.id);
if (cst) {
result.push({
id: String(node.id),
fill: focusCst === cst ? colors.bgPurple : colorBgGraphNode(cst, coloring, colors),
label: `${cst.alias}${cst.is_inherited ? '*' : ''}`,
subLabel: !filterParams.noText ? cst.term_resolved : undefined,
size: applyNodeSizing(cst, sizing)
});
}
});
return result;
}, [controller.schema, coloring, sizing, filtered.nodes, filterParams.noText, colors, focusCst]);
const edges: GraphEdge[] = useMemo(() => {
const result: GraphEdge[] = [];
let edgeID = 1;
filtered.nodes.forEach(source => {
source.outputs.forEach(target => {
if (nodes.find(node => node.id === String(target))) {
result.push({
id: String(edgeID),
source: String(source.id),
target: String(target)
});
edgeID += 1;
}
});
});
return result;
}, [filtered.nodes, nodes]);
function handleCreateCst() {
if (!controller.schema) {
return;
}
const definition = controller.selected.map(id => controller.schema!.cstByID.get(id)!.alias).join(' ');
controller.createCst(controller.selected.length === 0 ? CstType.BASE : CstType.TERM, false, definition);
}
function handleDeleteCst() {
if (!controller.schema || !controller.canDeleteSelected) {
return;
}
controller.promptDeleteCst();
}
const handleChangeLayout = useCallback(
(newLayout: GraphLayout) => {
if (newLayout === layout) {
return;
}
setLayout(newLayout);
setTimeout(() => {
setToggleResetView(prev => !prev);
}, PARAMETER.graphRefreshDelay);
},
[layout, setLayout]
);
const handleChangeParams = useCallback(
(params: GraphFilterParams) => {
setFilterParams(params);
},
[setFilterParams]
);
const handleSaveImage = useCallback(() => {
if (!graphRef?.current) {
return;
}
const data = graphRef.current.exportCanvas();
try {
fileDownload(convertBase64ToBlob(data), 'graph.png', 'data:image/png;base64');
} catch (error) {
console.error(error);
}
}, [graphRef]);
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (controller.isProcessing) {
return;
}
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
setFocusCst(undefined);
controller.deselectAll();
return;
}
if (!controller.isContentEditable) {
return;
}
if (event.key === 'Delete') {
event.preventDefault();
event.stopPropagation();
handleDeleteCst();
return;
}
}
const handleFoldDerived = useCallback(() => {
setFilterParams(prev => ({
...prev,
foldDerived: !prev.foldDerived
}));
setTimeout(() => {
setToggleResetView(prev => !prev);
}, PARAMETER.graphRefreshDelay);
}, [setFilterParams, setToggleResetView]);
const handleSetFocus = useCallback(
(cstID: ConstituentaID | undefined) => {
const target = cstID !== undefined ? controller.schema?.cstByID.get(cstID) : cstID;
setFocusCst(prev => (prev === target ? undefined : target));
if (target) {
controller.setSelected([]);
}
},
[controller]
);
const graph = useMemo(
() => (
<TermGraph
graphRef={graphRef}
nodes={nodes}
edges={edges}
selectedIDs={controller.selected}
layout={layout}
is3D={is3D}
orbit={orbit}
onSelect={controller.select}
onDeselect={controller.deselect}
setHoverID={setHoverID}
onEdit={onOpenEdit}
onSelectCentral={handleSetFocus}
toggleResetView={toggleResetView}
setHoverLeft={setHoverLeft}
/>
),
[
graphRef,
edges,
nodes,
controller.selected,
layout,
is3D,
orbit,
setHoverID,
onOpenEdit,
toggleResetView,
controller.select,
controller.deselect,
handleSetFocus
]
);
const selectors = useMemo(
() => (
<GraphSelectors
schema={controller.schema}
coloring={coloring}
layout={layout}
sizing={sizing}
setLayout={handleChangeLayout}
setColoring={setColoring}
setSizing={setSizing}
/>
),
[coloring, controller.schema, layout, sizing, handleChangeLayout, setColoring, setSizing]
);
const viewHidden = useMemo(
() => (
<ViewHidden
items={hidden}
selected={controller.selected}
schema={controller.schema}
coloringScheme={coloring}
toggleSelection={controller.toggleSelect}
setFocus={handleSetFocus}
onEdit={onOpenEdit}
/>
),
[hidden, controller.selected, controller.schema, coloring, controller.toggleSelect, handleSetFocus, onOpenEdit]
);
return ( return (
<> <ReactFlowProvider>
<AnimatePresence> <TGFlow onOpenEdit={onOpenEdit} />
{showParamsDialog ? ( </ReactFlowProvider>
<DlgGraphParams
hideWindow={() => setShowParamsDialog(false)}
initial={filterParams}
onConfirm={handleChangeParams}
/>
) : null}
</AnimatePresence>
<Overlay position='cc-tab-tools' className='flex flex-col items-center rounded-b-2xl cc-blur'>
<ToolbarTermGraph
is3D={is3D}
orbit={orbit}
noText={filterParams.noText}
foldDerived={filterParams.foldDerived}
showParamsDialog={() => setShowParamsDialog(true)}
onCreate={handleCreateCst}
onDelete={handleDeleteCst}
onFitView={() => setToggleResetView(prev => !prev)}
onSaveImage={handleSaveImage}
toggleOrbit={() => setOrbit(prev => !prev)}
toggleFoldDerived={handleFoldDerived}
toggleNoText={() =>
setFilterParams(prev => ({
...prev,
noText: !prev.noText
}))
}
/>
{!focusCst ? (
<ToolbarGraphSelection
graph={controller.schema!.graph}
isCore={cstID => isBasicConcept(controller.schema?.cstByID.get(cstID)?.cst_type)}
isOwned={cstID => !controller.schema?.cstByID.get(cstID)?.is_inherited}
setSelected={controller.setSelected}
emptySelection={controller.selected.length === 0}
/>
) : null}
{focusCst ? (
<ToolbarFocusedCst
center={focusCst}
reset={() => handleSetFocus(undefined)}
showInputs={filterParams.focusShowInputs}
showOutputs={filterParams.focusShowOutputs}
toggleShowInputs={() =>
setFilterParams(prev => ({
...prev,
focusShowInputs: !prev.focusShowInputs
}))
}
toggleShowOutputs={() =>
setFilterParams(prev => ({
...prev,
focusShowOutputs: !prev.focusShowOutputs
}))
}
/>
) : null}
</Overlay>
<AnimateFade tabIndex={-1} onKeyDown={handleKeyDown}>
<SelectedCounter
hideZero
totalCount={controller.schema?.stats?.count_all ?? 0}
selectedCount={controller.selected.length}
position='top-[4.3rem] sm:top-[2rem] left-0'
/>
{hoverCst && hoverCstDebounced && hoverCst === hoverCstDebounced ? (
<Overlay
layer='z-tooltip'
position={clsx('top-[3.5rem]', { 'left-[2.6rem]': hoverLeft, 'right-[2.6rem]': !hoverLeft })}
className={clsx(
'w-[25rem] max-h-[calc(100dvh-15rem)]',
'px-3',
'cc-scroll-y',
'border shadow-md',
'clr-input',
'text-sm'
)}
>
<InfoConstituenta className='pt-1 pb-2' data={hoverCstDebounced} />
</Overlay>
) : null}
<Overlay position='top-[8.15rem] sm:top-[5.9rem] left-0' className='flex gap-1'>
<div className='flex flex-col ml-2 w-[13.5rem]'>
{selectors}
{viewHidden}
</div>
</Overlay>
{graph}
</AnimateFade>
</>
); );
} }

View File

@ -1,57 +1,34 @@
import BadgeHelp from '@/components/info/BadgeHelp'; import BadgeHelp from '@/components/info/BadgeHelp';
import { GraphLayout } from '@/components/ui/GraphUI';
import Overlay from '@/components/ui/Overlay'; import Overlay from '@/components/ui/Overlay';
import SelectSingle from '@/components/ui/SelectSingle'; import SelectSingle from '@/components/ui/SelectSingle';
import { GraphColoring, GraphSizing, HelpTopic } from '@/models/miscellaneous'; import { GraphColoring, HelpTopic } from '@/models/miscellaneous';
import { IRSForm } from '@/models/rsform'; import { IRSForm } from '@/models/rsform';
import { mapLabelColoring, mapLabelLayout, mapLabelSizing } from '@/utils/labels'; import { mapLabelColoring } from '@/utils/labels';
import { SelectorGraphColoring, SelectorGraphLayout, SelectorGraphSizing } from '@/utils/selectors'; import { SelectorGraphColoring } from '@/utils/selectors';
import SchemasGuide from './SchemasGuide'; import SchemasGuide from './SchemasGuide';
interface GraphSelectorsProps { interface GraphSelectorsProps {
schema?: IRSForm; schema?: IRSForm;
coloring: GraphColoring; coloring: GraphColoring;
layout: GraphLayout; onChangeColoring: (newValue: GraphColoring) => void;
sizing: GraphSizing;
setLayout: (newValue: GraphLayout) => void;
setColoring: (newValue: GraphColoring) => void;
setSizing: (newValue: GraphSizing) => void;
} }
function GraphSelectors({ schema, coloring, setColoring, layout, setLayout, sizing, setSizing }: GraphSelectorsProps) { function GraphSelectors({ schema, coloring, onChangeColoring }: GraphSelectorsProps) {
return ( return (
<div className='border rounded-b-none select-none clr-input rounded-t-md'> <div className='border rounded-b-none select-none clr-input rounded-t-md'>
<SelectSingle <Overlay position='right-[2.5rem] top-[0.25rem]'>
noBorder
placeholder='Способ расположения'
options={SelectorGraphLayout}
isSearchable={false}
value={layout ? { value: layout, label: mapLabelLayout.get(layout) } : null}
onChange={data => setLayout(data?.value ?? SelectorGraphLayout[0].value)}
/>
<Overlay position='right-[2.5rem] top-[0.5rem]'>
{coloring === 'status' ? <BadgeHelp topic={HelpTopic.UI_CST_STATUS} className='min-w-[25rem]' /> : null} {coloring === 'status' ? <BadgeHelp topic={HelpTopic.UI_CST_STATUS} className='min-w-[25rem]' /> : null}
{coloring === 'type' ? <BadgeHelp topic={HelpTopic.UI_CST_CLASS} className='min-w-[25rem]' /> : null} {coloring === 'type' ? <BadgeHelp topic={HelpTopic.UI_CST_CLASS} className='min-w-[25rem]' /> : null}
{coloring === 'schemas' && !!schema ? <SchemasGuide schema={schema} /> : null} {coloring === 'schemas' && !!schema ? <SchemasGuide schema={schema} /> : null}
</Overlay> </Overlay>
<SelectSingle <SelectSingle
className='my-1'
noBorder noBorder
placeholder='Цветовая схема' placeholder='Цветовая схема'
options={SelectorGraphColoring} options={SelectorGraphColoring}
isSearchable={false} isSearchable={false}
value={coloring ? { value: coloring, label: mapLabelColoring.get(coloring) } : null} value={coloring ? { value: coloring, label: mapLabelColoring.get(coloring) } : null}
onChange={data => setColoring(data?.value ?? SelectorGraphColoring[0].value)} onChange={data => onChangeColoring(data?.value ?? SelectorGraphColoring[0].value)}
/>
<SelectSingle
noBorder
placeholder='Размер узлов'
options={SelectorGraphSizing}
isSearchable={false}
value={layout ? { value: sizing, label: mapLabelSizing.get(sizing) } : null}
onChange={data => setSizing(data?.value ?? SelectorGraphSizing[0].value)}
/> />
</div> </div>
); );

View File

@ -0,0 +1,479 @@
'use client';
import clsx from 'clsx';
import { AnimatePresence } from 'framer-motion';
import { toPng } from 'html-to-image';
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import {
Edge,
getNodesBounds,
getViewportForBounds,
MarkerType,
Node,
ReactFlow,
useEdgesState,
useNodesState,
useOnSelectionChange,
useReactFlow
} from 'reactflow';
import { useStoreApi } from 'reactflow';
import { useDebounce } from 'use-debounce';
import InfoConstituenta from '@/components/info/InfoConstituenta';
import SelectedCounter from '@/components/info/SelectedCounter';
import { CProps } from '@/components/props';
import ToolbarGraphSelection from '@/components/select/ToolbarGraphSelection';
import Overlay from '@/components/ui/Overlay';
import AnimateFade from '@/components/wrap/AnimateFade';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import DlgGraphParams from '@/dialogs/DlgGraphParams';
import useLocalStorage from '@/hooks/useLocalStorage';
import { GraphColoring, GraphFilterParams } from '@/models/miscellaneous';
import { ConstituentaID, CstType, IConstituenta } from '@/models/rsform';
import { isBasicConcept } from '@/models/rsformAPI';
import { colorBgGraphNode } from '@/styling/color';
import { PARAMETER, storage } from '@/utils/constants';
import { errors } from '@/utils/labels';
import { useRSEdit } from '../RSEditContext';
import { TGEdgeTypes } from './graph/TGEdgeTypes';
import { applyLayout } from './graph/TGLayout';
import { TGNodeData } from './graph/TGNode';
import { TGNodeTypes } from './graph/TGNodeTypes';
import GraphSelectors from './GraphSelectors';
import ToolbarFocusedCst from './ToolbarFocusedCst';
import ToolbarTermGraph from './ToolbarTermGraph';
import useGraphFilter from './useGraphFilter';
import ViewHidden from './ViewHidden';
const ZOOM_MAX = 3;
const ZOOM_MIN = 0.25;
interface TGFlowProps {
onOpenEdit: (cstID: ConstituentaID) => void;
}
function TGFlow({ onOpenEdit }: TGFlowProps) {
const { colors, mainHeight } = useConceptOptions();
const controller = useRSEdit();
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges] = useEdgesState([]);
const flow = useReactFlow();
const store = useStoreApi();
const { addSelectedNodes } = store.getState();
const [showParamsDialog, setShowParamsDialog] = useState(false);
const [filterParams, setFilterParams] = useLocalStorage<GraphFilterParams>(storage.rsgraphFilter, {
noHermits: true,
noTemplates: false,
noTransitive: true,
noText: false,
foldDerived: false,
focusShowInputs: true,
focusShowOutputs: true,
allowBase: true,
allowStruct: true,
allowTerm: true,
allowAxiom: true,
allowFunction: true,
allowPredicate: true,
allowConstant: true,
allowTheorem: true
});
const [coloring, setColoring] = useLocalStorage<GraphColoring>(storage.rsgraphColoring, 'type');
const [focusCst, setFocusCst] = useState<IConstituenta | undefined>(undefined);
const filteredGraph = useGraphFilter(controller.schema, filterParams, focusCst);
const [hidden, setHidden] = useState<ConstituentaID[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [hoverID, setHoverID] = useState<ConstituentaID | undefined>(undefined);
const hoverCst = useMemo(() => {
return hoverID && controller.schema?.cstByID.get(hoverID);
}, [controller.schema?.cstByID, hoverID]);
const [hoverCstDebounced] = useDebounce(hoverCst, PARAMETER.graphPopupDelay);
const [hoverLeft, setHoverLeft] = useState(true);
const [toggleResetView, setToggleResetView] = useState(false);
const onSelectionChange = useCallback(
({ nodes }: { nodes: Node[] }) => {
const ids = nodes.map(node => Number(node.id));
if (ids.length === 0) {
controller.setSelected([]);
} else {
controller.setSelected(prev => [...prev.filter(nodeID => !filteredGraph.hasNode(nodeID)), ...ids]);
}
},
[controller, filteredGraph]
);
useOnSelectionChange({
onChange: onSelectionChange
});
useLayoutEffect(() => {
if (!controller.schema) {
return;
}
const newDismissed: ConstituentaID[] = [];
controller.schema.items.forEach(cst => {
if (!filteredGraph.nodes.has(cst.id)) {
newDismissed.push(cst.id);
}
});
setHidden(newDismissed);
setHoverID(undefined);
}, [controller.schema, filteredGraph]);
useLayoutEffect(() => {
if (!controller.schema) {
return;
}
const newNodes: Node<TGNodeData>[] = [];
filteredGraph.nodes.forEach(node => {
const cst = controller.schema!.cstByID.get(node.id);
if (cst) {
newNodes.push({
id: String(node.id),
type: 'concept',
selected: controller.selected.includes(node.id),
position: { x: 0, y: 0 },
data: {
fill: focusCst === cst ? colors.bgPurple : colorBgGraphNode(cst, coloring, colors),
label: cst.alias,
description: !filterParams.noText ? cst.term_resolved : ''
}
});
}
});
const newEdges: Edge[] = [];
let edgeID = 1;
filteredGraph.nodes.forEach(source => {
source.outputs.forEach(target => {
if (newNodes.find(node => node.id === String(target))) {
newEdges.push({
id: String(edgeID),
source: String(source.id),
target: String(target),
type: 'termEdge',
focusable: false,
markerEnd: {
type: MarkerType.ArrowClosed,
width: 20,
height: 20
}
});
edgeID += 1;
}
});
});
applyLayout(newNodes, newEdges, !filterParams.noText);
setNodes(newNodes);
setEdges(newEdges);
// NOTE: Do not rerender on controller.selected change because it is only needed during first load
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filteredGraph, setNodes, setEdges, controller.schema, filterParams.noText, focusCst, coloring, colors, flow]);
useLayoutEffect(() => {
setTimeout(() => {
flow.fitView({ duration: PARAMETER.zoomDuration });
}, PARAMETER.minimalTimeout);
}, [toggleResetView, flow, focusCst, filterParams]);
function handleSetSelected(newSelection: number[]) {
controller.setSelected(newSelection);
addSelectedNodes(newSelection.map(id => String(id)));
}
function handleCreateCst() {
if (!controller.schema) {
return;
}
const definition = controller.selected.map(id => controller.schema!.cstByID.get(id)!.alias).join(' ');
controller.createCst(controller.selected.length === 0 ? CstType.BASE : CstType.TERM, false, definition);
}
function handleDeleteCst() {
if (!controller.schema || !controller.canDeleteSelected) {
return;
}
controller.promptDeleteCst();
}
const handleChangeParams = useCallback(
(params: GraphFilterParams) => {
setFilterParams(params);
},
[setFilterParams]
);
const handleSaveImage = useCallback(() => {
if (!controller.schema) {
return;
}
const canvas: HTMLElement | null = document.querySelector('.react-flow__viewport');
if (canvas === null) {
toast.error(errors.imageFailed);
return;
}
const imageWidth = PARAMETER.ossImageWidth;
const imageHeight = PARAMETER.ossImageHeight;
const nodesBounds = getNodesBounds(nodes);
const viewport = getViewportForBounds(nodesBounds, imageWidth, imageHeight, ZOOM_MIN, ZOOM_MAX);
toPng(canvas, {
backgroundColor: colors.bgDefault,
width: imageWidth,
height: imageHeight,
style: {
width: String(imageWidth),
height: String(imageHeight),
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom * 2})`
}
})
.then(dataURL => {
const a = document.createElement('a');
a.setAttribute('download', `${controller.schema?.alias ?? 'graph'}.png`);
a.setAttribute('href', dataURL);
a.click();
})
.catch(error => {
console.error(error);
toast.error(errors.imageFailed);
});
}, [colors, nodes, controller.schema]);
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (controller.isProcessing) {
return;
}
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
setFocusCst(undefined);
handleSetSelected([]);
return;
}
if (!controller.isContentEditable) {
return;
}
if (event.key === 'Delete') {
event.preventDefault();
event.stopPropagation();
handleDeleteCst();
return;
}
}
const handleFoldDerived = useCallback(() => {
setFilterParams(prev => ({
...prev,
foldDerived: !prev.foldDerived
}));
setTimeout(() => {
setToggleResetView(prev => !prev);
}, PARAMETER.graphRefreshDelay);
}, [setFilterParams, setToggleResetView]);
const handleSetFocus = useCallback(
(cstID: ConstituentaID | undefined) => {
const target = cstID !== undefined ? controller.schema?.cstByID.get(cstID) : cstID;
setFocusCst(prev => (prev === target ? undefined : target));
if (target) {
controller.setSelected([]);
}
},
[controller]
);
const handleNodeClick = useCallback(
(event: CProps.EventMouse, cstID: ConstituentaID) => {
if (event.altKey) {
event.preventDefault();
event.stopPropagation();
handleSetFocus(cstID);
}
},
[handleSetFocus]
);
const handleNodeDoubleClick = useCallback(
(event: CProps.EventMouse, cstID: ConstituentaID) => {
event.preventDefault();
event.stopPropagation();
onOpenEdit(cstID);
},
[onOpenEdit]
);
const handleNodeEnter = useCallback(
(event: CProps.EventMouse, cstID: ConstituentaID) => {
setHoverID(cstID);
setHoverLeft(
event.clientX / window.innerWidth >= PARAMETER.graphHoverXLimit ||
event.clientY / window.innerHeight >= PARAMETER.graphHoverYLimit
);
},
[setHoverID, setHoverLeft]
);
const handleNodeLeave = useCallback(() => {
setHoverID(undefined);
}, [setHoverID]);
const selectors = useMemo(
() => <GraphSelectors schema={controller.schema} coloring={coloring} onChangeColoring={setColoring} />,
[coloring, controller.schema, setColoring]
);
const viewHidden = useMemo(
() => (
<ViewHidden
items={hidden}
selected={controller.selected}
schema={controller.schema}
coloringScheme={coloring}
toggleSelection={controller.toggleSelect}
setFocus={handleSetFocus}
onEdit={onOpenEdit}
/>
),
[hidden, controller.selected, controller.schema, coloring, controller.toggleSelect, handleSetFocus, onOpenEdit]
);
const graph = useMemo(
() => (
<div className='relative outline-none w-[100dvw]' style={{ height: mainHeight }}>
<ReactFlow
nodes={nodes}
onNodesChange={onNodesChange}
edges={edges}
fitView
edgesFocusable={false}
nodesFocusable={false}
nodesConnectable={false}
nodeTypes={TGNodeTypes}
edgeTypes={TGEdgeTypes}
maxZoom={ZOOM_MAX}
minZoom={ZOOM_MIN}
onNodeDragStart={() => setIsDragging(true)}
onNodeDragStop={() => setIsDragging(false)}
onNodeMouseEnter={(event, node) => handleNodeEnter(event, Number(node.id))}
onNodeMouseLeave={handleNodeLeave}
onNodeClick={(event, node) => handleNodeClick(event, Number(node.id))}
onNodeDoubleClick={(event, node) => handleNodeDoubleClick(event, Number(node.id))}
/>
</div>
),
[nodes, edges, mainHeight, handleNodeClick, handleNodeDoubleClick, handleNodeLeave, handleNodeEnter, onNodesChange]
);
return (
<>
<AnimatePresence>
{showParamsDialog ? (
<DlgGraphParams
hideWindow={() => setShowParamsDialog(false)}
initial={filterParams}
onConfirm={handleChangeParams}
/>
) : null}
</AnimatePresence>
<AnimateFade tabIndex={-1} onKeyDown={handleKeyDown}>
<Overlay position='cc-tab-tools' className='flex flex-col items-center rounded-b-2xl cc-blur'>
<ToolbarTermGraph
noText={filterParams.noText}
foldDerived={filterParams.foldDerived}
showParamsDialog={() => setShowParamsDialog(true)}
onCreate={handleCreateCst}
onDelete={handleDeleteCst}
onFitView={() => setToggleResetView(prev => !prev)}
onSaveImage={handleSaveImage}
toggleFoldDerived={handleFoldDerived}
toggleNoText={() =>
setFilterParams(prev => ({
...prev,
noText: !prev.noText
}))
}
/>
{!focusCst ? (
<ToolbarGraphSelection
graph={controller.schema!.graph}
isCore={cstID => isBasicConcept(controller.schema?.cstByID.get(cstID)?.cst_type)}
isOwned={
controller.schema && controller.schema.inheritance.length > 0
? cstID => !controller.schema!.cstByID.get(cstID)?.is_inherited
: undefined
}
selected={controller.selected}
setSelected={handleSetSelected}
emptySelection={controller.selected.length === 0}
/>
) : null}
{focusCst ? (
<ToolbarFocusedCst
center={focusCst}
reset={() => handleSetFocus(undefined)}
showInputs={filterParams.focusShowInputs}
showOutputs={filterParams.focusShowOutputs}
toggleShowInputs={() =>
setFilterParams(prev => ({
...prev,
focusShowInputs: !prev.focusShowInputs
}))
}
toggleShowOutputs={() =>
setFilterParams(prev => ({
...prev,
focusShowOutputs: !prev.focusShowOutputs
}))
}
/>
) : null}
</Overlay>
<SelectedCounter
hideZero
totalCount={controller.schema?.stats?.count_all ?? 0}
selectedCount={controller.selected.length}
position='top-[4.4rem] sm:top-[4.1rem] left-[0.5rem] sm:left-[0.65rem]'
/>
{!isDragging && hoverCst && hoverCstDebounced && hoverCst === hoverCstDebounced ? (
<Overlay
layer='z-tooltip'
position={clsx('top-[3.5rem]', { 'left-[2.6rem]': hoverLeft, 'right-[2.6rem]': !hoverLeft })}
className={clsx(
'w-[25rem] max-h-[calc(100dvh-15rem)]',
'px-3',
'cc-scroll-y',
'border shadow-md',
'clr-input',
'text-sm'
)}
>
<InfoConstituenta className='pt-1 pb-2' data={hoverCstDebounced} />
</Overlay>
) : null}
<Overlay position='top-[6.15rem] sm:top-[5.9rem] left-0' className='flex gap-1'>
<div className='flex flex-col ml-2 w-[13.5rem]'>
{selectors}
{viewHidden}
</div>
</Overlay>
{graph}
</AnimateFade>
</>
);
}
export default TGFlow;

View File

@ -1,137 +0,0 @@
'use client';
import { RefObject, useCallback, useLayoutEffect } from 'react';
import GraphUI, {
CollapseProps,
GraphCanvasRef,
GraphEdge,
GraphLayout,
GraphMouseEvent,
GraphNode,
GraphPointerEvent,
useSelection
} from '@/components/ui/GraphUI';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { ConstituentaID } from '@/models/rsform';
import { graphDarkT, graphLightT } from '@/styling/color';
import { PARAMETER, resources } from '@/utils/constants';
interface TermGraphProps {
graphRef: RefObject<GraphCanvasRef>;
nodes: GraphNode[];
edges: GraphEdge[];
selectedIDs: ConstituentaID[];
layout: GraphLayout;
is3D: boolean;
orbit: boolean;
setHoverID: (newID: ConstituentaID | undefined) => void;
setHoverLeft: (value: boolean) => void;
onEdit: (cstID: ConstituentaID) => void;
onSelectCentral: (selectedID: ConstituentaID) => void;
onSelect: (newID: ConstituentaID) => void;
onDeselect: (newID: ConstituentaID) => void;
toggleResetView: boolean;
}
function TermGraph({
graphRef,
nodes,
edges,
selectedIDs,
layout,
is3D,
orbit,
toggleResetView,
setHoverID,
setHoverLeft,
onEdit,
onSelectCentral,
onSelect,
onDeselect
}: TermGraphProps) {
const { mainHeight, darkMode } = useConceptOptions();
const { selections, setSelections } = useSelection({
ref: graphRef,
nodes: nodes,
edges: edges,
type: 'multi',
focusOnSelect: false
});
const handleHoverIn = useCallback(
(node: GraphNode, event: GraphPointerEvent) => {
setHoverID(Number(node.id));
setHoverLeft(
event.clientX / window.innerWidth >= PARAMETER.graphHoverXLimit ||
event.clientY / window.innerHeight >= PARAMETER.graphHoverYLimit
);
},
[setHoverID, setHoverLeft]
);
const handleHoverOut = useCallback(() => {
setHoverID(undefined);
}, [setHoverID]);
const handleNodeClick = useCallback(
(node: GraphNode, _?: CollapseProps, event?: GraphMouseEvent) => {
if (event?.ctrlKey || event?.metaKey) {
onSelectCentral(Number(node.id));
} else if (selections.includes(node.id)) {
onDeselect(Number(node.id));
} else {
onSelect(Number(node.id));
}
},
[onSelect, selections, onDeselect, onSelectCentral]
);
const handleNodeDoubleClick = useCallback(
(node: GraphNode) => {
onEdit(Number(node.id));
},
[onEdit]
);
useLayoutEffect(() => {
graphRef.current?.fitNodesInView([], { animated: true });
}, [toggleResetView, graphRef]);
useLayoutEffect(() => {
const newSelections = nodes.filter(node => selectedIDs.includes(Number(node.id))).map(node => node.id);
setSelections(newSelections);
}, [selectedIDs, setSelections, nodes]);
return (
<div className='relative outline-none w-[100dvw]' style={{ height: mainHeight }}>
<GraphUI
nodes={nodes}
edges={edges}
ref={graphRef}
animated={false}
draggable
layoutType={layout}
selections={selections}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeClick={handleNodeClick}
onNodePointerOver={handleHoverIn}
onNodePointerOut={handleHoverOut}
minNodeSize={4}
maxNodeSize={8}
cameraMode={orbit ? 'orbit' : is3D ? 'rotate' : 'pan'}
layoutOverrides={
layout.includes('tree') ? { nodeLevelRatio: nodes.length < PARAMETER.smallTreeNodes ? 3 : 1 } : undefined
}
labelFontUrl={resources.graph_font}
theme={darkMode ? graphDarkT : graphLightT}
/>
</div>
);
}
export default TermGraph;

View File

@ -8,7 +8,6 @@ import {
IconFitImage, IconFitImage,
IconImage, IconImage,
IconNewItem, IconNewItem,
IconRotate3D,
IconText, IconText,
IconTextOff, IconTextOff,
IconTypeGraph IconTypeGraph
@ -22,9 +21,6 @@ import { PARAMETER } from '@/utils/constants';
import { useRSEdit } from '../RSEditContext'; import { useRSEdit } from '../RSEditContext';
interface ToolbarTermGraphProps { interface ToolbarTermGraphProps {
is3D: boolean;
orbit: boolean;
noText: boolean; noText: boolean;
foldDerived: boolean; foldDerived: boolean;
@ -36,17 +32,13 @@ interface ToolbarTermGraphProps {
toggleFoldDerived: () => void; toggleFoldDerived: () => void;
toggleNoText: () => void; toggleNoText: () => void;
toggleOrbit: () => void;
} }
function ToolbarTermGraph({ function ToolbarTermGraph({
is3D,
noText, noText,
foldDerived, foldDerived,
toggleNoText, toggleNoText,
toggleFoldDerived, toggleFoldDerived,
orbit,
toggleOrbit,
showParamsDialog, showParamsDialog,
onCreate, onCreate,
onDelete, onDelete,
@ -95,12 +87,6 @@ function ToolbarTermGraph({
} }
onClick={toggleFoldDerived} onClick={toggleFoldDerived}
/> />
<MiniButton
icon={<IconRotate3D size='1.25rem' className={orbit ? 'icon-green' : 'icon-primary'} />}
title='Анимация вращения'
disabled={!is3D}
onClick={toggleOrbit}
/>
{controller.isContentEditable ? ( {controller.isContentEditable ? (
<MiniButton <MiniButton
title='Новая конституента' title='Новая конституента'

View File

@ -76,7 +76,9 @@ function ViewHidden({ items, selected, toggleSelection, setFocus, schema, colori
animate={!isFolded ? 'open' : 'closed'} animate={!isFolded ? 'open' : 'closed'}
variants={animateHiddenHeader} variants={animateHiddenHeader}
initial={false} initial={false}
>{`Скрытые [${localSelected.length} | ${items.length}]`}</motion.div> >
{`Скрытые [${localSelected.length} | ${items.length}]`}
</motion.div>
</div> </div>
<motion.div <motion.div
@ -87,7 +89,7 @@ function ViewHidden({ items, selected, toggleSelection, setFocus, schema, colori
'text-sm', 'text-sm',
'cc-scroll-y' 'cc-scroll-y'
)} )}
style={{ maxHeight: calculateHeight(windowSize.isSmall ? '12.rem + 2px' : '16.4rem + 2px') }} style={{ maxHeight: calculateHeight(windowSize.isSmall ? '10.4rem + 2px' : '12.5rem + 2px') }}
initial={false} initial={false}
animate={!isFolded ? 'open' : 'closed'} animate={!isFolded ? 'open' : 'closed'}
variants={animateDropdown} variants={animateDropdown}
@ -117,7 +119,6 @@ function ViewHidden({ items, selected, toggleSelection, setFocus, schema, colori
onDoubleClick={() => onEdit(cstID)} onDoubleClick={() => onEdit(cstID)}
> >
{cst.alias} {cst.alias}
{cst.is_inherited ? '*' : ''}
</button> </button>
<TooltipConstituenta data={cst} anchor={`#${id}`} /> <TooltipConstituenta data={cst} anchor={`#${id}`} />
</div> </div>

View File

@ -0,0 +1,7 @@
import { EdgeTypes } from 'reactflow';
import DynamicEdge from '../../../../components/ui/Flow/DynamicEdge';
export const TGEdgeTypes: EdgeTypes = {
termEdge: DynamicEdge
};

View File

@ -0,0 +1,32 @@
import dagre from '@dagrejs/dagre';
import { Edge, Node } from 'reactflow';
import { PARAMETER } from '@/utils/constants';
import { TGNodeData } from './TGNode';
export function applyLayout(nodes: Node<TGNodeData>[], edges: Edge[], subLabels?: boolean) {
const dagreGraph = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
dagreGraph.setGraph({
rankdir: 'TB',
ranksep: subLabels ? 60 : 40,
nodesep: subLabels ? 100 : 20,
ranker: 'network-simplex',
align: undefined
});
nodes.forEach(node => {
dagreGraph.setNode(node.id, { width: 2 * PARAMETER.graphNodeRadius, height: 2 * PARAMETER.graphNodeRadius });
});
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 - PARAMETER.graphNodeRadius;
node.position.y = nodeWithPosition.y - PARAMETER.graphNodeRadius;
});
}

View File

@ -0,0 +1,86 @@
'use client';
import { useMemo } from 'react';
import { Handle, Position } from 'reactflow';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { truncateToLastWord } from '@/utils/utils';
const MAX_DESCRIPTION_LENGTH = 65;
const DESCRIPTION_THRESHOLD = 15;
const LABEL_THRESHOLD = 3;
const FONT_SIZE_MAX = 14;
const FONT_SIZE_MED = 12;
const FONT_SIZE_MIN = 10;
export interface TGNodeData {
fill: string;
label: string;
description: string;
}
/**
* Represents graph AST node internal data.
*/
interface TGNodeInternal {
id: string;
data: TGNodeData;
selected: boolean;
dragging: boolean;
xPos: number;
yPos: number;
}
function TGNode(node: TGNodeInternal) {
const { colors } = useConceptOptions();
const description = useMemo(
() => truncateToLastWord(node.data.description, MAX_DESCRIPTION_LENGTH),
[node.data.description]
);
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: !node.selected ? node.data.fill : colors.bgActiveSelection,
fontSize: node.data.label.length > LABEL_THRESHOLD ? FONT_SIZE_MED : FONT_SIZE_MAX
}}
>
<div
style={{
fontWeight: 600,
WebkitTextStrokeWidth: '0.6px',
WebkitTextStrokeColor: colors.bgDefault
}}
>
{node.data.label}
</div>
</div>
<Handle type='source' position={Position.Bottom} style={{ opacity: 0 }} />
{description ? (
<div
className='mt-1 w-[150px] px-1 text-center translate-x-[calc(-50%+20px)]'
style={{
fontSize: description.length > DESCRIPTION_THRESHOLD ? FONT_SIZE_MIN : FONT_SIZE_MED
}}
>
<div className='absolute top-0 px-1 left-0 text-center w-full'>{description}</div>
<div
aria-hidden='true'
style={{
WebkitTextStrokeWidth: '3px',
WebkitTextStrokeColor: colors.bgDefault
}}
>
{description}
</div>
</div>
) : null}
</>
);
}
export default TGNode;

View File

@ -0,0 +1,7 @@
import { NodeTypes } from 'reactflow';
import TGNode from './TGNode';
export const TGNodeTypes: NodeTypes = {
concept: TGNode
};

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router';
import { AccessModeState } from '@/context/AccessModeContext'; import { AccessModeState } from '@/context/AccessModeContext';
import { RSFormState } from '@/context/RSFormContext'; import { RSFormState } from '@/context/RSFormContext';

View File

@ -20,6 +20,7 @@ export interface IColorTheme {
bgDisabled: string; bgDisabled: string;
bgPrimary: string; bgPrimary: string;
bgSelected: string; bgSelected: string;
bgActiveSelection: string;
bgHover: string; bgHover: string;
bgWarning: string; bgWarning: string;
@ -62,6 +63,7 @@ export const lightT: IColorTheme = {
bgDisabled: 'var(--cl-bg-60)', bgDisabled: 'var(--cl-bg-60)',
bgPrimary: 'var(--cl-prim-bg-100)', bgPrimary: 'var(--cl-prim-bg-100)',
bgSelected: 'var(--cl-prim-bg-80)', bgSelected: 'var(--cl-prim-bg-80)',
bgActiveSelection: 'var(--cl-teal-bg-100)',
bgHover: 'var(--cl-prim-bg-60)', bgHover: 'var(--cl-prim-bg-60)',
bgWarning: 'var(--cl-red-bg-100)', bgWarning: 'var(--cl-red-bg-100)',
@ -104,6 +106,7 @@ export const darkT: IColorTheme = {
bgDisabled: 'var(--cd-bg-60)', bgDisabled: 'var(--cd-bg-60)',
bgPrimary: 'var(--cd-prim-bg-100)', bgPrimary: 'var(--cd-prim-bg-100)',
bgSelected: 'var(--cd-prim-bg-80)', bgSelected: 'var(--cd-prim-bg-80)',
bgActiveSelection: 'var(--cd-teal-bg-100)',
bgHover: 'var(--cd-prim-bg-60)', bgHover: 'var(--cd-prim-bg-60)',
bgWarning: 'var(--cd-red-bg-100)', bgWarning: 'var(--cd-red-bg-100)',
@ -184,96 +187,6 @@ export const selectDarkT = {
neutral90: darkT.fgWarning neutral90: darkT.fgWarning
}; };
/**
* Represents Graph component Light theme.
*/
export const graphLightT = {
canvas: {
background: '#f9fafb'
},
node: {
fill: '#7ca0ab',
activeFill: '#1DE9AC',
opacity: 1,
selectedOpacity: 1,
inactiveOpacity: 1,
label: {
color: '#2A6475',
stroke: '#fff',
activeColor: '#1DE9AC'
}
},
lasso: {
border: '1px solid #55aaff',
background: 'rgba(75, 160, 255, 0.1)'
},
ring: {
fill: '#D8E6EA',
activeFill: '#1DE9AC'
},
edge: {
fill: '#D8E6EA',
activeFill: '#1DE9AC',
opacity: 1,
selectedOpacity: 1,
inactiveOpacity: 1,
label: {
stroke: '#fff',
color: '#2A6475',
activeColor: '#1DE9AC'
}
},
arrow: {
fill: '#D8E6EA',
activeFill: '#1DE9AC'
}
};
/**
* Represents Graph component Dark theme.
*/
export const graphDarkT = {
canvas: {
background: '#171717' // var(--cd-bg-100)
},
node: {
fill: '#7a8c9e',
activeFill: '#1DE9AC',
opacity: 1,
selectedOpacity: 1,
inactiveOpacity: 1,
label: {
stroke: '#1E2026',
color: '#ACBAC7',
activeColor: '#1DE9AC'
}
},
lasso: {
border: '1px solid #55aaff',
background: 'rgba(75, 160, 255, 0.1)'
},
ring: {
fill: '#54616D',
activeFill: '#1DE9AC'
},
edge: {
fill: '#474B56',
activeFill: '#1DE9AC',
opacity: 1,
selectedOpacity: 1,
inactiveOpacity: 1,
label: {
stroke: '#1E2026',
color: '#ACBAC7',
activeColor: '#1DE9AC'
}
},
arrow: {
fill: '#474B56',
activeFill: '#1DE9AC'
}
};
/** /**
* Represents Brackets highlights Light theme. * Represents Brackets highlights Light theme.
*/ */

View File

@ -38,6 +38,8 @@
--cl-red-fg-100: hsl(000, 072%, 051%); --cl-red-fg-100: hsl(000, 072%, 051%);
--cl-green-fg-100: hsl(120, 080%, 37%); --cl-green-fg-100: hsl(120, 080%, 37%);
--cl-teal-bg-100: hsl(162, 082%, 051%);
/* Dark Theme */ /* Dark Theme */
--cd-bg-120: hsl(000, 000%, 005%); --cd-bg-120: hsl(000, 000%, 005%);
--cd-bg-100: hsl(000, 000%, 009%); --cd-bg-100: hsl(000, 000%, 009%);
@ -59,4 +61,6 @@
--cd-red-bg-100: hsl(000, 100%, 015%); --cd-red-bg-100: hsl(000, 100%, 015%);
--cd-red-fg-100: hsl(000, 080%, 055%); --cd-red-fg-100: hsl(000, 080%, 055%);
--cd-green-fg-100: hsl(120, 080%, 042%); --cd-green-fg-100: hsl(120, 080%, 042%);
--cd-teal-bg-100: hsl(162, 082%, 041%);
} }

View File

@ -97,10 +97,6 @@
box-shadow: 0 0 0 2px var(--cl-prim-bg-80) !important; box-shadow: 0 0 0 2px var(--cl-prim-bg-80) !important;
} }
&.selected {
border-color: var(--cd-bg-40);
}
.dark & { .dark & {
color: var(--cd-fg-100); color: var(--cd-fg-100);
border-color: var(--cd-bg-40); border-color: var(--cd-bg-40);
@ -109,10 +105,6 @@
&:hover:not(.selected) { &:hover:not(.selected) {
box-shadow: 0 0 0 3px var(--cd-prim-bg-80) !important; box-shadow: 0 0 0 3px var(--cd-prim-bg-80) !important;
} }
&.selected {
border-color: var(--cl-bg-40);
}
} }
} }
@ -124,13 +116,39 @@
padding: 2px; padding: 2px;
width: 150px; width: 150px;
height: 40px; height: 40px;
&.selected {
border-color: var(--cd-bg-40);
}
.dark & {
&.selected {
border-color: var(--cl-bg-40);
}
}
} }
.react-flow__node-step, .react-flow__node-step,
.react-flow__node-token { .react-flow__node-token,
.react-flow__node-concept {
cursor: default; cursor: default;
border-radius: 100%; border-radius: 100%;
width: 40px; width: 40px;
height: 40px; height: 40px;
outline-offset: 4px;
outline-style: solid;
outline-color: transparent;
&.selected {
outline-color: var(--cl-teal-bg-100);
border-color: transparent;
}
.dark & {
&.selected {
border-color: var(--cd-teal-bg-100);
}
}
} }

View File

@ -206,7 +206,7 @@
} }
.cc-tab-tools { .cc-tab-tools {
@apply top-[1.9rem] pt-1 right-1/2 translate-x-1/2; @apply top-[1.7rem] pt-[0.4rem] pb-1 right-1/2 translate-x-1/2;
} }
.cc-label { .cc-label {

View File

@ -10,8 +10,8 @@ export const PARAMETER = {
smallTreeNodes: 50, // amount of nodes threshold for size increase for large graphs smallTreeNodes: 50, // amount of nodes threshold for size increase for large graphs
refreshTimeout: 100, // milliseconds delay for post-refresh actions refreshTimeout: 100, // milliseconds delay for post-refresh actions
minimalTimeout: 10, // milliseconds delay for fast updates minimalTimeout: 10, // milliseconds delay for fast updates
zoomDuration: 500, // milliseconds animation duration zoomDuration: 500, // milliseconds animation duration
ossImageWidth: 1280, // pixels - size of OSS image ossImageWidth: 1280, // pixels - size of OSS image
ossImageHeight: 960, // pixels - size of OSS image ossImageHeight: 960, // pixels - size of OSS image
ossContextMenuWidth: 200, // pixels - width of OSS context menu ossContextMenuWidth: 200, // pixels - width of OSS context menu
@ -21,16 +21,17 @@ export const PARAMETER = {
ossDistanceX: 180, // pixels - insert x-distance between node centers ossDistanceX: 180, // pixels - insert x-distance between node centers
ossDistanceY: 100, // pixels - insert y-distance between node centers ossDistanceY: 100, // pixels - insert y-distance between node centers
graphHandleSize: 3, // pixels - size of graph connection handle
graphNodeRadius: 20, // pixels - radius of graph node
graphNodePadding: 5, // pixels - padding of graph node
graphHoverXLimit: 0.4, // ratio to clientWidth used to determine which side of screen popup should be graphHoverXLimit: 0.4, // ratio to clientWidth used to determine which side of screen popup should be
graphHoverYLimit: 0.6, // ratio to clientHeight used to determine which side of screen popup should be graphHoverYLimit: 0.6, // ratio to clientHeight used to determine which side of screen popup should be
graphPopupDelay: 500, // milliseconds delay for graph popup selections graphPopupDelay: 500, // milliseconds delay for graph popup selections
graphRefreshDelay: 10, // milliseconds delay for graph viewpoint reset graphRefreshDelay: 10, // milliseconds delay for graph viewpoint reset
typificationTruncate: 42, // characters - threshold for long typification - truncate typificationTruncate: 42, // characters - threshold for long typification - truncate
ossLongLabel: 14, // characters - threshold for long labels - small font ossLongLabel: 14, // characters - threshold for long labels - small font
ossTruncateLabel: 32, // characters - threshold for long labels - truncate ossTruncateLabel: 32, // characters - threshold for long labels - truncate
statSmallThreshold: 3, // characters - threshold for small labels - small font statSmallThreshold: 3, // characters - threshold for small labels - small font
logicLabel: 'LOGIC', logicLabel: 'LOGIC',
@ -64,7 +65,6 @@ export const patterns = {
* Local URIs. * Local URIs.
*/ */
export const resources = { export const resources = {
graph_font: '/DejaVu.ttf',
privacy_policy: '/privacy.pdf', privacy_policy: '/privacy.pdf',
logo: '/logo_full.svg', logo: '/logo_full.svg',
db_schema: '/db_schema.svg' db_schema: '/db_schema.svg'
@ -121,9 +121,7 @@ export const storage = {
libraryPagination: 'library.pagination', libraryPagination: 'library.pagination',
rsgraphFilter: 'rsgraph.filter2', rsgraphFilter: 'rsgraph.filter2',
rsgraphLayout: 'rsgraph.layout',
rsgraphColoring: 'rsgraph.coloring', rsgraphColoring: 'rsgraph.coloring',
rsgraphSizing: 'rsgraph.sizing',
rsgraphFoldHidden: 'rsgraph.fold_hidden', rsgraphFoldHidden: 'rsgraph.fold_hidden',
ossShowGrid: 'oss.show_grid', ossShowGrid: 'oss.show_grid',

View File

@ -4,12 +4,11 @@
* Label is a short text used to represent an entity. * Label is a short text used to represent an entity.
* Description is a long description used in tooltips. * Description is a long description used in tooltips.
*/ */
import { GraphLayout } from '@/components/ui/GraphUI';
import { FolderNode } from '@/models/FolderTree'; import { FolderNode } from '@/models/FolderTree';
import { GramData, Grammeme, ReferenceType } from '@/models/language'; import { GramData, Grammeme, ReferenceType } from '@/models/language';
import { AccessPolicy, LibraryItemType, LocationHead } from '@/models/library'; import { AccessPolicy, LibraryItemType, LocationHead } from '@/models/library';
import { validateLocation } from '@/models/libraryAPI'; import { validateLocation } from '@/models/libraryAPI';
import { CstMatchMode, DependencyMode, GraphColoring, GraphSizing, HelpTopic } from '@/models/miscellaneous'; import { CstMatchMode, DependencyMode, GraphColoring, HelpTopic } from '@/models/miscellaneous';
import { ISubstitutionErrorDescription, OperationType, SubstitutionErrorType } from '@/models/oss'; import { ISubstitutionErrorDescription, OperationType, SubstitutionErrorType } from '@/models/oss';
import { CstClass, CstType, ExpressionStatus, IConstituenta, IRSForm } from '@/models/rsform'; import { CstClass, CstType, ExpressionStatus, IConstituenta, IRSForm } from '@/models/rsform';
import { import {
@ -293,22 +292,6 @@ export function describeLocationHead(head: LocationHead): string {
} }
} }
/**
* Retrieves label for graph layout mode.
*/
export const mapLabelLayout = new Map<GraphLayout, string>([
['treeTd2d', 'Граф: ДеревоВ 2D'],
['treeTd3d', 'Граф: ДеревоВ 3D'],
['forceatlas2', 'Граф: Атлас 2D'],
['forceDirected2d', 'Граф: Силы 2D'],
['forceDirected3d', 'Граф: Силы 3D'],
['treeLr2d', 'Граф: ДеревоГ 2D'],
['treeLr3d', 'Граф: ДеревоГ 3D'],
['radialOut2d', 'Граф: Радиус 2D'],
['radialOut3d', 'Граф: Радиус 3D'],
['circular2d', 'Граф: Круговая']
]);
/** /**
* Retrieves label for {@link GraphColoring}. * Retrieves label for {@link GraphColoring}.
*/ */
@ -319,15 +302,6 @@ export const mapLabelColoring = new Map<GraphColoring, string>([
['schemas', 'Цвет: Схемы'] ['schemas', 'Цвет: Схемы']
]); ]);
/**
* Retrieves label for {@link GraphSizing}.
*/
export const mapLabelSizing = new Map<GraphSizing, string>([
['none', 'Узлы: Моно'],
['derived', 'Узлы: Порожденные'],
['complex', 'Узлы: Простые']
]);
/** /**
* Retrieves label for {@link ExpressionStatus}. * Retrieves label for {@link ExpressionStatus}.
*/ */

View File

@ -2,32 +2,20 @@
* Module: Mappings for selector UI elements. Do not confuse with html selectors * Module: Mappings for selector UI elements. Do not confuse with html selectors
*/ */
import { GraphLayout } from '@/components/ui/GraphUI';
import { type GramData, Grammeme, ReferenceType } from '@/models/language'; import { type GramData, Grammeme, ReferenceType } from '@/models/language';
import { grammemeCompare } from '@/models/languageAPI'; import { grammemeCompare } from '@/models/languageAPI';
import { GraphColoring, GraphSizing } from '@/models/miscellaneous'; import { GraphColoring } from '@/models/miscellaneous';
import { CstType } from '@/models/rsform'; import { CstType } from '@/models/rsform';
import { labelGrammeme, labelReferenceType, mapLabelColoring, mapLabelLayout, mapLabelSizing } from './labels'; import { labelGrammeme, labelReferenceType, mapLabelColoring } from './labels';
import { labelCstType } from './labels'; import { labelCstType } from './labels';
/**
* Represents options for GraphLayout selector.
*/
export const SelectorGraphLayout: { value: GraphLayout; label: string }[] = //
[...mapLabelLayout.entries()].map(item => ({ value: item[0], label: item[1] }));
/** /**
* Represents options for {@link GraphColoring} selector. * Represents options for {@link GraphColoring} selector.
*/ */
export const SelectorGraphColoring: { value: GraphColoring; label: string }[] = // export const SelectorGraphColoring: { value: GraphColoring; label: string }[] = //
[...mapLabelColoring.entries()].map(item => ({ value: item[0], label: item[1] })); [...mapLabelColoring.entries()].map(item => ({ value: item[0], label: item[1] }));
/**
* Represents options for {@link GraphSizing} selector.
*/
export const SelectorGraphSizing: { value: GraphSizing; label: string }[] = //
[...mapLabelSizing.entries()].map(item => ({ value: item[0], label: item[1] }));
/** /**
* Represents options for {@link CstType} selector. * Represents options for {@link CstType} selector.
*/ */

View File

@ -5,7 +5,7 @@ import { defineConfig, loadEnv, PluginOption } from 'vite';
import { dependencies } from './package.json'; import { dependencies } from './package.json';
// Packages to include in main app bundle // Packages to include in main app bundle
const inlinePackages = ['react', 'react-router-dom', 'react-dom']; const inlinePackages = ['react', 'react-router', 'react-dom'];
// Rollup warnings that should not be displayed // Rollup warnings that should not be displayed
const warningsToIgnore = [['SOURCEMAP_ERROR', "Can't resolve original location of error"]]; const warningsToIgnore = [['SOURCEMAP_ERROR', "Can't resolve original location of error"]];
@ -28,7 +28,7 @@ export default ({ mode }: { mode: string }) => {
sourcemap: false, sourcemap: false,
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks: { ...renderChunks(dependencies) } manualChunks: { ...renderChunks(dependencies), manuals: ['./src/pages/ManualsPage'] }
} }
} }
}, },