F: Refactor node ID and improve layout for new items

This commit is contained in:
Ivan 2025-06-05 15:26:35 +03:00
parent 7059c352b6
commit ee847043c8
26 changed files with 496 additions and 311 deletions

View File

@ -7,7 +7,15 @@ import { type ILibraryItem } from '@/features/library';
import { Graph } from '@/models/graph'; import { Graph } from '@/models/graph';
import { type RO } from '@/utils/meta'; import { type RO } from '@/utils/meta';
import { type IBlock, type IOperation, type IOperationSchema, type IOperationSchemaStats } from '../models/oss'; import {
type IBlock,
type IOperation,
type IOperationSchema,
type IOperationSchemaStats,
type IOssItem,
NodeType
} from '../models/oss';
import { constructNodeID } from '../models/oss-api';
import { BLOCK_NODE_MIN_HEIGHT, BLOCK_NODE_MIN_WIDTH } from '../pages/oss-page/editor-oss-graph/graph/block-node'; import { BLOCK_NODE_MIN_HEIGHT, BLOCK_NODE_MIN_WIDTH } from '../pages/oss-page/editor-oss-graph/graph/block-node';
import { type IOperationSchemaDTO, OperationType } from './types'; import { type IOperationSchemaDTO, OperationType } from './types';
@ -16,8 +24,9 @@ import { type IOperationSchemaDTO, OperationType } from './types';
export class OssLoader { export class OssLoader {
private oss: IOperationSchema; private oss: IOperationSchema;
private graph: Graph = new Graph(); private graph: Graph = new Graph();
private hierarchy: Graph = new Graph(); private hierarchy: Graph<string> = new Graph<string>();
private operationByID = new Map<number, IOperation>(); private operationByID = new Map<number, IOperation>();
private itemByNodeID = new Map<string, IOssItem>();
private blockByID = new Map<number, IBlock>(); private blockByID = new Map<number, IBlock>();
private schemaIDs: number[] = []; private schemaIDs: number[] = [];
private items: RO<ILibraryItem[]>; private items: RO<ILibraryItem[]>;
@ -37,6 +46,7 @@ export class OssLoader {
result.operationByID = this.operationByID; result.operationByID = this.operationByID;
result.blockByID = this.blockByID; result.blockByID = this.blockByID;
result.itemByNodeID = this.itemByNodeID;
result.graph = this.graph; result.graph = this.graph;
result.hierarchy = this.hierarchy; result.hierarchy = this.hierarchy;
result.schemas = this.schemaIDs; result.schemas = this.schemaIDs;
@ -46,18 +56,24 @@ export class OssLoader {
private prepareLookups() { private prepareLookups() {
this.oss.operations.forEach(operation => { this.oss.operations.forEach(operation => {
operation.nodeID = constructNodeID(NodeType.OPERATION, operation.id);
operation.nodeType = NodeType.OPERATION;
this.itemByNodeID.set(operation.nodeID, operation);
this.operationByID.set(operation.id, operation); this.operationByID.set(operation.id, operation);
this.graph.addNode(operation.id); this.graph.addNode(operation.id);
this.hierarchy.addNode(operation.id); this.hierarchy.addNode(operation.nodeID);
if (operation.parent) { if (operation.parent) {
this.hierarchy.addEdge(-operation.parent, operation.id); this.hierarchy.addEdge(constructNodeID(NodeType.BLOCK, operation.parent), operation.nodeID);
} }
}); });
this.oss.blocks.forEach(block => { this.oss.blocks.forEach(block => {
block.nodeID = constructNodeID(NodeType.BLOCK, block.id);
block.nodeType = NodeType.BLOCK;
this.itemByNodeID.set(block.nodeID, block);
this.blockByID.set(block.id, block); this.blockByID.set(block.id, block);
this.hierarchy.addNode(-block.id); this.hierarchy.addNode(block.nodeID);
if (block.parent) { if (block.parent) {
this.hierarchy.addEdge(-block.parent, -block.id); this.hierarchy.addEdge(constructNodeID(NodeType.BLOCK, block.parent), block.nodeID);
} }
}); });
} }

View File

@ -72,6 +72,12 @@ export type IRelocateConstituentsDTO = z.infer<typeof schemaRelocateConstituents
/** Represents {@link IConstituenta} reference. */ /** Represents {@link IConstituenta} reference. */
export type IConstituentaReference = z.infer<typeof schemaConstituentaReference>; export type IConstituentaReference = z.infer<typeof schemaConstituentaReference>;
/** Represents {@link IOperation} position. */
export type IOperationPosition = z.infer<typeof schemaOperationPosition>;
/** Represents {@link IBlock} position. */
export type IBlockPosition = z.infer<typeof schemaBlockPosition>;
// ====== Schemas ====== // ====== Schemas ======
export const schemaOperationType = z.enum(Object.values(OperationType) as [OperationType, ...OperationType[]]); export const schemaOperationType = z.enum(Object.values(OperationType) as [OperationType, ...OperationType[]]);

View File

@ -9,24 +9,22 @@ import { ComboBox } from '@/components/input/combo-box';
import { type Styling } from '@/components/props'; import { type Styling } from '@/components/props';
import { cn } from '@/components/utils'; import { cn } from '@/components/utils';
import { NoData } from '@/components/view'; import { NoData } from '@/components/view';
import { type RO } from '@/utils/meta';
import { labelOssItem } from '../labels'; import { labelOssItem } from '../labels';
import { type IOperationSchema, type IOssItem } from '../models/oss'; import { type IOperationSchema, type IOssItem, NodeType } from '../models/oss';
import { getItemID, isOperation } from '../models/oss-api';
const SELECTION_CLEAR_TIMEOUT = 1000; const SELECTION_CLEAR_TIMEOUT = 1000;
interface PickMultiOperationProps extends Styling { interface PickContentsProps extends Styling {
value: number[]; value: IOssItem[];
onChange: (newValue: number[]) => void; onChange: (newValue: IOssItem[]) => void;
schema: IOperationSchema; schema: IOperationSchema;
rows?: number; rows?: number;
exclude?: number[]; exclude?: IOssItem[];
disallowBlocks?: boolean; disallowBlocks?: boolean;
} }
const columnHelper = createColumnHelper<RO<IOssItem>>(); const columnHelper = createColumnHelper<IOssItem>();
export function PickContents({ export function PickContents({
rows, rows,
@ -37,29 +35,26 @@ export function PickContents({
onChange, onChange,
className, className,
...restProps ...restProps
}: PickMultiOperationProps) { }: PickContentsProps) {
const selectedItems = value const [lastSelected, setLastSelected] = useState<IOssItem | null>(null);
.map(itemID => (itemID > 0 ? schema.operationByID.get(itemID) : schema.blockByID.get(-itemID))) const items: IOssItem[] = [
.filter(item => item !== undefined); ...(disallowBlocks ? [] : schema.blocks.filter(item => !value.includes(item) && !exclude?.includes(item))),
const [lastSelected, setLastSelected] = useState<RO<IOssItem> | null>(null); ...schema.operations.filter(item => !value.includes(item) && !exclude?.includes(item))
const items = [
...(disallowBlocks ? [] : schema.blocks.filter(item => !value.includes(-item.id) && !exclude?.includes(-item.id))),
...schema.operations.filter(item => !value.includes(item.id) && !exclude?.includes(item.id))
]; ];
function handleDelete(target: number) { function handleDelete(target: IOssItem) {
onChange(value.filter(item => item !== target)); onChange(value.filter(item => item !== target));
} }
function handleSelect(target: RO<IOssItem> | null) { function handleSelect(target: IOssItem | null) {
if (target) { if (target) {
setLastSelected(target); setLastSelected(target);
onChange([...value, getItemID(target)]); onChange([...value, target]);
setTimeout(() => setLastSelected(null), SELECTION_CLEAR_TIMEOUT); setTimeout(() => setLastSelected(null), SELECTION_CLEAR_TIMEOUT);
} }
} }
function handleMoveUp(target: number) { function handleMoveUp(target: IOssItem) {
const index = value.indexOf(target); const index = value.indexOf(target);
if (index > 0) { if (index > 0) {
const newSelected = [...value]; const newSelected = [...value];
@ -69,7 +64,7 @@ export function PickContents({
} }
} }
function handleMoveDown(target: number) { function handleMoveDown(target: IOssItem) {
const index = value.indexOf(target); const index = value.indexOf(target);
if (index < value.length - 1) { if (index < value.length - 1) {
const newSelected = [...value]; const newSelected = [...value];
@ -80,13 +75,13 @@ export function PickContents({
} }
const columns = [ const columns = [
columnHelper.accessor(item => isOperation(item), { columnHelper.accessor(item => item.nodeType === NodeType.OPERATION, {
id: 'type', id: 'type',
header: 'Тип', header: 'Тип',
size: 150, size: 150,
minSize: 150, minSize: 150,
maxSize: 150, maxSize: 150,
cell: props => <div>{isOperation(props.row.original) ? 'Операция' : 'Блок'}</div> cell: props => <div>{props.getValue() ? 'Операция' : 'Блок'}</div>
}), }),
columnHelper.accessor('title', { columnHelper.accessor('title', {
id: 'title', id: 'title',
@ -106,21 +101,21 @@ export function PickContents({
noHover noHover
className='px-0' className='px-0'
icon={<IconRemove size='1rem' className='icon-red' />} icon={<IconRemove size='1rem' className='icon-red' />}
onClick={() => handleDelete(getItemID(props.row.original))} onClick={() => handleDelete(props.row.original)}
/> />
<MiniButton <MiniButton
title='Переместить выше' title='Переместить выше'
noHover noHover
className='px-0' className='px-0'
icon={<IconMoveUp size='1rem' className='icon-primary' />} icon={<IconMoveUp size='1rem' className='icon-primary' />}
onClick={() => handleMoveUp(getItemID(props.row.original))} onClick={() => handleMoveUp(props.row.original)}
/> />
<MiniButton <MiniButton
title='Переместить ниже' title='Переместить ниже'
noHover noHover
className='px-0' className='px-0'
icon={<IconMoveDown size='1rem' className='icon-primary' />} icon={<IconMoveDown size='1rem' className='icon-primary' />}
onClick={() => handleMoveDown(getItemID(props.row.original))} onClick={() => handleMoveDown(props.row.original)}
/> />
</div> </div>
) )
@ -134,7 +129,7 @@ export function PickContents({
items={items} items={items}
value={lastSelected} value={lastSelected}
placeholder='Выберите операцию или блок' placeholder='Выберите операцию или блок'
idFunc={item => String(getItemID(item))} idFunc={item => item.nodeID}
labelValueFunc={item => labelOssItem(item)} labelValueFunc={item => labelOssItem(item)}
labelOptionFunc={item => labelOssItem(item)} labelOptionFunc={item => labelOssItem(item)}
onChange={handleSelect} onChange={handleSelect}
@ -145,7 +140,7 @@ export function PickContents({
rows={rows} rows={rows}
contentHeight='1.3rem' contentHeight='1.3rem'
className='cc-scroll-y text-sm select-none border-y rounded-b-md' className='cc-scroll-y text-sm select-none border-y rounded-b-md'
data={selectedItems} data={value}
columns={columns} columns={columns}
headPosition='0rem' headPosition='0rem'
noDataComponent={ noDataComponent={

View File

@ -1,8 +1,7 @@
import { Tooltip } from '@/components/container'; import { Tooltip } from '@/components/container';
import { globalIDs } from '@/utils/constants'; import { globalIDs } from '@/utils/constants';
import { type IBlock, type IOperation } from '../models/oss'; import { NodeType } from '../models/oss';
import { isOperation } from '../models/oss-api';
import { useOperationTooltipStore } from '../stores/operation-tooltip'; import { useOperationTooltipStore } from '../stores/operation-tooltip';
import { InfoBlock } from './info-block'; import { InfoBlock } from './info-block';
@ -10,7 +9,7 @@ import { InfoOperation } from './info-operation';
export function OperationTooltip() { export function OperationTooltip() {
const hoverItem = useOperationTooltipStore(state => state.hoverItem); const hoverItem = useOperationTooltipStore(state => state.hoverItem);
const isOperationNode = isOperation(hoverItem); const isOperationNode = hoverItem?.nodeType === NodeType.OPERATION;
return ( return (
<Tooltip <Tooltip
@ -20,8 +19,8 @@ export function OperationTooltip() {
className='max-w-140 dense max-h-120! overflow-y-auto!' className='max-w-140 dense max-h-120! overflow-y-auto!'
hidden={!hoverItem} hidden={!hoverItem}
> >
{hoverItem && isOperationNode ? <InfoOperation operation={hoverItem as IOperation} /> : null} {hoverItem && isOperationNode ? <InfoOperation operation={hoverItem} /> : null}
{hoverItem && !isOperationNode ? <InfoBlock block={hoverItem as IBlock} /> : null} {hoverItem && !isOperationNode ? <InfoBlock block={hoverItem} /> : null}
</Tooltip> </Tooltip>
); );
} }

View File

@ -12,6 +12,7 @@ import { useDialogsStore } from '@/stores/dialogs';
import { type ICreateBlockDTO, schemaCreateBlock } from '../../backend/types'; import { type ICreateBlockDTO, schemaCreateBlock } from '../../backend/types';
import { useCreateBlock } from '../../backend/use-create-block'; import { useCreateBlock } from '../../backend/use-create-block';
import { type IOssItem, NodeType } from '../../models/oss';
import { type LayoutManager } from '../../models/oss-layout-api'; import { type LayoutManager } from '../../models/oss-layout-api';
import { BLOCK_NODE_MIN_HEIGHT, BLOCK_NODE_MIN_WIDTH } from '../../pages/oss-page/editor-oss-graph/graph/block-node'; import { BLOCK_NODE_MIN_HEIGHT, BLOCK_NODE_MIN_WIDTH } from '../../pages/oss-page/editor-oss-graph/graph/block-node';
@ -20,7 +21,7 @@ import { TabBlockChildren } from './tab-block-children';
export interface DlgCreateBlockProps { export interface DlgCreateBlockProps {
manager: LayoutManager; manager: LayoutManager;
initialChildren: number[]; initialChildren: IOssItem[];
initialParent: number | null; initialParent: number | null;
defaultX: number; defaultX: number;
defaultY: number; defaultY: number;
@ -52,8 +53,8 @@ export function DlgCreateBlock() {
position_y: defaultY, position_y: defaultY,
width: BLOCK_NODE_MIN_WIDTH, width: BLOCK_NODE_MIN_WIDTH,
height: BLOCK_NODE_MIN_HEIGHT, height: BLOCK_NODE_MIN_HEIGHT,
children_blocks: initialChildren.filter(id => id < 0).map(id => -id), children_blocks: initialChildren.filter(item => item.nodeType === NodeType.BLOCK).map(item => item.id),
children_operations: initialChildren.filter(id => id > 0), children_operations: initialChildren.filter(item => item.nodeType === NodeType.OPERATION).map(item => item.id),
layout: manager.layout layout: manager.layout
}, },
mode: 'onChange' mode: 'onChange'
@ -65,11 +66,12 @@ export function DlgCreateBlock() {
const isValid = !!title && !manager.oss.blocks.some(block => block.title === title); const isValid = !!title && !manager.oss.blocks.some(block => block.title === title);
function onSubmit(data: ICreateBlockDTO) { function onSubmit(data: ICreateBlockDTO) {
const rectangle = manager.calculateNewBlockPosition(data); const rectangle = manager.newBlockPosition(data);
data.position_x = rectangle.x; data.position_x = rectangle.x;
data.position_y = rectangle.y; data.position_y = rectangle.y;
data.width = rectangle.width; data.width = rectangle.width;
data.height = rectangle.height; data.height = rectangle.height;
data.layout = manager.layout;
void createBlock({ itemID: manager.oss.id, data: data }).then(response => onCreate?.(response.new_block.id)); void createBlock({ itemID: manager.oss.id, data: data }).then(response => onCreate?.(response.new_block.id));
} }

View File

@ -7,6 +7,8 @@ import { useDialogsStore } from '@/stores/dialogs';
import { type ICreateBlockDTO } from '../../backend/types'; import { type ICreateBlockDTO } from '../../backend/types';
import { SelectParent } from '../../components/select-parent'; import { SelectParent } from '../../components/select-parent';
import { NodeType } from '../../models/oss';
import { constructNodeID } from '../../models/oss-api';
import { type DlgCreateBlockProps } from './dlg-create-block'; import { type DlgCreateBlockProps } from './dlg-create-block';
@ -18,10 +20,8 @@ export function TabBlockCard() {
formState: { errors } formState: { errors }
} = useFormContext<ICreateBlockDTO>(); } = useFormContext<ICreateBlockDTO>();
const children_blocks = useWatch({ control, name: 'children_blocks' }); const children_blocks = useWatch({ control, name: 'children_blocks' });
const all_children = [ const block_ids = children_blocks.map(id => constructNodeID(NodeType.BLOCK, id));
...children_blocks, const all_children = [...block_ids, ...manager.oss.hierarchy.expandAllOutputs(block_ids)];
...manager.oss.hierarchy.expandAllOutputs(children_blocks.filter(id => id < 0).map(id => -id)).map(id => -id)
];
return ( return (
<div className='cc-fade-in cc-column'> <div className='cc-fade-in cc-column'>
@ -36,7 +36,7 @@ export function TabBlockCard() {
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<SelectParent <SelectParent
items={manager.oss.blocks.filter(block => !all_children.includes(block.id))} items={manager.oss.blocks.filter(block => !all_children.includes(block.nodeID))}
value={field.value ? manager.oss.blockByID.get(field.value) ?? null : null} value={field.value ? manager.oss.blockByID.get(field.value) ?? null : null}
placeholder='Блок содержания не выбран' placeholder='Блок содержания не выбран'
onChange={value => field.onChange(value ? value.id : null)} onChange={value => field.onChange(value ? value.id : null)}

View File

@ -6,6 +6,7 @@ import { useDialogsStore } from '@/stores/dialogs';
import { type ICreateBlockDTO } from '../../backend/types'; import { type ICreateBlockDTO } from '../../backend/types';
import { PickContents } from '../../components/pick-contents'; import { PickContents } from '../../components/pick-contents';
import { type IOssItem, NodeType } from '../../models/oss';
import { type DlgCreateBlockProps } from './dlg-create-block'; import { type DlgCreateBlockProps } from './dlg-create-block';
@ -15,19 +16,31 @@ export function TabBlockChildren() {
const parent = useWatch({ control, name: 'item_data.parent' }); const parent = useWatch({ control, name: 'item_data.parent' });
const children_blocks = useWatch({ control, name: 'children_blocks' }); const children_blocks = useWatch({ control, name: 'children_blocks' });
const children_operations = useWatch({ control, name: 'children_operations' }); const children_operations = useWatch({ control, name: 'children_operations' });
const exclude = parent ? [-parent, ...manager.oss.hierarchy.expandAllInputs([-parent]).filter(id => id < 0)] : [];
const value = [...children_blocks.map(id => -id), ...children_operations]; const parentItem = parent ? manager.oss.blockByID.get(parent) : null;
const internalBlocks = parentItem
? manager.oss.hierarchy
.expandAllInputs([parentItem.nodeID])
.map(id => manager.oss.itemByNodeID.get(id))
.filter(item => item !== null && item?.nodeType === NodeType.BLOCK)
: [];
function handleChangeSelected(newValue: number[]) { const exclude = parentItem ? [parentItem, ...internalBlocks] : [];
const value = [
...children_blocks.map(id => manager.oss.blockByID.get(id)!),
...children_operations.map(id => manager.oss.operationByID.get(id)!)
];
function handleChangeSelected(newValue: IOssItem[]) {
setValue( setValue(
'children_blocks', 'children_blocks',
newValue.filter(id => id < 0).map(id => -id), newValue.filter(item => item.nodeType === NodeType.BLOCK).map(item => item.id),
{ shouldValidate: true } { shouldValidate: true }
); );
setValue( setValue(
'children_operations', 'children_operations',
newValue.filter(id => id > 0), newValue.filter(item => item.nodeType === NodeType.OPERATION).map(item => item.id),
{ shouldValidate: true } { shouldValidate: true }
); );
} }

View File

@ -64,9 +64,10 @@ export function DlgCreateOperation() {
const isValid = !!alias && !manager.oss.operations.some(operation => operation.alias === alias); const isValid = !!alias && !manager.oss.operations.some(operation => operation.alias === alias);
function onSubmit(data: ICreateOperationDTO) { function onSubmit(data: ICreateOperationDTO) {
const target = manager.calculateNewOperationPosition(data); const target = manager.newOperationPosition(data);
data.position_x = target.x; data.position_x = target.x;
data.position_y = target.y; data.position_y = target.y;
data.layout = manager.layout;
void createOperation({ itemID: manager.oss.id, data: data }).then(response => void createOperation({ itemID: manager.oss.id, data: data }).then(response =>
onCreate?.(response.new_operation.id) onCreate?.(response.new_operation.id)
); );

View File

@ -44,6 +44,7 @@ export function DlgEditBlock() {
function onSubmit(data: IUpdateBlockDTO) { function onSubmit(data: IUpdateBlockDTO) {
if (data.item_data.parent !== target.parent) { if (data.item_data.parent !== target.parent) {
manager.onBlockChangeParent(data.target, data.item_data.parent); manager.onBlockChangeParent(data.target, data.item_data.parent);
data.layout = manager.layout;
} }
return updateBlock({ itemID: manager.oss.id, data }); return updateBlock({ itemID: manager.oss.id, data });
} }

View File

@ -60,6 +60,7 @@ export function DlgEditOperation() {
function onSubmit(data: IUpdateOperationDTO) { function onSubmit(data: IUpdateOperationDTO) {
if (data.item_data.parent !== target.parent) { if (data.item_data.parent !== target.parent) {
manager.onOperationChangeParent(data.target, data.item_data.parent); manager.onOperationChangeParent(data.target, data.item_data.parent);
data.layout = manager.layout;
} }
return updateOperation({ itemID: manager.oss.id, data }); return updateOperation({ itemID: manager.oss.id, data });
} }

View File

@ -5,9 +5,9 @@ import {
type IOperation, type IOperation,
type IOssItem, type IOssItem,
type ISubstitutionErrorDescription, type ISubstitutionErrorDescription,
NodeType,
SubstitutionErrorType SubstitutionErrorType
} from './models/oss'; } from './models/oss';
import { isOperation } from './models/oss-api';
/** Retrieves label for {@link OperationType}. */ /** Retrieves label for {@link OperationType}. */
export function labelOperationType(itemType: OperationType): string { export function labelOperationType(itemType: OperationType): string {
@ -58,7 +58,7 @@ export function describeSubstitutionError(error: RO<ISubstitutionErrorDescriptio
/** Retrieves label for {@link IOssItem}. */ /** Retrieves label for {@link IOssItem}. */
export function labelOssItem(item: RO<IOssItem>): string { export function labelOssItem(item: RO<IOssItem>): string {
if (isOperation(item)) { if (item.nodeType === NodeType.OPERATION) {
return `${(item as IOperation).alias}: ${item.title}`; return `${(item as IOperation).alias}: ${item.title}`;
} else { } else {
return `Блок: ${item.title}`; return `Блок: ${item.title}`;

View File

@ -20,23 +20,16 @@ import {
} from '@/features/rsform/models/rslang-api'; } from '@/features/rsform/models/rslang-api';
import { infoMsg } from '@/utils/labels'; import { infoMsg } from '@/utils/labels';
import { type RO } from '@/utils/meta';
import { Graph } from '../../../models/graph'; import { Graph } from '../../../models/graph';
import { describeSubstitutionError } from '../labels'; import { describeSubstitutionError } from '../labels';
import { type IOperationSchema, type IOssItem, SubstitutionErrorType } from './oss'; import { type IOperationSchema, NodeType, SubstitutionErrorType } from './oss';
const STARTING_SUB_INDEX = 900; // max semantic index for starting substitution const STARTING_SUB_INDEX = 900; // max semantic index for starting substitution
/** Checks if element is {@link IOperation} or {@link IBlock}. */ export function constructNodeID(type: NodeType, itemID: number): string {
export function isOperation(item: RO<IOssItem> | null): boolean { return type === NodeType.OPERATION ? 'o' + String(itemID) : 'b' + String(itemID);
return !!item && 'arguments' in item;
}
/** Extract contiguous ID of {@link IOperation} or {@link IBlock}. */
export function getItemID(item: RO<IOssItem>): number {
return isOperation(item) ? item.id : -item.id;
} }
/** Sorts library items relevant for the specified {@link IOperationSchema}. */ /** Sorts library items relevant for the specified {@link IOperationSchema}. */

View File

@ -1,4 +1,10 @@
import { type ICreateBlockDTO, type ICreateOperationDTO, type IOssLayout } from '../backend/types'; import {
type IBlockPosition,
type ICreateBlockDTO,
type ICreateOperationDTO,
type IOperationPosition,
type IOssLayout
} from '../backend/types';
import { type IOperationSchema } from './oss'; import { type IOperationSchema } from './oss';
import { type Position2D, type Rectangle2D } from './oss-layout'; import { type Position2D, type Rectangle2D } from './oss-layout';
@ -26,94 +32,105 @@ export class LayoutManager {
} }
/** Calculate insert position for a new {@link IOperation} */ /** Calculate insert position for a new {@link IOperation} */
calculateNewOperationPosition(data: ICreateOperationDTO): Position2D { newOperationPosition(data: ICreateOperationDTO): Position2D {
// TODO: check parent node let result = { x: data.position_x, y: data.position_y };
const result = { x: data.position_x, y: data.position_y };
const operations = this.layout.operations; const operations = this.layout.operations;
const parentNode = this.layout.blocks.find(pos => pos.id === data.item_data.parent);
if (operations.length === 0) { if (operations.length === 0) {
return result; return result;
} }
if (data.arguments.length === 0) { if (data.arguments.length !== 0) {
let inputsPositions = operations.filter(pos => result = calculatePositionFromArgs(data.arguments, operations);
this.oss.operations.find(operation => operation.arguments.length === 0 && operation.id === pos.id) } else if (parentNode) {
); result.x = parentNode.x + MIN_DISTANCE;
if (inputsPositions.length === 0) { result.y = parentNode.y + MIN_DISTANCE;
inputsPositions = operations;
}
const maxX = Math.max(...inputsPositions.map(node => node.x));
const minY = Math.min(...inputsPositions.map(node => node.y));
result.x = maxX + DISTANCE_X;
result.y = minY;
} else { } else {
const argNodes = operations.filter(pos => data.arguments.includes(pos.id)); result = this.calculatePositionForFreeOperation(result);
const maxY = Math.max(...argNodes.map(node => node.y));
const minX = Math.min(...argNodes.map(node => node.x));
const maxX = Math.max(...argNodes.map(node => node.x));
result.x = Math.ceil((maxX + minX) / 2 / GRID_SIZE) * GRID_SIZE;
result.y = maxY + DISTANCE_Y;
} }
let flagIntersect = false; result = preventOverlap(
do { { ...result, width: OPERATION_NODE_WIDTH, height: OPERATION_NODE_HEIGHT },
flagIntersect = operations.some( operations.map(node => ({ ...node, width: OPERATION_NODE_WIDTH, height: OPERATION_NODE_HEIGHT }))
position => Math.abs(position.x - result.x) < MIN_DISTANCE && Math.abs(position.y - result.y) < MIN_DISTANCE
); );
if (flagIntersect) {
result.x += MIN_DISTANCE; if (parentNode) {
result.y += MIN_DISTANCE; const borderX = result.x + OPERATION_NODE_WIDTH + MIN_DISTANCE;
const borderY = result.y + OPERATION_NODE_HEIGHT + MIN_DISTANCE;
if (borderX > parentNode.x + parentNode.width) {
parentNode.width = borderX - parentNode.x;
} }
} while (flagIntersect); if (borderY > parentNode.y + parentNode.height) {
return result; parentNode.height = borderY - parentNode.y;
}
// TODO: trigger cascading updates
}
return { x: result.x, y: result.y };
} }
/** Calculate insert position for a new {@link IBlock} */ /** Calculate insert position for a new {@link IBlock} */
calculateNewBlockPosition(data: ICreateBlockDTO): Rectangle2D { newBlockPosition(data: ICreateBlockDTO): Rectangle2D {
const block_nodes = data.children_blocks const block_nodes = data.children_blocks
.map(id => this.layout.blocks.find(block => block.id === id)) .map(id => this.layout.blocks.find(block => block.id === id))
.filter(node => !!node); .filter(node => !!node);
const operation_nodes = data.children_operations const operation_nodes = data.children_operations
.map(id => this.layout.operations.find(operation => operation.id === id)) .map(id => this.layout.operations.find(operation => operation.id === id))
.filter(node => !!node); .filter(node => !!node);
const parentNode = this.layout.blocks.find(pos => pos.id === data.item_data.parent);
let result: Rectangle2D = { x: data.position_x, y: data.position_y, width: data.width, height: data.height };
if (block_nodes.length !== 0 || operation_nodes.length !== 0) {
result = calculatePositionFromChildren(
{ x: data.position_x, y: data.position_y, width: data.width, height: data.height },
operation_nodes,
block_nodes
);
} else if (parentNode) {
result = {
x: parentNode.x + MIN_DISTANCE,
y: parentNode.y + MIN_DISTANCE,
width: data.width,
height: data.height
};
} else {
result = this.calculatePositionForFreeBlock(result);
}
if (block_nodes.length === 0 && operation_nodes.length === 0) { if (block_nodes.length === 0 && operation_nodes.length === 0) {
return { x: data.position_x, y: data.position_y, width: data.width, height: data.height }; if (parentNode) {
const siblings = this.oss.blocks.filter(block => block.parent === parentNode.id).map(block => block.id);
if (siblings.length > 0) {
result = preventOverlap(
result,
this.layout.blocks.filter(block => siblings.includes(block.id))
);
}
} else {
const rootBlocks = this.oss.blocks.filter(block => block.parent === null).map(block => block.id);
if (rootBlocks.length > 0) {
result = preventOverlap(
result,
this.layout.blocks.filter(block => rootBlocks.includes(block.id))
);
}
}
} }
let left = undefined; if (parentNode) {
let top = undefined; const borderX = result.x + result.width + MIN_DISTANCE;
let right = undefined; const borderY = result.y + result.height + MIN_DISTANCE;
let bottom = undefined; if (borderX > parentNode.x + parentNode.width) {
parentNode.width = borderX - parentNode.x;
for (const block of block_nodes) { }
left = !left ? block.x - MIN_DISTANCE : Math.min(left, block.x - MIN_DISTANCE); if (borderY > parentNode.y + parentNode.height) {
top = !top ? block.y - MIN_DISTANCE : Math.min(top, block.y - MIN_DISTANCE); parentNode.height = borderY - parentNode.y;
right = !right }
? Math.max(left + data.width, block.x + block.width + MIN_DISTANCE) // TODO: trigger cascading updates
: Math.max(right, block.x + block.width + MIN_DISTANCE);
bottom = !bottom
? Math.max(top + data.height, block.y + block.height + MIN_DISTANCE)
: Math.max(bottom, block.y + block.height + MIN_DISTANCE);
} }
for (const operation of operation_nodes) { return result;
left = !left ? operation.x - MIN_DISTANCE : Math.min(left, operation.x - MIN_DISTANCE);
top = !top ? operation.y - MIN_DISTANCE : Math.min(top, operation.y - MIN_DISTANCE);
right = !right
? Math.max(left + data.width, operation.x + OPERATION_NODE_WIDTH + MIN_DISTANCE)
: Math.max(right, operation.x + OPERATION_NODE_WIDTH + MIN_DISTANCE);
bottom = !bottom
? Math.max(top + data.height, operation.y + OPERATION_NODE_HEIGHT + MIN_DISTANCE)
: Math.max(bottom, operation.y + OPERATION_NODE_HEIGHT + MIN_DISTANCE);
}
return {
x: left ?? data.position_x,
y: top ?? data.position_y,
width: right && left ? right - left : data.width,
height: bottom && top ? bottom - top : data.height
};
} }
/** Update layout when parent changes */ /** Update layout when parent changes */
@ -125,4 +142,126 @@ export class LayoutManager {
onBlockChangeParent(targetID: number, newParent: number | null) { onBlockChangeParent(targetID: number, newParent: number | null) {
console.error('not implemented', targetID, newParent); console.error('not implemented', targetID, newParent);
} }
private calculatePositionForFreeOperation(initial: Position2D): Position2D {
const operations = this.layout.operations;
if (operations.length === 0) {
return initial;
}
const freeInputs = this.oss.operations
.filter(operation => operation.arguments.length === 0 && operation.parent === null)
.map(operation => operation.id);
let inputsPositions = operations.filter(pos => freeInputs.includes(pos.id));
if (inputsPositions.length === 0) {
inputsPositions = operations;
}
const maxX = Math.max(...inputsPositions.map(node => node.x));
const minY = Math.min(...inputsPositions.map(node => node.y));
return {
x: maxX + DISTANCE_X,
y: minY
};
}
private calculatePositionForFreeBlock(initial: Rectangle2D): Rectangle2D {
const rootBlocks = this.oss.blocks.filter(block => block.parent === null).map(block => block.id);
const blocksPositions = this.layout.blocks.filter(pos => rootBlocks.includes(pos.id));
if (blocksPositions.length === 0) {
return initial;
}
const maxX = Math.max(...blocksPositions.map(node => node.x + node.width));
const minY = Math.min(...blocksPositions.map(node => node.y));
return { ...initial, x: maxX + MIN_DISTANCE, y: minY };
}
}
// ======= Internals =======
function rectanglesOverlap(a: Rectangle2D, b: Rectangle2D): boolean {
return !(
a.x + a.width + MIN_DISTANCE <= b.x ||
b.x + b.width + MIN_DISTANCE <= a.x ||
a.y + a.height + MIN_DISTANCE <= b.y ||
b.y + b.height + MIN_DISTANCE <= a.y
);
}
function getOverlapAmount(a: Rectangle2D, b: Rectangle2D): Position2D {
const xOverlap = Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x));
const yOverlap = Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y));
return { x: xOverlap, y: yOverlap };
}
function preventOverlap(target: Rectangle2D, fixedRectangles: Rectangle2D[]): Rectangle2D {
let hasOverlap: boolean;
do {
hasOverlap = false;
for (const fixed of fixedRectangles) {
if (rectanglesOverlap(target, fixed)) {
hasOverlap = true;
const overlap = getOverlapAmount(target, fixed);
if (overlap.x >= overlap.y) {
target.x += overlap.x + MIN_DISTANCE;
} else {
target.y += overlap.y + MIN_DISTANCE;
}
break;
}
}
} while (hasOverlap);
return target;
}
function calculatePositionFromArgs(args: number[], operations: IOperationPosition[]): Position2D {
const argNodes = operations.filter(pos => args.includes(pos.id));
const maxY = Math.max(...argNodes.map(node => node.y));
const minX = Math.min(...argNodes.map(node => node.x));
const maxX = Math.max(...argNodes.map(node => node.x));
return {
x: Math.ceil((maxX + minX) / 2 / GRID_SIZE) * GRID_SIZE,
y: maxY + DISTANCE_Y
};
}
function calculatePositionFromChildren(
initial: Rectangle2D,
operations: IOperationPosition[],
blocks: IBlockPosition[]
): Rectangle2D {
let left = undefined;
let top = undefined;
let right = undefined;
let bottom = undefined;
for (const block of blocks) {
left = left === undefined ? block.x - MIN_DISTANCE : Math.min(left, block.x - MIN_DISTANCE);
top = top === undefined ? block.y - MIN_DISTANCE : Math.min(top, block.y - MIN_DISTANCE);
right =
right === undefined
? Math.max(left + initial.width, block.x + block.width + MIN_DISTANCE)
: Math.max(right, block.x + block.width + MIN_DISTANCE);
bottom = !bottom
? Math.max(top + initial.height, block.y + block.height + MIN_DISTANCE)
: Math.max(bottom, block.y + block.height + MIN_DISTANCE);
}
for (const operation of operations) {
left = left === undefined ? operation.x - MIN_DISTANCE : Math.min(left, operation.x - MIN_DISTANCE);
top = top === undefined ? operation.y - MIN_DISTANCE : Math.min(top, operation.y - MIN_DISTANCE);
right =
right === undefined
? Math.max(left + initial.width, operation.x + OPERATION_NODE_WIDTH + MIN_DISTANCE)
: Math.max(right, operation.x + OPERATION_NODE_WIDTH + MIN_DISTANCE);
bottom = !bottom
? Math.max(top + initial.height, operation.y + OPERATION_NODE_HEIGHT + MIN_DISTANCE)
: Math.max(bottom, operation.y + OPERATION_NODE_HEIGHT + MIN_DISTANCE);
}
return {
x: left ?? initial.x,
y: top ?? initial.y,
width: right !== undefined && left !== undefined ? right - left : initial.width,
height: bottom !== undefined && top !== undefined ? bottom - top : initial.height
};
} }

View File

@ -11,8 +11,17 @@ import {
type IOperationSchemaDTO type IOperationSchemaDTO
} from '../backend/types'; } from '../backend/types';
/** Represents OSS node type. */
export const NodeType = {
OPERATION: 1,
BLOCK: 2
} as const;
export type NodeType = (typeof NodeType)[keyof typeof NodeType];
/** Represents Operation. */ /** Represents Operation. */
export interface IOperation extends IOperationDTO { export interface IOperation extends IOperationDTO {
nodeID: string;
nodeType: typeof NodeType.OPERATION;
x: number; x: number;
y: number; y: number;
is_owned: boolean; is_owned: boolean;
@ -23,12 +32,17 @@ export interface IOperation extends IOperationDTO {
/** Represents Block. */ /** Represents Block. */
export interface IBlock extends IBlockDTO { export interface IBlock extends IBlockDTO {
nodeID: string;
nodeType: typeof NodeType.BLOCK;
x: number; x: number;
y: number; y: number;
width: number; width: number;
height: number; height: number;
} }
/** Represents item of OperationSchema. */
export type IOssItem = IOperation | IBlock;
/** Represents {@link IOperationSchema} statistics. */ /** Represents {@link IOperationSchema} statistics. */
export interface IOperationSchemaStats { export interface IOperationSchemaStats {
count_all: number; count_all: number;
@ -45,16 +59,14 @@ export interface IOperationSchema extends IOperationSchemaDTO {
blocks: IBlock[]; blocks: IBlock[];
graph: Graph; graph: Graph;
hierarchy: Graph; hierarchy: Graph<string>;
schemas: number[]; schemas: number[];
stats: IOperationSchemaStats; stats: IOperationSchemaStats;
operationByID: Map<number, IOperation>; operationByID: Map<number, IOperation>;
blockByID: Map<number, IBlock>; blockByID: Map<number, IBlock>;
itemByNodeID: Map<string, IOssItem>;
} }
/** Represents item of OperationSchema. */
export type IOssItem = IOperation | IBlock;
/** Represents substitution error description. */ /** Represents substitution error description. */
export interface ISubstitutionErrorDescription { export interface ISubstitutionErrorDescription {
errorType: SubstitutionErrorType; errorType: SubstitutionErrorType;

View File

@ -4,8 +4,7 @@ import { useRef } from 'react';
import { Dropdown } from '@/components/dropdown'; import { Dropdown } from '@/components/dropdown';
import { type IBlock, type IOperation, type IOssItem } from '../../../../models/oss'; import { type IOssItem, NodeType } from '../../../../models/oss';
import { isOperation } from '../../../../models/oss-api';
import { MenuBlock } from './menu-block'; import { MenuBlock } from './menu-block';
import { MenuOperation } from './menu-operation'; import { MenuOperation } from './menu-operation';
@ -27,7 +26,6 @@ interface ContextMenuProps extends ContextMenuData {
export function ContextMenu({ isOpen, item, cursorX, cursorY, onHide }: ContextMenuProps) { export function ContextMenu({ isOpen, item, cursorX, cursorY, onHide }: ContextMenuProps) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const isOperationNode = isOperation(item);
function handleBlur(event: React.FocusEvent<HTMLDivElement>) { function handleBlur(event: React.FocusEvent<HTMLDivElement>) {
if (!ref.current?.contains(event.relatedTarget as Node)) { if (!ref.current?.contains(event.relatedTarget as Node)) {
@ -49,10 +47,10 @@ export function ContextMenu({ isOpen, item, cursorX, cursorY, onHide }: ContextM
margin={cursorY >= window.innerHeight - MENU_HEIGHT ? 'mb-3' : 'mt-3'} margin={cursorY >= window.innerHeight - MENU_HEIGHT ? 'mb-3' : 'mt-3'}
> >
{!!item ? ( {!!item ? (
isOperationNode ? ( item.nodeType === NodeType.OPERATION ? (
<MenuOperation operation={item as IOperation} onHide={onHide} /> <MenuOperation operation={item} onHide={onHide} />
) : ( ) : (
<MenuBlock block={item as IBlock} onHide={onHide} /> <MenuBlock block={item} onHide={onHide} />
) )
) : null} ) : null}
</Dropdown> </Dropdown>

View File

@ -16,15 +16,15 @@ export const BLOCK_NODE_MIN_WIDTH = 160;
export const BLOCK_NODE_MIN_HEIGHT = 100; export const BLOCK_NODE_MIN_HEIGHT = 100;
export function BlockNode(node: BlockInternalNode) { export function BlockNode(node: BlockInternalNode) {
const { selected, schema } = useOssEdit(); const { selectedItems, schema } = useOssEdit();
const dropTarget = useDraggingStore(state => state.dropTarget); const dropTarget = useDraggingStore(state => state.dropTarget);
const isDragging = useDraggingStore(state => state.isDragging); const isDragging = useDraggingStore(state => state.isDragging);
const showCoordinates = useOSSGraphStore(state => state.showCoordinates); const showCoordinates = useOSSGraphStore(state => state.showCoordinates);
const setHover = useOperationTooltipStore(state => state.setHoverItem); const setHover = useOperationTooltipStore(state => state.setHoverItem);
const focus = selected.length === 1 ? selected[0] : null; const focus = selectedItems.length === 1 ? selectedItems[0] : null;
const isParent = (!!focus && schema.hierarchy.at(focus)?.inputs.includes(-node.data.block.id)) ?? false; const isParent = (!!focus && schema.hierarchy.at(focus.nodeID)?.inputs.includes(node.data.block.nodeID)) ?? false;
const isChild = (!!focus && schema.hierarchy.at(focus)?.outputs.includes(-node.data.block.id)) ?? false; const isChild = (!!focus && schema.hierarchy.at(focus.nodeID)?.outputs.includes(node.data.block.nodeID)) ?? false;
return ( return (
<> <>
<NodeResizeControl minWidth={BLOCK_NODE_MIN_WIDTH} minHeight={BLOCK_NODE_MIN_HEIGHT}> <NodeResizeControl minWidth={BLOCK_NODE_MIN_WIDTH} minHeight={BLOCK_NODE_MIN_HEIGHT}>

View File

@ -21,9 +21,9 @@ interface NodeCoreProps {
} }
export function NodeCore({ node }: NodeCoreProps) { export function NodeCore({ node }: NodeCoreProps) {
const { selected, schema } = useOssEdit(); const { selectedItems, schema } = useOssEdit();
const focus = selected.length === 1 ? selected[0] : null; const focus = selectedItems.length === 1 ? selectedItems[0] : null;
const isChild = (!!focus && schema.hierarchy.at(focus)?.outputs.includes(node.data.operation.id)) ?? false; const isChild = (!!focus && schema.hierarchy.at(focus.nodeID)?.outputs.includes(node.data.operation.nodeID)) ?? false;
const setHover = useOperationTooltipStore(state => state.setHoverItem); const setHover = useOperationTooltipStore(state => state.setHoverItem);
const showCoordinates = useOSSGraphStore(state => state.showCoordinates); const showCoordinates = useOSSGraphStore(state => state.showCoordinates);

View File

@ -3,12 +3,12 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { type Edge, type Node, useEdgesState, useNodesState, useOnSelectionChange, useReactFlow } from 'reactflow'; import { type Edge, type Node, useEdgesState, useNodesState, useOnSelectionChange, useReactFlow } from 'reactflow';
import { type IOperationSchema } from '@/features/oss/models/oss';
import { type Position2D } from '@/features/oss/models/oss-layout';
import { useOSSGraphStore } from '@/features/oss/stores/oss-graph';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
import { type IOperationSchema, NodeType } from '../../../models/oss';
import { constructNodeID } from '../../../models/oss-api';
import { type Position2D } from '../../../models/oss-layout';
import { useOSSGraphStore } from '../../../stores/oss-graph';
import { useOssEdit } from '../oss-edit-context'; import { useOssEdit } from '../oss-edit-context';
import { flowOptions } from './oss-flow'; import { flowOptions } from './oss-flow';
@ -29,10 +29,10 @@ export const OssFlowState = ({ children }: React.PropsWithChildren) => {
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]); const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
function onSelectionChange({ nodes }: { nodes: Node[] }) { function onSelectionChange({ nodes }: { nodes: Node[] }) {
const ids = nodes.map(node => Number(node.id)); const ids = nodes.map(node => node.id);
setSelected(prev => [ setSelected(prev => [
...prev.filter(nodeID => ids.includes(nodeID)), ...prev.filter(nodeID => ids.includes(nodeID)),
...ids.filter(nodeID => !prev.includes(Number(nodeID))) ...ids.filter(nodeID => !prev.includes(nodeID))
]); ]);
} }
@ -41,46 +41,45 @@ export const OssFlowState = ({ children }: React.PropsWithChildren) => {
}); });
const resetGraph = useCallback(() => { const resetGraph = useCallback(() => {
const newNodes: Node[] = [ const newNodes: Node[] = schema.hierarchy.topologicalOrder().map(nodeID => {
...schema.hierarchy const item = schema.itemByNodeID.get(nodeID)!;
.topologicalOrder() if (item.nodeType === NodeType.BLOCK) {
.filter(id => id < 0)
.map(id => {
const block = schema.blockByID.get(-id)!;
return { return {
id: String(id), id: nodeID,
type: 'block', type: 'block',
data: { label: block.title, block: block }, data: { label: item.title, block: item },
position: computeRelativePosition(schema, { x: block.x, y: block.y }, block.parent), position: computeRelativePosition(schema, { x: item.x, y: item.y }, item.parent),
style: { style: {
width: block.width, width: item.width,
height: block.height height: item.height
}, },
parentId: block.parent ? `-${block.parent}` : undefined, parentId: item.parent ? constructNodeID(NodeType.BLOCK, item.parent) : undefined,
zIndex: Z_BLOCK zIndex: Z_BLOCK
}; };
}), } else {
...schema.operations.map(operation => ({ return {
id: String(operation.id), id: item.nodeID,
type: operation.operation_type.toString(), type: item.operation_type.toString(),
data: { label: operation.alias, operation: operation }, data: { label: item.alias, operation: item },
position: computeRelativePosition(schema, { x: operation.x, y: operation.y }, operation.parent), position: computeRelativePosition(schema, { x: item.x, y: item.y }, item.parent),
parentId: operation.parent ? `-${operation.parent}` : undefined, parentId: item.parent ? constructNodeID(NodeType.BLOCK, item.parent) : undefined,
zIndex: Z_SCHEMA zIndex: Z_SCHEMA
})) };
]; }
});
const newEdges: Edge[] = schema.arguments.map((argument, index) => ({ const newEdges: Edge[] = schema.arguments.map((argument, index) => {
const source = schema.operationByID.get(argument.argument)!;
const target = schema.operationByID.get(argument.operation)!;
return {
id: String(index), id: String(index),
source: String(argument.argument), source: source.nodeID,
target: String(argument.operation), target: target.nodeID,
type: edgeStraight ? 'straight' : 'simplebezier', type: edgeStraight ? 'straight' : 'simplebezier',
animated: edgeAnimate, animated: edgeAnimate,
targetHandle: targetHandle: source.x > target.x ? 'right' : 'left'
schema.operationByID.get(argument.argument)!.x > schema.operationByID.get(argument.operation)!.x };
? 'right' });
: 'left'
}));
setNodes(newNodes); setNodes(newNodes);
setEdges(newEdges); setEdges(newEdges);

View File

@ -12,6 +12,7 @@ import { promptText } from '@/utils/labels';
import { useDeleteBlock } from '../../../backend/use-delete-block'; import { useDeleteBlock } from '../../../backend/use-delete-block';
import { useMutatingOss } from '../../../backend/use-mutating-oss'; import { useMutatingOss } from '../../../backend/use-mutating-oss';
import { useUpdateLayout } from '../../../backend/use-update-layout'; import { useUpdateLayout } from '../../../backend/use-update-layout';
import { type IOssItem, NodeType } from '../../../models/oss';
import { type OssNode, type Position2D } from '../../../models/oss-layout'; import { type OssNode, type Position2D } from '../../../models/oss-layout';
import { GRID_SIZE, LayoutManager } from '../../../models/oss-layout-api'; import { GRID_SIZE, LayoutManager } from '../../../models/oss-layout-api';
import { useOSSGraphStore } from '../../../stores/oss-graph'; import { useOSSGraphStore } from '../../../stores/oss-graph';
@ -41,7 +42,7 @@ export const flowOptions = {
export function OssFlow() { export function OssFlow() {
const mainHeight = useMainHeight(); const mainHeight = useMainHeight();
const { navigateOperationSchema, schema, selected, isMutable, canDeleteOperation } = useOssEdit(); const { navigateOperationSchema, schema, selected, selectedItems, isMutable, canDeleteOperation } = useOssEdit();
const { screenToFlowPosition } = useReactFlow(); const { screenToFlowPosition } = useReactFlow();
const { containMovement, nodes, onNodesChange, edges, onEdgesChange, resetGraph, resetView } = useOssFlow(); const { containMovement, nodes, onNodesChange, edges, onEdgesChange, resetGraph, resetView } = useOssFlow();
const store = useStoreApi(); const store = useStoreApi();
@ -76,20 +77,21 @@ export function OssFlow() {
manager: new LayoutManager(schema, getLayout()), manager: new LayoutManager(schema, getLayout()),
defaultX: targetPosition.x, defaultX: targetPosition.x,
defaultY: targetPosition.y, defaultY: targetPosition.y,
initialInputs: selected.filter(id => id > 0), initialInputs: selectedItems.filter(item => item?.nodeType === NodeType.OPERATION).map(item => item.id),
initialParent: extractSingleBlock(selected), initialParent: extractBlockParent(selectedItems),
onCreate: resetView onCreate: resetView
}); });
} }
function handleCreateBlock() { function handleCreateBlock() {
const targetPosition = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); const targetPosition = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
const parent = extractSingleBlock(selected); const parent = extractBlockParent(selectedItems);
showCreateBlock({ showCreateBlock({
manager: new LayoutManager(schema, getLayout()), manager: new LayoutManager(schema, getLayout()),
defaultX: targetPosition.x, defaultX: targetPosition.x,
defaultY: targetPosition.y, defaultY: targetPosition.y,
initialChildren: parent !== null ? [] : selected, initialChildren:
parent !== null && selectedItems.length === 1 && parent === selectedItems[0].id ? [] : selectedItems,
initialParent: parent, initialParent: parent,
onCreate: resetView onCreate: resetView
}); });
@ -99,25 +101,24 @@ export function OssFlow() {
if (selected.length !== 1) { if (selected.length !== 1) {
return; return;
} }
if (selected[0] > 0) { const item = schema.itemByNodeID.get(selected[0]);
const operation = schema.operationByID.get(selected[0]); if (!item) {
if (!operation || !canDeleteOperation(operation)) { return;
}
if (item.nodeType === NodeType.OPERATION) {
if (!canDeleteOperation(item)) {
return; return;
} }
showDeleteOperation({ showDeleteOperation({
oss: schema, oss: schema,
target: operation, target: item,
layout: getLayout() layout: getLayout()
}); });
} else { } else {
const block = schema.blockByID.get(-selected[0]);
if (!block) {
return;
}
if (!window.confirm(promptText.deleteBlock)) { if (!window.confirm(promptText.deleteBlock)) {
return; return;
} }
void deleteBlock({ itemID: schema.id, data: { target: block.id, layout: getLayout() } }); void deleteBlock({ itemID: schema.id, data: { target: item.id, layout: getLayout() } });
} }
} }
@ -219,7 +220,10 @@ export function OssFlow() {
} }
// -------- Internals -------- // -------- Internals --------
function extractSingleBlock(selected: number[]): number | null { function extractBlockParent(selectedItems: IOssItem[]): number | null {
const blocks = selected.filter(id => id < 0); if (selectedItems.length === 1 && selectedItems[0].nodeType === NodeType.BLOCK) {
return blocks.length === 1 ? -blocks[0] : null; return selectedItems[0].id;
}
const parents = selectedItems.map(item => item.parent).filter(id => id !== null);
return parents.length === 0 ? null : parents[0];
} }

View File

@ -27,6 +27,7 @@ import { OperationType } from '../../../backend/types';
import { useExecuteOperation } from '../../../backend/use-execute-operation'; import { useExecuteOperation } from '../../../backend/use-execute-operation';
import { useMutatingOss } from '../../../backend/use-mutating-oss'; import { useMutatingOss } from '../../../backend/use-mutating-oss';
import { useUpdateLayout } from '../../../backend/use-update-layout'; import { useUpdateLayout } from '../../../backend/use-update-layout';
import { NodeType } from '../../../models/oss';
import { LayoutManager } from '../../../models/oss-layout-api'; import { LayoutManager } from '../../../models/oss-layout-api';
import { useOssEdit } from '../oss-edit-context'; import { useOssEdit } from '../oss-edit-context';
@ -48,11 +49,13 @@ export function ToolbarOssGraph({
className, className,
...restProps ...restProps
}: ToolbarOssGraphProps) { }: ToolbarOssGraphProps) {
const { schema, selected, isMutable, canDeleteOperation: canDelete } = useOssEdit(); const { schema, selectedItems, isMutable, canDeleteOperation: canDelete } = useOssEdit();
const isProcessing = useMutatingOss(); const isProcessing = useMutatingOss();
const { resetView } = useOssFlow(); const { resetView } = useOssFlow();
const selectedOperation = selected.length !== 1 ? null : schema.operationByID.get(selected[0]) ?? null; const selectedOperation =
const selectedBlock = selected.length !== 1 ? null : schema.blockByID.get(-selected[0]) ?? null; selectedItems.length === 1 && selectedItems[0].nodeType === NodeType.OPERATION ? selectedItems[0] : null;
const selectedBlock =
selectedItems.length === 1 && selectedItems[0].nodeType === NodeType.BLOCK ? selectedItems[0] : null;
const getLayout = useGetLayout(); const getLayout = useGetLayout();
const { updateLayout } = useUpdateLayout(); const { updateLayout } = useUpdateLayout();
@ -145,7 +148,9 @@ export function ToolbarOssGraph({
title='Исправить позиции узлов' title='Исправить позиции узлов'
icon={<IconFixLayout size='1.25rem' className='icon-primary' />} icon={<IconFixLayout size='1.25rem' className='icon-primary' />}
onClick={handleFixLayout} onClick={handleFixLayout}
disabled={selected.length > 1 || selected[0] > 0} disabled={
selectedItems.length > 1 || (selectedItems.length > 0 && selectedItems[0].nodeType === NodeType.OPERATION)
}
/> />
<MiniButton <MiniButton
title='Настройки отображения' title='Настройки отображения'
@ -181,14 +186,14 @@ export function ToolbarOssGraph({
title='Активировать операцию' title='Активировать операцию'
icon={<IconExecute size='1.25rem' className='icon-green' />} icon={<IconExecute size='1.25rem' className='icon-green' />}
onClick={handleOperationExecute} onClick={handleOperationExecute}
disabled={isProcessing || selected.length !== 1 || !readyForSynthesis} disabled={isProcessing || selectedItems.length !== 1 || !readyForSynthesis}
/> />
<MiniButton <MiniButton
titleHtml={prepareTooltip('Редактировать выбранную', 'Двойной клик')} titleHtml={prepareTooltip('Редактировать выбранную', 'Двойной клик')}
aria-label='Редактировать выбранную' aria-label='Редактировать выбранную'
icon={<IconEdit2 size='1.25rem' className='icon-primary' />} icon={<IconEdit2 size='1.25rem' className='icon-primary' />}
onClick={handleEditItem} onClick={handleEditItem}
disabled={selected.length !== 1 || isProcessing} disabled={selectedItems.length !== 1 || isProcessing}
/> />
<MiniButton <MiniButton
titleHtml={prepareTooltip('Удалить выбранную', 'Delete')} titleHtml={prepareTooltip('Удалить выбранную', 'Delete')}

View File

@ -1,10 +1,10 @@
import { type Node } from 'reactflow'; import { type Node } from 'reactflow';
import { useMoveItems } from '@/features/oss/backend/use-move-items';
import { useThrottleCallback } from '@/hooks/use-throttle-callback'; import { useThrottleCallback } from '@/hooks/use-throttle-callback';
import { useDraggingStore } from '@/stores/dragging'; import { useDraggingStore } from '@/stores/dragging';
import { useMoveItems } from '../../../backend/use-move-items';
import { NodeType } from '../../../models/oss';
import { useOssEdit } from '../oss-edit-context'; import { useOssEdit } from '../oss-edit-context';
import { useOssFlow } from './oss-flow-context'; import { useOssFlow } from './oss-flow-context';
@ -21,6 +21,7 @@ interface DraggingProps {
export function useDragging({ hideContextMenu }: DraggingProps) { export function useDragging({ hideContextMenu }: DraggingProps) {
const { setContainMovement, containMovement, setNodes } = useOssFlow(); const { setContainMovement, containMovement, setNodes } = useOssFlow();
const setIsDragging = useDraggingStore(state => state.setIsDragging); const setIsDragging = useDraggingStore(state => state.setIsDragging);
const isDragging = useDraggingStore(state => state.isDragging);
const getLayout = useGetLayout(); const getLayout = useGetLayout();
const { selected, schema } = useOssEdit(); const { selected, schema } = useOssEdit();
const dropTarget = useDropTarget(); const dropTarget = useDropTarget();
@ -43,7 +44,7 @@ export function useDragging({ hideContextMenu }: DraggingProps) {
function handleDragStart(event: React.MouseEvent, target: Node) { function handleDragStart(event: React.MouseEvent, target: Node) {
if (event.shiftKey) { if (event.shiftKey) {
setContainMovement(true); setContainMovement(true);
applyContainMovement([target.id, ...selected.map(id => String(id))], true); applyContainMovement([target.id, ...selected], true);
} else { } else {
setContainMovement(false); setContainMovement(false);
dropTarget.update(event); dropTarget.update(event);
@ -61,24 +62,20 @@ export function useDragging({ hideContextMenu }: DraggingProps) {
function handleDragStop(event: React.MouseEvent, target: Node) { function handleDragStop(event: React.MouseEvent, target: Node) {
if (containMovement) { if (containMovement) {
applyContainMovement([target.id, ...selected.map(id => String(id))], false); applyContainMovement([target.id, ...selected], false);
} else { } else {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (isDragging) {
setIsDragging(false);
const new_parent = dropTarget.evaluate(event); const new_parent = dropTarget.evaluate(event);
const allSelected = [...selected.filter(id => id != Number(target.id)), Number(target.id)]; const allSelected = [...selected.filter(id => id != target.id), target.id].map(id =>
const operations = allSelected schema.itemByNodeID.get(id)
.filter(id => id > 0)
.map(id => schema.operationByID.get(id))
.filter(operation => !!operation);
const blocks = allSelected
.filter(id => id < 0)
.map(id => schema.blockByID.get(-id))
.filter(operation => !!operation);
const parents = new Set(
[...blocks.map(block => block.parent), ...operations.map(operation => operation.parent)].filter(id => !!id)
); );
const parents = new Set(allSelected.map(item => item?.parent).filter(id => !!id));
const operations = allSelected.filter(item => item?.nodeType === NodeType.OPERATION);
const blocks = allSelected.filter(item => item?.nodeType === NodeType.BLOCK);
if ( if (
(parents.size !== 1 || parents.values().next().value !== new_parent) && (parents.size !== 1 || parents.values().next().value !== new_parent) &&
(parents.size !== 0 || new_parent !== null) (parents.size !== 0 || new_parent !== null)
@ -94,8 +91,8 @@ export function useDragging({ hideContextMenu }: DraggingProps) {
}); });
} }
} }
}
setIsDragging(false);
setContainMovement(false); setContainMovement(false);
dropTarget.reset(); dropTarget.reset();
} }

View File

@ -1,5 +1,7 @@
import { useReactFlow } from 'reactflow'; import { useReactFlow } from 'reactflow';
import { NodeType } from '@/features/oss/models/oss';
import { useDraggingStore } from '@/stores/dragging'; import { useDraggingStore } from '@/stores/dragging';
import { useOssEdit } from '../oss-edit-context'; import { useOssEdit } from '../oss-edit-context';
@ -7,7 +9,7 @@ import { useOssEdit } from '../oss-edit-context';
/** Hook to encapsulate drop target logic. */ /** Hook to encapsulate drop target logic. */
export function useDropTarget() { export function useDropTarget() {
const { getIntersectingNodes, screenToFlowPosition } = useReactFlow(); const { getIntersectingNodes, screenToFlowPosition } = useReactFlow();
const { selected, schema } = useOssEdit(); const { selectedItems, selected, schema } = useOssEdit();
const dropTarget = useDraggingStore(state => state.dropTarget); const dropTarget = useDraggingStore(state => state.dropTarget);
const setDropTarget = useDraggingStore(state => state.setDropTarget); const setDropTarget = useDraggingStore(state => state.setDropTarget);
@ -19,17 +21,16 @@ export function useDropTarget() {
width: 1, width: 1,
height: 1 height: 1
}) })
.map(node => Number(node.id)) .filter(node => !selected.includes(node.id))
.filter(id => id < 0 && !selected.includes(id)) .map(node => schema.itemByNodeID.get(node.id))
.map(id => schema.blockByID.get(-id)) .filter(item => item?.nodeType === NodeType.BLOCK);
.filter(block => !!block);
if (blocks.length === 0) { if (blocks.length === 0) {
return null; return null;
} }
const successors = schema.hierarchy.expandAllOutputs([...selected]).filter(id => id < 0); const successors = schema.hierarchy.expandAllOutputs(selectedItems.map(item => item.nodeID));
blocks = blocks.filter(block => !successors.includes(-block.id)); blocks = blocks.filter(block => !successors.includes(block.nodeID));
if (blocks.length === 0) { if (blocks.length === 0) {
return null; return null;
} }

View File

@ -18,13 +18,13 @@ export function useGetLayout() {
operations: nodes operations: nodes
.filter(node => node.type !== 'block') .filter(node => node.type !== 'block')
.map(node => ({ .map(node => ({
id: Number(node.id), id: schema.itemByNodeID.get(node.id)!.id,
...computeAbsolutePosition(node, schema, nodeById) ...computeAbsolutePosition(node, schema, nodeById)
})), })),
blocks: nodes blocks: nodes
.filter(node => node.type === 'block') .filter(node => node.type === 'block')
.map(node => ({ .map(node => ({
id: -Number(node.id), id: schema.itemByNodeID.get(node.id)!.id,
...computeAbsolutePosition(node, schema, nodeById), ...computeAbsolutePosition(node, schema, nodeById),
width: node.width ?? BLOCK_NODE_MIN_WIDTH, width: node.width ?? BLOCK_NODE_MIN_WIDTH,
height: node.height ?? BLOCK_NODE_MIN_HEIGHT height: node.height ?? BLOCK_NODE_MIN_HEIGHT
@ -35,7 +35,7 @@ export function useGetLayout() {
// ------- Internals ------- // ------- Internals -------
function computeAbsolutePosition(target: Node, schema: IOperationSchema, nodeById: Map<string, Node>): Position2D { function computeAbsolutePosition(target: Node, schema: IOperationSchema, nodeById: Map<string, Node>): Position2D {
const nodes = schema.hierarchy.expandAllInputs([Number(target.id)]); const nodes = schema.hierarchy.expandAllInputs([target.id]);
let x = target.position.x; let x = target.position.x;
let y = target.position.y; let y = target.position.y;
for (const nodeID of nodes) { for (const nodeID of nodes) {

View File

@ -2,7 +2,7 @@
import { createContext, use } from 'react'; import { createContext, use } from 'react';
import { type IOperation, type IOperationSchema } from '../../models/oss'; import { type IOperation, type IOperationSchema, type IOssItem } from '../../models/oss';
export const OssTabID = { export const OssTabID = {
CARD: 0, CARD: 0,
@ -12,7 +12,8 @@ export type OssTabID = (typeof OssTabID)[keyof typeof OssTabID];
interface IOssEditContext { interface IOssEditContext {
schema: IOperationSchema; schema: IOperationSchema;
selected: number[]; selected: string[];
selectedItems: IOssItem[];
isOwned: boolean; isOwned: boolean;
isMutable: boolean; isMutable: boolean;
@ -22,7 +23,7 @@ interface IOssEditContext {
canDeleteOperation: (target: IOperation) => boolean; canDeleteOperation: (target: IOperation) => boolean;
deleteSchema: () => void; deleteSchema: () => void;
setSelected: React.Dispatch<React.SetStateAction<number[]>>; setSelected: React.Dispatch<React.SetStateAction<string[]>>;
} }
export const OssEditContext = createContext<IOssEditContext | null>(null); export const OssEditContext = createContext<IOssEditContext | null>(null);

View File

@ -38,7 +38,8 @@ export const OssEditState = ({ itemID, children }: React.PropsWithChildren<OssEd
const isMutable = role > UserRole.READER && !schema.read_only; const isMutable = role > UserRole.READER && !schema.read_only;
const isEditor = !!user.id && schema.editors.includes(user.id); const isEditor = !!user.id && schema.editors.includes(user.id);
const [selected, setSelected] = useState<number[]>([]); const [selected, setSelected] = useState<string[]>([]);
const selectedItems = selected.map(id => schema.itemByNodeID.get(id)).filter(item => !!item);
const { deleteItem } = useDeleteItem(); const { deleteItem } = useDeleteItem();
@ -92,6 +93,7 @@ export const OssEditState = ({ itemID, children }: React.PropsWithChildren<OssEd
value={{ value={{
schema, schema,
selected, selected,
selectedItems,
isOwned, isOwned,
isMutable, isMutable,

View File

@ -5,41 +5,41 @@
/** /**
* Represents single node of a {@link Graph}, as implemented by storing outgoing and incoming connections. * Represents single node of a {@link Graph}, as implemented by storing outgoing and incoming connections.
*/ */
export class GraphNode { export class GraphNode<NodeID> {
/** Unique identifier of the node. */ /** Unique identifier of the node. */
id: number; id: NodeID;
/** List of outgoing nodes. */ /** List of outgoing nodes. */
outputs: number[]; outputs: NodeID[];
/** List of incoming nodes. */ /** List of incoming nodes. */
inputs: number[]; inputs: NodeID[];
constructor(id: number) { constructor(id: NodeID) {
this.id = id; this.id = id;
this.outputs = []; this.outputs = [];
this.inputs = []; this.inputs = [];
} }
clone(): GraphNode { clone(): GraphNode<NodeID> {
const result = new GraphNode(this.id); const result = new GraphNode(this.id);
result.outputs = [...this.outputs]; result.outputs = [...this.outputs];
result.inputs = [...this.inputs]; result.inputs = [...this.inputs];
return result; return result;
} }
addOutput(node: number): void { addOutput(node: NodeID): void {
this.outputs.push(node); this.outputs.push(node);
} }
addInput(node: number): void { addInput(node: NodeID): void {
this.inputs.push(node); this.inputs.push(node);
} }
removeInput(target: number): number | null { removeInput(target: NodeID): NodeID | null {
const index = this.inputs.findIndex(node => node === target); const index = this.inputs.findIndex(node => node === target);
return index > -1 ? this.inputs.splice(index, 1)[0] : null; return index > -1 ? this.inputs.splice(index, 1)[0] : null;
} }
removeOutput(target: number): number | null { removeOutput(target: NodeID): NodeID | null {
const index = this.outputs.findIndex(node => node === target); const index = this.outputs.findIndex(node => node === target);
return index > -1 ? this.outputs.splice(index, 1)[0] : null; return index > -1 ? this.outputs.splice(index, 1)[0] : null;
} }
@ -50,11 +50,11 @@ export class GraphNode {
* *
* This class is optimized for TermGraph use case and not supposed to be used as generic graph implementation. * This class is optimized for TermGraph use case and not supposed to be used as generic graph implementation.
*/ */
export class Graph { export class Graph<NodeID = number> {
/** Map of nodes. */ /** Map of nodes. */
nodes = new Map<number, GraphNode>(); nodes = new Map<NodeID, GraphNode<NodeID>>();
constructor(arr?: number[][]) { constructor(arr?: NodeID[][]) {
if (!arr) { if (!arr) {
return; return;
} }
@ -67,17 +67,17 @@ export class Graph {
}); });
} }
clone(): Graph { clone(): Graph<NodeID> {
const result = new Graph(); const result = new Graph<NodeID>();
this.nodes.forEach(node => result.nodes.set(node.id, node.clone())); this.nodes.forEach(node => result.nodes.set(node.id, node.clone()));
return result; return result;
} }
at(target: number): GraphNode | undefined { at(target: NodeID): GraphNode<NodeID> | undefined {
return this.nodes.get(target); return this.nodes.get(target);
} }
addNode(target: number): GraphNode { addNode(target: NodeID): GraphNode<NodeID> {
let node = this.nodes.get(target); let node = this.nodes.get(target);
if (!node) { if (!node) {
node = new GraphNode(target); node = new GraphNode(target);
@ -86,11 +86,11 @@ export class Graph {
return node; return node;
} }
hasNode(target: number): boolean { hasNode(target: NodeID): boolean {
return !!this.nodes.get(target); return !!this.nodes.get(target);
} }
removeNode(target: number): void { removeNode(target: NodeID): void {
this.nodes.forEach(node => { this.nodes.forEach(node => {
node.removeInput(target); node.removeInput(target);
node.removeOutput(target); node.removeOutput(target);
@ -98,7 +98,7 @@ export class Graph {
this.nodes.delete(target); this.nodes.delete(target);
} }
foldNode(target: number): void { foldNode(target: NodeID): void {
const nodeToRemove = this.nodes.get(target); const nodeToRemove = this.nodes.get(target);
if (!nodeToRemove) { if (!nodeToRemove) {
return; return;
@ -111,8 +111,8 @@ export class Graph {
this.removeNode(target); this.removeNode(target);
} }
removeIsolated(): GraphNode[] { removeIsolated(): GraphNode<NodeID>[] {
const result: GraphNode[] = []; const result: GraphNode<NodeID>[] = [];
this.nodes.forEach(node => { this.nodes.forEach(node => {
if (node.outputs.length === 0 && node.inputs.length === 0) { if (node.outputs.length === 0 && node.inputs.length === 0) {
result.push(node); result.push(node);
@ -122,7 +122,7 @@ export class Graph {
return result; return result;
} }
addEdge(source: number, destination: number): void { addEdge(source: NodeID, destination: NodeID): void {
if (this.hasEdge(source, destination)) { if (this.hasEdge(source, destination)) {
return; return;
} }
@ -132,7 +132,7 @@ export class Graph {
destinationNode.addInput(sourceNode.id); destinationNode.addInput(sourceNode.id);
} }
removeEdge(source: number, destination: number): void { removeEdge(source: NodeID, destination: NodeID): void {
const sourceNode = this.nodes.get(source); const sourceNode = this.nodes.get(source);
const destinationNode = this.nodes.get(destination); const destinationNode = this.nodes.get(destination);
if (sourceNode && destinationNode) { if (sourceNode && destinationNode) {
@ -141,7 +141,7 @@ export class Graph {
} }
} }
hasEdge(source: number, destination: number): boolean { hasEdge(source: NodeID, destination: NodeID): boolean {
const sourceNode = this.nodes.get(source); const sourceNode = this.nodes.get(source);
if (!sourceNode) { if (!sourceNode) {
return false; return false;
@ -149,8 +149,8 @@ export class Graph {
return !!sourceNode.outputs.find(id => id === destination); return !!sourceNode.outputs.find(id => id === destination);
} }
expandOutputs(origin: number[]): number[] { expandOutputs(origin: NodeID[]): NodeID[] {
const result: number[] = []; const result: NodeID[] = [];
origin.forEach(id => { origin.forEach(id => {
const node = this.nodes.get(id); const node = this.nodes.get(id);
if (node) { if (node) {
@ -164,8 +164,8 @@ export class Graph {
return result; return result;
} }
expandInputs(origin: number[]): number[] { expandInputs(origin: NodeID[]): NodeID[] {
const result: number[] = []; const result: NodeID[] = [];
origin.forEach(id => { origin.forEach(id => {
const node = this.nodes.get(id); const node = this.nodes.get(id);
if (node) { if (node) {
@ -179,13 +179,13 @@ export class Graph {
return result; return result;
} }
expandAllOutputs(origin: number[]): number[] { expandAllOutputs(origin: NodeID[]): NodeID[] {
const result: number[] = this.expandOutputs(origin); const result: NodeID[] = this.expandOutputs(origin);
if (result.length === 0) { if (result.length === 0) {
return []; return [];
} }
const marked = new Map<number, boolean>(); const marked = new Map<NodeID, boolean>();
origin.forEach(id => marked.set(id, true)); origin.forEach(id => marked.set(id, true));
let position = 0; let position = 0;
while (position < result.length) { while (position < result.length) {
@ -203,13 +203,13 @@ export class Graph {
return result; return result;
} }
expandAllInputs(origin: number[]): number[] { expandAllInputs(origin: NodeID[]): NodeID[] {
const result: number[] = this.expandInputs(origin); const result: NodeID[] = this.expandInputs(origin);
if (result.length === 0) { if (result.length === 0) {
return []; return [];
} }
const marked = new Map<number, boolean>(); const marked = new Map<NodeID, boolean>();
origin.forEach(id => marked.set(id, true)); origin.forEach(id => marked.set(id, true));
let position = 0; let position = 0;
while (position < result.length) { while (position < result.length) {
@ -227,8 +227,8 @@ export class Graph {
return result; return result;
} }
maximizePart(origin: number[]): number[] { maximizePart(origin: NodeID[]): NodeID[] {
const outputs: number[] = this.expandAllOutputs(origin); const outputs: NodeID[] = this.expandAllOutputs(origin);
const result = [...origin]; const result = [...origin];
this.topologicalOrder() this.topologicalOrder()
.filter(id => outputs.includes(id)) .filter(id => outputs.includes(id))
@ -241,10 +241,10 @@ export class Graph {
return result; return result;
} }
topologicalOrder(): number[] { topologicalOrder(): NodeID[] {
const result: number[] = []; const result: NodeID[] = [];
const marked = new Set<number>(); const marked = new Set<NodeID>();
const nodeStack: number[] = []; const nodeStack: NodeID[] = [];
this.nodes.forEach(node => { this.nodes.forEach(node => {
if (marked.has(node.id)) { if (marked.has(node.id)) {
return; return;
@ -275,12 +275,12 @@ export class Graph {
transitiveReduction() { transitiveReduction() {
const order = this.topologicalOrder(); const order = this.topologicalOrder();
const marked = new Map<number, boolean>(); const marked = new Map<NodeID, boolean>();
order.forEach(nodeID => { order.forEach(nodeID => {
if (marked.get(nodeID)) { if (marked.get(nodeID)) {
return; return;
} }
const stack: { id: number; parents: number[] }[] = []; const stack: { id: NodeID; parents: NodeID[] }[] = [];
stack.push({ id: nodeID, parents: [] }); stack.push({ id: nodeID, parents: [] });
while (stack.length > 0) { while (stack.length > 0) {
const item = stack.splice(0, 1)[0]; const item = stack.splice(0, 1)[0];
@ -299,20 +299,20 @@ export class Graph {
/** /**
* Finds a cycle in the graph. * Finds a cycle in the graph.
* *
* @returns {number[] | null} The cycle if found, otherwise `null`. * @returns {NodeID[] | null} The cycle if found, otherwise `null`.
* Uses non-recursive DFS. * Uses non-recursive DFS.
*/ */
findCycle(): number[] | null { findCycle(): NodeID[] | null {
const visited = new Set<number>(); const visited = new Set<NodeID>();
const nodeStack = new Set<number>(); const nodeStack = new Set<NodeID>();
const parents = new Map<number, number>(); const parents = new Map<NodeID, NodeID>();
for (const nodeId of this.nodes.keys()) { for (const nodeId of this.nodes.keys()) {
if (visited.has(nodeId)) { if (visited.has(nodeId)) {
continue; continue;
} }
const callStack: { nodeId: number; parentId: number | null }[] = []; const callStack: { nodeId: NodeID; parentId: NodeID | null }[] = [];
callStack.push({ nodeId: nodeId, parentId: null }); callStack.push({ nodeId: nodeId, parentId: null });
while (callStack.length > 0) { while (callStack.length > 0) {
const { nodeId, parentId } = callStack[callStack.length - 1]; const { nodeId, parentId } = callStack[callStack.length - 1];
@ -336,7 +336,7 @@ export class Graph {
if (!nodeStack.has(child)) { if (!nodeStack.has(child)) {
continue; continue;
} }
const cycle: number[] = []; const cycle: NodeID[] = [];
let current = nodeId; let current = nodeId;
cycle.push(child); cycle.push(child);
while (current !== child) { while (current !== child) {