R: Refactor layout data structure

This commit is contained in:
Ivan 2025-06-11 16:39:12 +03:00
parent e101aea631
commit c92641f671
22 changed files with 277 additions and 195 deletions

View File

@ -68,8 +68,6 @@ class TestLibraryViewset(EndpointTester):
self.assertEqual(response.data['access_policy'], data['access_policy'])
self.assertEqual(response.data['visible'], data['visible'])
self.assertEqual(response.data['read_only'], data['read_only'])
self.assertEqual(oss.layout().data['operations'], [])
self.assertEqual(oss.layout().data['blocks'], [])
self.logout()
data = {'title': 'Title2'}

View File

@ -41,7 +41,7 @@ class LibraryViewSet(viewsets.ModelViewSet):
else:
serializer.save()
if serializer.data.get('item_type') == m.LibraryItemType.OPERATION_SCHEMA:
Layout.objects.create(oss=serializer.instance, data={'operations': [], 'blocks': []})
Layout.objects.create(oss=serializer.instance, data=[])
def perform_update(self, serializer) -> None:
instance = serializer.save()

View File

@ -0,0 +1,41 @@
from django.db import migrations
def migrate_layout(apps, schema_editor):
Layout = apps.get_model('oss', 'Layout')
for layout in Layout.objects.all():
previous_data = layout.data
new_layout = []
for operation in previous_data['operations']:
new_layout.append({
'nodeID': 'o' + str(operation['id']),
'x': operation['x'],
'y': operation['y'],
'width': 150,
'height': 40
})
for block in previous_data['blocks']:
new_layout.append({
'nodeID': 'b' + str(block['id']),
'x': block['x'],
'y': block['y'],
'width': block['width'],
'height': block['height']
})
layout.data = new_layout
layout.save(update_fields=['data'])
class Migration(migrations.Migration):
dependencies = [
('oss', '0011_remove_operation_position_x_and_more'),
]
operations = [
migrations.RunPython(migrate_layout),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.1 on 2025-06-11 10:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('oss', '0012 restructure_layout'),
]
operations = [
migrations.AlterField(
model_name='layout',
name='data',
field=models.JSONField(default=list, verbose_name='Расположение'),
),
]

View File

@ -13,7 +13,7 @@ class Layout(Model):
data = JSONField(
verbose_name='Расположение',
default=dict
default=list
)
class Meta:

View File

@ -40,7 +40,7 @@ class OperationSchema:
def create(**kwargs) -> 'OperationSchema':
''' Create LibraryItem via OperationSchema. '''
model = LibraryItem.objects.create(item_type=LibraryItemType.OPERATION_SCHEMA, **kwargs)
Layout.objects.create(oss=model, data={'operations': [], 'blocks': []})
Layout.objects.create(oss=model, data=[])
return OperationSchema(model)
@staticmethod

View File

@ -2,16 +2,9 @@
from rest_framework import serializers
class OperationNodeSerializer(serializers.Serializer):
''' Operation position. '''
id = serializers.IntegerField()
x = serializers.FloatField()
y = serializers.FloatField()
class BlockNodeSerializer(serializers.Serializer):
class NodeSerializer(serializers.Serializer):
''' Block position. '''
id = serializers.IntegerField()
nodeID = serializers.CharField()
x = serializers.FloatField()
y = serializers.FloatField()
width = serializers.FloatField()
@ -19,13 +12,8 @@ class BlockNodeSerializer(serializers.Serializer):
class LayoutSerializer(serializers.Serializer):
''' Layout for OperationSchema. '''
blocks = serializers.ListField(
child=BlockNodeSerializer()
)
operations = serializers.ListField(
child=OperationNodeSerializer()
)
''' Serializer: Layout data. '''
data = serializers.ListField(child=NodeSerializer()) # type: ignore
class SubstitutionExSerializer(serializers.Serializer):

View File

@ -13,7 +13,7 @@ from apps.rsform.serializers import SubstitutionSerializerBase
from shared import messages as msg
from ..models import Argument, Block, Inheritance, Operation, OperationSchema, OperationType
from .basics import LayoutSerializer, SubstitutionExSerializer
from .basics import NodeSerializer, SubstitutionExSerializer
class OperationSerializer(serializers.ModelSerializer):
@ -52,7 +52,9 @@ class CreateBlockSerializer(serializers.Serializer):
model = Block
fields = 'title', 'description', 'parent'
layout = LayoutSerializer()
layout = serializers.ListField(
child=NodeSerializer()
)
item_data = BlockCreateData()
width = serializers.FloatField()
height = serializers.FloatField()
@ -100,7 +102,10 @@ class UpdateBlockSerializer(serializers.Serializer):
model = Block
fields = 'title', 'description', 'parent'
layout = LayoutSerializer(required=False)
layout = serializers.ListField(
child=NodeSerializer(),
required=False
)
target = PKField(many=False, queryset=Block.objects.all())
item_data = UpdateBlockData()
@ -127,7 +132,9 @@ class UpdateBlockSerializer(serializers.Serializer):
class DeleteBlockSerializer(serializers.Serializer):
''' Serializer: Delete block. '''
layout = LayoutSerializer()
layout = serializers.ListField(
child=NodeSerializer()
)
target = PKField(many=False, queryset=Block.objects.all().only('oss_id'))
def validate(self, attrs):
@ -142,7 +149,9 @@ class DeleteBlockSerializer(serializers.Serializer):
class MoveItemsSerializer(serializers.Serializer):
''' Serializer: Move items to another parent. '''
layout = LayoutSerializer()
layout = serializers.ListField(
child=NodeSerializer()
)
operations = PKField(many=True, queryset=Operation.objects.all().only('oss_id', 'parent'))
blocks = PKField(many=True, queryset=Block.objects.all().only('oss_id', 'parent'))
destination = PKField(many=False, queryset=Block.objects.all().only('oss_id'), allow_null=True)
@ -196,8 +205,12 @@ class CreateOperationSerializer(serializers.Serializer):
'alias', 'operation_type', 'title', \
'description', 'result', 'parent'
layout = LayoutSerializer()
layout = serializers.ListField(
child=NodeSerializer()
)
item_data = CreateOperationData()
width = serializers.FloatField()
height = serializers.FloatField()
position_x = serializers.FloatField()
position_y = serializers.FloatField()
create_schema = serializers.BooleanField(default=False, required=False)
@ -230,7 +243,10 @@ class UpdateOperationSerializer(serializers.Serializer):
model = Operation
fields = 'alias', 'title', 'description', 'parent'
layout = LayoutSerializer(required=False)
layout = serializers.ListField(
child=NodeSerializer(),
required=False
)
target = PKField(many=False, queryset=Operation.objects.all())
item_data = UpdateOperationData()
arguments = PKField(many=True, queryset=Operation.objects.all().only('oss_id', 'result_id'), required=False)
@ -297,7 +313,9 @@ class UpdateOperationSerializer(serializers.Serializer):
class DeleteOperationSerializer(serializers.Serializer):
''' Serializer: Delete operation. '''
layout = LayoutSerializer()
layout = serializers.ListField(
child=NodeSerializer()
)
target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'result'))
keep_constituents = serializers.BooleanField(default=False, required=False)
delete_schema = serializers.BooleanField(default=False, required=False)
@ -314,7 +332,9 @@ class DeleteOperationSerializer(serializers.Serializer):
class TargetOperationSerializer(serializers.Serializer):
''' Serializer: Target single operation. '''
layout = LayoutSerializer()
layout = serializers.ListField(
child=NodeSerializer()
)
target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'result_id'))
def validate(self, attrs):
@ -329,7 +349,9 @@ class TargetOperationSerializer(serializers.Serializer):
class SetOperationInputSerializer(serializers.Serializer):
''' Serializer: Set input schema for operation. '''
layout = LayoutSerializer()
layout = serializers.ListField(
child=NodeSerializer()
)
target = PKField(many=False, queryset=Operation.objects.all())
input = PKField(
many=False,
@ -366,7 +388,9 @@ class OperationSchemaSerializer(serializers.ModelSerializer):
substitutions = serializers.ListField(
child=SubstitutionExSerializer()
)
layout = LayoutSerializer()
layout = serializers.ListField(
child=NodeSerializer()
)
class Meta:
''' serializer metadata. '''
@ -459,7 +483,7 @@ class RelocateConstituentsSerializer(serializers.Serializer):
return attrs
# ====== Internals =================================================================================
# ====== Internals ============
def _collect_descendants(start_blocks: list[Block]) -> set[int]:

View File

@ -59,14 +59,11 @@ class TestChangeAttributes(EndpointTester):
self.operation3.refresh_from_db()
self.ks3 = RSForm(self.operation3.result)
self.layout_data = {
'operations': [
{'id': self.operation1.pk, 'x': 0, 'y': 0},
{'id': self.operation2.pk, 'x': 0, 'y': 0},
{'id': self.operation3.pk, 'x': 0, 'y': 0},
],
'blocks': []
}
self.layout_data = [
{'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
]
layout = self.owned.layout()
layout.data = self.layout_data
layout.save()

View File

@ -57,14 +57,11 @@ class TestChangeConstituents(EndpointTester):
self.ks3 = RSForm(self.operation3.result)
self.assertEqual(self.ks3.constituents().count(), 4)
self.layout_data = {
'operations': [
{'id': self.operation1.pk, 'x': 0, 'y': 0},
{'id': self.operation2.pk, 'x': 0, 'y': 0},
{'id': self.operation3.pk, 'x': 0, 'y': 0},
],
'blocks': []
}
self.layout_data = [
{'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
]
layout = self.owned.layout()
layout.data = self.layout_data
layout.save()

View File

@ -107,16 +107,13 @@ class TestChangeOperations(EndpointTester):
convention='KS5D4'
)
self.layout_data = {
'operations': [
{'id': self.operation1.pk, 'x': 0, 'y': 0},
{'id': self.operation2.pk, 'x': 0, 'y': 0},
{'id': self.operation3.pk, 'x': 0, 'y': 0},
{'id': self.operation4.pk, 'x': 0, 'y': 0},
{'id': self.operation5.pk, 'x': 0, 'y': 0},
],
'blocks': []
}
self.layout_data = [
{'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation4.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation5.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}
]
layout = self.owned.layout()
layout.data = self.layout_data
layout.save()

View File

@ -107,16 +107,13 @@ class TestChangeSubstitutions(EndpointTester):
convention='KS5D4'
)
self.layout_data = {
'operations': [
{'id': self.operation1.pk, 'x': 0, 'y': 0},
{'id': self.operation2.pk, 'x': 0, 'y': 0},
{'id': self.operation3.pk, 'x': 0, 'y': 0},
{'id': self.operation4.pk, 'x': 0, 'y': 0},
{'id': self.operation5.pk, 'x': 0, 'y': 0},
],
'blocks': []
}
self.layout_data = [
{'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation4.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation5.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
]
layout = self.owned.layout()
layout.data = self.layout_data
layout.save()

View File

@ -49,16 +49,14 @@ class TestOssBlocks(EndpointTester):
title='3',
parent=self.block1
)
self.layout_data = {
'operations': [
{'id': self.operation1.pk, 'x': 0, 'y': 0},
{'id': self.operation2.pk, 'x': 0, 'y': 0},
],
'blocks': [
{'id': self.block1.pk, 'x': 0, 'y': 0, 'width': 0.5, 'height': 0.5},
{'id': self.block2.pk, 'x': 0, 'y': 0, 'width': 0.5, 'height': 0.5},
self.layout_data = [
{'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'b' + str(self.block1.pk), 'x': 0, 'y': 0, 'width': 0.5, 'height': 0.5},
{'nodeID': 'b' + str(self.block2.pk), 'x': 0, 'y': 0, 'width': 0.5, 'height': 0.5},
]
}
layout = self.owned.layout()
layout.data = self.layout_data
layout.save()
@ -88,7 +86,7 @@ class TestOssBlocks(EndpointTester):
self.assertEqual(len(response.data['oss']['blocks']), 3)
new_block = response.data['new_block']
layout = response.data['oss']['layout']
item = [item for item in layout['blocks'] if item['id'] == new_block['id']][0]
item = [item for item in layout if item['nodeID'] == 'b' + str(new_block['id'])][0]
self.assertEqual(new_block['title'], data['item_data']['title'])
self.assertEqual(new_block['description'], data['item_data']['description'])
self.assertEqual(new_block['parent'], None)

View File

@ -54,14 +54,11 @@ class TestOssOperations(EndpointTester):
alias='3',
operation_type=OperationType.SYNTHESIS
)
self.layout_data = {
'operations': [
{'id': self.operation1.pk, 'x': 0, 'y': 0},
{'id': self.operation2.pk, 'x': 0, 'y': 0},
{'id': self.operation3.pk, 'x': 0, 'y': 0},
],
'blocks': []
}
self.layout_data = [
{'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
]
layout = self.owned.layout()
layout.data = self.layout_data
layout.save()
@ -87,7 +84,9 @@ class TestOssOperations(EndpointTester):
},
'layout': self.layout_data,
'position_x': 1,
'position_y': 1
'position_y': 1,
'width': 500,
'height': 50
}
self.executeBadData(data=data)
@ -102,7 +101,7 @@ class TestOssOperations(EndpointTester):
self.assertEqual(len(response.data['oss']['operations']), 4)
new_operation = response.data['new_operation']
layout = response.data['oss']['layout']
item = [item for item in layout['operations'] if item['id'] == new_operation['id']][0]
item = [item for item in layout if item['nodeID'] == 'o' + str(new_operation['id'])][0]
self.assertEqual(new_operation['alias'], data['item_data']['alias'])
self.assertEqual(new_operation['operation_type'], data['item_data']['operation_type'])
self.assertEqual(new_operation['title'], data['item_data']['title'])
@ -111,6 +110,8 @@ class TestOssOperations(EndpointTester):
self.assertEqual(new_operation['parent'], None)
self.assertEqual(item['x'], data['position_x'])
self.assertEqual(item['y'], data['position_y'])
self.assertEqual(item['width'], data['width'])
self.assertEqual(item['height'], data['height'])
self.operation1.refresh_from_db()
self.executeForbidden(data=data, item=self.unowned_id)
@ -132,7 +133,9 @@ class TestOssOperations(EndpointTester):
},
'layout': self.layout_data,
'position_x': 1,
'position_y': 1
'position_y': 1,
'width': 500,
'height': 50
}
self.executeBadData(data=data, item=self.owned_id)
@ -160,6 +163,8 @@ class TestOssOperations(EndpointTester):
'layout': self.layout_data,
'position_x': 1,
'position_y': 1,
'width': 500,
'height': 50,
'arguments': [self.operation1.pk, self.operation3.pk]
}
response = self.executeCreated(data=data, item=self.owned_id)
@ -185,7 +190,9 @@ class TestOssOperations(EndpointTester):
},
'layout': self.layout_data,
'position_x': 1,
'position_y': 1
'position_y': 1,
'width': 500,
'height': 50
}
response = self.executeCreated(data=data, item=self.owned_id)
new_operation = response.data['new_operation']
@ -207,7 +214,9 @@ class TestOssOperations(EndpointTester):
'create_schema': True,
'layout': self.layout_data,
'position_x': 1,
'position_y': 1
'position_y': 1,
'width': 500,
'height': 50
}
self.executeBadData(data=data, item=self.owned_id)
data['item_data']['result'] = None
@ -244,7 +253,7 @@ class TestOssOperations(EndpointTester):
self.login()
response = self.executeOK(data=data)
layout = response.data['layout']
deleted_items = [item for item in layout['operations'] if item['id'] == data['target']]
deleted_items = [item for item in layout if item['nodeID'] == 'o' + str(data['target'])]
self.assertEqual(len(response.data['operations']), 2)
self.assertEqual(len(deleted_items), 0)

View File

@ -55,11 +55,11 @@ class TestOssViewset(EndpointTester):
alias='3',
operation_type=OperationType.SYNTHESIS
)
self.layout_data = {'operations': [
{'id': self.operation1.pk, 'x': 0, 'y': 0},
{'id': self.operation2.pk, 'x': 0, 'y': 0},
{'id': self.operation3.pk, 'x': 0, 'y': 0},
], 'blocks': []}
self.layout_data = [
{'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}
]
layout = self.owned.layout()
layout.data = self.layout_data
layout.save()
@ -107,10 +107,9 @@ class TestOssViewset(EndpointTester):
self.assertEqual(arguments[1]['argument'], self.operation2.pk)
layout = response.data['layout']
self.assertEqual(layout['blocks'], [])
self.assertEqual(layout['operations'][0], {'id': self.operation1.pk, 'x': 0, 'y': 0})
self.assertEqual(layout['operations'][1], {'id': self.operation2.pk, 'x': 0, 'y': 0})
self.assertEqual(layout['operations'][2], {'id': self.operation3.pk, 'x': 0, 'y': 0})
self.assertEqual(layout[0], self.layout_data[0])
self.assertEqual(layout[1], self.layout_data[1])
self.assertEqual(layout[2], self.layout_data[2])
self.executeOK(item=self.unowned_id)
self.executeForbidden(item=self.private_id)
@ -126,23 +125,21 @@ class TestOssViewset(EndpointTester):
self.populateData()
self.executeBadData(item=self.owned_id)
data = {'operations': [], 'blocks': []}
data = {'data': []}
self.executeOK(data=data)
data = {
'operations': [
{'id': self.operation1.pk, 'x': 42.1, 'y': 1337},
{'id': self.operation2.pk, 'x': 36.1, 'y': 1437},
{'id': self.operation3.pk, 'x': 36.1, 'y': 1435}
], 'blocks': []
}
data = {'data': [
{'nodeID': 'o' + str(self.operation1.pk), 'x': 42.1, 'y': 1337, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation2.pk), 'x': 36.1, 'y': 1437, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation3.pk), 'x': 36.1, 'y': 1435, 'width': 150, 'height': 40}
]}
self.toggle_admin(True)
self.executeOK(data=data, item=self.unowned_id)
self.toggle_admin(False)
self.executeOK(data=data, item=self.owned_id)
self.owned.refresh_from_db()
self.assertEqual(self.owned.layout().data, data)
self.assertEqual(self.owned.layout().data, data['data'])
self.executeForbidden(data=data, item=self.unowned_id)
self.executeForbidden(data=data, item=self.private_id)

View File

@ -91,7 +91,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
''' Endpoint: Update schema layout. '''
serializer = s.LayoutSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
m.OperationSchema(self.get_object()).update_layout(serializer.validated_data)
m.OperationSchema(self.get_object()).update_layout(serializer.validated_data['data'])
return Response(status=c.HTTP_200_OK)
@extend_schema(
@ -120,8 +120,8 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
children_operations: list[m.Operation] = serializer.validated_data['children_operations']
with transaction.atomic():
new_block = oss.create_block(**serializer.validated_data['item_data'])
layout['blocks'].append({
'id': new_block.pk,
layout.append({
'nodeID': 'b' + str(new_block.pk),
'x': serializer.validated_data['position_x'],
'y': serializer.validated_data['position_y'],
'width': serializer.validated_data['width'],
@ -205,7 +205,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
oss = m.OperationSchema(self.get_object())
block = cast(m.Block, serializer.validated_data['target'])
layout = serializer.validated_data['layout']
layout['blocks'] = [x for x in layout['blocks'] if x['id'] != block.pk]
layout = [x for x in layout if x['nodeID'] != 'b' + str(block.pk)]
with transaction.atomic():
oss.delete_block(block)
oss.update_layout(layout)
@ -274,10 +274,12 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
layout = serializer.validated_data['layout']
with transaction.atomic():
new_operation = oss.create_operation(**serializer.validated_data['item_data'])
layout['operations'].append({
'id': new_operation.pk,
layout.append({
'nodeID': 'o' + str(new_operation.pk),
'x': serializer.validated_data['position_x'],
'y': serializer.validated_data['position_y']
'y': serializer.validated_data['position_y'],
'width': serializer.validated_data['width'],
'height': serializer.validated_data['height']
})
oss.update_layout(layout)
@ -384,7 +386,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
operation = cast(m.Operation, serializer.validated_data['target'])
old_schema = operation.result
layout = serializer.validated_data['layout']
layout['operations'] = [x for x in layout['operations'] if x['id'] != operation.pk]
layout = [x for x in layout if x['nodeID'] != 'o' + str(operation.pk)]
with transaction.atomic():
oss.delete_operation(operation.pk, serializer.validated_data['keep_constituents'])
oss.update_layout(layout)

View File

@ -50,7 +50,7 @@ export const ossApi = {
axiosPatch({
endpoint: `/api/oss/${itemID}/update-layout`,
request: {
data: data,
data: { data: data },
successMessage: isSilent ? undefined : infoMsg.changesSaved
}
}),

View File

@ -90,7 +90,7 @@ export class OssLoader {
this.graph.topologicalOrder().forEach(operationID => {
const operation = this.operationByID.get(operationID)!;
const schema = this.items.find(item => item.id === operation.result);
const position = this.oss.layout.operations.find(item => item.id === operationID);
const position = this.oss.layout.find(item => item.nodeID === operation.nodeID);
operation.x = position?.x ?? 0;
operation.y = position?.y ?? 0;
operation.is_consolidation = this.inferConsolidation(operationID);
@ -104,7 +104,7 @@ export class OssLoader {
private inferBlockAttributes() {
this.oss.blocks.forEach(block => {
const geometry = this.oss.layout.blocks.find(item => item.id === block.id);
const geometry = this.oss.layout.find(item => item.nodeID === block.nodeID);
block.x = geometry?.x ?? 0;
block.y = geometry?.y ?? 0;
block.width = geometry?.width ?? BLOCK_NODE_MIN_WIDTH;

View File

@ -72,11 +72,8 @@ export type IRelocateConstituentsDTO = z.infer<typeof schemaRelocateConstituents
/** Represents {@link IConstituenta} reference. */
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>;
/** Represents {@link IOperationSchema} node position. */
export type INodePosition = z.infer<typeof schemaNodePosition>;
// ====== Schemas ======
export const schemaOperationType = z.enum(Object.values(OperationType) as [OperationType, ...OperationType[]]);
@ -108,24 +105,15 @@ export const schemaCstSubstituteInfo = schemaSubstituteConstituents.extend({
substitution_term: z.string()
});
export const schemaOperationPosition = z.strictObject({
id: z.number(),
x: z.number(),
y: z.number()
});
export const schemaBlockPosition = z.strictObject({
id: z.number(),
export const schemaNodePosition = z.strictObject({
nodeID: z.string(),
x: z.number(),
y: z.number(),
width: z.number(),
height: z.number()
});
export const schemaOssLayout = z.strictObject({
operations: z.array(schemaOperationPosition),
blocks: z.array(schemaBlockPosition)
});
export const schemaOssLayout = z.array(schemaNodePosition);
export const schemaOperationSchema = schemaLibraryItem.extend({
editors: z.number().array(),
@ -188,6 +176,8 @@ export const schemaCreateOperation = z.strictObject({
}),
position_x: z.number(),
position_y: z.number(),
width: z.number(),
height: z.number(),
arguments: z.array(z.number()),
create_schema: z.boolean()
});

View File

@ -13,7 +13,7 @@ import { useDialogsStore } from '@/stores/dialogs';
import { type ICreateOperationDTO, OperationType, schemaCreateOperation } from '../../backend/types';
import { useCreateOperation } from '../../backend/use-create-operation';
import { describeOperationType, labelOperationType } from '../../labels';
import { type LayoutManager } from '../../models/oss-layout-api';
import { type LayoutManager, OPERATION_NODE_HEIGHT, OPERATION_NODE_WIDTH } from '../../models/oss-layout-api';
import { TabInputOperation } from './tab-input-operation';
import { TabSynthesisOperation } from './tab-synthesis-operation';
@ -54,6 +54,8 @@ export function DlgCreateOperation() {
position_x: defaultX,
position_y: defaultY,
arguments: initialInputs,
width: OPERATION_NODE_WIDTH,
height: OPERATION_NODE_HEIGHT,
create_schema: false,
layout: manager.layout
},

View File

@ -1,10 +1,4 @@
import {
type IBlockPosition,
type ICreateBlockDTO,
type ICreateOperationDTO,
type IOperationPosition,
type IOssLayout
} from '../backend/types';
import { type ICreateBlockDTO, type ICreateOperationDTO, type INodePosition, type IOssLayout } from '../backend/types';
import { type IOperationSchema } from './oss';
import { type Position2D, type Rectangle2D } from './oss-layout';
@ -12,8 +6,8 @@ import { type Position2D, type Rectangle2D } from './oss-layout';
export const GRID_SIZE = 10; // pixels - size of OSS grid
const MIN_DISTANCE = 2 * GRID_SIZE; // pixels - minimum distance between nodes
const OPERATION_NODE_WIDTH = 150;
const OPERATION_NODE_HEIGHT = 40;
export const OPERATION_NODE_WIDTH = 150;
export const OPERATION_NODE_HEIGHT = 40;
/** Layout manipulations for {@link IOperationSchema}. */
export class LayoutManager {
@ -30,27 +24,30 @@ export class LayoutManager {
}
/** Calculate insert position for a new {@link IOperation} */
newOperationPosition(data: ICreateOperationDTO): Position2D {
let result = { x: data.position_x, y: data.position_y };
const operations = this.layout.operations;
const parentNode = this.layout.blocks.find(pos => pos.id === data.item_data.parent);
if (operations.length === 0) {
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 operations = this.layout.filter(pos => pos.nodeID.startsWith('o'));
if (data.arguments.length !== 0) {
result = calculatePositionFromArgs(data.arguments, operations);
const pos = calculatePositionFromArgs(
operations.filter(node => data.arguments.includes(Number(node.nodeID.slice(1))))
);
result.x = pos.x;
result.y = pos.y;
} else if (parentNode) {
result.x = parentNode.x + MIN_DISTANCE;
result.y = parentNode.y + MIN_DISTANCE;
} else {
result = this.calculatePositionForFreeOperation(result);
const pos = this.calculatePositionForFreeOperation(result);
result.x = pos.x;
result.y = pos.y;
}
result = preventOverlap(
{ ...result, width: OPERATION_NODE_WIDTH, height: OPERATION_NODE_HEIGHT },
operations.map(node => ({ ...node, width: OPERATION_NODE_WIDTH, height: OPERATION_NODE_HEIGHT }))
);
result = preventOverlap(result, operations);
if (parentNode) {
const borderX = result.x + OPERATION_NODE_WIDTH + MIN_DISTANCE;
@ -64,18 +61,18 @@ export class LayoutManager {
// TODO: trigger cascading updates
}
return { x: result.x, y: result.y };
return result;
}
/** Calculate insert position for a new {@link IBlock} */
newBlockPosition(data: ICreateBlockDTO): Rectangle2D {
const block_nodes = data.children_blocks
.map(id => this.layout.blocks.find(block => block.id === id))
.map(id => this.layout.find(block => block.nodeID === `b${id}`))
.filter(node => !!node);
const operation_nodes = data.children_operations
.map(id => this.layout.operations.find(operation => operation.id === id))
.map(id => this.layout.find(operation => operation.nodeID === `o${id}`))
.filter(node => !!node);
const parentNode = this.layout.blocks.find(pos => pos.id === data.item_data.parent);
const parentNode = this.layout.find(pos => pos.nodeID === `b${data.item_data.parent}`);
let result: Rectangle2D = { x: data.position_x, y: data.position_y, width: data.width, height: data.height };
@ -98,19 +95,21 @@ export class LayoutManager {
if (block_nodes.length === 0 && operation_nodes.length === 0) {
if (parentNode) {
const siblings = this.oss.blocks.filter(block => block.parent === parentNode.id).map(block => block.id);
const siblings = this.oss.blocks
.filter(block => block.parent === data.item_data.parent)
.map(block => block.nodeID);
if (siblings.length > 0) {
result = preventOverlap(
result,
this.layout.blocks.filter(block => siblings.includes(block.id))
this.layout.filter(node => siblings.includes(node.nodeID))
);
}
} else {
const rootBlocks = this.oss.blocks.filter(block => block.parent === null).map(block => block.id);
const rootBlocks = this.oss.blocks.filter(block => block.parent === null).map(block => block.nodeID);
if (rootBlocks.length > 0) {
result = preventOverlap(
result,
this.layout.blocks.filter(block => rootBlocks.includes(block.id))
this.layout.filter(node => rootBlocks.includes(node.nodeID))
);
}
}
@ -133,7 +132,34 @@ export class LayoutManager {
/** Update layout when parent changes */
onOperationChangeParent(targetID: number, newParent: number | null) {
console.error('not implemented', targetID, newParent);
const targetNode = this.layout.find(pos => pos.nodeID === `o${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;
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
}
}
/** Update layout when parent changes */
@ -142,17 +168,16 @@ export class LayoutManager {
}
private calculatePositionForFreeOperation(initial: Position2D): Position2D {
const operations = this.layout.operations;
if (operations.length === 0) {
if (this.oss.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));
.map(operation => operation.nodeID);
let inputsPositions = this.layout.filter(pos => freeInputs.includes(pos.nodeID));
if (inputsPositions.length === 0) {
inputsPositions = operations;
inputsPositions = this.layout.filter(pos => pos.nodeID.startsWith('o'));
}
const maxX = Math.max(...inputsPositions.map(node => node.x));
const minY = Math.min(...inputsPositions.map(node => node.y));
@ -163,8 +188,8 @@ export class LayoutManager {
}
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));
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 initial;
}
@ -211,11 +236,10 @@ function preventOverlap(target: Rectangle2D, fixedRectangles: Rectangle2D[]): Re
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));
function calculatePositionFromArgs(args: INodePosition[]): Position2D {
const maxY = Math.max(...args.map(node => node.y));
const minX = Math.min(...args.map(node => node.x));
const maxX = Math.max(...args.map(node => node.x));
return {
x: Math.ceil((maxX + minX) / 2 / GRID_SIZE) * GRID_SIZE,
y: maxY + 2 * OPERATION_NODE_HEIGHT + MIN_DISTANCE
@ -224,8 +248,8 @@ function calculatePositionFromArgs(args: number[], operations: IOperationPositio
function calculatePositionFromChildren(
initial: Rectangle2D,
operations: IOperationPosition[],
blocks: IBlockPosition[]
operations: INodePosition[],
blocks: INodePosition[]
): Rectangle2D {
let left = undefined;
let top = undefined;
@ -249,11 +273,11 @@ function calculatePositionFromChildren(
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);
? 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_NODE_HEIGHT + MIN_DISTANCE)
: Math.max(bottom, operation.y + OPERATION_NODE_HEIGHT + MIN_DISTANCE);
? Math.max(top + initial.height, operation.y + operation.height + MIN_DISTANCE)
: Math.max(bottom, operation.y + operation.height + MIN_DISTANCE);
}
return {

View File

@ -3,6 +3,7 @@ import { type Node, useReactFlow } from 'reactflow';
import { type IOssLayout } from '../../../backend/types';
import { type IOperationSchema } from '../../../models/oss';
import { type Position2D } from '../../../models/oss-layout';
import { OPERATION_NODE_HEIGHT, OPERATION_NODE_WIDTH } from '../../../models/oss-layout-api';
import { useOssEdit } from '../oss-edit-context';
import { BLOCK_NODE_MIN_HEIGHT, BLOCK_NODE_MIN_WIDTH } from './graph/block-node';
@ -14,22 +15,24 @@ export function useGetLayout() {
return function getLayout(): IOssLayout {
const nodes = getNodes();
const nodeById = new Map(nodes.map(node => [node.id, node]));
return {
operations: nodes
return [
...nodes
.filter(node => node.type !== 'block')
.map(node => ({
id: schema.itemByNodeID.get(node.id)!.id,
...computeAbsolutePosition(node, schema, nodeById)
nodeID: node.id,
...computeAbsolutePosition(node, schema, nodeById),
width: OPERATION_NODE_WIDTH,
height: OPERATION_NODE_HEIGHT
})),
blocks: nodes
...nodes
.filter(node => node.type === 'block')
.map(node => ({
id: schema.itemByNodeID.get(node.id)!.id,
nodeID: node.id,
...computeAbsolutePosition(node, schema, nodeById),
width: node.width ?? BLOCK_NODE_MIN_WIDTH,
height: node.height ?? BLOCK_NODE_MIN_HEIGHT
}))
};
];
};
}