From e39193567a18c876138d42fa7bba6eeb4994c8ef Mon Sep 17 00:00:00 2001
From: Ivan <8611739+IRBorisov@users.noreply.github.com>
Date: Thu, 12 Jun 2025 19:44:49 +0300
Subject: [PATCH] F: Improve layout when parent is changed
---
rsconcept/backend/apps/oss/views/oss.py | 4 +-
rsconcept/frontend/src/components/icons.tsx | 1 -
.../features/help/items/ui/help-oss-graph.tsx | 4 -
.../features/oss/dialogs/dlg-edit-block.tsx | 2 +-
.../dlg-edit-operation/dlg-edit-operation.tsx | 2 +-
.../src/features/oss/models/oss-layout-api.ts | 170 +++++++-----------
.../editor-oss-graph/toolbar-oss-graph.tsx | 16 --
rsconcept/frontend/src/models/graph.ts | 4 +
8 files changed, 74 insertions(+), 129 deletions(-)
diff --git a/rsconcept/backend/apps/oss/views/oss.py b/rsconcept/backend/apps/oss/views/oss.py
index 07cb7071..b796534d 100644
--- a/rsconcept/backend/apps/oss/views/oss.py
+++ b/rsconcept/backend/apps/oss/views/oss.py
@@ -344,7 +344,9 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
operation.title = serializer.validated_data['item_data']['title']
if 'description' in serializer.validated_data['item_data']:
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:
can_edit = permissions.can_edit_item(request.user, operation.result)
diff --git a/rsconcept/frontend/src/components/icons.tsx b/rsconcept/frontend/src/components/icons.tsx
index 93468bcc..cb16b72b 100644
--- a/rsconcept/frontend/src/components/icons.tsx
+++ b/rsconcept/frontend/src/components/icons.tsx
@@ -141,7 +141,6 @@ export { GrConnect as IconConnect } from 'react-icons/gr';
export { BiPlayCircle as IconExecute } from 'react-icons/bi';
// ======== Graph UI =======
-export { LuLayoutDashboard as IconFixLayout } from 'react-icons/lu';
export { BiCollapse as IconGraphCollapse } from 'react-icons/bi';
export { BiExpand as IconGraphExpand } from 'react-icons/bi';
export { LuMaximize as IconGraphMaximize } from 'react-icons/lu';
diff --git a/rsconcept/frontend/src/features/help/items/ui/help-oss-graph.tsx b/rsconcept/frontend/src/features/help/items/ui/help-oss-graph.tsx
index c0e746c6..a9dba114 100644
--- a/rsconcept/frontend/src/features/help/items/ui/help-oss-graph.tsx
+++ b/rsconcept/frontend/src/features/help/items/ui/help-oss-graph.tsx
@@ -11,7 +11,6 @@ import {
IconEdit2,
IconExecute,
IconFitImage,
- IconFixLayout,
IconGrid,
IconLineStraight,
IconLineWave,
@@ -40,9 +39,6 @@ export function HelpOssGraph() {
Вписать в экран
-
- Исправить расположения
-
Диалог настроек
diff --git a/rsconcept/frontend/src/features/oss/dialogs/dlg-edit-block.tsx b/rsconcept/frontend/src/features/oss/dialogs/dlg-edit-block.tsx
index 04192dc4..3a3cd5b4 100644
--- a/rsconcept/frontend/src/features/oss/dialogs/dlg-edit-block.tsx
+++ b/rsconcept/frontend/src/features/oss/dialogs/dlg-edit-block.tsx
@@ -43,7 +43,7 @@ export function DlgEditBlock() {
function onSubmit(data: IUpdateBlockDTO) {
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;
}
return updateBlock({ itemID: manager.oss.id, data });
diff --git a/rsconcept/frontend/src/features/oss/dialogs/dlg-edit-operation/dlg-edit-operation.tsx b/rsconcept/frontend/src/features/oss/dialogs/dlg-edit-operation/dlg-edit-operation.tsx
index 2104a12a..19664381 100644
--- a/rsconcept/frontend/src/features/oss/dialogs/dlg-edit-operation/dlg-edit-operation.tsx
+++ b/rsconcept/frontend/src/features/oss/dialogs/dlg-edit-operation/dlg-edit-operation.tsx
@@ -59,7 +59,7 @@ export function DlgEditOperation() {
function onSubmit(data: IUpdateOperationDTO) {
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;
}
return updateOperation({ itemID: manager.oss.id, data });
diff --git a/rsconcept/frontend/src/features/oss/models/oss-layout-api.ts b/rsconcept/frontend/src/features/oss/models/oss-layout-api.ts
index b560860b..8b222bef 100644
--- a/rsconcept/frontend/src/features/oss/models/oss-layout-api.ts
+++ b/rsconcept/frontend/src/features/oss/models/oss-layout-api.ts
@@ -25,12 +25,8 @@ export class LayoutManager {
/** Calculate insert position for a new {@link IOperation} */
newOperationPosition(data: ICreateOperationDTO): Rectangle2D {
- let 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}`);
- if (this.oss.operations.length === 0) {
- return result;
- }
-
+ 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}`) ?? null;
const operations = this.layout.filter(pos => pos.nodeID.startsWith('o'));
if (data.arguments.length !== 0) {
const pos = calculatePositionFromArgs(
@@ -47,19 +43,8 @@ export class LayoutManager {
result.y = pos.y;
}
- result = preventOverlap(result, operations);
-
- 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
- }
+ preventOverlap(result, operations);
+ this.extendParentBounds(parentNode, result);
return result;
}
@@ -72,7 +57,7 @@ export class LayoutManager {
const operation_nodes = data.children_operations
.map(id => this.layout.find(operation => operation.nodeID === `o${id}`))
.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 };
@@ -99,7 +84,7 @@ export class LayoutManager {
.filter(block => block.parent === data.item_data.parent)
.map(block => block.nodeID);
if (siblings.length > 0) {
- result = preventOverlap(
+ preventOverlap(
result,
this.layout.filter(node => siblings.includes(node.nodeID))
);
@@ -107,7 +92,7 @@ export class LayoutManager {
} else {
const rootBlocks = this.oss.blocks.filter(block => block.parent === null).map(block => block.nodeID);
if (rootBlocks.length > 0) {
- result = preventOverlap(
+ preventOverlap(
result,
this.layout.filter(node => rootBlocks.includes(node.nodeID))
);
@@ -115,56 +100,45 @@ export class LayoutManager {
}
}
- if (parentNode) {
- 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
- }
-
+ this.extendParentBounds(parentNode, result);
return result;
}
/** Update layout when parent changes */
- onOperationChangeParent(targetID: number, newParent: number | null) {
- const targetNode = this.layout.find(pos => pos.nodeID === `o${targetID}`);
+ onChangeParent(targetID: string, newParent: string | null) {
+ const targetNode = this.layout.find(pos => pos.nodeID === targetID);
if (!targetNode) {
return;
}
- if (newParent === null) {
- const rootBlocks = this.oss.blocks.filter(block => block.parent === null).map(block => block.nodeID);
- const blocksPositions = this.layout.filter(pos => rootBlocks.includes(pos.nodeID));
- if (blocksPositions.length === 0) {
- return;
- }
-
- const operationPositions = this.layout.filter(pos => pos.nodeID.startsWith('o') && pos.nodeID !== `o${targetID}`);
- const newRect = preventOverlap(targetNode, [...blocksPositions, ...operationPositions]);
- targetNode.x = newRect.x;
- targetNode.y = newRect.y;
+ const parentNode = this.layout.find(pos => pos.nodeID === newParent) ?? null;
+ const offset = this.calculateOffsetForParentChange(targetNode, parentNode);
+ if (offset.x === 0 && offset.y === 0) {
return;
- } else {
- const parentNode = this.layout.find(pos => pos.nodeID === `b${newParent}`);
- if (!parentNode) {
- return;
- }
- if (rectanglesOverlap(parentNode, targetNode)) {
- return;
- }
-
- // TODO: fix position based on parent
}
+
+ targetNode.x += offset.x;
+ targetNode.y += offset.y;
+
+ const children = this.oss.hierarchy.expandAllOutputs([targetID]);
+ const childrenPositions = this.layout.filter(pos => children.includes(pos.nodeID));
+ for (const child of childrenPositions) {
+ child.x += offset.x;
+ child.y += offset.y;
+ }
+
+ this.extendParentBounds(parentNode, targetNode);
}
- /** Update layout when parent changes */
- onBlockChangeParent(targetID: number, newParent: number | null) {
- console.error('not implemented', targetID, newParent);
+ private extendParentBounds(parent: INodePosition | null, child: Rectangle2D) {
+ if (!parent) {
+ 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 {
@@ -197,6 +171,23 @@ export class LayoutManager {
const minY = Math.min(...blocksPositions.map(node => node.y));
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 =======
@@ -209,31 +200,19 @@ function rectanglesOverlap(a: Rectangle2D, b: Rectangle2D): boolean {
);
}
-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 {
+function preventOverlap(target: Rectangle2D, fixedRectangles: 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;
- }
+ target.x += MIN_DISTANCE;
+ target.y += MIN_DISTANCE;
break;
}
}
} while (hasOverlap);
-
- return target;
}
function calculatePositionFromArgs(args: INodePosition[]): Position2D {
@@ -251,39 +230,20 @@ function calculatePositionFromChildren(
operations: INodePosition[],
blocks: INodePosition[]
): 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);
+ const allNodes = [...blocks, ...operations];
+ if (allNodes.length === 0) {
+ return initial;
}
- 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.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);
- }
+ const left = Math.min(...allNodes.map(n => n.x)) - MIN_DISTANCE;
+ const top = Math.min(...allNodes.map(n => n.y)) - MIN_DISTANCE;
+ const right = Math.max(...allNodes.map(n => n.x + n.width)) + MIN_DISTANCE;
+ const bottom = Math.max(...allNodes.map(n => n.y + n.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
+ x: left,
+ y: top,
+ width: right - left,
+ height: bottom - top
};
}
diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/toolbar-oss-graph.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/toolbar-oss-graph.tsx
index 6499108f..8748213e 100644
--- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/toolbar-oss-graph.tsx
+++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/toolbar-oss-graph.tsx
@@ -1,7 +1,5 @@
'use client';
-import { toast } from 'react-toastify';
-
import { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components';
@@ -12,7 +10,6 @@ import {
IconEdit2,
IconExecute,
IconFitImage,
- IconFixLayout,
IconNewItem,
IconReset,
IconSave,
@@ -86,11 +83,6 @@ export function ToolbarOssGraph({
return true;
})();
- function handleFixLayout() {
- // TODO: implement layout algorithm
- toast.info('Еще не реализовано');
- }
-
function handleShowOptions() {
showOssOptions();
}
@@ -144,14 +136,6 @@ export function ToolbarOssGraph({
icon={}
onClick={resetView}
/>
- }
- onClick={handleFixLayout}
- disabled={
- selectedItems.length > 1 || (selectedItems.length > 0 && selectedItems[0].nodeType === NodeType.OPERATION)
- }
- />
}
diff --git a/rsconcept/frontend/src/models/graph.ts b/rsconcept/frontend/src/models/graph.ts
index 50ac4e1a..c53c4058 100644
--- a/rsconcept/frontend/src/models/graph.ts
+++ b/rsconcept/frontend/src/models/graph.ts
@@ -149,6 +149,10 @@ export class Graph {
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[] {
const result: NodeID[] = [];
origin.forEach(id => {