Compare commits
7 Commits
9e9d8a3f08
...
2b97e109b4
Author | SHA1 | Date | |
---|---|---|---|
![]() |
2b97e109b4 | ||
![]() |
527785a544 | ||
![]() |
c60b4398ed | ||
![]() |
abca112b28 | ||
![]() |
26a74202a1 | ||
![]() |
c0eac11539 | ||
![]() |
12afd5997e |
652
rsconcept/frontend/package-lock.json
generated
652
rsconcept/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
@ -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": {
|
||||
|
|
|
@ -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)} />;
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,13 @@
|
|||
import { StraightEdge } from 'reactflow';
|
||||
|
||||
import { MGraphEdgeInternal } from '@/models/miscellaneous';
|
||||
|
||||
function BooleanEdge(props: MGraphEdgeInternal) {
|
||||
return (
|
||||
<>
|
||||
<StraightEdge {...props} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default BooleanEdge;
|
|
@ -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;
|
|
@ -0,0 +1,9 @@
|
|||
import { EdgeTypes } from 'reactflow';
|
||||
|
||||
import BooleanEdge from './BooleanEdge';
|
||||
import CartesianEdge from './CartesianEdge';
|
||||
|
||||
export const TMGraphEdgeTypes: EdgeTypes = {
|
||||
boolean: BooleanEdge,
|
||||
cartesian: CartesianEdge
|
||||
};
|
|
@ -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)!
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -0,0 +1,7 @@
|
|||
import { NodeTypes } from 'reactflow';
|
||||
|
||||
import MGraphNode from './MGraphNode';
|
||||
|
||||
export const TMGraphNodeTypes: NodeTypes = {
|
||||
step: MGraphNode
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { default } from './DlgShowTypification';
|
|
@ -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[][]) {
|
||||
|
|
72
rsconcept/frontend/src/models/TMGraph.test.ts
Normal file
72
rsconcept/frontend/src/models/TMGraph.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
215
rsconcept/frontend/src/models/TMGraph.ts
Normal file
215
rsconcept/frontend/src/models/TMGraph.ts
Normal 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()!;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -14,7 +14,7 @@ function HelpSubstitutions() {
|
|||
учетом других отождествлений
|
||||
</li>
|
||||
<li>логические выражения могут замещать только другие логические выражения</li>
|
||||
<li>при отождествлении параметризованных конституент количество и типизации операндов должно совпадать</li>
|
||||
<li>при отождествлении параметризованных конституент количество и типизации аргументов должно совпадать</li>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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: 'Повторное использование удаляемой конституенты при отождествлении',
|
||||
|
|
Loading…
Reference in New Issue
Block a user