F: Improve layout when parent is changed
Some checks failed
Backend CI / build (3.12) (push) Has been cancelled
Frontend CI / build (22.x) (push) Has been cancelled
Backend CI / notify-failure (push) Has been cancelled
Frontend CI / notify-failure (push) Has been cancelled

This commit is contained in:
Ivan 2025-06-12 19:44:49 +03:00
parent f2b06261b8
commit e39193567a
8 changed files with 74 additions and 129 deletions

View File

@ -344,7 +344,9 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
operation.title = serializer.validated_data['item_data']['title'] operation.title = serializer.validated_data['item_data']['title']
if 'description' in serializer.validated_data['item_data']: if 'description' in serializer.validated_data['item_data']:
operation.description = serializer.validated_data['item_data']['description'] operation.description = serializer.validated_data['item_data']['description']
operation.save(update_fields=['alias', 'title', 'description']) if 'parent' in serializer.validated_data['item_data']:
operation.parent = serializer.validated_data['item_data']['parent']
operation.save(update_fields=['alias', 'title', 'description', 'parent'])
if operation.result is not None: if operation.result is not None:
can_edit = permissions.can_edit_item(request.user, operation.result) can_edit = permissions.can_edit_item(request.user, operation.result)

View File

@ -141,7 +141,6 @@ export { GrConnect as IconConnect } from 'react-icons/gr';
export { BiPlayCircle as IconExecute } from 'react-icons/bi'; export { BiPlayCircle as IconExecute } from 'react-icons/bi';
// ======== Graph UI ======= // ======== Graph UI =======
export { LuLayoutDashboard as IconFixLayout } from 'react-icons/lu';
export { BiCollapse as IconGraphCollapse } from 'react-icons/bi'; export { BiCollapse as IconGraphCollapse } from 'react-icons/bi';
export { BiExpand as IconGraphExpand } from 'react-icons/bi'; export { BiExpand as IconGraphExpand } from 'react-icons/bi';
export { LuMaximize as IconGraphMaximize } from 'react-icons/lu'; export { LuMaximize as IconGraphMaximize } from 'react-icons/lu';

View File

@ -11,7 +11,6 @@ import {
IconEdit2, IconEdit2,
IconExecute, IconExecute,
IconFitImage, IconFitImage,
IconFixLayout,
IconGrid, IconGrid,
IconLineStraight, IconLineStraight,
IconLineWave, IconLineWave,
@ -40,9 +39,6 @@ export function HelpOssGraph() {
<li> <li>
<IconFitImage className='inline-icon' /> Вписать в экран <IconFitImage className='inline-icon' /> Вписать в экран
</li> </li>
<li>
<IconFixLayout className='inline-icon' /> Исправить расположения
</li>
<li> <li>
<IconSettings className='inline-icon' /> Диалог настроек <IconSettings className='inline-icon' /> Диалог настроек
</li> </li>

View File

@ -43,7 +43,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.onChangeParent(target.nodeID, data.item_data.parent === null ? null : `b${data.item_data.parent}`);
data.layout = manager.layout; data.layout = manager.layout;
} }
return updateBlock({ itemID: manager.oss.id, data }); return updateBlock({ itemID: manager.oss.id, data });

View File

@ -59,7 +59,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.onChangeParent(target.nodeID, data.item_data.parent === null ? null : `b${data.item_data.parent}`);
data.layout = manager.layout; data.layout = manager.layout;
} }
return updateOperation({ itemID: manager.oss.id, data }); return updateOperation({ itemID: manager.oss.id, data });

View File

@ -25,12 +25,8 @@ export class LayoutManager {
/** Calculate insert position for a new {@link IOperation} */ /** Calculate insert position for a new {@link IOperation} */
newOperationPosition(data: ICreateOperationDTO): Rectangle2D { newOperationPosition(data: ICreateOperationDTO): Rectangle2D {
let result = { x: data.position_x, y: data.position_y, width: data.width, height: data.height }; const result = { x: data.position_x, y: data.position_y, width: data.width, height: data.height };
const parentNode = this.layout.find(pos => pos.nodeID === `b${data.item_data.parent}`); const parentNode = this.layout.find(pos => pos.nodeID === `b${data.item_data.parent}`) ?? null;
if (this.oss.operations.length === 0) {
return result;
}
const operations = this.layout.filter(pos => pos.nodeID.startsWith('o')); const operations = this.layout.filter(pos => pos.nodeID.startsWith('o'));
if (data.arguments.length !== 0) { if (data.arguments.length !== 0) {
const pos = calculatePositionFromArgs( const pos = calculatePositionFromArgs(
@ -47,19 +43,8 @@ export class LayoutManager {
result.y = pos.y; result.y = pos.y;
} }
result = preventOverlap(result, operations); preventOverlap(result, operations);
this.extendParentBounds(parentNode, result);
if (parentNode) {
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;
}
if (borderY > parentNode.y + parentNode.height) {
parentNode.height = borderY - parentNode.y;
}
// TODO: trigger cascading updates
}
return result; return result;
} }
@ -72,7 +57,7 @@ export class LayoutManager {
const operation_nodes = data.children_operations const operation_nodes = data.children_operations
.map(id => this.layout.find(operation => operation.nodeID === `o${id}`)) .map(id => this.layout.find(operation => operation.nodeID === `o${id}`))
.filter(node => !!node); .filter(node => !!node);
const parentNode = this.layout.find(pos => pos.nodeID === `b${data.item_data.parent}`); const parentNode = this.layout.find(pos => pos.nodeID === `b${data.item_data.parent}`) ?? null;
let result: Rectangle2D = { x: data.position_x, y: data.position_y, width: data.width, height: data.height }; let result: Rectangle2D = { x: data.position_x, y: data.position_y, width: data.width, height: data.height };
@ -99,7 +84,7 @@ export class LayoutManager {
.filter(block => block.parent === data.item_data.parent) .filter(block => block.parent === data.item_data.parent)
.map(block => block.nodeID); .map(block => block.nodeID);
if (siblings.length > 0) { if (siblings.length > 0) {
result = preventOverlap( preventOverlap(
result, result,
this.layout.filter(node => siblings.includes(node.nodeID)) this.layout.filter(node => siblings.includes(node.nodeID))
); );
@ -107,7 +92,7 @@ export class LayoutManager {
} else { } else {
const rootBlocks = this.oss.blocks.filter(block => block.parent === null).map(block => block.nodeID); const rootBlocks = this.oss.blocks.filter(block => block.parent === null).map(block => block.nodeID);
if (rootBlocks.length > 0) { if (rootBlocks.length > 0) {
result = preventOverlap( preventOverlap(
result, result,
this.layout.filter(node => rootBlocks.includes(node.nodeID)) this.layout.filter(node => rootBlocks.includes(node.nodeID))
); );
@ -115,56 +100,45 @@ export class LayoutManager {
} }
} }
if (parentNode) { this.extendParentBounds(parentNode, result);
const borderX = result.x + result.width + MIN_DISTANCE;
const borderY = result.y + result.height + MIN_DISTANCE;
if (borderX > parentNode.x + parentNode.width) {
parentNode.width = borderX - parentNode.x;
}
if (borderY > parentNode.y + parentNode.height) {
parentNode.height = borderY - parentNode.y;
}
// TODO: trigger cascading updates
}
return result; return result;
} }
/** Update layout when parent changes */ /** Update layout when parent changes */
onOperationChangeParent(targetID: number, newParent: number | null) { onChangeParent(targetID: string, newParent: string | null) {
const targetNode = this.layout.find(pos => pos.nodeID === `o${targetID}`); const targetNode = this.layout.find(pos => pos.nodeID === targetID);
if (!targetNode) { if (!targetNode) {
return; return;
} }
if (newParent === null) { const parentNode = this.layout.find(pos => pos.nodeID === newParent) ?? null;
const rootBlocks = this.oss.blocks.filter(block => block.parent === null).map(block => block.nodeID); const offset = this.calculateOffsetForParentChange(targetNode, parentNode);
const blocksPositions = this.layout.filter(pos => rootBlocks.includes(pos.nodeID)); if (offset.x === 0 && offset.y === 0) {
if (blocksPositions.length === 0) {
return; return;
} }
const operationPositions = this.layout.filter(pos => pos.nodeID.startsWith('o') && pos.nodeID !== `o${targetID}`); targetNode.x += offset.x;
const newRect = preventOverlap(targetNode, [...blocksPositions, ...operationPositions]); targetNode.y += offset.y;
targetNode.x = newRect.x;
targetNode.y = newRect.y; const children = this.oss.hierarchy.expandAllOutputs([targetID]);
return; const childrenPositions = this.layout.filter(pos => children.includes(pos.nodeID));
} else { for (const child of childrenPositions) {
const parentNode = this.layout.find(pos => pos.nodeID === `b${newParent}`); child.x += offset.x;
if (!parentNode) { child.y += offset.y;
return;
}
if (rectanglesOverlap(parentNode, targetNode)) {
return;
} }
// TODO: fix position based on parent this.extendParentBounds(parentNode, targetNode);
}
} }
/** Update layout when parent changes */ private extendParentBounds(parent: INodePosition | null, child: Rectangle2D) {
onBlockChangeParent(targetID: number, newParent: number | null) { if (!parent) {
console.error('not implemented', targetID, newParent); return;
}
const borderX = child.x + child.width + MIN_DISTANCE;
const borderY = child.y + child.height + MIN_DISTANCE;
parent.width = Math.max(parent.width, borderX - parent.x);
parent.height = Math.max(parent.height, borderY - parent.y);
// TODO: cascade update
} }
private calculatePositionForFreeOperation(initial: Position2D): Position2D { private calculatePositionForFreeOperation(initial: Position2D): Position2D {
@ -197,6 +171,23 @@ export class LayoutManager {
const minY = Math.min(...blocksPositions.map(node => node.y)); const minY = Math.min(...blocksPositions.map(node => node.y));
return { ...initial, x: maxX + MIN_DISTANCE, y: minY }; return { ...initial, x: maxX + MIN_DISTANCE, y: minY };
} }
private calculateOffsetForParentChange(target: INodePosition, parent: INodePosition | null): Position2D {
const newPosition = { ...target };
if (parent === null) {
const rootElements = this.oss.hierarchy.rootNodes();
const positions = this.layout.filter(pos => rootElements.includes(pos.nodeID));
preventOverlap(newPosition, positions);
} else if (!rectanglesOverlap(target, parent)) {
newPosition.x = parent.x + MIN_DISTANCE;
newPosition.y = parent.y + MIN_DISTANCE;
const siblings = this.oss.hierarchy.at(parent.nodeID)?.outputs ?? [];
const siblingsPositions = this.layout.filter(pos => siblings.includes(pos.nodeID));
preventOverlap(newPosition, siblingsPositions);
}
return { x: newPosition.x - target.x, y: newPosition.y - target.y };
}
} }
// ======= Internals ======= // ======= Internals =======
@ -209,31 +200,19 @@ function rectanglesOverlap(a: Rectangle2D, b: Rectangle2D): boolean {
); );
} }
function getOverlapAmount(a: Rectangle2D, b: Rectangle2D): Position2D { function preventOverlap(target: Rectangle2D, fixedRectangles: Rectangle2D[]) {
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; let hasOverlap: boolean;
do { do {
hasOverlap = false; hasOverlap = false;
for (const fixed of fixedRectangles) { for (const fixed of fixedRectangles) {
if (rectanglesOverlap(target, fixed)) { if (rectanglesOverlap(target, fixed)) {
hasOverlap = true; hasOverlap = true;
const overlap = getOverlapAmount(target, fixed); target.x += MIN_DISTANCE;
if (overlap.x >= overlap.y) { target.y += MIN_DISTANCE;
target.x += overlap.x + MIN_DISTANCE;
} else {
target.y += overlap.y + MIN_DISTANCE;
}
break; break;
} }
} }
} while (hasOverlap); } while (hasOverlap);
return target;
} }
function calculatePositionFromArgs(args: INodePosition[]): Position2D { function calculatePositionFromArgs(args: INodePosition[]): Position2D {
@ -251,39 +230,20 @@ function calculatePositionFromChildren(
operations: INodePosition[], operations: INodePosition[],
blocks: INodePosition[] blocks: INodePosition[]
): Rectangle2D { ): Rectangle2D {
let left = undefined; const allNodes = [...blocks, ...operations];
let top = undefined; if (allNodes.length === 0) {
let right = undefined; return initial;
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) { const left = Math.min(...allNodes.map(n => n.x)) - MIN_DISTANCE;
left = left === undefined ? operation.x - MIN_DISTANCE : Math.min(left, operation.x - MIN_DISTANCE); const top = Math.min(...allNodes.map(n => n.y)) - MIN_DISTANCE;
top = top === undefined ? operation.y - MIN_DISTANCE : Math.min(top, operation.y - MIN_DISTANCE); const right = Math.max(...allNodes.map(n => n.x + n.width)) + MIN_DISTANCE;
right = const bottom = Math.max(...allNodes.map(n => n.y + n.height)) + MIN_DISTANCE;
right === undefined
? Math.max(left + initial.width, operation.x + operation.width + MIN_DISTANCE)
: Math.max(right, operation.x + operation.width + MIN_DISTANCE);
bottom = !bottom
? Math.max(top + initial.height, operation.y + operation.height + MIN_DISTANCE)
: Math.max(bottom, operation.y + operation.height + MIN_DISTANCE);
}
return { return {
x: left ?? initial.x, x: left,
y: top ?? initial.y, y: top,
width: right !== undefined && left !== undefined ? right - left : initial.width, width: right - left,
height: bottom !== undefined && top !== undefined ? bottom - top : initial.height height: bottom - top
}; };
} }

View File

@ -1,7 +1,5 @@
'use client'; 'use client';
import { toast } from 'react-toastify';
import { HelpTopic } from '@/features/help'; import { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components'; import { BadgeHelp } from '@/features/help/components';
@ -12,7 +10,6 @@ import {
IconEdit2, IconEdit2,
IconExecute, IconExecute,
IconFitImage, IconFitImage,
IconFixLayout,
IconNewItem, IconNewItem,
IconReset, IconReset,
IconSave, IconSave,
@ -86,11 +83,6 @@ export function ToolbarOssGraph({
return true; return true;
})(); })();
function handleFixLayout() {
// TODO: implement layout algorithm
toast.info('Еще не реализовано');
}
function handleShowOptions() { function handleShowOptions() {
showOssOptions(); showOssOptions();
} }
@ -144,14 +136,6 @@ export function ToolbarOssGraph({
icon={<IconFitImage size='1.25rem' className='icon-primary' />} icon={<IconFitImage size='1.25rem' className='icon-primary' />}
onClick={resetView} onClick={resetView}
/> />
<MiniButton
title='Исправить позиции узлов'
icon={<IconFixLayout size='1.25rem' className='icon-primary' />}
onClick={handleFixLayout}
disabled={
selectedItems.length > 1 || (selectedItems.length > 0 && selectedItems[0].nodeType === NodeType.OPERATION)
}
/>
<MiniButton <MiniButton
title='Настройки отображения' title='Настройки отображения'
icon={<IconSettings size='1.25rem' className='icon-primary' />} icon={<IconSettings size='1.25rem' className='icon-primary' />}

View File

@ -149,6 +149,10 @@ export class Graph<NodeID = number> {
return !!sourceNode.outputs.find(id => id === destination); return !!sourceNode.outputs.find(id => id === destination);
} }
rootNodes(): NodeID[] {
return [...this.nodes.keys()].filter(id => !this.nodes.get(id)?.inputs.length);
}
expandOutputs(origin: NodeID[]): NodeID[] { expandOutputs(origin: NodeID[]): NodeID[] {
const result: NodeID[] = []; const result: NodeID[] = [];
origin.forEach(id => { origin.forEach(id => {