F: Term graph rework pt1

This commit is contained in:
Ivan 2024-12-02 15:59:03 +03:00
parent 2b09044e8f
commit 23f2e142cf
26 changed files with 696 additions and 1746 deletions

View File

@ -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

@ -36,7 +36,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": {

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

@ -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

@ -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

@ -32,8 +32,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 +49,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 сбросить выделение

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,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,480 @@
'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 [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 filteredGraph = useGraphFilter(controller.schema, filterParams, focusCst);
const [hidden, setHidden] = useState<ConstituentaID[]>([]);
const [coloring, setColoring] = useLocalStorage<GraphColoring>(storage.rsgraphColoring, 'type');
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 { addSelectedNodes } = store.getState();
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,
subLabel: !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) {
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.3rem] sm:top-[2rem] left-0'
/>
{!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-[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>
</>
);
}
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

@ -0,0 +1,27 @@
import { EdgeProps, getStraightPath } from 'reactflow';
import { PARAMETER } from '@/utils/constants';
function TGEdge({ id, markerEnd, style, ...props }: EdgeProps) {
const sourceY = props.sourceY - PARAMETER.graphNodeRadius;
const targetY = props.targetY + PARAMETER.graphNodeRadius;
const scale =
(PARAMETER.graphNodePadding + PARAMETER.graphNodeRadius) /
Math.sqrt(Math.pow(props.sourceX - props.targetX, 2) + Math.pow(Math.abs(sourceY - targetY), 2));
const [path] = getStraightPath({
sourceX: props.sourceX - (props.sourceX - props.targetX) * scale,
sourceY: sourceY - (sourceY - targetY) * scale,
targetX: props.targetX + (props.sourceX - props.targetX) * scale,
targetY: targetY + (sourceY - targetY) * scale
});
return (
<>
<path id={id} className='react-flow__edge-path' d={path} markerEnd={markerEnd} style={style} />
</>
);
}
export default TGEdge;

View File

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

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,78 @@
'use client';
import { useMemo } from 'react';
import { Handle, Position } from 'reactflow';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { truncateToLastWord } from '@/utils/utils';
const MAX_LABEL_LENGTH = 65;
export interface TGNodeData {
fill: string;
label: string;
subLabel: 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 subLabel = useMemo(() => truncateToLastWord(node.data.subLabel, MAX_LABEL_LENGTH), [node.data.subLabel]);
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,
outlineOffset: '4px',
outlineStyle: 'solid',
outlineColor: node.selected ? colors.bgActiveSelection : 'transparent'
}}
>
<div className='absolute top-[9px] left-0 text-center w-full'>{node.data.label}</div>
<div
style={{
WebkitTextStrokeWidth: 2,
WebkitTextStrokeColor: colors.bgDefault
}}
>
{node.data.label}
</div>
</div>
<Handle type='source' position={Position.Bottom} style={{ opacity: 0 }} />
{subLabel ? (
<div
className='mt-1 w-[150px] px-1 text-center translate-x-[calc(-50%+20px)]'
style={{
fontSize: subLabel.length > 15 ? 10 : 12
}}
>
<div
style={{
WebkitTextStrokeWidth: 3,
WebkitTextStrokeColor: colors.bgDefault
}}
>
{subLabel}
</div>
<div className='absolute top-0 px-1 left-0 text-center w-full'>{subLabel}</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

@ -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

@ -127,10 +127,17 @@
} }
.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;
} }
.react-flow__node-concept {
&.selected {
border-color: transparent;
}
}

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,16 @@ 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
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',
@ -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.
*/ */