Add settings page and configurable network map types.
Let users define sector/circle labels and geometry per map type, choose a type when creating maps, and sync types through local backup and API migrations. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,6 +7,7 @@ router = DefaultRouter()
|
|||||||
router.register('contacts', contact_views.ContactViewSet)
|
router.register('contacts', contact_views.ContactViewSet)
|
||||||
router.register('relations', contact_views.RelationViewSet)
|
router.register('relations', contact_views.RelationViewSet)
|
||||||
router.register('network-maps', contact_views.NetworkMapViewSet)
|
router.register('network-maps', contact_views.NetworkMapViewSet)
|
||||||
|
router.register('network-map-types', contact_views.NetworkMapTypeViewSet)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
def validate_map_type_payload(sectors, circles):
|
||||||
|
errors = {}
|
||||||
|
if not sectors or not isinstance(sectors, list):
|
||||||
|
errors['sectors'] = 'Нужен хотя бы один сектор.'
|
||||||
|
if not circles or not isinstance(circles, list):
|
||||||
|
errors['circles'] = 'Нужен хотя бы один круг.'
|
||||||
|
if errors:
|
||||||
|
return errors
|
||||||
|
|
||||||
|
sector_keys = []
|
||||||
|
for i, item in enumerate(sectors):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
errors['sectors'] = f'Сектор {i + 1}: неверный формат.'
|
||||||
|
break
|
||||||
|
key = str(item.get('key', '')).strip()
|
||||||
|
label = str(item.get('label', '')).strip()
|
||||||
|
if not key or not label:
|
||||||
|
errors['sectors'] = f'Сектор {i + 1}: укажите ключ и подпись.'
|
||||||
|
break
|
||||||
|
sector_keys.append(key)
|
||||||
|
|
||||||
|
circle_keys = []
|
||||||
|
for i, item in enumerate(circles):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
errors['circles'] = f'Круг {i + 1}: неверный формат.'
|
||||||
|
break
|
||||||
|
key = str(item.get('key', '')).strip()
|
||||||
|
label = str(item.get('label', '')).strip()
|
||||||
|
if not key or not label:
|
||||||
|
errors['circles'] = f'Круг {i + 1}: укажите ключ и подпись.'
|
||||||
|
break
|
||||||
|
circle_keys.append(key)
|
||||||
|
|
||||||
|
if len(set(sector_keys)) != len(sector_keys):
|
||||||
|
errors['sectors'] = 'Ключи секторов должны быть уникальными.'
|
||||||
|
if len(set(circle_keys)) != len(circle_keys):
|
||||||
|
errors['circles'] = 'Ключи кругов должны быть уникальными.'
|
||||||
|
|
||||||
|
return errors
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_SECTORS = [
|
||||||
|
{'key': 'work', 'label': 'Работа'},
|
||||||
|
{'key': 'study', 'label': 'Учёба'},
|
||||||
|
{'key': 'hobby', 'label': 'Хобби'},
|
||||||
|
{'key': 'family', 'label': 'Семья'},
|
||||||
|
{'key': 'health', 'label': 'Здоровье'},
|
||||||
|
{'key': 'other', 'label': 'Другое'},
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_CIRCLES = [
|
||||||
|
{'key': 'support', 'label': 'Круг поддержки'},
|
||||||
|
{'key': 'productivity', 'label': 'Круг продуктивности'},
|
||||||
|
{'key': 'development', 'label': 'Круг развития'},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def seed_default_map_type(apps, schema_editor):
|
||||||
|
NetworkMapType = apps.get_model('contacts', 'NetworkMapType')
|
||||||
|
NetworkMap = apps.get_model('contacts', 'NetworkMap')
|
||||||
|
|
||||||
|
default_type, _ = NetworkMapType.objects.get_or_create(
|
||||||
|
is_default=True,
|
||||||
|
defaults={
|
||||||
|
'name': 'Стандартная',
|
||||||
|
'sectors': DEFAULT_SECTORS,
|
||||||
|
'circles': DEFAULT_CIRCLES,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
NetworkMap.objects.filter(map_type__isnull=True).update(map_type=default_type)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('contacts', '0006_network_maps'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='NetworkMapType',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=255, verbose_name='Название типа')),
|
||||||
|
('sectors', models.JSONField(default=list, verbose_name='Секторы')),
|
||||||
|
('circles', models.JSONField(default=list, verbose_name='Круги')),
|
||||||
|
('is_default', models.BooleanField(default=False, verbose_name='Тип по умолчанию')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Тип карты сети',
|
||||||
|
'verbose_name_plural': 'Типы карт сети',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='networkmap',
|
||||||
|
name='map_type',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name='maps',
|
||||||
|
to='contacts.networkmaptype',
|
||||||
|
verbose_name='Тип карты',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(seed_default_map_type, migrations.RunPython.noop),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='networkmap',
|
||||||
|
name='map_type',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name='maps',
|
||||||
|
to='contacts.networkmaptype',
|
||||||
|
verbose_name='Тип карты',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='networkmapmembership',
|
||||||
|
name='life_sphere',
|
||||||
|
field=models.CharField(default='other', max_length=32, verbose_name='Сфера жизни'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='networkmapmembership',
|
||||||
|
name='network_circle',
|
||||||
|
field=models.CharField(default='productivity', max_length=32, verbose_name='Круг сети'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.9 on 2026-06-25 02:21
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('contacts', '0007_network_map_types'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='relation',
|
||||||
|
name='interaction_intensity',
|
||||||
|
field=models.CharField(choices=[('intense', 'Интенсивные контакты'), ('periodic', 'Периодические контакты'), ('sparse', 'Редкие контакты')], default='intense', max_length=32, verbose_name='Интенсивность общения'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -2,8 +2,6 @@ from django.core.validators import MaxValueValidator, MinValueValidator
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from core.choices import (
|
from core.choices import (
|
||||||
LIFE_SPHERES,
|
|
||||||
NETWORK_CIRCLES,
|
|
||||||
INTERACTION_INTENSITY,
|
INTERACTION_INTENSITY,
|
||||||
RELATION_TYPES,
|
RELATION_TYPES,
|
||||||
)
|
)
|
||||||
@@ -30,11 +28,36 @@ class Contact(models.Model):
|
|||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkMapType(models.Model):
|
||||||
|
"""Тип карты сети: настраиваемые секторы и концентрические круги."""
|
||||||
|
|
||||||
|
name = models.CharField(max_length=255, verbose_name='Название типа')
|
||||||
|
sectors = models.JSONField(default=list, verbose_name='Секторы')
|
||||||
|
circles = models.JSONField(default=list, verbose_name='Круги')
|
||||||
|
is_default = models.BooleanField(default=False, verbose_name='Тип по умолчанию')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['name']
|
||||||
|
verbose_name = 'Тип карты сети'
|
||||||
|
verbose_name_plural = 'Типы карт сети'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class NetworkMap(models.Model):
|
class NetworkMap(models.Model):
|
||||||
"""Карта сети — отдельный контекст для визуализации подмножества контактов."""
|
"""Карта сети — отдельный контекст для визуализации подмножества контактов."""
|
||||||
|
|
||||||
name = models.CharField(max_length=255, verbose_name='Название')
|
name = models.CharField(max_length=255, verbose_name='Название')
|
||||||
description = models.TextField(blank=True, verbose_name='Описание')
|
description = models.TextField(blank=True, verbose_name='Описание')
|
||||||
|
map_type = models.ForeignKey(
|
||||||
|
NetworkMapType,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name='maps',
|
||||||
|
verbose_name='Тип карты',
|
||||||
|
)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
@@ -64,13 +87,11 @@ class NetworkMapMembership(models.Model):
|
|||||||
)
|
)
|
||||||
life_sphere = models.CharField(
|
life_sphere = models.CharField(
|
||||||
max_length=32,
|
max_length=32,
|
||||||
choices=LIFE_SPHERES,
|
|
||||||
default='other',
|
default='other',
|
||||||
verbose_name='Сфера жизни',
|
verbose_name='Сфера жизни',
|
||||||
)
|
)
|
||||||
network_circle = models.CharField(
|
network_circle = models.CharField(
|
||||||
max_length=32,
|
max_length=32,
|
||||||
choices=NETWORK_CIRCLES,
|
|
||||||
default='productivity',
|
default='productivity',
|
||||||
verbose_name='Круг сети',
|
verbose_name='Круг сети',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .models import Contact, Relation, NetworkMap, NetworkMapMembership
|
from .models import Contact, Relation, NetworkMap, NetworkMapMembership, NetworkMapType
|
||||||
|
from .map_type_validation import validate_map_type_payload
|
||||||
|
|
||||||
|
|
||||||
class ContactSerializer(serializers.ModelSerializer):
|
class ContactSerializer(serializers.ModelSerializer):
|
||||||
@@ -43,17 +44,52 @@ class RelationSerializer(serializers.ModelSerializer):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkMapTypeSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = NetworkMapType
|
||||||
|
fields = [
|
||||||
|
'id', 'name', 'sectors', 'circles', 'is_default',
|
||||||
|
'created_at', 'updated_at',
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'is_default', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
sectors = data.get('sectors', getattr(self.instance, 'sectors', None))
|
||||||
|
circles = data.get('circles', getattr(self.instance, 'circles', None))
|
||||||
|
errors = validate_map_type_payload(sectors, circles)
|
||||||
|
if errors:
|
||||||
|
raise serializers.ValidationError(errors)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
if NetworkMapType.objects.filter(is_default=True).exists():
|
||||||
|
validated_data['is_default'] = False
|
||||||
|
return super().create(validated_data)
|
||||||
|
|
||||||
|
|
||||||
class NetworkMapSerializer(serializers.ModelSerializer):
|
class NetworkMapSerializer(serializers.ModelSerializer):
|
||||||
memberships_count = serializers.SerializerMethodField()
|
memberships_count = serializers.SerializerMethodField()
|
||||||
|
map_type = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=NetworkMapType.objects.all(),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = NetworkMap
|
model = NetworkMap
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'description',
|
'id', 'name', 'description', 'map_type',
|
||||||
'created_at', 'updated_at', 'memberships_count',
|
'created_at', 'updated_at', 'memberships_count',
|
||||||
]
|
]
|
||||||
read_only_fields = ['id', 'created_at', 'updated_at', 'memberships_count']
|
read_only_fields = ['id', 'created_at', 'updated_at', 'memberships_count']
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
if not data.get('map_type') and not getattr(self.instance, 'map_type_id', None):
|
||||||
|
default_type = NetworkMapType.objects.filter(is_default=True).first()
|
||||||
|
if not default_type:
|
||||||
|
raise serializers.ValidationError({'map_type': 'Нет типа карты по умолчанию.'})
|
||||||
|
data['map_type'] = default_type
|
||||||
|
return data
|
||||||
|
|
||||||
def get_memberships_count(self, obj):
|
def get_memberships_count(self, obj):
|
||||||
return obj.memberships.count()
|
return obj.memberships.count()
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
from rest_framework import viewsets
|
from rest_framework import viewsets
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
from .models import Contact, Relation, NetworkMap, NetworkMapMembership
|
from .models import Contact, Relation, NetworkMap, NetworkMapMembership, NetworkMapType
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
ContactSerializer,
|
ContactSerializer,
|
||||||
RelationSerializer,
|
RelationSerializer,
|
||||||
NetworkMapSerializer,
|
NetworkMapSerializer,
|
||||||
NetworkMapMembershipSerializer,
|
NetworkMapMembershipSerializer,
|
||||||
|
NetworkMapTypeSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -27,10 +29,22 @@ class RelationViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
|
|
||||||
class NetworkMapViewSet(viewsets.ModelViewSet):
|
class NetworkMapViewSet(viewsets.ModelViewSet):
|
||||||
queryset = NetworkMap.objects.all()
|
queryset = NetworkMap.objects.select_related('map_type').all()
|
||||||
serializer_class = NetworkMapSerializer
|
serializer_class = NetworkMapSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkMapTypeViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = NetworkMapType.objects.all()
|
||||||
|
serializer_class = NetworkMapTypeSerializer
|
||||||
|
|
||||||
|
def perform_destroy(self, instance):
|
||||||
|
if instance.is_default:
|
||||||
|
raise ValidationError({'detail': 'Нельзя удалить тип карты по умолчанию.'})
|
||||||
|
if instance.maps.exists():
|
||||||
|
raise ValidationError({'detail': 'Тип используется картами сети. Сначала смените тип у карт.'})
|
||||||
|
instance.delete()
|
||||||
|
|
||||||
|
|
||||||
class NetworkMapMembershipViewSet(viewsets.ModelViewSet):
|
class NetworkMapMembershipViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = NetworkMapMembershipSerializer
|
serializer_class = NetworkMapMembershipSerializer
|
||||||
|
|
||||||
|
|||||||
@@ -38,3 +38,12 @@ def choices_payload():
|
|||||||
'network_circles': [{'value': v, 'label': l} for v, l in NETWORK_CIRCLES],
|
'network_circles': [{'value': v, 'label': l} for v, l in NETWORK_CIRCLES],
|
||||||
'interaction_intensities': [{'value': v, 'label': l} for v, l in INTERACTION_INTENSITY],
|
'interaction_intensities': [{'value': v, 'label': l} for v, l in INTERACTION_INTENSITY],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_MAP_TYPE_SECTORS = [
|
||||||
|
{'key': v, 'label': l} for v, l in LIFE_SPHERES
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_MAP_TYPE_CIRCLES = [
|
||||||
|
{'key': v, 'label': l} for v, l in NETWORK_CIRCLES
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from contacts.models import NetworkMap, NetworkMapType
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_network_map_types_list(api_client):
|
||||||
|
response = api_client.get('/api/v1/network-map-types/')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data['count'] >= 1
|
||||||
|
default = next(r for r in response.data['results'] if r['is_default'])
|
||||||
|
assert len(default['sectors']) == 6
|
||||||
|
assert len(default['circles']) == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_network_map_type_create(api_client):
|
||||||
|
response = api_client.post('/api/v1/network-map-types/', {
|
||||||
|
'name': 'Корпоративная',
|
||||||
|
'sectors': [{'key': 'team', 'label': 'Команда'}],
|
||||||
|
'circles': [{'key': 'inner', 'label': 'Ядро'}],
|
||||||
|
}, format='json')
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert response.data['name'] == 'Корпоративная'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_network_map_type_create_validation(api_client):
|
||||||
|
response = api_client.post('/api/v1/network-map-types/', {
|
||||||
|
'name': 'Пустая',
|
||||||
|
'sectors': [],
|
||||||
|
'circles': [{'key': 'a', 'label': 'A'}],
|
||||||
|
}, format='json')
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_network_map_type_delete_default_forbidden(api_client):
|
||||||
|
default_type = NetworkMapType.objects.filter(is_default=True).first()
|
||||||
|
response = api_client.delete(f'/api/v1/network-map-types/{default_type.id}/')
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_network_map_create_with_type(api_client):
|
||||||
|
map_type = NetworkMapType.objects.create(
|
||||||
|
name='Тестовая',
|
||||||
|
sectors=[{'key': 'a', 'label': 'A'}],
|
||||||
|
circles=[{'key': 'b', 'label': 'B'}],
|
||||||
|
)
|
||||||
|
response = api_client.post('/api/v1/network-maps/', {
|
||||||
|
'name': 'Карта 1',
|
||||||
|
'description': '',
|
||||||
|
'map_type': map_type.id,
|
||||||
|
}, format='json')
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert response.data['map_type'] == map_type.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_network_map_type_delete_in_use(api_client):
|
||||||
|
map_type = NetworkMapType.objects.create(
|
||||||
|
name='Занятая',
|
||||||
|
sectors=[{'key': 'a', 'label': 'A'}],
|
||||||
|
circles=[{'key': 'b', 'label': 'B'}],
|
||||||
|
)
|
||||||
|
NetworkMap.objects.create(name='Карта', map_type=map_type)
|
||||||
|
response = api_client.delete(f'/api/v1/network-map-types/{map_type.id}/')
|
||||||
|
assert response.status_code == 400
|
||||||
@@ -48,6 +48,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="nav-label">Импорт</span>
|
<span class="nav-label">Импорт</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<RouterLink to="/settings" class="nav-link" active-class="active">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||||
|
</svg>
|
||||||
|
<span class="nav-label">Настройки</span>
|
||||||
|
</RouterLink>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="item in pluginNavItems"
|
v-for="item in pluginNavItems"
|
||||||
:key="item.to"
|
:key="item.to"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { listContacts, createContact } from './contacts'
|
|||||||
import { listRelations } from './relations'
|
import { listRelations } from './relations'
|
||||||
import { localDb, DEFAULT_MAP_NAME } from '../../infrastructure/db/localDb'
|
import { localDb, DEFAULT_MAP_NAME } from '../../infrastructure/db/localDb'
|
||||||
import { generateId } from '../../lib/uuid'
|
import { generateId } from '../../lib/uuid'
|
||||||
|
import { createDefaultMapTypeRecord } from '../../domain/mapTypeDefaults'
|
||||||
import { parseVcf } from '../../lib/import/vcard'
|
import { parseVcf } from '../../lib/import/vcard'
|
||||||
import { serializeContactsExport } from '../../lib/export/contacts'
|
import { serializeContactsExport } from '../../lib/export/contacts'
|
||||||
|
|
||||||
@@ -216,15 +217,41 @@ function normalizeDumpRelation(relation) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function migrateV1Dump(dump) {
|
function migrateV1Dump(dump) {
|
||||||
if ((dump.version || 1) >= 2) {
|
let next = dump
|
||||||
return {
|
if ((dump.version || 1) < 2) {
|
||||||
|
next = migrateV1ToV2(dump)
|
||||||
|
}
|
||||||
|
return migrateV2Dump(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateV2Dump(dump) {
|
||||||
|
const base = {
|
||||||
...dump,
|
...dump,
|
||||||
networkMaps: dump.networkMaps || [],
|
networkMaps: dump.networkMaps || [],
|
||||||
networkMapMemberships: dump.networkMapMemberships || [],
|
networkMapMemberships: dump.networkMapMemberships || [],
|
||||||
|
networkMapTypes: dump.networkMapTypes || [],
|
||||||
contacts: (dump.contacts || []).map(stripLegacyContactFields),
|
contacts: (dump.contacts || []).map(stripLegacyContactFields),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (base.networkMapTypes.length) {
|
||||||
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ts = new Date().toISOString()
|
||||||
|
const defaultType = createDefaultMapTypeRecord('default-map-type', ts)
|
||||||
|
const maps = base.networkMaps.map((map) => ({
|
||||||
|
...map,
|
||||||
|
mapTypeId: map.mapTypeId || defaultType.id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
networkMapTypes: [defaultType],
|
||||||
|
networkMaps: maps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateV1ToV2(dump) {
|
||||||
const ts = new Date().toISOString()
|
const ts = new Date().toISOString()
|
||||||
const mapId = generateId()
|
const mapId = generateId()
|
||||||
const networkMaps = [{
|
const networkMaps = [{
|
||||||
@@ -278,6 +305,7 @@ export async function exportLocalData({ passphrase = '' } = {}) {
|
|||||||
relations: await listRelations(),
|
relations: await listRelations(),
|
||||||
networkMaps: await localDb.networkMaps.toArray(),
|
networkMaps: await localDb.networkMaps.toArray(),
|
||||||
networkMapMemberships: await localDb.networkMapMemberships.toArray(),
|
networkMapMemberships: await localDb.networkMapMemberships.toArray(),
|
||||||
|
networkMapTypes: await localDb.networkMapTypes.toArray(),
|
||||||
changes: await localDb.changelog.toArray(),
|
changes: await localDb.changelog.toArray(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,8 +359,14 @@ export async function importLocalDump(file, passphrase = '') {
|
|||||||
localDb.relations,
|
localDb.relations,
|
||||||
localDb.networkMaps,
|
localDb.networkMaps,
|
||||||
localDb.networkMapMemberships,
|
localDb.networkMapMemberships,
|
||||||
|
localDb.networkMapTypes,
|
||||||
localDb.changelog,
|
localDb.changelog,
|
||||||
async () => {
|
async () => {
|
||||||
|
if (Array.isArray(dump.networkMapTypes)) {
|
||||||
|
for (const mapType of dump.networkMapTypes) {
|
||||||
|
await localDb.networkMapTypes.put(mapType)
|
||||||
|
}
|
||||||
|
}
|
||||||
for (const contact of dump.contacts) {
|
for (const contact of dump.contacts) {
|
||||||
await localDb.contacts.put(normalizeDumpContact(contact))
|
await localDb.contacts.put(normalizeDumpContact(contact))
|
||||||
}
|
}
|
||||||
@@ -362,5 +396,6 @@ export async function importLocalDump(file, passphrase = '') {
|
|||||||
importedRelations: dump.relations.length,
|
importedRelations: dump.relations.length,
|
||||||
importedMaps: dump.networkMaps?.length || 0,
|
importedMaps: dump.networkMaps?.length || 0,
|
||||||
importedMemberships: dump.networkMapMemberships?.length || 0,
|
importedMemberships: dump.networkMapMemberships?.length || 0,
|
||||||
|
importedMapTypes: dump.networkMapTypes?.length || 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { appendChange } from '../../infrastructure/sync/changeLogRepository'
|
||||||
|
import { getNetworkMapTypeRepository } from '../../infrastructure/repositories/repositoryFactory'
|
||||||
|
|
||||||
|
const typeRepo = () => getNetworkMapTypeRepository()
|
||||||
|
|
||||||
|
export async function listNetworkMapTypes() {
|
||||||
|
return typeRepo().list()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNetworkMapTypeById(id) {
|
||||||
|
return typeRepo().getById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDefaultNetworkMapType() {
|
||||||
|
return typeRepo().getDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createNetworkMapType(payload) {
|
||||||
|
const created = await typeRepo().create(payload)
|
||||||
|
await appendChange({
|
||||||
|
entityType: 'networkMapType',
|
||||||
|
entityId: created.id,
|
||||||
|
op: 'created',
|
||||||
|
payloadPatch: created,
|
||||||
|
})
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateNetworkMapType(id, payload) {
|
||||||
|
const updated = await typeRepo().update(id, payload)
|
||||||
|
await appendChange({
|
||||||
|
entityType: 'networkMapType',
|
||||||
|
entityId: id,
|
||||||
|
op: 'updated',
|
||||||
|
payloadPatch: payload,
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNetworkMapType(id) {
|
||||||
|
await typeRepo().remove(id)
|
||||||
|
await appendChange({
|
||||||
|
entityType: 'networkMapType',
|
||||||
|
entityId: id,
|
||||||
|
op: 'deleted',
|
||||||
|
payloadPatch: {},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -10,6 +10,14 @@
|
|||||||
<label>Название *</label>
|
<label>Название *</label>
|
||||||
<input v-model="form.name" class="form-control" required placeholder="Например: Коллектив А" />
|
<input v-model="form.name" class="form-control" required placeholder="Например: Коллектив А" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Тип карты *</label>
|
||||||
|
<select v-model="form.mapTypeId" class="form-control" required>
|
||||||
|
<option v-for="type in mapTypes" :key="type.id" :value="String(type.id)">
|
||||||
|
{{ type.name }}{{ type.isDefault ? ' (по умолчанию)' : '' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Описание</label>
|
<label>Описание</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -45,6 +53,8 @@ const props = defineProps({
|
|||||||
open: { type: Boolean, default: false },
|
open: { type: Boolean, default: false },
|
||||||
initial: { type: Object, default: () => ({}) },
|
initial: { type: Object, default: () => ({}) },
|
||||||
deletable: { type: Boolean, default: false },
|
deletable: { type: Boolean, default: false },
|
||||||
|
mapTypes: { type: Array, default: () => [] },
|
||||||
|
defaultMapTypeId: { type: String, default: '' },
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['close', 'submit', 'delete'])
|
const emit = defineEmits(['close', 'submit', 'delete'])
|
||||||
|
|
||||||
@@ -53,20 +63,28 @@ const isEdit = computed(() => Boolean(props.initial?.id))
|
|||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
mapTypeId: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.open, props.initial],
|
() => [props.open, props.initial, props.defaultMapTypeId, props.mapTypes],
|
||||||
() => {
|
() => {
|
||||||
if (!props.open) return
|
if (!props.open) return
|
||||||
form.name = props.initial?.name || ''
|
form.name = props.initial?.name || ''
|
||||||
form.description = props.initial?.description || ''
|
form.description = props.initial?.description || ''
|
||||||
|
form.mapTypeId = String(
|
||||||
|
props.initial?.mapTypeId || props.defaultMapTypeId || props.mapTypes[0]?.id || ''
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{ immediate: true, deep: true }
|
{ immediate: true, deep: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
function onSubmit() {
|
function onSubmit() {
|
||||||
emit('submit', { name: form.name.trim(), description: form.description.trim() })
|
emit('submit', {
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim(),
|
||||||
|
mapTypeId: form.mapTypeId,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="open" class="modal-overlay" @click.self="$emit('close')">
|
||||||
|
<div class="modal modal-wide">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>{{ isEdit ? 'Редактировать тип карты' : 'Новый тип карты' }}</h3>
|
||||||
|
<button class="btn btn-secondary btn-sm" type="button" @click="$emit('close')">✕</button>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="onSubmit">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Название типа *</label>
|
||||||
|
<input v-model="form.name" class="form-control" required placeholder="Например: Корпоративная" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Секторы (подписи по кругу)</label>
|
||||||
|
<div v-for="(sector, index) in form.sectors" :key="sector._id" class="dynamic-row">
|
||||||
|
<input
|
||||||
|
v-model="sector.label"
|
||||||
|
class="form-control"
|
||||||
|
required
|
||||||
|
:placeholder="`Сектор ${index + 1}`"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="form.sectors.length > 1"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary btn-sm"
|
||||||
|
@click="removeSector(index)"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" @click="addSector">+ Добавить сектор</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Концентрические круги (от центра к краю)</label>
|
||||||
|
<div v-for="(circle, index) in form.circles" :key="circle._id" class="dynamic-row">
|
||||||
|
<input
|
||||||
|
v-model="circle.label"
|
||||||
|
class="form-control"
|
||||||
|
required
|
||||||
|
:placeholder="`Круг ${index + 1}`"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="form.circles.length > 1"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary btn-sm"
|
||||||
|
@click="removeCircle(index)"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" @click="addCircle">+ Добавить круг</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="formError" class="text-danger" style="font-size:13px;">{{ formError }}</p>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button
|
||||||
|
v-if="isEdit && !initial?.isDefault"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-danger"
|
||||||
|
@click="$emit('delete')"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
<div class="modal-footer-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" @click="$emit('close')">Отмена</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, ref, watch, computed } from 'vue'
|
||||||
|
import { assignKeysToItems, validateMapTypePayload } from '../domain/mapTypeDefaults'
|
||||||
|
|
||||||
|
let rowId = 0
|
||||||
|
function nextRowId() {
|
||||||
|
rowId += 1
|
||||||
|
return rowId
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
initial: { type: Object, default: () => ({}) },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['close', 'submit', 'delete'])
|
||||||
|
|
||||||
|
const isEdit = computed(() => Boolean(props.initial?.id))
|
||||||
|
const formError = ref('')
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
sectors: [],
|
||||||
|
circles: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
function blankSector(label = '') {
|
||||||
|
return { _id: nextRowId(), label, key: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function blankCircle(label = '') {
|
||||||
|
return { _id: nextRowId(), label, key: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.name = props.initial?.name || ''
|
||||||
|
form.sectors = (props.initial?.sectors || [{ label: 'Сектор 1', key: 'sector_1' }]).map((s) => ({
|
||||||
|
_id: nextRowId(),
|
||||||
|
label: s.label,
|
||||||
|
key: s.key,
|
||||||
|
}))
|
||||||
|
form.circles = (props.initial?.circles || [{ label: 'Круг 1', key: 'circle_1' }]).map((c) => ({
|
||||||
|
_id: nextRowId(),
|
||||||
|
label: c.label,
|
||||||
|
key: c.key,
|
||||||
|
}))
|
||||||
|
formError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.open, props.initial],
|
||||||
|
() => {
|
||||||
|
if (!props.open) return
|
||||||
|
resetForm()
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
function addSector() {
|
||||||
|
form.sectors.push(blankSector())
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSector(index) {
|
||||||
|
form.sectors.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCircle() {
|
||||||
|
form.circles.push(blankCircle())
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCircle(index) {
|
||||||
|
form.circles.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubmit() {
|
||||||
|
const sectors = assignKeysToItems(
|
||||||
|
form.sectors.map((s) => ({ label: s.label.trim(), key: s.key || undefined }))
|
||||||
|
)
|
||||||
|
const circles = assignKeysToItems(
|
||||||
|
form.circles.map((c) => ({ label: c.label.trim(), key: c.key || undefined }))
|
||||||
|
)
|
||||||
|
const errors = validateMapTypePayload({
|
||||||
|
name: form.name.trim(),
|
||||||
|
sectors,
|
||||||
|
circles,
|
||||||
|
})
|
||||||
|
if (Object.keys(errors).length) {
|
||||||
|
formError.value = Object.values(errors)[0]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
formError.value = ''
|
||||||
|
emit('submit', {
|
||||||
|
name: form.name.trim(),
|
||||||
|
sectors,
|
||||||
|
circles,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-wide {
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
.dynamic-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.dynamic-row .form-control {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.modal-footer-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.text-danger {
|
||||||
|
color: var(--red, #e74c3c);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { LIFE_SPHERES, NETWORK_CIRCLES } from './networkChoices'
|
||||||
|
|
||||||
|
export const DEFAULT_MAP_TYPE_NAME = 'Стандартная'
|
||||||
|
|
||||||
|
export const DEFAULT_MAP_TYPE_SECTORS = LIFE_SPHERES.map(({ value, label }) => ({
|
||||||
|
key: value,
|
||||||
|
label,
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const DEFAULT_MAP_TYPE_CIRCLES = NETWORK_CIRCLES.map(({ value, label }) => ({
|
||||||
|
key: value,
|
||||||
|
label,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const CYRILLIC_MAP = {
|
||||||
|
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z',
|
||||||
|
и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r',
|
||||||
|
с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'sch',
|
||||||
|
ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slugifyKey(label, existingKeys = []) {
|
||||||
|
const lower = String(label || '').trim().toLowerCase()
|
||||||
|
let base = lower
|
||||||
|
.split('')
|
||||||
|
.map((ch) => CYRILLIC_MAP[ch] ?? ch)
|
||||||
|
.join('')
|
||||||
|
.replace(/[^a-z0-9]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '')
|
||||||
|
.slice(0, 32)
|
||||||
|
|
||||||
|
if (!base) base = 'item'
|
||||||
|
let key = base
|
||||||
|
let i = 2
|
||||||
|
const used = new Set(existingKeys)
|
||||||
|
while (used.has(key)) {
|
||||||
|
key = `${base}_${i}`
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assignKeysToItems(items) {
|
||||||
|
const keys = []
|
||||||
|
return items.map((item) => {
|
||||||
|
const label = String(item.label || '').trim()
|
||||||
|
const key = item.key?.trim() || slugifyKey(label, keys)
|
||||||
|
keys.push(key)
|
||||||
|
return { key, label }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateMapTypePayload({ name, sectors, circles }) {
|
||||||
|
const errors = {}
|
||||||
|
if (!String(name || '').trim()) errors.name = 'Укажите название типа.'
|
||||||
|
if (!sectors?.length) errors.sectors = 'Нужен хотя бы один сектор.'
|
||||||
|
if (!circles?.length) errors.circles = 'Нужен хотя бы один круг.'
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
export function geometryFromMapType(mapType) {
|
||||||
|
const sectorKeys = (mapType?.sectors || DEFAULT_MAP_TYPE_SECTORS).map((s) => s.key)
|
||||||
|
const circleKeys = (mapType?.circles || DEFAULT_MAP_TYPE_CIRCLES).map((c) => c.key)
|
||||||
|
return { sectorKeys, circleKeys }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function labelsFromMapType(mapType) {
|
||||||
|
const sectors = mapType?.sectors || DEFAULT_MAP_TYPE_SECTORS
|
||||||
|
const circles = mapType?.circles || DEFAULT_MAP_TYPE_CIRCLES
|
||||||
|
const sphereLabels = {}
|
||||||
|
const circleLabels = {}
|
||||||
|
for (const s of sectors) sphereLabels[s.key] = s.label
|
||||||
|
for (const c of circles) circleLabels[c.key] = c.label
|
||||||
|
return { sphereLabels, circleLabels }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultMapTypeRecord(id, ts = new Date().toISOString()) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: DEFAULT_MAP_TYPE_NAME,
|
||||||
|
sectors: DEFAULT_MAP_TYPE_SECTORS,
|
||||||
|
circles: DEFAULT_MAP_TYPE_CIRCLES,
|
||||||
|
isDefault: true,
|
||||||
|
workspaceId: 'personal',
|
||||||
|
version: 1,
|
||||||
|
createdAt: ts,
|
||||||
|
updatedAt: ts,
|
||||||
|
deletedAt: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
slugifyKey,
|
||||||
|
assignKeysToItems,
|
||||||
|
validateMapTypePayload,
|
||||||
|
} from './mapTypeDefaults'
|
||||||
|
|
||||||
|
describe('mapTypeDefaults', () => {
|
||||||
|
it('slugifyKey transliterates and deduplicates', () => {
|
||||||
|
expect(slugifyKey('Работа')).toBe('rabota')
|
||||||
|
expect(slugifyKey('Работа', ['rabota'])).toBe('rabota_2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('assignKeysToItems preserves explicit keys', () => {
|
||||||
|
const items = assignKeysToItems([
|
||||||
|
{ label: 'A', key: 'custom' },
|
||||||
|
{ label: 'B' },
|
||||||
|
])
|
||||||
|
expect(items[0].key).toBe('custom')
|
||||||
|
expect(items[1].key).toBe('b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validateMapTypePayload requires sectors and circles', () => {
|
||||||
|
expect(validateMapTypePayload({ name: 'T', sectors: [], circles: [{ key: 'a', label: 'A' }] }))
|
||||||
|
.toHaveProperty('sectors')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,8 +1,20 @@
|
|||||||
import Dexie from 'dexie'
|
import Dexie from 'dexie'
|
||||||
import { generateId } from '../../lib/uuid'
|
import { generateId } from '../../lib/uuid'
|
||||||
|
import { createDefaultMapTypeRecord } from '../../domain/mapTypeDefaults'
|
||||||
|
|
||||||
const DEFAULT_MAP_NAME = 'Основная карта'
|
const DEFAULT_MAP_NAME = 'Основная карта'
|
||||||
|
|
||||||
|
const BASE_STORES_V2 = {
|
||||||
|
contacts: 'id, name, updatedAt, deletedAt, workspaceId',
|
||||||
|
relations: 'id, source, target, updatedAt, deletedAt, workspaceId',
|
||||||
|
networkMaps: 'id, name, updatedAt, deletedAt, workspaceId',
|
||||||
|
networkMapMemberships: 'id, mapId, contactId, updatedAt, deletedAt, [mapId+contactId]',
|
||||||
|
meta: 'key',
|
||||||
|
changelog: 'id, ts, entityType, entityId, syncStatus, workspaceId',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTACT_TAGS_STORE = 'id, contactId, label, updatedAt, deletedAt, workspaceId'
|
||||||
|
|
||||||
class SocialGraphDb extends Dexie {
|
class SocialGraphDb extends Dexie {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('socialGraphDb')
|
super('socialGraphDb')
|
||||||
@@ -13,12 +25,7 @@ class SocialGraphDb extends Dexie {
|
|||||||
changelog: 'id, ts, entityType, entityId, syncStatus, workspaceId',
|
changelog: 'id, ts, entityType, entityId, syncStatus, workspaceId',
|
||||||
})
|
})
|
||||||
this.version(2).stores({
|
this.version(2).stores({
|
||||||
contacts: 'id, name, updatedAt, deletedAt, workspaceId',
|
...BASE_STORES_V2,
|
||||||
relations: 'id, source, target, updatedAt, deletedAt, workspaceId',
|
|
||||||
networkMaps: 'id, name, updatedAt, deletedAt, workspaceId',
|
|
||||||
networkMapMemberships: 'id, mapId, contactId, updatedAt, deletedAt, [mapId+contactId]',
|
|
||||||
meta: 'key',
|
|
||||||
changelog: 'id, ts, entityType, entityId, syncStatus, workspaceId',
|
|
||||||
}).upgrade(async (tx) => {
|
}).upgrade(async (tx) => {
|
||||||
const contacts = await tx.table('contacts').toArray()
|
const contacts = await tx.table('contacts').toArray()
|
||||||
const ts = new Date().toISOString()
|
const ts = new Date().toISOString()
|
||||||
@@ -67,4 +74,43 @@ class SocialGraphDb extends Dexie {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const localDb = new SocialGraphDb()
|
export const localDb = new SocialGraphDb()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core schema v4: network map types (runs after plugin Dexie upgraders).
|
||||||
|
*/
|
||||||
|
export function applyCoreDbUpgrades(db) {
|
||||||
|
if (db.verno >= 4) return
|
||||||
|
|
||||||
|
const stores = {
|
||||||
|
...BASE_STORES_V2,
|
||||||
|
networkMapTypes: 'id, name, updatedAt, deletedAt, workspaceId',
|
||||||
|
}
|
||||||
|
if (db.verno >= 3) {
|
||||||
|
stores.contactTags = CONTACT_TAGS_STORE
|
||||||
|
}
|
||||||
|
|
||||||
|
db.version(4).stores(stores).upgrade(async (tx) => {
|
||||||
|
const ts = new Date().toISOString()
|
||||||
|
const types = await tx.table('networkMapTypes').toArray()
|
||||||
|
const activeTypes = types.filter((t) => !t.deletedAt)
|
||||||
|
let defaultType = activeTypes.find((t) => t.isDefault)
|
||||||
|
|
||||||
|
if (!defaultType) {
|
||||||
|
const typeId = generateId()
|
||||||
|
defaultType = createDefaultMapTypeRecord(typeId, ts)
|
||||||
|
await tx.table('networkMapTypes').add(defaultType)
|
||||||
|
}
|
||||||
|
|
||||||
|
const maps = await tx.table('networkMaps').toArray()
|
||||||
|
for (const map of maps) {
|
||||||
|
if (!map.deletedAt && !map.mapTypeId) {
|
||||||
|
await tx.table('networkMaps').update(map.id, {
|
||||||
|
mapTypeId: defaultType.id,
|
||||||
|
updatedAt: ts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export { DEFAULT_MAP_NAME }
|
export { DEFAULT_MAP_NAME }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { localDb, DEFAULT_MAP_NAME } from '../db/localDb'
|
import { localDb, DEFAULT_MAP_NAME } from '../db/localDb'
|
||||||
import { generateId } from '../../lib/uuid'
|
import { generateId } from '../../lib/uuid'
|
||||||
|
import { localNetworkMapTypeRepository } from './networkMapTypeRepository.local'
|
||||||
|
|
||||||
function nowIso() {
|
function nowIso() {
|
||||||
return new Date().toISOString()
|
return new Date().toISOString()
|
||||||
@@ -9,6 +10,7 @@ function withDefaults(payload = {}) {
|
|||||||
return {
|
return {
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
mapTypeId: payload.mapTypeId || null,
|
||||||
workspaceId: 'personal',
|
workspaceId: 'personal',
|
||||||
...payload,
|
...payload,
|
||||||
}
|
}
|
||||||
@@ -40,12 +42,14 @@ async function ensureDefaultMap() {
|
|||||||
const active = all.filter((m) => !m.deletedAt)
|
const active = all.filter((m) => !m.deletedAt)
|
||||||
if (active.length) return active.sort((a, b) => a.name.localeCompare(b.name, 'ru'))[0]
|
if (active.length) return active.sort((a, b) => a.name.localeCompare(b.name, 'ru'))[0]
|
||||||
|
|
||||||
|
const defaultType = await localNetworkMapTypeRepository.getDefault()
|
||||||
const ts = nowIso()
|
const ts = nowIso()
|
||||||
const id = generateId()
|
const id = generateId()
|
||||||
await localDb.networkMaps.put({
|
await localDb.networkMaps.put({
|
||||||
id,
|
id,
|
||||||
name: DEFAULT_MAP_NAME,
|
name: DEFAULT_MAP_NAME,
|
||||||
description: '',
|
description: '',
|
||||||
|
mapTypeId: defaultType?.id || null,
|
||||||
workspaceId: 'personal',
|
workspaceId: 'personal',
|
||||||
version: 1,
|
version: 1,
|
||||||
createdAt: ts,
|
createdAt: ts,
|
||||||
@@ -75,8 +79,10 @@ export const localNetworkMapRepository = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async create(payload) {
|
async create(payload) {
|
||||||
|
const defaultType = await localNetworkMapTypeRepository.getDefault()
|
||||||
const ts = nowIso()
|
const ts = nowIso()
|
||||||
const record = withDefaults(payload)
|
const record = withDefaults(payload)
|
||||||
|
if (!record.mapTypeId) record.mapTypeId = defaultType?.id || null
|
||||||
const id = generateId()
|
const id = generateId()
|
||||||
await localDb.networkMaps.put({
|
await localDb.networkMaps.put({
|
||||||
...record,
|
...record,
|
||||||
|
|||||||
@@ -3,19 +3,29 @@ import { fetchAllPages } from '../../lib/api/pagination'
|
|||||||
|
|
||||||
export const remoteNetworkMapRepository = {
|
export const remoteNetworkMapRepository = {
|
||||||
async list() {
|
async list() {
|
||||||
return fetchAllPages((page) => api.get('/network-maps/', { params: { page } }))
|
const pages = await fetchAllPages((page) => api.get('/network-maps/', { params: { page } }))
|
||||||
|
return pages.map((m) => ({ ...m, mapTypeId: m.map_type }))
|
||||||
},
|
},
|
||||||
async getById(id) {
|
async getById(id) {
|
||||||
const { data } = await api.get(`/network-maps/${id}/`)
|
const { data } = await api.get(`/network-maps/${id}/`)
|
||||||
return data
|
return { ...data, mapTypeId: data.map_type }
|
||||||
},
|
},
|
||||||
async create(payload) {
|
async create(payload) {
|
||||||
const { data } = await api.post('/network-maps/', payload)
|
const { data } = await api.post('/network-maps/', {
|
||||||
return data
|
name: payload.name,
|
||||||
|
description: payload.description,
|
||||||
|
map_type: payload.mapTypeId || payload.map_type,
|
||||||
|
})
|
||||||
|
return { ...data, mapTypeId: data.map_type }
|
||||||
},
|
},
|
||||||
async update(id, payload) {
|
async update(id, payload) {
|
||||||
const { data } = await api.patch(`/network-maps/${id}/`, payload)
|
const body = { ...payload }
|
||||||
return data
|
if (body.mapTypeId !== undefined) {
|
||||||
|
body.map_type = body.mapTypeId
|
||||||
|
delete body.mapTypeId
|
||||||
|
}
|
||||||
|
const { data } = await api.patch(`/network-maps/${id}/`, body)
|
||||||
|
return { ...data, mapTypeId: data.map_type }
|
||||||
},
|
},
|
||||||
async remove(id) {
|
async remove(id) {
|
||||||
await api.delete(`/network-maps/${id}/`)
|
await api.delete(`/network-maps/${id}/`)
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { localDb } from '../db/localDb'
|
||||||
|
import { generateId } from '../../lib/uuid'
|
||||||
|
import { createDefaultMapTypeRecord } from '../../domain/mapTypeDefaults'
|
||||||
|
|
||||||
|
function nowIso() {
|
||||||
|
return new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeType(record) {
|
||||||
|
if (!record || record.deletedAt) return null
|
||||||
|
return {
|
||||||
|
...record,
|
||||||
|
isDefault: Boolean(record.isDefault),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findActiveType(id) {
|
||||||
|
const direct = await localDb.networkMapTypes.get(id)
|
||||||
|
if (direct && !direct.deletedAt) return direct
|
||||||
|
const sid = String(id)
|
||||||
|
const all = await localDb.networkMapTypes.toArray()
|
||||||
|
return all.find((t) => !t.deletedAt && String(t.id) === sid) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDefaultType() {
|
||||||
|
const all = await localDb.networkMapTypes.toArray()
|
||||||
|
const active = all.filter((t) => !t.deletedAt)
|
||||||
|
const existing = active.find((t) => t.isDefault)
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
const ts = nowIso()
|
||||||
|
const id = generateId()
|
||||||
|
const record = createDefaultMapTypeRecord(id, ts)
|
||||||
|
await localDb.networkMapTypes.put(record)
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
export const localNetworkMapTypeRepository = {
|
||||||
|
async list() {
|
||||||
|
await ensureDefaultType()
|
||||||
|
const all = await localDb.networkMapTypes.toArray()
|
||||||
|
return all
|
||||||
|
.filter((t) => !t.deletedAt)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
|
||||||
|
.map(normalizeType)
|
||||||
|
.filter(Boolean)
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id) {
|
||||||
|
const type = await findActiveType(id)
|
||||||
|
return normalizeType(type)
|
||||||
|
},
|
||||||
|
|
||||||
|
async getDefault() {
|
||||||
|
await ensureDefaultType()
|
||||||
|
const all = await localDb.networkMapTypes.toArray()
|
||||||
|
const active = all.filter((t) => !t.deletedAt)
|
||||||
|
return normalizeType(active.find((t) => t.isDefault) || active[0])
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(payload) {
|
||||||
|
const ts = nowIso()
|
||||||
|
const id = generateId()
|
||||||
|
await localDb.networkMapTypes.put({
|
||||||
|
id,
|
||||||
|
name: payload.name || '',
|
||||||
|
sectors: payload.sectors || [],
|
||||||
|
circles: payload.circles || [],
|
||||||
|
isDefault: false,
|
||||||
|
workspaceId: 'personal',
|
||||||
|
version: 1,
|
||||||
|
createdAt: ts,
|
||||||
|
updatedAt: ts,
|
||||||
|
deletedAt: null,
|
||||||
|
})
|
||||||
|
return this.getById(id)
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id, payload) {
|
||||||
|
const existing = await findActiveType(id)
|
||||||
|
if (!existing) throw new Error('Тип карты не найден')
|
||||||
|
if (existing.isDefault && payload.isDefault === false) {
|
||||||
|
throw new Error('Нельзя снять флаг типа по умолчанию')
|
||||||
|
}
|
||||||
|
await localDb.networkMapTypes.update(existing.id, {
|
||||||
|
...payload,
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
version: Number(existing.version || 1) + 1,
|
||||||
|
})
|
||||||
|
return this.getById(existing.id)
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(id) {
|
||||||
|
const type = await findActiveType(id)
|
||||||
|
if (!type) throw new Error('Тип карты не найден')
|
||||||
|
if (type.isDefault) throw new Error('Нельзя удалить тип карты по умолчанию')
|
||||||
|
|
||||||
|
const maps = await localDb.networkMaps.toArray()
|
||||||
|
const inUse = maps.some((m) => !m.deletedAt && String(m.mapTypeId) === String(type.id))
|
||||||
|
if (inUse) throw new Error('Тип используется картами сети')
|
||||||
|
|
||||||
|
const ts = nowIso()
|
||||||
|
await localDb.networkMapTypes.update(type.id, {
|
||||||
|
deletedAt: ts,
|
||||||
|
updatedAt: ts,
|
||||||
|
version: Number(type.version || 1) + 1,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async countMapsUsing(id) {
|
||||||
|
const maps = await localDb.networkMaps.toArray()
|
||||||
|
return maps.filter((m) => !m.deletedAt && String(m.mapTypeId) === String(id)).length
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import api from '../../api'
|
||||||
|
import { fetchAllPages } from '../../lib/api/pagination'
|
||||||
|
|
||||||
|
export const remoteNetworkMapTypeRepository = {
|
||||||
|
async list() {
|
||||||
|
const rows = await fetchAllPages((page) => api.get('/network-map-types/', { params: { page } }))
|
||||||
|
return rows.map((t) => ({ ...t, isDefault: t.is_default }))
|
||||||
|
},
|
||||||
|
async getById(id) {
|
||||||
|
const { data } = await api.get(`/network-map-types/${id}/`)
|
||||||
|
return { ...data, isDefault: data.is_default }
|
||||||
|
},
|
||||||
|
async getDefault() {
|
||||||
|
const list = await this.list()
|
||||||
|
return list.find((t) => t.isDefault) || list[0] || null
|
||||||
|
},
|
||||||
|
async create(payload) {
|
||||||
|
const { data } = await api.post('/network-map-types/', {
|
||||||
|
name: payload.name,
|
||||||
|
sectors: payload.sectors,
|
||||||
|
circles: payload.circles,
|
||||||
|
})
|
||||||
|
return { ...data, isDefault: data.is_default }
|
||||||
|
},
|
||||||
|
async update(id, payload) {
|
||||||
|
const { data } = await api.patch(`/network-map-types/${id}/`, {
|
||||||
|
name: payload.name,
|
||||||
|
sectors: payload.sectors,
|
||||||
|
circles: payload.circles,
|
||||||
|
})
|
||||||
|
return { ...data, isDefault: data.is_default }
|
||||||
|
},
|
||||||
|
async remove(id) {
|
||||||
|
await api.delete(`/network-map-types/${id}/`)
|
||||||
|
},
|
||||||
|
async countMapsUsing() {
|
||||||
|
return 0
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -5,10 +5,12 @@ import { localContactRepository } from './contactRepository.local'
|
|||||||
import { localRelationRepository } from './relationRepository.local'
|
import { localRelationRepository } from './relationRepository.local'
|
||||||
import { localNetworkMapRepository } from './networkMapRepository.local'
|
import { localNetworkMapRepository } from './networkMapRepository.local'
|
||||||
import { localNetworkMapMembershipRepository } from './networkMapMembershipRepository.local'
|
import { localNetworkMapMembershipRepository } from './networkMapMembershipRepository.local'
|
||||||
|
import { localNetworkMapTypeRepository } from './networkMapTypeRepository.local'
|
||||||
import { remoteContactRepository } from './contactRepository.remote'
|
import { remoteContactRepository } from './contactRepository.remote'
|
||||||
import { remoteRelationRepository } from './relationRepository.remote'
|
import { remoteRelationRepository } from './relationRepository.remote'
|
||||||
import { remoteNetworkMapRepository } from './networkMapRepository.remote'
|
import { remoteNetworkMapRepository } from './networkMapRepository.remote'
|
||||||
import { remoteNetworkMapMembershipRepository } from './networkMapMembershipRepository.remote'
|
import { remoteNetworkMapMembershipRepository } from './networkMapMembershipRepository.remote'
|
||||||
|
import { remoteNetworkMapTypeRepository } from './networkMapTypeRepository.remote'
|
||||||
|
|
||||||
function mode() {
|
function mode() {
|
||||||
return getDataMode()
|
return getDataMode()
|
||||||
@@ -30,6 +32,10 @@ export function getNetworkMapMembershipRepository() {
|
|||||||
return mode() === 'remote' ? remoteNetworkMapMembershipRepository : localNetworkMapMembershipRepository
|
return mode() === 'remote' ? remoteNetworkMapMembershipRepository : localNetworkMapMembershipRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getNetworkMapTypeRepository() {
|
||||||
|
return mode() === 'remote' ? remoteNetworkMapTypeRepository : localNetworkMapTypeRepository
|
||||||
|
}
|
||||||
|
|
||||||
export async function getRelationTypes() {
|
export async function getRelationTypes() {
|
||||||
if (mode() === 'remote') {
|
if (mode() === 'remote') {
|
||||||
const { data } = await api.get('/meta/choices/')
|
const { data } = await api.get('/meta/choices/')
|
||||||
|
|||||||
@@ -1,57 +1,79 @@
|
|||||||
export const SPHERE_ORDER = ['work', 'study', 'hobby', 'family', 'health', 'other']
|
export const SPHERE_ORDER = ['work', 'study', 'hobby', 'family', 'health', 'other']
|
||||||
|
|
||||||
export function ringRadius(networkCircle, layout) {
|
const DEFAULT_CIRCLE_KEYS = ['support', 'productivity', 'development']
|
||||||
if (networkCircle === 'support') return layout.rInner
|
|
||||||
if (networkCircle === 'productivity') return layout.rMid
|
export function defaultGeometry() {
|
||||||
return layout.rOuter
|
return {
|
||||||
|
sectorKeys: [...SPHERE_ORDER],
|
||||||
|
circleKeys: [...DEFAULT_CIRCLE_KEYS],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ringByRadius(r, L) {
|
export function ringRadius(networkCircle, layout, geometry = defaultGeometry()) {
|
||||||
const t1 = (L.rInner + L.rMid) / 2
|
const { circleKeys } = geometry
|
||||||
const t2 = (L.rMid + L.rOuter) / 2
|
const idx = circleKeys.indexOf(networkCircle)
|
||||||
if (r <= t1) return 'support'
|
if (idx < 0) return layout.rOuter * 0.5
|
||||||
if (r <= t2) return 'productivity'
|
const n = circleKeys.length
|
||||||
return 'development'
|
return layout.rOuter * ((idx + 1) / n) * 0.95
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sphereByAngle(angle) {
|
export function ringByRadius(r, layout, geometry = defaultGeometry()) {
|
||||||
|
const { circleKeys } = geometry
|
||||||
|
const n = circleKeys.length
|
||||||
|
if (!n) return 'productivity'
|
||||||
|
for (let i = 0; i < n; i += 1) {
|
||||||
|
const threshold = layout.rOuter * (i + 0.5) / n
|
||||||
|
if (r <= threshold) return circleKeys[i]
|
||||||
|
}
|
||||||
|
return circleKeys[n - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sphereByAngle(angle, geometry = defaultGeometry()) {
|
||||||
|
const { sectorKeys } = geometry
|
||||||
|
const n = sectorKeys.length
|
||||||
|
if (!n) return 'other'
|
||||||
const full = 2 * Math.PI
|
const full = 2 * Math.PI
|
||||||
const norm = ((angle + Math.PI / 2) % full + full) % full
|
const norm = ((angle + Math.PI / 2) % full + full) % full
|
||||||
const sectorW = full / SPHERE_ORDER.length
|
const sectorW = full / n
|
||||||
const idx = Math.floor(norm / sectorW)
|
const idx = Math.floor(norm / sectorW)
|
||||||
return SPHERE_ORDER[Math.max(0, Math.min(SPHERE_ORDER.length - 1, idx))]
|
return sectorKeys[Math.max(0, Math.min(n - 1, idx))]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizedSphere(n) {
|
export function normalizedSphere(n, geometry = defaultGeometry()) {
|
||||||
|
const { sectorKeys } = geometry
|
||||||
const s = n.life_sphere
|
const s = n.life_sphere
|
||||||
return SPHERE_ORDER.includes(s) ? s : 'other'
|
if (sectorKeys.includes(s)) return s
|
||||||
|
return sectorKeys[sectorKeys.length - 1] || sectorKeys[0] || 'other'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizedCircle(n) {
|
export function normalizedCircle(n, geometry = defaultGeometry()) {
|
||||||
|
const { circleKeys } = geometry
|
||||||
const c = n.network_circle
|
const c = n.network_circle
|
||||||
return c === 'support' || c === 'productivity' || c === 'development' ? c : 'productivity'
|
if (circleKeys.includes(c)) return c
|
||||||
|
return circleKeys[Math.floor(circleKeys.length / 2)] || circleKeys[0] || 'productivity'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computePolarPositions(rawNodes, L) {
|
export function computePolarPositions(rawNodes, layout, geometry = defaultGeometry()) {
|
||||||
|
const { sectorKeys } = geometry
|
||||||
const groups = new Map()
|
const groups = new Map()
|
||||||
for (const n of rawNodes) {
|
for (const n of rawNodes) {
|
||||||
const key = `${normalizedSphere(n)}|${normalizedCircle(n)}`
|
const key = `${normalizedSphere(n, geometry)}|${normalizedCircle(n, geometry)}`
|
||||||
if (!groups.has(key)) groups.set(key, [])
|
if (!groups.has(key)) groups.set(key, [])
|
||||||
groups.get(key).push(n)
|
groups.get(key).push(n)
|
||||||
}
|
}
|
||||||
const posById = new Map()
|
const posById = new Map()
|
||||||
const nSectors = SPHERE_ORDER.length
|
const nSectors = sectorKeys.length || 1
|
||||||
for (const [, group] of groups) {
|
for (const [, group] of groups) {
|
||||||
group.sort((a, b) => Number(a.id) - Number(b.id))
|
group.sort((a, b) => Number(a.id) - Number(b.id))
|
||||||
const sample = group[0]
|
const sample = group[0]
|
||||||
const sphere = normalizedSphere(sample)
|
const sphere = normalizedSphere(sample, geometry)
|
||||||
const circle = normalizedCircle(sample)
|
const circle = normalizedCircle(sample, geometry)
|
||||||
const idx = SPHERE_ORDER.indexOf(sphere)
|
const idx = sectorKeys.indexOf(sphere)
|
||||||
const sectorStart = (idx / nSectors) * 2 * Math.PI - Math.PI / 2
|
const sectorStart = (Math.max(0, idx) / nSectors) * 2 * Math.PI - Math.PI / 2
|
||||||
const sectorW = (2 * Math.PI) / nSectors
|
const sectorW = (2 * Math.PI) / nSectors
|
||||||
const pad = sectorW * 0.07
|
const pad = sectorW * 0.07
|
||||||
const usable = Math.max(sectorW - 2 * pad, sectorW * 0.2)
|
const usable = Math.max(sectorW - 2 * pad, sectorW * 0.2)
|
||||||
const rBase = ringRadius(circle, L)
|
const rBase = ringRadius(circle, layout, geometry)
|
||||||
const k = group.length
|
const k = group.length
|
||||||
group.forEach((n, i) => {
|
group.forEach((n, i) => {
|
||||||
const angle = k === 1
|
const angle = k === 1
|
||||||
@@ -63,7 +85,8 @@ export function computePolarPositions(rawNodes, L) {
|
|||||||
return posById
|
return posById
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nodeXY(n, layout, posById) {
|
export function nodeXY(n, layout, posById, geometry = defaultGeometry()) {
|
||||||
|
const { sectorKeys } = geometry
|
||||||
const ratio = Number(n.map_radius_ratio)
|
const ratio = Number(n.map_radius_ratio)
|
||||||
const storedAngle = Number(n.map_angle)
|
const storedAngle = Number(n.map_angle)
|
||||||
if (Number.isFinite(ratio) && Number.isFinite(storedAngle)) {
|
if (Number.isFinite(ratio) && Number.isFinite(storedAngle)) {
|
||||||
@@ -73,9 +96,19 @@ export function nodeXY(n, layout, posById) {
|
|||||||
}
|
}
|
||||||
const p = posById.get(n.id)
|
const p = posById.get(n.id)
|
||||||
if (p) return p
|
if (p) return p
|
||||||
const idx = SPHERE_ORDER.indexOf(normalizedSphere(n))
|
const idx = sectorKeys.indexOf(normalizedSphere(n, geometry))
|
||||||
const sectorStart = (Math.max(0, idx) / SPHERE_ORDER.length) * 2 * Math.PI - Math.PI / 2
|
const nSectors = sectorKeys.length || 1
|
||||||
const r = ringRadius(normalizedCircle(n), layout)
|
const sectorStart = (Math.max(0, idx) / nSectors) * 2 * Math.PI - Math.PI / 2
|
||||||
const angle = sectorStart + (2 * Math.PI) / SPHERE_ORDER.length / 2
|
const r = ringRadius(normalizedCircle(n, geometry), layout, geometry)
|
||||||
|
const angle = sectorStart + (2 * Math.PI) / nSectors / 2
|
||||||
return { x: r * Math.cos(angle), y: r * Math.sin(angle) }
|
return { x: r * Math.cos(angle), y: r * Math.sin(angle) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function circleLabelPositions(layout, geometry = defaultGeometry()) {
|
||||||
|
const { circleKeys } = geometry
|
||||||
|
const n = circleKeys.length
|
||||||
|
return circleKeys.map((key, i) => ({
|
||||||
|
key,
|
||||||
|
y: layout.cy - layout.rOuter * ((i + 1) / n) * 0.95,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,23 +1,46 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { sphereByAngle, ringByRadius, nodeXY } from './positioning'
|
import {
|
||||||
|
SPHERE_ORDER,
|
||||||
|
sphereByAngle,
|
||||||
|
ringByRadius,
|
||||||
|
nodeXY,
|
||||||
|
defaultGeometry,
|
||||||
|
} from './positioning'
|
||||||
|
|
||||||
describe('map positioning', () => {
|
describe('map positioning', () => {
|
||||||
it('maps angle to sphere deterministically', () => {
|
const L = { rInner: 10, rMid: 20, rOuter: 30, cx: 0, cy: 0 }
|
||||||
|
|
||||||
|
it('maps angle to sphere deterministically with default geometry', () => {
|
||||||
expect(sphereByAngle(-Math.PI / 2)).toBe('work')
|
expect(sphereByAngle(-Math.PI / 2)).toBe('work')
|
||||||
expect(sphereByAngle(0)).toBe('study')
|
expect(sphereByAngle(0)).toBe('study')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('maps radius to ring', () => {
|
it('maps angle with custom sector count', () => {
|
||||||
const L = { rInner: 10, rMid: 20, rOuter: 30 }
|
const geometry = { sectorKeys: ['a', 'b', 'c', 'd', 'e'], circleKeys: ['x'] }
|
||||||
|
expect(sphereByAngle(-Math.PI / 2, geometry)).toBe('a')
|
||||||
|
expect(sphereByAngle(Math.PI / 2, geometry)).toBe('c')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps radius to ring with default geometry', () => {
|
||||||
expect(ringByRadius(5, L)).toBe('support')
|
expect(ringByRadius(5, L)).toBe('support')
|
||||||
expect(ringByRadius(16, L)).toBe('productivity')
|
expect(ringByRadius(16, L)).toBe('productivity')
|
||||||
expect(ringByRadius(28, L)).toBe('development')
|
expect(ringByRadius(28, L)).toBe('development')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('maps radius with two circles', () => {
|
||||||
|
const geometry = { sectorKeys: ['a'], circleKeys: ['inner', 'outer'] }
|
||||||
|
expect(ringByRadius(5, L, geometry)).toBe('inner')
|
||||||
|
expect(ringByRadius(20, L, geometry)).toBe('outer')
|
||||||
|
})
|
||||||
|
|
||||||
it('prefers persisted coordinates', () => {
|
it('prefers persisted coordinates', () => {
|
||||||
const L = { rInner: 10, rMid: 20, rOuter: 100 }
|
const layout = { rInner: 10, rMid: 20, rOuter: 100, cx: 0, cy: 0 }
|
||||||
const p = nodeXY({ map_radius_ratio: 0.5, map_angle: 0 }, L, new Map())
|
const p = nodeXY({ map_radius_ratio: 0.5, map_angle: 0 }, layout, new Map())
|
||||||
expect(p.x).toBeCloseTo(50)
|
expect(p.x).toBeCloseTo(50)
|
||||||
expect(p.y).toBeCloseTo(0)
|
expect(p.y).toBeCloseTo(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('defaultGeometry matches SPHERE_ORDER', () => {
|
||||||
|
expect(defaultGeometry().sectorKeys).toEqual(SPHERE_ORDER)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import { createPinia } from 'pinia'
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import { bootstrapPlugins } from './core/bootstrapPlugins'
|
import { bootstrapPlugins } from './core/bootstrapPlugins'
|
||||||
import { applyDexiePluginUpgrades } from './core/pluginRegistry'
|
import { applyDexiePluginUpgrades } from './core/pluginRegistry'
|
||||||
import { localDb } from './infrastructure/db/localDb'
|
import { localDb, applyCoreDbUpgrades } from './infrastructure/db/localDb'
|
||||||
import { initRouter } from './router'
|
import { initRouter } from './router'
|
||||||
import './assets/style.css'
|
import './assets/style.css'
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
await bootstrapPlugins()
|
await bootstrapPlugins()
|
||||||
applyDexiePluginUpgrades(localDb)
|
applyDexiePluginUpgrades(localDb)
|
||||||
|
applyCoreDbUpgrades(localDb)
|
||||||
const router = await initRouter()
|
const router = await initRouter()
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ const coreRoutes = [
|
|||||||
name: 'Import',
|
name: 'Import',
|
||||||
component: () => import('../views/ImportView.vue'),
|
component: () => import('../views/ImportView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'Settings',
|
||||||
|
component: () => import('../views/SettingsView.vue'),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
let router = null
|
let router = null
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import {
|
||||||
|
listNetworkMapTypes,
|
||||||
|
getNetworkMapTypeById,
|
||||||
|
getDefaultNetworkMapType,
|
||||||
|
createNetworkMapType,
|
||||||
|
updateNetworkMapType,
|
||||||
|
deleteNetworkMapType,
|
||||||
|
} from '../application/usecases/networkMapTypes'
|
||||||
|
import { syncPendingChanges } from '../application/usecases/sync'
|
||||||
|
|
||||||
|
export const useNetworkMapTypesStore = defineStore('networkMapTypes', {
|
||||||
|
state: () => ({
|
||||||
|
types: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
defaultType(state) {
|
||||||
|
return state.types.find((t) => t.isDefault) || state.types[0] || null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
setError(error) {
|
||||||
|
this.error = error?.message || String(error)
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchTypes() {
|
||||||
|
this.loading = true
|
||||||
|
this.error = null
|
||||||
|
try {
|
||||||
|
this.types = await listNetworkMapTypes()
|
||||||
|
return this.types
|
||||||
|
} catch (e) {
|
||||||
|
this.setError(e)
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getById(id) {
|
||||||
|
return this.types.find((t) => String(t.id) === String(id)) || null
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchTypeById(id) {
|
||||||
|
const data = await getNetworkMapTypeById(id)
|
||||||
|
const idx = this.types.findIndex((t) => String(t.id) === String(id))
|
||||||
|
if (idx !== -1) this.types[idx] = data
|
||||||
|
else if (data) this.types.push(data)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchDefaultType() {
|
||||||
|
const data = await getDefaultNetworkMapType()
|
||||||
|
if (data) {
|
||||||
|
const idx = this.types.findIndex((t) => String(t.id) === String(data.id))
|
||||||
|
if (idx !== -1) this.types[idx] = data
|
||||||
|
else this.types.push(data)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
async createType(payload) {
|
||||||
|
const data = await createNetworkMapType(payload)
|
||||||
|
this.types.push(data)
|
||||||
|
await syncPendingChanges()
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateType(id, payload) {
|
||||||
|
const data = await updateNetworkMapType(id, payload)
|
||||||
|
const idx = this.types.findIndex((t) => String(t.id) === String(id))
|
||||||
|
if (idx !== -1) this.types[idx] = data
|
||||||
|
await syncPendingChanges()
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteType(id) {
|
||||||
|
await deleteNetworkMapType(id)
|
||||||
|
this.types = this.types.filter((t) => String(t.id) !== String(id))
|
||||||
|
await syncPendingChanges()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -114,6 +114,8 @@
|
|||||||
:open="mapFormOpen"
|
:open="mapFormOpen"
|
||||||
:initial="mapFormTarget"
|
:initial="mapFormTarget"
|
||||||
:deletable="Boolean(mapFormTarget?.id)"
|
:deletable="Boolean(mapFormTarget?.id)"
|
||||||
|
:map-types="typesStore.types"
|
||||||
|
:default-map-type-id="String(typesStore.defaultType?.id || '')"
|
||||||
@close="closeMapForm"
|
@close="closeMapForm"
|
||||||
@submit="onMapFormSubmit"
|
@submit="onMapFormSubmit"
|
||||||
@delete="onMapDelete"
|
@delete="onMapDelete"
|
||||||
@@ -138,14 +140,15 @@ import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
|
|||||||
import { RELATION_COLORS } from '../lib/graph/relationColors'
|
import { RELATION_COLORS } from '../lib/graph/relationColors'
|
||||||
import { applyIntensityToVisEdge } from '../lib/graph/relationIntensity'
|
import { applyIntensityToVisEdge } from '../lib/graph/relationIntensity'
|
||||||
import {
|
import {
|
||||||
SPHERE_ORDER,
|
|
||||||
ringRadius,
|
|
||||||
ringByRadius,
|
ringByRadius,
|
||||||
sphereByAngle,
|
sphereByAngle,
|
||||||
computePolarPositions,
|
computePolarPositions,
|
||||||
nodeXY,
|
nodeXY,
|
||||||
|
defaultGeometry,
|
||||||
|
circleLabelPositions,
|
||||||
} from '../lib/map/positioning'
|
} from '../lib/map/positioning'
|
||||||
import { fetchGraphBundle, fetchMapChoices } from '../composables/useGraphData'
|
import { geometryFromMapType, labelsFromMapType } from '../domain/mapTypeDefaults'
|
||||||
|
import { fetchGraphBundle } from '../composables/useGraphData'
|
||||||
import { edgeFromRelation } from '../application/usecases/graph'
|
import { edgeFromRelation } from '../application/usecases/graph'
|
||||||
import RelationTypeFilters from '../components/RelationTypeFilters.vue'
|
import RelationTypeFilters from '../components/RelationTypeFilters.vue'
|
||||||
import NetworkMapTopPanel from '../components/NetworkMapTopPanel.vue'
|
import NetworkMapTopPanel from '../components/NetworkMapTopPanel.vue'
|
||||||
@@ -158,6 +161,7 @@ import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
|
|||||||
import EditRelationModal from '../components/EditRelationModal.vue'
|
import EditRelationModal from '../components/EditRelationModal.vue'
|
||||||
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
|
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
|
||||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||||
|
import { useNetworkMapTypesStore } from '../stores/networkMapTypes'
|
||||||
|
|
||||||
defineOptions({ name: 'NetworkMap' })
|
defineOptions({ name: 'NetworkMap' })
|
||||||
let themeObserver = null
|
let themeObserver = null
|
||||||
@@ -196,8 +200,8 @@ async function persistNodePlacement(nodeId, canvasX, canvasY) {
|
|||||||
const r = Math.sqrt(dx * dx + dy * dy)
|
const r = Math.sqrt(dx * dx + dy * dy)
|
||||||
const angle = Math.atan2(dy, dx)
|
const angle = Math.atan2(dy, dx)
|
||||||
const ratio = Math.max(0, Math.min(1, r / (L.rOuter || 1)))
|
const ratio = Math.max(0, Math.min(1, r / (L.rOuter || 1)))
|
||||||
const nextSphere = sphereByAngle(angle)
|
const nextSphere = sphereByAngle(angle, mapGeometry.value)
|
||||||
const nextCircle = ringByRadius(r, L)
|
const nextCircle = ringByRadius(r, L, mapGeometry.value)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
node.life_sphere === nextSphere &&
|
node.life_sphere === nextSphere &&
|
||||||
@@ -279,6 +283,7 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useContactsStore()
|
const store = useContactsStore()
|
||||||
const mapsStore = useNetworkMapsStore()
|
const mapsStore = useNetworkMapsStore()
|
||||||
|
const typesStore = useNetworkMapTypesStore()
|
||||||
|
|
||||||
const mapId = computed(() => String(route.params.mapId || ''))
|
const mapId = computed(() => String(route.params.mapId || ''))
|
||||||
const activeMap = computed(() => mapsStore.maps.find((m) => String(m.id) === mapId.value) || null)
|
const activeMap = computed(() => mapsStore.maps.find((m) => String(m.id) === mapId.value) || null)
|
||||||
@@ -316,6 +321,15 @@ const activeFilters = ref([])
|
|||||||
const layout = ref({ w: 0, h: 0, cx: 0, cy: 0, rInner: 0, rMid: 0, rOuter: 0 })
|
const layout = ref({ w: 0, h: 0, cx: 0, cy: 0, rInner: 0, rMid: 0, rOuter: 0 })
|
||||||
const sphereLabels = ref({})
|
const sphereLabels = ref({})
|
||||||
const circleLabels = ref({})
|
const circleLabels = ref({})
|
||||||
|
const mapGeometry = ref(defaultGeometry())
|
||||||
|
|
||||||
|
const RING_FILL_COLORS = [
|
||||||
|
'rgba(91, 141, 238, 0.08)',
|
||||||
|
'rgba(78, 204, 163, 0.08)',
|
||||||
|
'rgba(244, 162, 97, 0.09)',
|
||||||
|
'rgba(155, 89, 182, 0.08)',
|
||||||
|
'rgba(52, 152, 219, 0.08)',
|
||||||
|
]
|
||||||
|
|
||||||
let nodesDS = null
|
let nodesDS = null
|
||||||
let edgesDS = null
|
let edgesDS = null
|
||||||
@@ -477,33 +491,25 @@ function drawPolarGuide(ctx) {
|
|||||||
const L = layout.value
|
const L = layout.value
|
||||||
const net = network.value
|
const net = network.value
|
||||||
if (!L.rOuter || !net) return
|
if (!L.rOuter || !net) return
|
||||||
const { cx, cy, rInner, rMid, rOuter } = L
|
const { cx, cy, rOuter } = L
|
||||||
const n = SPHERE_ORDER.length
|
const { sectorKeys, circleKeys } = mapGeometry.value
|
||||||
|
const n = sectorKeys.length
|
||||||
|
const nCircles = circleKeys.length
|
||||||
|
const radii = circleKeys.map((_, i) => rOuter * (i + 1) / nCircles)
|
||||||
|
|
||||||
// Полупрозрачная заливка колец (от внешнего к внутреннему)
|
for (let i = nCircles - 1; i >= 0; i -= 1) {
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.arc(cx, cy, rOuter, 0, 2 * Math.PI)
|
ctx.arc(cx, cy, radii[i], 0, 2 * Math.PI)
|
||||||
ctx.arc(cx, cy, rMid, 0, 2 * Math.PI, true)
|
if (i > 0) ctx.arc(cx, cy, radii[i - 1], 0, 2 * Math.PI, true)
|
||||||
|
else ctx.arc(cx, cy, 0, 0, 2 * Math.PI, true)
|
||||||
ctx.closePath()
|
ctx.closePath()
|
||||||
ctx.fillStyle = 'rgba(91, 141, 238, 0.08)'
|
ctx.fillStyle = RING_FILL_COLORS[i % RING_FILL_COLORS.length]
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc(cx, cy, rMid, 0, 2 * Math.PI)
|
|
||||||
ctx.arc(cx, cy, rInner, 0, 2 * Math.PI, true)
|
|
||||||
ctx.closePath()
|
|
||||||
ctx.fillStyle = 'rgba(78, 204, 163, 0.08)'
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc(cx, cy, rInner, 0, 2 * Math.PI)
|
|
||||||
ctx.closePath()
|
|
||||||
ctx.fillStyle = 'rgba(244, 162, 97, 0.09)'
|
|
||||||
ctx.fill()
|
ctx.fill()
|
||||||
|
}
|
||||||
|
|
||||||
ctx.strokeStyle = 'rgba(123, 130, 166, 0.55)'
|
ctx.strokeStyle = 'rgba(123, 130, 166, 0.55)'
|
||||||
ctx.lineWidth = 1
|
ctx.lineWidth = 1
|
||||||
for (const r of [rInner, rMid, rOuter]) {
|
for (const r of radii) {
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.arc(cx, cy, r, 0, 2 * Math.PI)
|
ctx.arc(cx, cy, r, 0, 2 * Math.PI)
|
||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
@@ -526,22 +532,18 @@ function drawPolarGuide(ctx) {
|
|||||||
const labelR = rOuter + 36
|
const labelR = rOuter + 36
|
||||||
for (let i = 0; i < n; i += 1) {
|
for (let i = 0; i < n; i += 1) {
|
||||||
const mid = ((i + 0.5) / n) * 2 * Math.PI - Math.PI / 2
|
const mid = ((i + 0.5) / n) * 2 * Math.PI - Math.PI / 2
|
||||||
const key = SPHERE_ORDER[i]
|
const key = sectorKeys[i]
|
||||||
const text = sphereLabels.value[key] || key
|
const text = sphereLabels.value[key] || key
|
||||||
ctx.fillText(text, cx + labelR * Math.cos(mid), cy + labelR * Math.sin(mid))
|
ctx.fillText(text, cx + labelR * Math.cos(mid), cy + labelR * Math.sin(mid))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Подписи кругов
|
|
||||||
const supportR = rInner * 0.55
|
|
||||||
const productivityR = (rInner + rMid) / 2
|
|
||||||
const developmentR = (rMid + rOuter) / 2
|
|
||||||
ctx.fillStyle = palette.guideText
|
ctx.fillStyle = palette.guideText
|
||||||
ctx.font = '13px system-ui, -apple-system, sans-serif'
|
ctx.font = '13px system-ui, -apple-system, sans-serif'
|
||||||
ctx.textAlign = 'left'
|
ctx.textAlign = 'left'
|
||||||
ctx.textBaseline = 'middle'
|
ctx.textBaseline = 'middle'
|
||||||
ctx.fillText('Круг поддержки', cx + 12, cy - supportR)
|
for (const { key, y } of circleLabelPositions(L, mapGeometry.value)) {
|
||||||
ctx.fillText('Круг продуктивности', cx + 12, cy - productivityR)
|
ctx.fillText(circleLabels.value[key] || key, cx + 12, y)
|
||||||
ctx.fillText('Круг развития', cx + 12, cy - developmentR)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function measureLayout() {
|
function measureLayout() {
|
||||||
@@ -591,11 +593,11 @@ function mapEdgeToVis(e) {
|
|||||||
|
|
||||||
function buildVisNodes() {
|
function buildVisNodes() {
|
||||||
const L = layout.value
|
const L = layout.value
|
||||||
const posById = computePolarPositions(nodes.value, L)
|
const posById = computePolarPositions(nodes.value, L, mapGeometry.value)
|
||||||
const labelMap = buildLabelMap(posById, network.value?.getScale?.() || 1)
|
const labelMap = buildLabelMap(posById, network.value?.getScale?.() || 1)
|
||||||
const palette = mapPalette()
|
const palette = mapPalette()
|
||||||
return nodes.value.map((n) => {
|
return nodes.value.map((n) => {
|
||||||
const { x, y } = nodeXY(n, L, posById)
|
const { x, y } = nodeXY(n, L, posById, mapGeometry.value)
|
||||||
const name = n.label || String(n.id)
|
const name = n.label || String(n.id)
|
||||||
return {
|
return {
|
||||||
id: String(n.id),
|
id: String(n.id),
|
||||||
@@ -709,10 +711,10 @@ function refreshPositions() {
|
|||||||
measureLayout()
|
measureLayout()
|
||||||
if (!nodesDS || !network.value) return
|
if (!nodesDS || !network.value) return
|
||||||
const L = layout.value
|
const L = layout.value
|
||||||
const posById = computePolarPositions(nodes.value, L)
|
const posById = computePolarPositions(nodes.value, L, mapGeometry.value)
|
||||||
const labelMap = buildLabelMap(posById, network.value?.getScale?.() || 1)
|
const labelMap = buildLabelMap(posById, network.value?.getScale?.() || 1)
|
||||||
const updates = nodes.value.map((n) => {
|
const updates = nodes.value.map((n) => {
|
||||||
const { x, y } = nodeXY(n, L, posById)
|
const { x, y } = nodeXY(n, L, posById, mapGeometry.value)
|
||||||
return {
|
return {
|
||||||
id: String(n.id),
|
id: String(n.id),
|
||||||
x: L.cx + x,
|
x: L.cx + x,
|
||||||
@@ -731,7 +733,7 @@ function refreshPositions() {
|
|||||||
function refreshLabelsByZoom() {
|
function refreshLabelsByZoom() {
|
||||||
if (!nodesDS || !network.value) return
|
if (!nodesDS || !network.value) return
|
||||||
const L = layout.value
|
const L = layout.value
|
||||||
const posById = computePolarPositions(nodes.value, L)
|
const posById = computePolarPositions(nodes.value, L, mapGeometry.value)
|
||||||
const labelMap = buildLabelMap(posById, network.value.getScale())
|
const labelMap = buildLabelMap(posById, network.value.getScale())
|
||||||
const palette = mapPalette()
|
const palette = mapPalette()
|
||||||
const updates = nodes.value.map((n) => ({
|
const updates = nodes.value.map((n) => ({
|
||||||
@@ -776,25 +778,33 @@ function fitView() {
|
|||||||
network.value?.fit({ animation: { duration: 400, easingFunction: 'easeInOutQuad' }, padding: 56 })
|
network.value?.fit({ animation: { duration: 400, easingFunction: 'easeInOutQuad' }, padding: 56 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveMapTypeLabels() {
|
||||||
|
if (!typesStore.types.length) {
|
||||||
|
await typesStore.fetchTypes()
|
||||||
|
}
|
||||||
|
const mapTypeId = activeMap.value?.mapTypeId
|
||||||
|
let mapType = mapTypeId ? typesStore.getById(mapTypeId) : null
|
||||||
|
if (!mapType) {
|
||||||
|
mapType = typesStore.defaultType || await typesStore.fetchDefaultType()
|
||||||
|
}
|
||||||
|
mapGeometry.value = geometryFromMapType(mapType)
|
||||||
|
const labels = labelsFromMapType(mapType)
|
||||||
|
sphereLabels.value = labels.sphereLabels
|
||||||
|
circleLabels.value = labels.circleLabels
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
if (!mapId.value) return
|
if (!mapId.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
if (!mapsStore.maps.length) await mapsStore.fetchMaps()
|
||||||
mapsStore.setActiveMapId(mapId.value)
|
mapsStore.setActiveMapId(mapId.value)
|
||||||
const [bundle, mapChoices] = await Promise.all([
|
await resolveMapTypeLabels()
|
||||||
fetchGraphBundle({ mapId: mapId.value }),
|
const bundle = await fetchGraphBundle({ mapId: mapId.value })
|
||||||
fetchMapChoices(),
|
|
||||||
])
|
|
||||||
nodes.value = bundle.nodes
|
nodes.value = bundle.nodes
|
||||||
edges.value = bundle.edges
|
edges.value = bundle.edges
|
||||||
allRelationTypes.value = bundle.relationTypes
|
allRelationTypes.value = bundle.relationTypes
|
||||||
activeFilters.value = bundle.relationTypes.map((r) => r.value)
|
activeFilters.value = bundle.relationTypes.map((r) => r.value)
|
||||||
const sm = {}
|
|
||||||
for (const o of mapChoices.life_spheres) sm[o.value] = o.label
|
|
||||||
sphereLabels.value = sm
|
|
||||||
const cm = {}
|
|
||||||
for (const o of mapChoices.network_circles) cm[o.value] = o.label
|
|
||||||
circleLabels.value = cm
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -817,6 +827,7 @@ function switchMap(nextId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCreateMap() {
|
function openCreateMap() {
|
||||||
|
typesStore.fetchTypes().catch(() => {})
|
||||||
mapFormTarget.value = {}
|
mapFormTarget.value = {}
|
||||||
mapFormOpen.value = true
|
mapFormOpen.value = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>Настройки</h2>
|
||||||
|
</div>
|
||||||
|
<div class="page-content content-narrow">
|
||||||
|
<div class="card">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3 class="section-title">Типы карты сети</h3>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" @click="openCreate">+ Создать тип</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted section-subtitle">
|
||||||
|
Тип задаёт подписи секторов и концентрических кругов. При создании карты сети выбирается один из типов.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="typesStore.loading" class="spinner" style="margin: 24px auto;"></div>
|
||||||
|
<div v-else-if="!typesStore.types.length" class="empty-state">
|
||||||
|
<p>Типов карт пока нет.</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="type-list">
|
||||||
|
<div v-for="type in typesStore.types" :key="type.id" class="type-card">
|
||||||
|
<div>
|
||||||
|
<strong>{{ type.name }}</strong>
|
||||||
|
<span v-if="type.isDefault" class="badge">По умолчанию</span>
|
||||||
|
<div class="text-muted type-meta">
|
||||||
|
{{ type.sectors?.length || 0 }} секторов · {{ type.circles?.length || 0 }} кругов
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" @click="openEdit(type)">
|
||||||
|
Редактировать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="actionError" class="alert alert-error" style="margin-top: 16px;">{{ actionError }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NetworkMapTypeForm
|
||||||
|
:open="formOpen"
|
||||||
|
:initial="formTarget"
|
||||||
|
@close="closeForm"
|
||||||
|
@submit="onFormSubmit"
|
||||||
|
@delete="onFormDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import NetworkMapTypeForm from '../components/NetworkMapTypeForm.vue'
|
||||||
|
import { useNetworkMapTypesStore } from '../stores/networkMapTypes'
|
||||||
|
|
||||||
|
defineOptions({ name: 'Settings' })
|
||||||
|
|
||||||
|
const typesStore = useNetworkMapTypesStore()
|
||||||
|
const formOpen = ref(false)
|
||||||
|
const formTarget = ref({})
|
||||||
|
const actionError = ref('')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
typesStore.fetchTypes().catch((e) => {
|
||||||
|
actionError.value = e?.message || String(e)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
formTarget.value = {}
|
||||||
|
formOpen.value = true
|
||||||
|
actionError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(type) {
|
||||||
|
formTarget.value = { ...type }
|
||||||
|
formOpen.value = true
|
||||||
|
actionError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeForm() {
|
||||||
|
formOpen.value = false
|
||||||
|
formTarget.value = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFormSubmit(payload) {
|
||||||
|
try {
|
||||||
|
if (formTarget.value?.id) {
|
||||||
|
await typesStore.updateType(formTarget.value.id, payload)
|
||||||
|
} else {
|
||||||
|
await typesStore.createType(payload)
|
||||||
|
}
|
||||||
|
closeForm()
|
||||||
|
} catch (e) {
|
||||||
|
actionError.value = e?.message || String(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFormDelete() {
|
||||||
|
if (!formTarget.value?.id) return
|
||||||
|
try {
|
||||||
|
await typesStore.deleteType(formTarget.value.id)
|
||||||
|
closeForm()
|
||||||
|
} catch (e) {
|
||||||
|
actionError.value = e?.message || String(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.type-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.type-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-alt);
|
||||||
|
}
|
||||||
|
.type-meta {
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent-muted, rgba(91, 141, 238, 0.15));
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user