Compare commits

..

7 Commits

Author SHA1 Message Date
Ivan
2b97e109b4 F: Implementing M-Graph pt3
Some checks failed
Frontend CI / build (22.x) (push) Has been cancelled
2024-11-14 22:09:40 +03:00
Ivan
527785a544 npm update 2024-11-07 23:18:20 +03:00
Ivan
c60b4398ed F: Implementing M-Graph pt2 2024-11-07 23:14:38 +03:00
Ivan
abca112b28 Update LinkTopic.tsx 2024-10-31 15:21:21 +03:00
Ivan
26a74202a1 F: Implementing M-Graph pt1 2024-10-31 15:20:50 +03:00
Ivan
c0eac11539 M: Update emoji 2024-10-31 11:27:32 +03:00
Ivan
12afd5997e M: Decouple statusbar and graph nodes colors 2024-10-31 10:55:45 +03:00
23 changed files with 1129 additions and 351 deletions

File diff suppressed because it is too large Load Diff

View File

@ -19,45 +19,45 @@
"axios": "^1.7.7",
"clsx": "^2.1.1",
"eslint-plugin-react-hooks": "^5.0.0",
"framer-motion": "^11.11.10",
"framer-motion": "^11.11.11",
"html-to-image": "^1.11.11",
"js-file-download": "^0.4.12",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.1.2",
"react-icons": "^5.3.0",
"react-intl": "^6.8.4",
"react-intl": "^6.8.7",
"react-loader-spinner": "^6.1.6",
"react-router-dom": "^6.27.0",
"react-router-dom": "^6.28.0",
"react-select": "^5.8.2",
"react-tabs": "^6.0.2",
"react-toastify": "^10.0.6",
"react-tooltip": "^5.28.0",
"react-zoom-pan-pinch": "^3.6.1",
"reactflow": "^11.11.4",
"reagraph": "^4.19.4",
"reagraph": "^4.19.5",
"use-debounce": "^10.0.4"
},
"devDependencies": {
"@lezer/generator": "^1.7.1",
"@types/jest": "^29.5.14",
"@types/node": "^22.8.1",
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^8.0.1",
"@typescript-eslint/parser": "^8.0.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"eslint": "^9.13.0",
"eslint": "^9.14.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-simple-import-sort": "^12.1.1",
"globals": "^15.11.0",
"globals": "^15.12.0",
"jest": "^29.7.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"ts-jest": "^29.2.5",
"typescript": "^5.6.3",
"typescript-eslint": "^8.11.0",
"typescript-eslint": "^8.13.0",
"vite": "^5.4.10"
},
"jest": {

View File

@ -4,10 +4,15 @@ import { HelpTopic } from '@/models/miscellaneous';
import TextURL from './TextURL';
interface TextURLProps {
/** Text to display. */
text: string;
/** Topic to link to. */
topic: HelpTopic;
}
/**
* Displays a link to a help topic.
*/
function LinkTopic({ text, topic }: TextURLProps) {
return <TextURL text={text} href={urls.help_topic(topic)} />;
}

View File

@ -0,0 +1,47 @@
'use client';
import { useMemo } from 'react';
import { toast } from 'react-toastify';
import { ReactFlowProvider } from 'reactflow';
import Modal, { ModalProps } from '@/components/ui/Modal';
import { IArgumentInfo } from '@/models/rslang';
import { TMGraph } from '@/models/TMGraph';
import { errors } from '@/utils/labels';
import MGraphFlow from './MGraphFlow';
interface DlgShowTypificationProps extends Pick<ModalProps, 'hideWindow'> {
alias: string;
resultTypification: string;
args: IArgumentInfo[];
}
function DlgShowTypification({ hideWindow, alias, resultTypification, args }: DlgShowTypificationProps) {
const graph = useMemo(() => {
const result = new TMGraph();
result.addConstituenta(alias, resultTypification, args);
return result;
}, [alias, resultTypification, args]);
if (graph.nodes.length === 0) {
toast.error(errors.typeStructureFailed);
hideWindow();
return null;
}
return (
<Modal
header='Структура типизации'
readonly
hideWindow={hideWindow}
className='flex flex-col justify-stretch w-[calc(100dvw-3rem)] h-[calc(100dvh-6rem)]'
>
<ReactFlowProvider>
<MGraphFlow data={graph} />
</ReactFlowProvider>
</Modal>
);
}
export default DlgShowTypification;

View File

@ -0,0 +1,77 @@
'use client';
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';
import { TMGraphNodeTypes } from './graph/MGraphNodeTypes';
interface MGraphFlowProps {
data: TMGraph;
}
function MGraphFlow({ data }: MGraphFlowProps) {
const [nodes, setNodes] = useNodesState([]);
const [edges, setEdges] = useEdgesState([]);
const flow = useReactFlow();
useLayoutEffect(() => {
const newNodes = data.nodes.map(node => ({
id: String(node.id),
data: node,
position: { x: 0, y: 0 },
type: 'step'
}));
const newEdges: Edge[] = [];
data.nodes.forEach(node => {
const visited = new Set<number>();
const edges = new Map<number, number>();
node.parents.forEach((parent, index) => {
if (visited.has(parent)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
newEdges.at(edges.get(parent)!)!.data!.indices.push(index + 1);
} else {
newEdges.push({
id: String(newEdges.length),
data: node.parents.length > 1 ? { indices: [index + 1] } : undefined,
source: String(parent),
target: String(node.id),
type: node.parents.length > 1 ? 'cartesian' : 'boolean'
});
edges.set(parent, newEdges.length - 1);
visited.add(parent);
}
});
});
applyLayout(newNodes);
setNodes(newNodes);
setEdges(newEdges);
flow.fitView({ duration: PARAMETER.zoomDuration });
}, [data, setNodes, setEdges, flow]);
return (
<ReactFlow
nodes={nodes}
edges={edges}
edgesFocusable={false}
nodesFocusable={false}
nodeTypes={TMGraphNodeTypes}
edgeTypes={TMGraphEdgeTypes}
fitView
maxZoom={2}
minZoom={0.5}
nodesConnectable={false}
snapToGrid={true}
snapGrid={[PARAMETER.ossGridSize, PARAMETER.ossGridSize]}
/>
);
}
export default MGraphFlow;

View File

@ -0,0 +1,13 @@
import { StraightEdge } from 'reactflow';
import { MGraphEdgeInternal } from '@/models/miscellaneous';
function BooleanEdge(props: MGraphEdgeInternal) {
return (
<>
<StraightEdge {...props} />
</>
);
}
export default BooleanEdge;

View File

@ -0,0 +1,20 @@
import { SimpleBezierEdge } from 'reactflow';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { MGraphEdgeInternal } from '@/models/miscellaneous';
function CartesianEdge({ data, ...restProps }: MGraphEdgeInternal) {
const { colors } = useConceptOptions();
return (
<>
<SimpleBezierEdge
{...restProps}
label={data?.indices.join(', ')}
labelBgStyle={{ fill: colors.bgDefault }}
labelStyle={{ fill: colors.fgDefault }}
/>
</>
);
}
export default CartesianEdge;

View File

@ -0,0 +1,9 @@
import { EdgeTypes } from 'reactflow';
import BooleanEdge from './BooleanEdge';
import CartesianEdge from './CartesianEdge';
export const TMGraphEdgeTypes: EdgeTypes = {
boolean: BooleanEdge,
cartesian: CartesianEdge
};

View File

@ -0,0 +1,171 @@
import { Node } from 'reactflow';
import { Graph } from '@/models/Graph';
import { TMGraphNode } from '@/models/TMGraph';
export function applyLayout(nodes: Node<TMGraphNode>[]) {
new LayoutManager(nodes).execute();
}
const UNIT_HEIGHT = 100;
const UNIT_WIDTH = 100;
const MIN_NODE_DISTANCE = 80;
const LAYOUT_ITERATIONS = 8;
class LayoutManager {
nodes: Node<TMGraphNode>[];
graph = new Graph();
ranks = new Map<number, number>();
posX = new Map<number, number>();
posY = new Map<number, number>();
maxRank = 0;
virtualCount = 0;
layers: number[][] = [];
constructor(nodes: Node<TMGraphNode>[]) {
this.nodes = nodes;
this.prepareGraph();
}
/** Prepares graph for layout calculations.
*
* Assumes that nodes are already topologically sorted.
* 1. Adds nodes to graph.
* 2. Adds elementary edges to graph.
* 3. Splits non-elementary edges via virtual nodes.
*/
private prepareGraph(): void {
this.nodes.forEach(node => {
if (this.maxRank < node.data.rank) {
this.maxRank = node.data.rank;
}
const nodeID = node.data.id;
this.ranks.set(nodeID, node.data.rank);
this.graph.addNode(nodeID);
if (node.data.parents.length === 0) {
return;
}
const visited = new Set<number>();
node.data.parents.forEach(parent => {
if (!visited.has(parent)) {
visited.add(parent);
let target = nodeID;
let currentRank = node.data.rank;
const parentRank = this.ranks.get(parent)!;
while (currentRank - 1 > parentRank) {
currentRank = currentRank - 1;
this.virtualCount = this.virtualCount + 1;
this.ranks.set(-this.virtualCount, currentRank);
this.graph.addEdge(-this.virtualCount, target);
target = -this.virtualCount;
}
this.graph.addEdge(parent, target);
}
});
});
}
execute(): void {
this.calculateLayers();
this.calculatePositions();
this.savePositions();
}
private calculateLayers(): void {
this.initLayers();
// TODO: implement ordering algorithm iterations
}
private initLayers(): void {
this.layers = Array.from({ length: this.maxRank + 1 }, () => []);
const visited = new Set<number>();
const dfs = (nodeID: number) => {
if (visited.has(nodeID)) {
return;
}
visited.add(nodeID);
this.layers[this.ranks.get(nodeID)!].push(nodeID);
this.graph.at(nodeID)!.outputs.forEach(dfs);
};
const simpleNodes = this.nodes
.filter(node => node.data.rank === 0)
.sort((a, b) => a.data.text.localeCompare(b.data.text))
.map(node => node.data.id);
simpleNodes.forEach(dfs);
}
private calculatePositions(): void {
this.initPositions();
for (let i = 0; i < LAYOUT_ITERATIONS; i++) {
this.fixLayersPositions();
}
}
private fixLayersPositions(): void {
for (let rank = 1; rank <= this.maxRank; rank++) {
this.layers[rank].reverse().forEach(nodeID => {
const inputs = this.graph.at(nodeID)!.inputs;
const currentPos = this.posX.get(nodeID)!;
if (inputs.length === 1) {
const parent = inputs[0];
const parentPos = this.posX.get(parent)!;
if (currentPos === parentPos) {
return;
}
if (currentPos > parentPos) {
this.tryMoveNodeX(parent, currentPos);
} else {
this.tryMoveNodeX(nodeID, parentPos);
}
} else if (inputs.length % 2 === 1) {
const median = inputs[Math.floor(inputs.length / 2)];
const medianPos = this.posX.get(median)!;
if (currentPos === medianPos) {
return;
}
this.tryMoveNodeX(nodeID, medianPos);
} else {
const median1 = inputs[Math.floor(inputs.length / 2)];
const median2 = inputs[Math.floor(inputs.length / 2) - 1];
const medianPos = (this.posX.get(median1)! + this.posX.get(median2)!) / 2;
this.tryMoveNodeX(nodeID, medianPos);
}
});
}
}
private tryMoveNodeX(nodeID: number, targetX: number) {
const rank = this.ranks.get(nodeID)!;
if (this.layers[rank].some(id => id !== nodeID && Math.abs(targetX - this.posX.get(id)!) < MIN_NODE_DISTANCE)) {
return;
}
this.posX.set(nodeID, targetX);
}
private initPositions(): void {
this.layers.forEach((layer, rank) => {
layer.forEach((nodeID, index) => {
this.posX.set(nodeID, index * UNIT_WIDTH);
this.posY.set(nodeID, -rank * UNIT_HEIGHT);
});
});
}
private savePositions(): void {
this.nodes.forEach(node => {
const nodeID = node.data.id;
node.position = {
x: this.posX.get(nodeID)!,
y: this.posY.get(nodeID)!
};
});
}
}

View File

@ -0,0 +1,27 @@
import { Handle, Position } from 'reactflow';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { MGraphNodeInternal } from '@/models/miscellaneous';
import { colorBgTMGraphNode } from '@/styling/color';
import { globals } from '@/utils/constants';
function MGraphNode(node: MGraphNodeInternal) {
const { colors } = useConceptOptions();
return (
<>
<Handle type='source' position={Position.Top} style={{ opacity: 0 }} />
<div
className='w-full h-full cursor-default flex items-center justify-center rounded-full'
data-tooltip-id={globals.tooltip}
data-tooltip-content={node.data.text}
style={{ backgroundColor: colorBgTMGraphNode(node.data, colors) }}
>
{node.data.rank === 0 ? node.data.text : ''}
</div>
<Handle type='target' position={Position.Bottom} style={{ opacity: 0 }} />
</>
);
}
export default MGraphNode;

View File

@ -0,0 +1,7 @@
import { NodeTypes } from 'reactflow';
import MGraphNode from './MGraphNode';
export const TMGraphNodeTypes: NodeTypes = {
step: MGraphNode
};

View File

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

View File

@ -6,8 +6,11 @@
* Represents single node of a {@link Graph}, as implemented by storing outgoing and incoming connections.
*/
export class GraphNode {
/** Unique identifier of the node. */
id: number;
/** List of outgoing nodes. */
outputs: number[];
/** List of incoming nodes. */
inputs: number[];
constructor(id: number) {
@ -48,6 +51,7 @@ export class GraphNode {
* This class is optimized for TermGraph use case and not supposed to be used as generic graph implementation.
*/
export class Graph {
/** Map of nodes. */
nodes = new Map<number, GraphNode>();
constructor(arr?: number[][]) {

View File

@ -0,0 +1,72 @@
import { TMGraph } from './TMGraph';
const typificationData = [
['', ''],
['X1', 'X1'],
['Z', 'Z'],
['R1', 'R1'],
['C1', 'C1'],
['C1×X1', 'C1 X1 C1×X1'],
['X1×X1', 'X1 X1×X1'],
['X1×X1×X1', 'X1 X1×X1×X1'],
['(X1)', 'X1 (X1)'],
['(X1)', 'X1 (X1) (X1)'],
['(X1×X2)', 'X1 X2 X1×X2 (X1×X2) (X1×X2)'],
['((X1×X1)×X2)', 'X1 X1×X1 X2 (X1×X1)×X2 ((X1×X1)×X2)'],
['((X1)×(X1))', 'X1 (X1) (X1)×(X1) ((X1)×(X1))'],
[
'(((X1×(X1))×X1)×X2)',
'X1 (X1) X1×(X1) (X1×(X1))×X1 ((X1×(X1))×X1) X2 ((X1×(X1))×X1)×X2 (((X1×(X1))×X1)×X2)'
]
];
describe('Testing parsing typifications', () => {
it.each(typificationData)('Typification parsing %p', (input: string, expected: string) => {
const graph = new TMGraph();
graph.addConstituenta('X1', input, []);
const nodeText = graph.nodes.map(node => node.text).join(' ');
expect(nodeText).toBe(expected);
});
});
describe('Testing constituents parsing', () => {
test('simple expression no arguments', () => {
const graph = new TMGraph();
graph.addConstituenta('X1', '(X1)', []);
expect(graph.nodes.length).toBe(2);
expect(graph.nodes.at(-1)?.annotations).toStrictEqual(['X1']);
});
test('no expression with single argument', () => {
const graph = new TMGraph();
graph.addConstituenta('X1', '', [{ alias: 'a', typification: 'X1' }]);
const nodeText = graph.nodes.map(node => node.text).join(' ');
expect(nodeText).toBe('X1 (X1)');
expect(graph.nodes.at(-1)?.annotations).toStrictEqual(['X1']);
});
test('no expression with multiple arguments', () => {
const graph = new TMGraph();
graph.addConstituenta('X1', '', [
{ alias: 'a', typification: 'X1' },
{ alias: 'b', typification: 'R1×X1' }
]);
const nodeText = graph.nodes.map(node => node.text).join(' ');
expect(nodeText).toBe('X1 R1 R1×X1 X1×(R1×X1) (X1×(R1×X1))');
expect(graph.nodes.at(-1)?.annotations).toStrictEqual(['X1']);
});
test('expression with multiple arguments', () => {
const graph = new TMGraph();
graph.addConstituenta('X1', '(X2×Z)', [
{ alias: 'a', typification: 'X1' },
{ alias: 'b', typification: 'R1×X1' }
]);
const nodeText = graph.nodes.map(node => node.text).join(' ');
expect(nodeText).toBe('X1 R1 R1×X1 X1×(R1×X1) X2 Z X2×Z (X2×Z) (X1×(R1×X1))×(X2×Z) ((X1×(R1×X1))×(X2×Z))');
expect(graph.nodes.at(-1)?.annotations).toStrictEqual(['X1']);
});
});

View File

@ -0,0 +1,215 @@
/**
* Module: Multi-graph for typifications.
*/
import { PARAMETER } from '@/utils/constants';
import { IArgumentInfo } from './rslang';
/**
* Represents a single node of a {@link TMGraph}.
*/
export interface TMGraphNode {
id: number;
rank: number;
text: string;
parents: number[];
annotations: string[];
}
/**
* Represents a typification multi-graph.
*/
export class TMGraph {
/** List of nodes. */
nodes: TMGraphNode[] = [];
/** Map of nodes by ID. */
nodeById = new Map<number, TMGraphNode>();
/** Map of nodes by alias. */
nodeByAlias = new Map<string, TMGraphNode>();
/**
* Adds a constituent to the graph.
*
* @param alias - The alias of the constituent.
* @param result - typification of the formal definition.
* @param args - arguments for term or predicate function.
*/
addConstituenta(alias: string, result: string, args: IArgumentInfo[]): void {
const argsNode = this.processArguments(args);
const resultNode = this.processResult(result);
const combinedNode = this.combineResults(resultNode, argsNode);
if (!combinedNode) {
return;
}
this.addAliasAnnotation(combinedNode.id, alias);
}
addBaseNode(baseAlias: string): TMGraphNode {
const existingNode = this.nodes.find(node => node.text === baseAlias);
if (existingNode) {
return existingNode;
}
const node: TMGraphNode = {
id: this.nodes.length,
text: baseAlias,
rank: 0,
parents: [],
annotations: []
};
this.nodes.push(node);
this.nodeById.set(node.id, node);
return node;
}
addBooleanNode(parent: number): TMGraphNode {
const existingNode = this.nodes.find(node => node.parents.length === 1 && node.parents[0] === parent);
if (existingNode) {
return existingNode;
}
const parentNode = this.nodeById.get(parent);
if (!parentNode) {
throw new Error(`Parent node ${parent} not found`);
}
const text = parentNode.parents.length === 1 ? `${parentNode.text}` : `(${parentNode.text})`;
const node: TMGraphNode = {
id: this.nodes.length,
rank: parentNode.rank + 1,
text: text,
parents: [parent],
annotations: []
};
this.nodes.push(node);
this.nodeById.set(node.id, node);
return node;
}
addCartesianNode(parents: number[]): TMGraphNode {
const existingNode = this.nodes.find(
node => node.parents.length === parents.length && node.parents.every((p, i) => p === parents[i])
);
if (existingNode) {
return existingNode;
}
const parentNodes = parents.map(parent => this.nodeById.get(parent));
if (parentNodes.some(parent => !parent) || parents.length < 2) {
throw new Error(`Parent nodes ${parents.join(', ')} not found`);
}
const text = parentNodes.map(node => (node!.parents.length > 1 ? `(${node!.text})` : node!.text)).join('×');
const node: TMGraphNode = {
id: this.nodes.length,
text: text,
rank: Math.max(...parentNodes.map(parent => parent!.rank)) + 1,
parents: parents,
annotations: []
};
this.nodes.push(node);
this.nodeById.set(node.id, node);
return node;
}
addAliasAnnotation(node: number, alias: string): void {
const nodeToAnnotate = this.nodeById.get(node);
if (!nodeToAnnotate) {
throw new Error(`Node ${node} not found`);
}
nodeToAnnotate.annotations.push(alias);
this.nodeByAlias.set(alias, nodeToAnnotate);
}
private processArguments(args: IArgumentInfo[]): TMGraphNode | undefined {
if (args.length === 0) {
return undefined;
}
const argsNodes = args.map(argument => this.parseToNode(argument.typification));
if (args.length === 1) {
return argsNodes[0];
}
return this.addCartesianNode(argsNodes.map(node => node.id));
}
private processResult(result: string): TMGraphNode | undefined {
if (!result || result === PARAMETER.logicLabel) {
return undefined;
}
return this.parseToNode(result);
}
private combineResults(result: TMGraphNode | undefined, args: TMGraphNode | undefined): TMGraphNode | undefined {
if (!result && !args) {
return undefined;
}
if (!result) {
return this.addBooleanNode(args!.id);
}
if (!args) {
return result;
}
const argsAndResult = this.addCartesianNode([args.id, result.id]);
return this.addBooleanNode(argsAndResult.id);
}
private parseToNode(typification: string): TMGraphNode {
const tokens = this.tokenize(typification);
return this.parseTokens(tokens);
}
private tokenize(expression: string): string[] {
const tokens = [];
let currentToken = '';
for (const char of expression) {
if (['(', ')', '×', ''].includes(char)) {
if (currentToken) {
tokens.push(currentToken);
currentToken = '';
}
tokens.push(char);
} else {
currentToken += char;
}
}
if (currentToken) {
tokens.push(currentToken);
}
return tokens;
}
private parseTokens(tokens: string[], isBoolean: boolean = false): TMGraphNode {
const stack: TMGraphNode[] = [];
let isCartesian = false;
while (tokens.length > 0) {
const token = tokens.shift();
if (!token) {
throw new Error('Unexpected end of expression');
}
if (isBoolean && token === '(') {
return this.parseTokens(tokens);
}
if (token === ')') {
break;
} else if (token === '') {
const innerNode = this.parseTokens(tokens, true);
stack.push(this.addBooleanNode(innerNode.id));
} else if (token === '×') {
isCartesian = true;
} else if (token === '(') {
stack.push(this.parseTokens(tokens));
} else {
stack.push(this.addBaseNode(token));
}
}
if (isCartesian) {
return this.addCartesianNode(stack.map(node => node.id));
} else {
return stack.pop()!;
}
}
}

View File

@ -2,10 +2,11 @@
* Module: Miscellaneous frontend model types. Future targets for refactoring aimed at extracting modules.
*/
import { Node } from 'reactflow';
import { EdgeProps, Node } from 'reactflow';
import { LibraryItemType, LocationHead } from './library';
import { IOperation } from './oss';
import { TMGraphNode } from './TMGraph';
import { UserID } from './user';
/**
@ -45,6 +46,24 @@ 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.
*/
export interface MGraphEdgeInternal extends EdgeProps {
data?: { indices: number[] };
}
/**
* Represents graph node coloring scheme.
*/

View File

@ -14,7 +14,7 @@ function HelpSubstitutions() {
учетом других отождествлений
</li>
<li>логические выражения могут замещать только другие логические выражения</li>
<li>при отождествлении параметризованных конституент количество и типизации операндов должно совпадать</li>
<li>при отождествлении параметризованных конституент количество и типизации аргументов должно совпадать</li>
</p>
</div>
);

View File

@ -6,6 +6,7 @@ import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import { IconChild, IconPredecessor, IconSave } from '@/components/Icons';
import { CProps } from '@/components/props';
import RefsInput from '@/components/RefsInput';
import Indicator from '@/components/ui/Indicator';
import Overlay from '@/components/ui/Overlay';
@ -13,9 +14,11 @@ import SubmitButton from '@/components/ui/SubmitButton';
import TextArea from '@/components/ui/TextArea';
import AnimateFade from '@/components/wrap/AnimateFade';
import { useRSForm } from '@/context/RSFormContext';
import DlgShowTypification from '@/dialogs/DlgShowTypification';
import { ConstituentaID, CstType, IConstituenta, ICstUpdateData } from '@/models/rsform';
import { isBaseSet, isBasicConcept, isFunctional } from '@/models/rsformAPI';
import { information, labelCstTypification } from '@/utils/labels';
import { IExpressionParse, ParsingStatus } from '@/models/rslang';
import { errors, information, labelCstTypification } from '@/utils/labels';
import EditorRSExpression from '../EditorRSExpression';
import ControlsOverlay from './ControlsOverlay';
@ -55,6 +58,8 @@ function FormConstituenta({
const [expression, setExpression] = useState('');
const [convention, setConvention] = useState('');
const [typification, setTypification] = useState('N/A');
const [showTypification, setShowTypification] = useState(false);
const [localParse, setLocalParse] = useState<IExpressionParse | undefined>(undefined);
const [forceComment, setForceComment] = useState(false);
@ -98,6 +103,7 @@ function FormConstituenta({
setExpression(state.definition_formal || '');
setTypification(state ? labelCstTypification(state) : 'N/A');
setForceComment(false);
setLocalParse(undefined);
}
}, [state, schema, toggleReset]);
@ -127,8 +133,28 @@ function FormConstituenta({
cstUpdate(data, () => toast.success(information.changesSaved));
}
function handleTypificationClick(event: CProps.EventMouse) {
if (!state || (localParse && !localParse.parseResult) || state.parse.status !== ParsingStatus.VERIFIED) {
toast.error(errors.typeStructureFailed);
return;
}
event.stopPropagation();
event.preventDefault();
setShowTypification(true);
}
return (
<AnimateFade className='mx-0 md:mx-auto pt-[2rem] xs:pt-0'>
<AnimatePresence>
{showTypification && state ? (
<DlgShowTypification
alias={state.alias}
resultTypification={localParse ? localParse.typification : state.parse.typification}
args={localParse ? localParse.args : state.parse.args}
hideWindow={() => setShowTypification(false)}
/>
) : null}
</AnimatePresence>
{state ? (
<ControlsOverlay
disabled={disabled}
@ -161,10 +187,13 @@ function FormConstituenta({
dense
noResize
noBorder
disabled
noOutline
readOnly
label='Типизация'
title='Отобразить структуру типизации'
value={typification}
colors='clr-app clr-text-default'
colors='clr-app clr-text-default cursor-pointer'
onClick={event => handleTypificationClick(event)}
/>
) : null}
{state ? (
@ -192,6 +221,7 @@ function FormConstituenta({
toggleReset={toggleReset}
onChange={newValue => setExpression(newValue)}
setTypification={setTypification}
setLocalParse={setLocalParse}
onOpenEdit={onOpenEdit}
/>
</AnimateFade>

View File

@ -2,7 +2,7 @@
import { ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { AnimatePresence } from 'framer-motion';
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import BadgeHelp from '@/components/info/BadgeHelp';
@ -40,6 +40,7 @@ interface EditorRSExpressionProps {
toggleReset?: boolean;
setTypification: (typification: string) => void;
setLocalParse: React.Dispatch<React.SetStateAction<IExpressionParse | undefined>>;
onChange: (newValue: string) => void;
onOpenEdit?: (cstID: ConstituentaID) => void;
}
@ -50,6 +51,7 @@ function EditorRSExpression({
value,
toggleReset,
setTypification,
setLocalParse,
onChange,
onOpenEdit,
...restProps
@ -78,6 +80,7 @@ function EditorRSExpression({
function handleCheckExpression(callback?: (parse: IExpressionParse) => void) {
parser.checkConstituenta(value, activeCst, parse => {
setLocalParse(parse);
if (parse.errors.length > 0) {
onShowError(parse.errors[0], parse.prefixLen);
} else {
@ -137,7 +140,6 @@ function EditorRSExpression({
toast.error(errors.astFailed);
} else {
setSyntaxTree(parse.ast);
// TODO: return prefix from parser API instead of prefixLength
setExpression(getDefinitionPrefix(activeCst) + value);
setShowAST(true);
}

View File

@ -11,7 +11,7 @@ import { ExpressionStatus } from '@/models/rsform';
import { type IConstituenta } from '@/models/rsform';
import { inferStatus } from '@/models/rsformAPI';
import { IExpressionParse, ParsingStatus } from '@/models/rslang';
import { colorBgCstStatus } from '@/styling/color';
import { colorStatusBar } from '@/styling/color';
import { globals } from '@/utils/constants';
import { labelExpressionStatus, prepareTooltip } from '@/utils/labels';
@ -48,7 +48,7 @@ function StatusBar({ isModified, processing, activeCst, parseData, onAnalyze }:
'focus-frame',
'duration-500 transition-colors'
)}
style={{ backgroundColor: processing ? colors.bgDefault : colorBgCstStatus(status, colors) }}
style={{ backgroundColor: processing ? colors.bgDefault : colorStatusBar(status, colors) }}
data-tooltip-id={globals.tooltip}
data-tooltip-html={prepareTooltip('Проверить определение', 'Ctrl + Q')}
onClick={onAnalyze}

View File

@ -6,6 +6,7 @@ import { GramData, Grammeme, NounGrams, PartOfSpeech, VerbGrams } from '@/models
import { GraphColoring } from '@/models/miscellaneous';
import { CstClass, ExpressionStatus, IConstituenta } from '@/models/rsform';
import { ISyntaxTreeNode, TokenID } from '@/models/rslang';
import { TMGraphNode } from '@/models/TMGraph';
import { PARAMETER } from '@/utils/constants';
/**
@ -441,6 +442,21 @@ export function colorBgSyntaxTree(node: ISyntaxTreeNode, colors: IColorTheme): s
* Determines background color for {@link ExpressionStatus}.
*/
export function colorBgCstStatus(status: ExpressionStatus, colors: IColorTheme): string {
// prettier-ignore
switch (status) {
case ExpressionStatus.VERIFIED: return colors.bgGreen;
case ExpressionStatus.INCORRECT: return colors.bgRed;
case ExpressionStatus.INCALCULABLE: return colors.bgOrange;
case ExpressionStatus.PROPERTY: return colors.bgTeal;
case ExpressionStatus.UNKNOWN: return colors.bgSelected;
case ExpressionStatus.UNDEFINED: return colors.bgBlue;
}
}
/**
* Determines statusbar color for {@link ExpressionStatus}.
*/
export function colorStatusBar(status: ExpressionStatus, colors: IColorTheme): string {
// prettier-ignore
switch (status) {
case ExpressionStatus.VERIFIED: return colors.bgGreen50;
@ -548,3 +564,16 @@ export function colorBgGraphNode(cst: IConstituenta, coloringScheme: GraphColori
}
return '';
}
/**
* Determines m-graph color for {@link TMGraphNode}.
*/
export function colorBgTMGraphNode(node: TMGraphNode, colors: IColorTheme): string {
if (node.rank === 0) {
return colors.bgControls;
}
if (node.parents.length === 1) {
return colors.bgTeal;
}
return colors.bgOrange;
}

View File

@ -66,9 +66,15 @@
cursor: default;
}
.react-flow__edge {
cursor: default;
}
.react-flow__attribution {
font-size: var(--font-size-sm);
font-family: var(--font-ui);
margin-left: 3px;
margin-right: 3px;
background-color: transparent;
color: var(--cl-fg-60);
@ -77,19 +83,12 @@
}
}
.react-flow__node-input,
.react-flow__node-synthesis {
cursor: pointer;
border: 1px solid;
padding: 2px;
width: 150px;
height: 40px;
[class*='react-flow__node-'] {
font-size: 14px;
border-radius: 5px;
background-color: var(--cl-bg-120);
border: 1px solid;
background-color: var(--cl-bg-120);
color: var(--cl-fg-100);
border-color: var(--cl-bg-40);
background-color: var(--cl-bg-120);
@ -116,3 +115,21 @@
}
}
}
.react-flow__node-input,
.react-flow__node-synthesis {
cursor: pointer;
border-radius: 5px;
padding: 2px;
width: 150px;
height: 40px;
}
.react-flow__node-step {
cursor: default;
border-radius: 100%;
width: 40px;
height: 40px;
}

View File

@ -22,6 +22,8 @@ import {
} from '@/models/rslang';
import { UserLevel } from '@/models/user';
import { PARAMETER } from './constants';
/**
* Remove html tags from target string.
*/
@ -366,7 +368,7 @@ export function labelHelpTopic(topic: HelpTopic): string {
case HelpTopic.THESAURUS: return '📖 Тезаурус';
case HelpTopic.INTERFACE: return '🦄 Интерфейс';
case HelpTopic.INTERFACE: return '🌀 Интерфейс';
case HelpTopic.UI_LIBRARY: return 'Библиотека';
case HelpTopic.UI_RS_MENU: return 'Меню схемы';
case HelpTopic.UI_RS_CARD: return 'Карточка схемы';
@ -396,7 +398,7 @@ export function labelHelpTopic(topic: HelpTopic): string {
case HelpTopic.RSL_TEMPLATES: return 'Банк выражений';
case HelpTopic.TERM_CONTROL: return '🪸 Терминологизация';
case HelpTopic.ACCESS: return '👀 Доступы';
case HelpTopic.ACCESS: return '🔐 Доступы';
case HelpTopic.VERSIONS: return '🏺 Версионирование';
case HelpTopic.INFO: return '📰 Информация';
@ -531,7 +533,7 @@ export function labelTypification({
if (!isValid) {
return 'N/A';
}
if (resultType === '' || resultType === 'LOGIC') {
if (resultType === '' || resultType === PARAMETER.logicLabel) {
resultType = 'Logical';
}
if (args.length === 0) {
@ -1000,6 +1002,7 @@ export const information = {
*/
export const errors = {
astFailed: 'Невозможно построить дерево разбора',
typeStructureFailed: 'Структура отсутствует',
passwordsMismatch: 'Пароли не совпадают',
imageFailed: 'Ошибка при создании изображения',
reuseOriginal: 'Повторное использование удаляемой конституенты при отождествлении',