Implement graph operations and improve UI

This commit is contained in:
IRBorisov 2024-04-03 18:48:56 +03:00
parent 79be1167be
commit b33dceebf8
21 changed files with 238 additions and 161 deletions

View File

@ -59,7 +59,7 @@ function ConstituentaMultiPicker({ id, schema, prefixID, rows, selected, setSele
if (!schema || selected.length === 0) {
return;
}
const addition = schema.graph.expandInputs(selected).filter(id => !selected.includes(id));
const addition = schema.graph.expandAllInputs(selected).filter(id => !selected.includes(id));
if (addition.length > 0) {
setSelected([...selected, ...addition]);
}
@ -69,7 +69,7 @@ function ConstituentaMultiPicker({ id, schema, prefixID, rows, selected, setSele
if (!schema || selected.length === 0) {
return;
}
const addition = schema.graph.expandOutputs(selected).filter(id => !selected.includes(id));
const addition = schema.graph.expandAllOutputs(selected).filter(id => !selected.includes(id));
if (addition.length > 0) {
setSelected([...selected, ...addition]);
}

View File

@ -26,7 +26,7 @@ function MiniButton({
type='button'
tabIndex={tabIndex ?? -1}
className={clsx(
'rounded-full',
'rounded-lg',
'clr-btn-clear',
'cursor-pointer disabled:cursor-not-allowed',
{

View File

@ -14,7 +14,7 @@ function TabLabel({ label, title, titleHtml, hideTitle, className, ...otherProps
return (
<TabImpl
className={clsx(
'min-w-[6rem] h-full',
'min-w-[5.5rem] h-full',
'px-2 py-1 flex justify-center',
'clr-tab',
'text-sm whitespace-nowrap font-controls',

View File

@ -18,7 +18,7 @@ interface DlgDeleteCstProps extends Pick<ModalProps, 'hideWindow'> {
function DlgDeleteCst({ hideWindow, selected, schema, onDelete }: DlgDeleteCstProps) {
const [expandOut, setExpandOut] = useState(false);
const expansion: number[] = useMemo(() => schema.graph.expandOutputs(selected), [selected, schema.graph]);
const expansion: number[] = useMemo(() => schema.graph.expandAllOutputs(selected), [selected, schema.graph]);
function handleSubmit() {
hideWindow();

View File

@ -88,18 +88,20 @@ function DlgEditVersions({ hideWindow, versions, onDelete, onUpdate }: DlgEditVe
value={version}
onChange={event => setVersion(event.target.value)}
/>
<MiniButton
title='Сохранить изменения'
disabled={!isModified || !isValid || processing}
icon={<FiSave size='1.25rem' className='icon-primary' />}
onClick={handleUpdate}
/>
<MiniButton
title='Сбросить несохраненные изменения'
disabled={!isModified}
onClick={handleReset}
icon={<BiReset size='1.25rem' className='icon-primary' />}
/>
<div className='cc-icons'>
<MiniButton
title='Сохранить изменения'
disabled={!isModified || !isValid || processing}
icon={<FiSave size='1.25rem' className='icon-primary' />}
onClick={handleUpdate}
/>
<MiniButton
title='Сбросить несохраненные изменения'
disabled={!isModified}
onClick={handleReset}
icon={<BiReset size='1.25rem' className='icon-primary' />}
/>
</div>
</div>
<TextArea
id='dlg_description'

View File

@ -95,11 +95,22 @@ describe('Testing Graph sort', () => {
describe('Testing Graph queries', () => {
test('expand outputs', () => {
const graph = new Graph([[1, 2], [2, 3], [2, 5], [5, 6], [6, 1], [7]]);
const graph = new Graph([
[1, 2], //
[2, 3],
[2, 5],
[5, 6],
[6, 1],
[7]
]);
expect(graph.expandOutputs([])).toStrictEqual([]);
expect(graph.expandAllOutputs([])).toStrictEqual([]);
expect(graph.expandOutputs([3])).toStrictEqual([]);
expect(graph.expandAllOutputs([3])).toStrictEqual([]);
expect(graph.expandOutputs([7])).toStrictEqual([]);
expect(graph.expandOutputs([2, 5])).toStrictEqual([3, 6, 1]);
expect(graph.expandAllOutputs([7])).toStrictEqual([]);
expect(graph.expandOutputs([2, 5])).toStrictEqual([3, 6]);
expect(graph.expandAllOutputs([2, 5])).toStrictEqual([3, 6, 1]);
});
test('expand into unique array', () => {
@ -109,13 +120,43 @@ describe('Testing Graph queries', () => {
[2, 5],
[3, 5]
]);
expect(graph.expandOutputs([1])).toStrictEqual([2, 3, 5]);
expect(graph.expandAllOutputs([1])).toStrictEqual([2, 3, 5]);
});
test('expand inputs', () => {
const graph = new Graph([[1, 2], [2, 3], [2, 5], [5, 6], [6, 1], [7]]);
const graph = new Graph([
[1, 2], //
[2, 3],
[2, 5],
[5, 6],
[6, 1],
[7]
]);
expect(graph.expandInputs([])).toStrictEqual([]);
expect(graph.expandAllInputs([])).toStrictEqual([]);
expect(graph.expandInputs([7])).toStrictEqual([]);
expect(graph.expandInputs([6])).toStrictEqual([5, 2, 1]);
expect(graph.expandAllInputs([7])).toStrictEqual([]);
expect(graph.expandInputs([6])).toStrictEqual([5]);
expect(graph.expandAllInputs([6])).toStrictEqual([5, 2, 1]);
});
test('maximize part', () => {
const graph = new Graph([
[1, 7], //
[1, 3],
[2, 3],
[2, 4],
[3, 5],
[3, 6],
[3, 4],
[7, 5],
[8]
]);
expect(graph.maximizePart([])).toStrictEqual([]);
expect(graph.maximizePart([8])).toStrictEqual([8]);
expect(graph.maximizePart([5])).toStrictEqual([5]);
expect(graph.maximizePart([3])).toStrictEqual([3, 6]);
expect(graph.maximizePart([3, 2])).toStrictEqual([3, 2, 6, 4]);
expect(graph.maximizePart([3, 1])).toStrictEqual([3, 1, 7, 5, 6]);
});
});

View File

@ -144,18 +144,42 @@ export class Graph {
expandOutputs(origin: number[]): number[] {
const result: number[] = [];
const marked = new Map<number, boolean>();
origin.forEach(id => marked.set(id, true));
origin.forEach(id => {
const node = this.nodes.get(id);
if (node) {
node.outputs.forEach(child => {
if (!marked.get(child) && !result.find(id => id === child)) {
if (!origin.includes(child) && !result.includes(child)) {
result.push(child);
}
});
}
});
return result;
}
expandInputs(origin: number[]): number[] {
const result: number[] = [];
origin.forEach(id => {
const node = this.nodes.get(id);
if (node) {
node.inputs.forEach(child => {
if (!origin.includes(child) && !result.includes(child)) {
result.push(child);
}
});
}
});
return result;
}
expandAllOutputs(origin: number[]): number[] {
const result: number[] = this.expandOutputs(origin);
if (result.length === 0) {
return [];
}
const marked = new Map<number, boolean>();
origin.forEach(id => marked.set(id, true));
let position = 0;
while (position < result.length) {
const node = this.nodes.get(result[position]);
@ -172,20 +196,14 @@ export class Graph {
return result;
}
expandInputs(origin: number[]): number[] {
const result: number[] = [];
expandAllInputs(origin: number[]): number[] {
const result: number[] = this.expandInputs(origin);
if (result.length === 0) {
return [];
}
const marked = new Map<number, boolean>();
origin.forEach(id => marked.set(id, true));
origin.forEach(id => {
const node = this.nodes.get(id);
if (node) {
node.inputs.forEach(child => {
if (!marked.get(child) && !result.find(id => id === child)) {
result.push(child);
}
});
}
});
let position = 0;
while (position < result.length) {
const node = this.nodes.get(result[position]);
@ -202,6 +220,20 @@ export class Graph {
return result;
}
maximizePart(origin: number[]): number[] {
const outputs: number[] = this.expandAllOutputs(origin);
const result = [...origin];
this.topologicalOrder()
.filter(id => outputs.includes(id))
.forEach(id => {
const node = this.nodes.get(id);
if (node?.inputs.every(parent => result.includes(parent))) {
result.push(id);
}
});
return result;
}
topologicalOrder(): number[] {
const result: number[] = [];
const marked = new Map<number, boolean>();

View File

@ -27,10 +27,10 @@ export function applyGraphFilter(target: IRSForm, start: number, mode: Dependenc
return target.graph.nodes.get(start)?.inputs;
}
case DependencyMode.EXPAND_OUTPUTS: {
return target.graph.expandOutputs([start]);
return target.graph.expandAllOutputs([start]);
}
case DependencyMode.EXPAND_INPUTS: {
return target.graph.expandInputs([start]);
return target.graph.expandAllInputs([start]);
}
}
return undefined;

View File

@ -31,7 +31,7 @@ function ConstituentaToolbar({
onCreate
}: ConstituentaToolbarProps) {
return (
<Overlay position='top-1 right-4 sm:right-1/2 sm:translate-x-1/2' className='flex'>
<Overlay position='top-1 right-4 sm:right-1/2 sm:translate-x-1/2' className='cc-icons'>
<MiniButton
titleHtml={prepareTooltip('Сохранить изменения', 'Ctrl + S')}
icon={<FiSave size='1.25rem' className='icon-primary' />}

View File

@ -18,7 +18,7 @@ interface ControlsOverlayProps {
function ControlsOverlay({ constituenta, disabled, modified, processing, onRename, onEditTerm }: ControlsOverlayProps) {
return (
<Overlay position='top-1 left-[4.1rem]' className='flex select-none'>
<Overlay position='top-1 left-[4.3rem]' className='flex select-none'>
{!disabled || processing ? (
<MiniButton
title={

View File

@ -161,7 +161,7 @@ function EditorRSExpression({
) : null}
</AnimatePresence>
<Overlay position='top-[-0.5rem] right-0 flex'>
<Overlay position='top-[-0.5rem] right-0 cc-icons'>
<MiniButton
title='Изменить шрифт'
onClick={toggleFont}
@ -169,20 +169,17 @@ function EditorRSExpression({
/>
{!disabled || model.processing ? (
<MiniButton
noHover
title='Отображение специальной клавиатуры'
onClick={() => setShowControls(prev => !prev)}
icon={<FaRegKeyboard size='1.25rem' className={showControls ? 'icon-primary' : ''} />}
/>
) : null}
<MiniButton
noHover
title='Отображение списка конституент'
onClick={onToggleList}
icon={<BiListUl size='1.25rem' className={showList ? 'icon-primary' : ''} />}
/>
<MiniButton
noHover
title='Дерево разбора выражения'
onClick={handleShowAST}
icon={<RiNodeTree size='1.25rem' className='icon-primary' />}

View File

@ -117,18 +117,16 @@ function FormRSForm({ id, isModified, setIsModified }: FormRSFormProps) {
onChange={event => setAlias(event.target.value)}
/>
<div className='flex flex-col'>
<Overlay position='top-[-0.25rem] right-[-0.25rem] flex'>
<Overlay position='top-[-0.25rem] right-[-0.25rem] cc-icons'>
{controller.isMutable ? (
<>
<MiniButton
noHover
title={controller.isContentEditable ? 'Создать версию' : 'Переключитесь на актуальную версию'}
disabled={!controller.isContentEditable}
onClick={controller.createVersion}
icon={<LuGitBranchPlus size='1.25rem' className='icon-green' />}
/>
<MiniButton
noHover
title={schema?.versions.length === 0 ? 'Список версий пуст' : 'Редактировать версии'}
disabled={!schema || schema?.versions.length === 0}
onClick={controller.editVersions}

View File

@ -26,7 +26,7 @@ function RSFormToolbar({ modified, anonymous, subscribed, claimable, onSubmit, o
const controller = useRSEdit();
const canSave = useMemo(() => modified && !controller.isProcessing, [modified, controller.isProcessing]);
return (
<Overlay position='top-1 right-1/2 translate-x-1/2' className='flex'>
<Overlay position='top-1 right-1/2 translate-x-1/2' className='cc-icons'>
{controller.isContentEditable ? (
<MiniButton
titleHtml={prepareTooltip('Сохранить изменения', 'Ctrl + S')}

View File

@ -19,7 +19,7 @@ function RSListToolbar() {
const insertMenu = useDropdown();
return (
<Overlay position='top-1 right-1/2 translate-x-1/2' className='flex items-start'>
<Overlay position='top-1 right-1/2 translate-x-1/2' className='items-start cc-icons'>
<MiniButton
titleHtml={prepareTooltip('Переместить вверх', 'Alt + вверх')}
icon={<BiUpvote size='1.25rem' className='icon-primary' />}

View File

@ -18,7 +18,6 @@ import { storage, TIMEOUT_GRAPH_REFRESH } from '@/utils/constants';
import { useRSEdit } from '../RSEditContext';
import GraphSelectors from './GraphSelectors';
import GraphSidebar from './GraphSidebar';
import GraphToolbar from './GraphToolbar';
import TermGraph from './TermGraph';
import useGraphFilter from './useGraphFilter';
@ -158,6 +157,10 @@ function EditorTermGraph({ onOpenEdit }: EditorTermGraphProps) {
event.preventDefault();
handleDeleteCst();
}
if (event.key === 'Escape') {
event.preventDefault();
controller.deselectAll();
}
}
const graph = useMemo(
@ -254,7 +257,6 @@ function EditorTermGraph({ onOpenEdit }: EditorTermGraphProps) {
onEdit={onOpenEdit}
/>
</div>
<GraphSidebar />
</Overlay>
{graph}

View File

@ -1,47 +0,0 @@
import { BiGitBranch, BiGitMerge, BiReset } from 'react-icons/bi';
import { LuExpand, LuMaximize, LuMinimize } from 'react-icons/lu';
import MiniButton from '@/components/ui/MiniButton';
import { useRSEdit } from '../RSEditContext';
function GraphSidebar() {
const controller = useRSEdit();
return (
<div className='flex flex-col gap-1 clr-app'>
<MiniButton
titleHtml='<b>Сбросить выделение</b>'
icon={<BiReset size='1.25rem' className='icon-primary' />}
onClick={controller.deselectAll}
/>
<MiniButton
titleHtml='<b>Выделение базиса</b> - замыкание выделения влияющими конституентами'
icon={<LuMinimize size='1.25rem' className='icon-primary' />}
disabled={controller.nothingSelected}
/>
<MiniButton
titleHtml='<b>Максимизация части</b> - замыкание выделения конституентами, зависимыми только от выделенных'
icon={<LuMaximize size='1.25rem' className='icon-primary' />}
disabled={controller.nothingSelected}
/>
<MiniButton
title='Выделить все зависимые'
icon={<LuExpand size='1.25rem' className='icon-primary' />}
disabled={controller.nothingSelected}
/>
<MiniButton
title='Выделить поставщиков'
icon={<BiGitBranch size='1.25rem' className='icon-primary' />}
disabled={controller.nothingSelected}
/>
<MiniButton
title='Выделить потребителей'
icon={<BiGitMerge size='1.25rem' className='icon-primary' />}
disabled={controller.nothingSelected}
/>
</div>
);
}
export default GraphSidebar;

View File

@ -1,7 +1,17 @@
'use client';
import { BiFilterAlt, BiFont, BiFontFamily, BiPlanet, BiPlusCircle, BiTrash } from 'react-icons/bi';
import { LuImage } from 'react-icons/lu';
import {
BiFilterAlt,
BiFont,
BiFontFamily,
BiGitBranch,
BiGitMerge,
BiPlanet,
BiPlusCircle,
BiReset,
BiTrash
} from 'react-icons/bi';
import { LuExpand, LuImage, LuMaximize, LuMinimize } from 'react-icons/lu';
import BadgeHelp from '@/components/man/BadgeHelp';
import MiniButton from '@/components/ui/MiniButton';
@ -39,51 +49,93 @@ function GraphToolbar({
const controller = useRSEdit();
return (
<Overlay position='top-0 pt-1 right-1/2 translate-x-1/2 clr-app' className='flex'>
<MiniButton
title='Настройки фильтрации узлов и связей'
icon={<BiFilterAlt size='1.25rem' className='icon-primary' />}
onClick={showParamsDialog}
/>
<MiniButton
title={!noText ? 'Скрыть текст' : 'Отобразить текст'}
icon={
!noText ? (
<BiFontFamily size='1.25rem' className='icon-green' />
) : (
<BiFont size='1.25rem' className='icon-primary' />
)
}
onClick={toggleNoText}
/>
<MiniButton
icon={<LuImage size='1.25rem' className='icon-primary' />}
title='Восстановить камеру'
onClick={onResetViewpoint}
/>
<MiniButton
icon={<BiPlanet size='1.25rem' className={orbit ? 'icon-green' : 'icon-primary'} />}
title='Анимация вращения'
disabled={!is3D}
onClick={toggleOrbit}
/>
{controller.isContentEditable ? (
<Overlay
position='top-0 pt-1 right-1/2 translate-x-1/2'
className='flex flex-col items-center bg-opacity-10 clr-app'
>
<div className='cc-icons'>
<MiniButton
title='Новая конституента'
icon={<BiPlusCircle size='1.25rem' className='icon-green' />}
disabled={controller.isProcessing}
onClick={onCreate}
title='Настройки фильтрации узлов и связей'
icon={<BiFilterAlt size='1.25rem' className='icon-primary' />}
onClick={showParamsDialog}
/>
) : null}
{controller.isContentEditable ? (
<MiniButton
title='Удалить выбранные'
icon={<BiTrash size='1.25rem' className='icon-red' />}
disabled={controller.nothingSelected || controller.isProcessing}
onClick={onDelete}
title={!noText ? 'Скрыть текст' : 'Отобразить текст'}
icon={
!noText ? (
<BiFontFamily size='1.25rem' className='icon-green' />
) : (
<BiFont size='1.25rem' className='icon-primary' />
)
}
onClick={toggleNoText}
/>
) : null}
<BadgeHelp topic={HelpTopic.GRAPH_TERM} className='max-w-[calc(100vw-4rem)]' offset={4} />
<MiniButton
icon={<LuImage size='1.25rem' className='icon-primary' />}
title='Восстановить камеру'
onClick={onResetViewpoint}
/>
<MiniButton
icon={<BiPlanet size='1.25rem' className={orbit ? 'icon-green' : 'icon-primary'} />}
title='Анимация вращения'
disabled={!is3D}
onClick={toggleOrbit}
/>
{controller.isContentEditable ? (
<MiniButton
title='Новая конституента'
icon={<BiPlusCircle size='1.25rem' className='icon-green' />}
disabled={controller.isProcessing}
onClick={onCreate}
/>
) : null}
{controller.isContentEditable ? (
<MiniButton
title='Удалить выбранные'
icon={<BiTrash size='1.25rem' className='icon-red' />}
disabled={controller.nothingSelected || controller.isProcessing}
onClick={onDelete}
/>
) : null}
<BadgeHelp topic={HelpTopic.GRAPH_TERM} className='max-w-[calc(100vw-4rem)]' offset={4} />
</div>
<div className='cc-icons'>
<MiniButton
titleHtml='<b>[ESC]</b><br/>Сбросить выделение'
icon={<BiReset size='1.25rem' className='icon-primary' />}
onClick={controller.deselectAll}
/>
<MiniButton
titleHtml='<b>Замыкание</b> - дополнение выделения влияющими конституентами'
icon={<LuMinimize size='1.25rem' className='icon-primary' />}
disabled={controller.nothingSelected}
onClick={controller.selectAllInputs}
/>
<MiniButton
titleHtml='<b>Максимизация</b> - дополнение выделения конституентами, зависимыми только от выделенных'
icon={<LuMaximize size='1.25rem' className='icon-primary' />}
disabled={controller.nothingSelected}
onClick={controller.selectMax}
/>
<MiniButton
titleHtml='Выделить все зависимые'
icon={<LuExpand size='1.25rem' className='icon-primary' />}
disabled={controller.nothingSelected}
onClick={controller.selectAllOutputs}
/>
<MiniButton
titleHtml='Выделить поставщиков'
icon={<BiGitBranch size='1.25rem' className='icon-primary' />}
disabled={controller.nothingSelected}
onClick={controller.selectInputs}
/>
<MiniButton
titleHtml='Выделить потребителей'
icon={<BiGitMerge size='1.25rem' className='icon-primary' />}
disabled={controller.nothingSelected}
onClick={controller.selectOutputs}
/>
</div>
</Overlay>
);
}

View File

@ -58,6 +58,11 @@ interface IRSEditContext {
setSelection: (selected: ConstituentaID[]) => void;
select: (target: ConstituentaID) => void;
selectAllInputs: () => void;
selectAllOutputs: () => void;
selectMax: () => void;
selectInputs: () => void;
selectOutputs: () => void;
deselect: (target: ConstituentaID) => void;
toggleSelect: (target: ConstituentaID) => void;
deselectAll: () => void;
@ -484,6 +489,11 @@ export const RSEditState = ({
setSelection: (selected: ConstituentaID[]) => setSelected(selected),
select: (target: ConstituentaID) => setSelected(prev => [...prev, target]),
deselect: (target: ConstituentaID) => setSelected(prev => prev.filter(id => id !== target)),
selectAllInputs: () => setSelected(prev => [...prev, ...(model.schema?.graph.expandAllInputs(prev) ?? [])]),
selectAllOutputs: () => setSelected(prev => [...prev, ...(model.schema?.graph.expandAllOutputs(prev) ?? [])]),
selectOutputs: () => setSelected(prev => [...prev, ...(model.schema?.graph.expandOutputs(prev) ?? [])]),
selectInputs: () => setSelected(prev => [...prev, ...(model.schema?.graph.expandInputs(prev) ?? [])]),
selectMax: () => setSelected(prev => model.schema?.graph.maximizePart(prev) ?? []),
toggleSelect: (target: ConstituentaID) =>
setSelected(prev => (prev.includes(target) ? prev.filter(id => id !== target) : [...prev, target])),
deselectAll: () => setSelected([]),

View File

@ -204,7 +204,7 @@ function RSTabsMenu({ onDestroy }: RSTabsMenuProps) {
tabIndex={-1}
title={'Редактирование'}
hideTitle={editMenu.isOpen}
className='h-full'
className='h-full px-2'
icon={<FiEdit size='1.25rem' className={controller.isContentEditable ? 'icon-green' : 'icon-red'} />}
onClick={editMenu.toggle}
/>

View File

@ -179,7 +179,7 @@ export const graphLightT = {
activeFill: '#1DE9AC',
opacity: 1,
selectedOpacity: 1,
inactiveOpacity: 0.5,
inactiveOpacity: 1,
label: {
color: '#2A6475',
stroke: '#fff',
@ -199,7 +199,7 @@ export const graphLightT = {
activeFill: '#1DE9AC',
opacity: 1,
selectedOpacity: 1,
inactiveOpacity: 0.1,
inactiveOpacity: 1,
label: {
stroke: '#fff',
color: '#2A6475',
@ -209,13 +209,6 @@ export const graphLightT = {
arrow: {
fill: '#D8E6EA',
activeFill: '#1DE9AC'
},
cluster: {
stroke: '#D8E6EA',
label: {
stroke: '#fff',
color: '#2A6475'
}
}
};
@ -231,7 +224,7 @@ export const graphDarkT = {
activeFill: '#1DE9AC',
opacity: 1,
selectedOpacity: 1,
inactiveOpacity: 0.5,
inactiveOpacity: 1,
label: {
stroke: '#1E2026',
color: '#ACBAC7',
@ -251,7 +244,7 @@ export const graphDarkT = {
activeFill: '#1DE9AC',
opacity: 1,
selectedOpacity: 1,
inactiveOpacity: 0.1,
inactiveOpacity: 1,
label: {
stroke: '#1E2026',
color: '#ACBAC7',
@ -261,13 +254,6 @@ export const graphDarkT = {
arrow: {
fill: '#474B56',
activeFill: '#1DE9AC'
},
cluster: {
stroke: '#474B56',
label: {
stroke: '#1E2026',
color: '#ACBAC7'
}
}
};

View File

@ -206,4 +206,8 @@
.cc-column {
@apply flex flex-col gap-3;
}
.cc-icons {
@apply flex gap-1;
}
}