ConceptPortal-public/rsconcept/frontend/src/pages/RSFormPage/EditorTermGraph.tsx

340 lines
12 KiB
TypeScript
Raw Normal View History

2023-08-03 16:42:49 +03:00
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2023-08-15 21:22:21 +03:00
import { darkTheme, GraphCanvas, GraphCanvasRef, GraphEdge,
GraphNode, LayoutTypes, lightTheme, Sphere, useSelection
} from 'reagraph';
2023-07-29 23:00:03 +03:00
import Button from '../../components/Common/Button';
import Checkbox from '../../components/Common/Checkbox';
import ConceptSelect from '../../components/Common/ConceptSelect';
2023-08-15 21:22:21 +03:00
import ConceptTooltip from '../../components/Common/ConceptTooltip';
import Divider from '../../components/Common/Divider';
2023-08-15 21:43:15 +03:00
import ConstituentaInfo from '../../components/Help/ConstituentaInfo';
import CstStatusInfo from '../../components/Help/CstStatusInfo';
2023-08-15 21:22:21 +03:00
import { ArrowsRotateIcon, HelpIcon } from '../../components/Icons';
2023-07-29 21:23:18 +03:00
import { useRSForm } from '../../context/RSFormContext';
import { useConceptTheme } from '../../context/ThemeContext';
2023-07-30 00:47:07 +03:00
import useLocalStorage from '../../hooks/useLocalStorage';
2023-08-15 21:22:21 +03:00
import { prefixes, resources } from '../../utils/constants';
2023-08-03 16:42:49 +03:00
import { Graph } from '../../utils/Graph';
2023-08-15 21:22:21 +03:00
import { IConstituenta } from '../../utils/models';
2023-08-15 21:43:15 +03:00
import { getCstStatusColor, getCstTypeColor,
2023-08-15 21:22:21 +03:00
GraphColoringSelector, GraphLayoutSelector,
mapColoringLabels, mapLayoutLabels, mapStatusInfo
} from '../../utils/staticUI';
import ConstituentaTooltip from './elements/ConstituentaTooltip';
2023-07-29 21:23:18 +03:00
2023-08-15 21:22:21 +03:00
export type ColoringScheme = 'none' | 'status' | 'type';
const TREE_SIZE_MILESTONE = 50;
function getCstNodeColor(cst: IConstituenta, coloringScheme: ColoringScheme, darkMode: boolean): string {
if (coloringScheme === 'type') {
return getCstTypeColor(cst.cstType, darkMode);
}
if (coloringScheme === 'status') {
return getCstStatusColor(cst.status, darkMode);
}
return '';
}
interface EditorTermGraphProps {
onOpenEdit: (cstID: number) => void
// onCreateCst: (selectedID: number | undefined, type: CstType | undefined, skipDialog?: boolean) => void
// onDeleteCst: (selected: number[], callback: (items: number[]) => void) => void
}
function EditorTermGraph({ onOpenEdit }: EditorTermGraphProps) {
2023-07-29 21:23:18 +03:00
const { schema } = useRSForm();
2023-08-04 13:26:51 +03:00
const { darkMode, noNavigation } = useConceptTheme();
2023-08-15 21:22:21 +03:00
2023-08-08 18:44:30 +03:00
const [ layout, setLayout ] = useLocalStorage<LayoutTypes>('graph_layout', 'treeTd2d');
2023-08-15 21:22:21 +03:00
const [ coloringScheme, setColoringScheme ] = useLocalStorage<ColoringScheme>('graph_coloring', 'none');
const [ orbit, setOrbit ] = useState(false);
2023-08-03 16:42:49 +03:00
const [ noHermits, setNoHermits ] = useLocalStorage('graph_no_hermits', true);
const [ noTransitive, setNoTransitive ] = useLocalStorage('graph_no_transitive', false);
2023-08-15 21:22:21 +03:00
const [ filtered, setFiltered ] = useState<Graph>(new Graph());
const [ dismissed, setDismissed ] = useState<number[]>([]);
const [ selectedDismissed, setSelectedDismissed ] = useState<number[]>([]);
2023-07-30 00:47:07 +03:00
const graphRef = useRef<GraphCanvasRef | null>(null);
2023-08-15 21:22:21 +03:00
const [hoverID, setHoverID] = useState<string | undefined>(undefined);
const hoverCst = useMemo(
() => {
return schema?.items.find(cst => String(cst.id) == hoverID);
}, [schema?.items, hoverID]);
2023-07-29 23:00:03 +03:00
2023-08-15 21:22:21 +03:00
const is3D = useMemo(() => layout.includes('3d'), [layout]);
useEffect(
() => {
2023-08-03 16:42:49 +03:00
if (!schema) {
setFiltered(new Graph());
return;
}
const graph = schema.graph.clone();
if (noHermits) {
graph.removeIsolated();
}
if (noTransitive) {
graph.transitiveReduction();
}
2023-08-15 21:22:21 +03:00
const newDismissed: number[] = [];
schema.items.forEach(cst => {
if (!graph.nodes.has(cst.id)) {
newDismissed.push(cst.id);
}
});
2023-08-03 16:42:49 +03:00
setFiltered(graph);
2023-08-15 21:22:21 +03:00
setDismissed(newDismissed);
setSelectedDismissed([]);
setHoverID(undefined);
2023-08-03 16:42:49 +03:00
}, [schema, noHermits, noTransitive]);
2023-08-15 21:22:21 +03:00
function toggleDismissed(cstID: number) {
setSelectedDismissed(prev => {
const index = prev.findIndex(id => cstID == id);
if (index !== -1) {
prev.splice(index, 1);
} else {
prev.push(cstID);
}
return [... prev];
});
}
const nodes: GraphNode[] = useMemo(
() => {
2023-08-03 16:42:49 +03:00
const result: GraphNode[] = [];
if (!schema) {
return result;
}
filtered.nodes.forEach(node => {
const cst = schema.items.find(cst => cst.id === node.id);
if (cst) {
result.push({
id: String(node.id),
2023-08-15 21:22:21 +03:00
fill: getCstNodeColor(cst, coloringScheme, darkMode),
2023-08-03 16:42:49 +03:00
label: cst.term.resolved ? `${cst.alias}: ${cst.term.resolved}` : cst.alias
});
}
});
return result;
2023-08-15 21:22:21 +03:00
}, [schema, coloringScheme, filtered.nodes, darkMode]);
2023-07-29 21:23:18 +03:00
2023-08-15 21:22:21 +03:00
const edges: GraphEdge[] = useMemo(
() => {
2023-07-29 23:00:03 +03:00
const result: GraphEdge[] = [];
let edgeID = 1;
2023-08-03 16:42:49 +03:00
filtered.nodes.forEach(source => {
source.outputs.forEach(target => {
2023-07-29 23:00:03 +03:00
result.push({
id: String(edgeID),
source: String(source.id),
target: String(target)
});
edgeID += 1;
});
});
return result;
2023-08-03 16:42:49 +03:00
}, [filtered.nodes]);
2023-08-15 21:22:21 +03:00
2023-07-30 00:47:07 +03:00
const {
selections, actives,
onNodeClick,
onCanvasClick,
onNodePointerOver,
onNodePointerOut
} = useSelection({
ref: graphRef,
nodes,
edges,
type: 'multi', // 'single' | 'multi' | 'multiModifier'
2023-07-30 00:47:07 +03:00
pathSelectionType: 'all',
focusOnSelect: false
2023-07-30 00:47:07 +03:00
});
2023-08-15 21:22:21 +03:00
const handleCenter = useCallback(
() => {
graphRef.current?.resetControls();
graphRef.current?.centerGraph();
}, []);
const handleHoverIn = useCallback(
(node: GraphNode) => {
setHoverID(node.id);
if (onNodePointerOver) onNodePointerOver(node);
}, [onNodePointerOver]);
const handleHoverOut = useCallback(
(node: GraphNode) => {
setHoverID(undefined);
if (onNodePointerOut) onNodePointerOut(node);
}, [onNodePointerOut]);
const handleNodeClick = useCallback(
(node: GraphNode) => {
if (selections.includes(node.id)) {
onOpenEdit(Number(node.id));
return;
}
if (onNodeClick) onNodeClick(node);
}, [onNodeClick, selections, onOpenEdit]);
const canvasWidth = useMemo(
() => {
return 'calc(100vw - 14.6rem)';
}, []);
const canvasHeight = useMemo(
() => {
return !noNavigation ?
'calc(100vh - 13rem)'
: 'calc(100vh - 8.5rem)';
}, [noNavigation]);
2023-08-04 13:26:51 +03:00
2023-08-15 21:22:21 +03:00
const dismissedStyle = useCallback(
(cstID: number) => {
return selectedDismissed.includes(cstID) ? {outlineWidth: '2px', outlineStyle: 'solid'}: {};
}, [selectedDismissed]);
return (
<div className='flex justify-between w-full'>
<div className='flex flex-col py-2 border-t border-r w-[14.7rem] pr-2 text-sm' style={{height: canvasHeight}}>
{hoverCst &&
<div className='relative'>
2023-08-15 21:43:15 +03:00
<ConstituentaInfo
data={hoverCst}
className='absolute top-0 left-0 z-50 w-[25rem] min-h-[11rem] overflow-y-auto border h-fit clr-app px-3'
/>
2023-08-15 21:22:21 +03:00
</div>}
<div className='flex items-center w-full gap-1'>
2023-08-03 16:42:49 +03:00
<Button
2023-08-15 21:22:21 +03:00
icon={<ArrowsRotateIcon size={7} />}
2023-08-03 16:42:49 +03:00
dense
tooltip='Центрировать изображение'
widthClass='h-full'
onClick={handleCenter}
/>
<ConceptSelect
2023-08-15 21:22:21 +03:00
className='min-w-[9.3rem]'
options={GraphColoringSelector}
searchable={false}
placeholder='Выберите цвет'
values={coloringScheme ? [{ value: coloringScheme, label: mapColoringLabels.get(coloringScheme) }] : []}
onChange={data => { setColoringScheme(data.length > 0 ? data[0].value : GraphColoringSelector[0].value); }}
2023-08-03 16:42:49 +03:00
/>
2023-08-15 21:22:21 +03:00
2023-08-03 16:42:49 +03:00
</div>
2023-08-15 21:22:21 +03:00
<ConceptSelect
className='mt-1 w-fit'
options={GraphLayoutSelector}
searchable={false}
placeholder='Выберите тип'
values={layout ? [{ value: layout, label: mapLayoutLabels.get(layout) }] : []}
onChange={data => { setLayout(data.length > 0 ? data[0].value : GraphLayoutSelector[0].value); }}
/>
<Checkbox
2023-08-15 21:22:21 +03:00
disabled={!is3D}
label='Анимация вращения'
value={orbit}
2023-08-03 16:42:49 +03:00
onChange={ event => setOrbit(event.target.checked) }
/>
<Checkbox
label='Удалить несвязанные'
value={noHermits}
onChange={ event => setNoHermits(event.target.checked) }
/>
<Checkbox
label='Транзитивная редукция'
value={noTransitive}
onChange={ event => setNoTransitive(event.target.checked) }
2023-07-30 00:47:07 +03:00
/>
2023-08-15 21:22:21 +03:00
<Divider margins='mt-3 mb-2' />
<div className='flex flex-col overflow-y-auto'>
<p className='text-center'><b>Скрытые конституенты</b></p>
<div className='flex flex-wrap justify-center gap-2 py-2'>
{dismissed.map(cstID => {
const cst = schema!.items.find(cst => cst.id === cstID)!;
const info = mapStatusInfo.get(cst.status)!;
return (<>
<div
key={`${cst.alias}`}
id={`${prefixes.cst_list}${cst.alias}`}
className={`w-fit min-w-[3rem] rounded-md text-center cursor-pointer ${info.color}`}
style={dismissedStyle(cstID)}
onClick={() => toggleDismissed(cstID)}
onDoubleClick={() => onOpenEdit(cstID)}
>
{cst.alias}
</div>
<ConstituentaTooltip
data={cst}
anchor={`#${prefixes.cst_list}${cst.alias}`}
/>
</>);
})}
</div>
</div>
2023-07-30 00:47:07 +03:00
</div>
<div className='flex-wrap w-full h-full overflow-auto'>
2023-08-15 21:22:21 +03:00
<div
className='relative border-t border-r'
style={{width: canvasWidth, height: canvasHeight}}
>
<div id='items-graph-help' className='relative top-0 right-0 z-10 m-2'>
<HelpIcon color='text-primary' size={6} />
</div>
<ConceptTooltip anchorSelect='#items-graph-help'>
<div>
<h1>Настройка графа</h1>
<p><b>Цвет</b> - выбор правила покраски узлов</p>
<p><i>Скрытые конституенты окрашены в цвет статуса</i></p>
<p><b>Граф</b> - выбор модели расположения узлов</p>
<p><b>Удалить несвязанные</b> - в графе не отображаются одинокие вершины</p>
<p><b>Транзитивная редукция</b> - в графе устраняются транзитивные пути</p>
<Divider margins='mt-2' />
<h1>Горячие клавиши</h1>
<p><b>Двойной клик</b> - редактирование конституенты</p>
<p><b>Delete</b> - удаление конституент</p>
<p><b>Alt + 1-6,Q,W</b> - добавление конституент</p>
<Divider margins='mt-2' />
2023-08-15 21:43:15 +03:00
<CstStatusInfo title='Статусы' />
2023-08-15 21:22:21 +03:00
</div>
</ConceptTooltip>
<GraphCanvas
draggable
ref={graphRef}
nodes={nodes}
edges={edges}
layoutType={layout}
selections={selections}
actives={actives}
2023-08-15 21:22:21 +03:00
onNodeClick={handleNodeClick}
onCanvasClick={onCanvasClick}
2023-08-15 21:22:21 +03:00
onNodePointerOver={handleHoverIn}
onNodePointerOut={handleHoverOut}
cameraMode={ orbit ? 'orbit' : is3D ? 'rotate' : 'pan'}
layoutOverrides={
layout.includes('tree') ? { nodeLevelRatio: schema && schema?.items.length < TREE_SIZE_MILESTONE ? 3 : 1 }
: undefined
}
labelFontUrl={resources.graph_font}
theme={darkMode ? darkTheme : lightTheme}
2023-08-03 16:42:49 +03:00
renderNode={({ node, ...rest }) => (
<Sphere {...rest} node={node} />
)}
2023-07-29 23:00:03 +03:00
/>
2023-07-29 21:23:18 +03:00
</div>
</div>
2023-08-15 21:22:21 +03:00
</div>);
2023-07-29 21:23:18 +03:00
}
2023-07-29 23:00:03 +03:00
2023-07-29 21:23:18 +03:00
export default EditorTermGraph;