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:
2026-06-25 10:45:34 +03:00
co-authored by Cursor
parent cafc7335ad
commit e80d0d0e88
29 changed files with 1370 additions and 117 deletions
+1
View File
@@ -7,6 +7,7 @@ router = DefaultRouter()
router.register('contacts', contact_views.ContactViewSet)
router.register('relations', contact_views.RelationViewSet)
router.register('network-maps', contact_views.NetworkMapViewSet)
router.register('network-map-types', contact_views.NetworkMapTypeViewSet)
urlpatterns = [
path('', include(router.urls)),
+39
View File
@@ -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='Интенсивность общения'),
),
]
+25 -4
View File
@@ -2,8 +2,6 @@ from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from core.choices import (
LIFE_SPHERES,
NETWORK_CIRCLES,
INTERACTION_INTENSITY,
RELATION_TYPES,
)
@@ -30,11 +28,36 @@ class Contact(models.Model):
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):
"""Карта сети — отдельный контекст для визуализации подмножества контактов."""
name = models.CharField(max_length=255, 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)
updated_at = models.DateTimeField(auto_now=True)
@@ -64,13 +87,11 @@ class NetworkMapMembership(models.Model):
)
life_sphere = models.CharField(
max_length=32,
choices=LIFE_SPHERES,
default='other',
verbose_name='Сфера жизни',
)
network_circle = models.CharField(
max_length=32,
choices=NETWORK_CIRCLES,
default='productivity',
verbose_name='Круг сети',
)
+38 -2
View File
@@ -1,5 +1,6 @@
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):
@@ -43,17 +44,52 @@ class RelationSerializer(serializers.ModelSerializer):
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):
memberships_count = serializers.SerializerMethodField()
map_type = serializers.PrimaryKeyRelatedField(
queryset=NetworkMapType.objects.all(),
required=False,
)
class Meta:
model = NetworkMap
fields = [
'id', 'name', 'description',
'id', 'name', 'description', 'map_type',
'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):
return obj.memberships.count()
+16 -2
View File
@@ -1,11 +1,13 @@
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 (
ContactSerializer,
RelationSerializer,
NetworkMapSerializer,
NetworkMapMembershipSerializer,
NetworkMapTypeSerializer,
)
@@ -27,10 +29,22 @@ class RelationViewSet(viewsets.ModelViewSet):
class NetworkMapViewSet(viewsets.ModelViewSet):
queryset = NetworkMap.objects.all()
queryset = NetworkMap.objects.select_related('map_type').all()
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):
serializer_class = NetworkMapMembershipSerializer
+9
View File
@@ -38,3 +38,12 @@ def choices_payload():
'network_circles': [{'value': v, 'label': l} for v, l in NETWORK_CIRCLES],
'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
+7
View File
@@ -48,6 +48,13 @@
</svg>
<span class="nav-label">Импорт</span>
</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
v-for="item in pluginNavItems"
:key="item.to"
@@ -2,6 +2,7 @@ import { listContacts, createContact } from './contacts'
import { listRelations } from './relations'
import { localDb, DEFAULT_MAP_NAME } from '../../infrastructure/db/localDb'
import { generateId } from '../../lib/uuid'
import { createDefaultMapTypeRecord } from '../../domain/mapTypeDefaults'
import { parseVcf } from '../../lib/import/vcard'
import { serializeContactsExport } from '../../lib/export/contacts'
@@ -216,15 +217,41 @@ function normalizeDumpRelation(relation) {
}
function migrateV1Dump(dump) {
if ((dump.version || 1) >= 2) {
return {
let next = dump
if ((dump.version || 1) < 2) {
next = migrateV1ToV2(dump)
}
return migrateV2Dump(next)
}
function migrateV2Dump(dump) {
const base = {
...dump,
networkMaps: dump.networkMaps || [],
networkMapMemberships: dump.networkMapMemberships || [],
networkMapTypes: dump.networkMapTypes || [],
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 mapId = generateId()
const networkMaps = [{
@@ -278,6 +305,7 @@ export async function exportLocalData({ passphrase = '' } = {}) {
relations: await listRelations(),
networkMaps: await localDb.networkMaps.toArray(),
networkMapMemberships: await localDb.networkMapMemberships.toArray(),
networkMapTypes: await localDb.networkMapTypes.toArray(),
changes: await localDb.changelog.toArray(),
}
@@ -331,8 +359,14 @@ export async function importLocalDump(file, passphrase = '') {
localDb.relations,
localDb.networkMaps,
localDb.networkMapMemberships,
localDb.networkMapTypes,
localDb.changelog,
async () => {
if (Array.isArray(dump.networkMapTypes)) {
for (const mapType of dump.networkMapTypes) {
await localDb.networkMapTypes.put(mapType)
}
}
for (const contact of dump.contacts) {
await localDb.contacts.put(normalizeDumpContact(contact))
}
@@ -362,5 +396,6 @@ export async function importLocalDump(file, passphrase = '') {
importedRelations: dump.relations.length,
importedMaps: dump.networkMaps?.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>
<input v-model="form.name" class="form-control" required placeholder="Например: Коллектив А" />
</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">
<label>Описание</label>
<textarea
@@ -45,6 +53,8 @@ const props = defineProps({
open: { type: Boolean, default: false },
initial: { type: Object, default: () => ({}) },
deletable: { type: Boolean, default: false },
mapTypes: { type: Array, default: () => [] },
defaultMapTypeId: { type: String, default: '' },
})
const emit = defineEmits(['close', 'submit', 'delete'])
@@ -53,20 +63,28 @@ const isEdit = computed(() => Boolean(props.initial?.id))
const form = reactive({
name: '',
description: '',
mapTypeId: '',
})
watch(
() => [props.open, props.initial],
() => [props.open, props.initial, props.defaultMapTypeId, props.mapTypes],
() => {
if (!props.open) return
form.name = props.initial?.name || ''
form.description = props.initial?.description || ''
form.mapTypeId = String(
props.initial?.mapTypeId || props.defaultMapTypeId || props.mapTypes[0]?.id || ''
)
},
{ immediate: true, deep: true }
)
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>
@@ -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>
+90
View File
@@ -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')
})
})
+52 -6
View File
@@ -1,8 +1,20 @@
import Dexie from 'dexie'
import { generateId } from '../../lib/uuid'
import { createDefaultMapTypeRecord } from '../../domain/mapTypeDefaults'
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 {
constructor() {
super('socialGraphDb')
@@ -13,12 +25,7 @@ class SocialGraphDb extends Dexie {
changelog: 'id, ts, entityType, entityId, syncStatus, workspaceId',
})
this.version(2).stores({
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',
...BASE_STORES_V2,
}).upgrade(async (tx) => {
const contacts = await tx.table('contacts').toArray()
const ts = new Date().toISOString()
@@ -67,4 +74,43 @@ class SocialGraphDb extends Dexie {
}
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 }
@@ -1,5 +1,6 @@
import { localDb, DEFAULT_MAP_NAME } from '../db/localDb'
import { generateId } from '../../lib/uuid'
import { localNetworkMapTypeRepository } from './networkMapTypeRepository.local'
function nowIso() {
return new Date().toISOString()
@@ -9,6 +10,7 @@ function withDefaults(payload = {}) {
return {
name: '',
description: '',
mapTypeId: payload.mapTypeId || null,
workspaceId: 'personal',
...payload,
}
@@ -40,12 +42,14 @@ async function ensureDefaultMap() {
const active = all.filter((m) => !m.deletedAt)
if (active.length) return active.sort((a, b) => a.name.localeCompare(b.name, 'ru'))[0]
const defaultType = await localNetworkMapTypeRepository.getDefault()
const ts = nowIso()
const id = generateId()
await localDb.networkMaps.put({
id,
name: DEFAULT_MAP_NAME,
description: '',
mapTypeId: defaultType?.id || null,
workspaceId: 'personal',
version: 1,
createdAt: ts,
@@ -75,8 +79,10 @@ export const localNetworkMapRepository = {
},
async create(payload) {
const defaultType = await localNetworkMapTypeRepository.getDefault()
const ts = nowIso()
const record = withDefaults(payload)
if (!record.mapTypeId) record.mapTypeId = defaultType?.id || null
const id = generateId()
await localDb.networkMaps.put({
...record,
@@ -3,19 +3,29 @@ import { fetchAllPages } from '../../lib/api/pagination'
export const remoteNetworkMapRepository = {
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) {
const { data } = await api.get(`/network-maps/${id}/`)
return data
return { ...data, mapTypeId: data.map_type }
},
async create(payload) {
const { data } = await api.post('/network-maps/', payload)
return data
const { data } = await api.post('/network-maps/', {
name: payload.name,
description: payload.description,
map_type: payload.mapTypeId || payload.map_type,
})
return { ...data, mapTypeId: data.map_type }
},
async update(id, payload) {
const { data } = await api.patch(`/network-maps/${id}/`, payload)
return data
const body = { ...payload }
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) {
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 { localNetworkMapRepository } from './networkMapRepository.local'
import { localNetworkMapMembershipRepository } from './networkMapMembershipRepository.local'
import { localNetworkMapTypeRepository } from './networkMapTypeRepository.local'
import { remoteContactRepository } from './contactRepository.remote'
import { remoteRelationRepository } from './relationRepository.remote'
import { remoteNetworkMapRepository } from './networkMapRepository.remote'
import { remoteNetworkMapMembershipRepository } from './networkMapMembershipRepository.remote'
import { remoteNetworkMapTypeRepository } from './networkMapTypeRepository.remote'
function mode() {
return getDataMode()
@@ -30,6 +32,10 @@ export function getNetworkMapMembershipRepository() {
return mode() === 'remote' ? remoteNetworkMapMembershipRepository : localNetworkMapMembershipRepository
}
export function getNetworkMapTypeRepository() {
return mode() === 'remote' ? remoteNetworkMapTypeRepository : localNetworkMapTypeRepository
}
export async function getRelationTypes() {
if (mode() === 'remote') {
const { data } = await api.get('/meta/choices/')
+63 -30
View File
@@ -1,57 +1,79 @@
export const SPHERE_ORDER = ['work', 'study', 'hobby', 'family', 'health', 'other']
export function ringRadius(networkCircle, layout) {
if (networkCircle === 'support') return layout.rInner
if (networkCircle === 'productivity') return layout.rMid
return layout.rOuter
const DEFAULT_CIRCLE_KEYS = ['support', 'productivity', 'development']
export function defaultGeometry() {
return {
sectorKeys: [...SPHERE_ORDER],
circleKeys: [...DEFAULT_CIRCLE_KEYS],
}
}
export function ringByRadius(r, L) {
const t1 = (L.rInner + L.rMid) / 2
const t2 = (L.rMid + L.rOuter) / 2
if (r <= t1) return 'support'
if (r <= t2) return 'productivity'
return 'development'
export function ringRadius(networkCircle, layout, geometry = defaultGeometry()) {
const { circleKeys } = geometry
const idx = circleKeys.indexOf(networkCircle)
if (idx < 0) return layout.rOuter * 0.5
const n = circleKeys.length
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 norm = ((angle + Math.PI / 2) % full + full) % full
const sectorW = full / SPHERE_ORDER.length
const sectorW = full / n
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
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
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()
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, [])
groups.get(key).push(n)
}
const posById = new Map()
const nSectors = SPHERE_ORDER.length
const nSectors = sectorKeys.length || 1
for (const [, group] of groups) {
group.sort((a, b) => Number(a.id) - Number(b.id))
const sample = group[0]
const sphere = normalizedSphere(sample)
const circle = normalizedCircle(sample)
const idx = SPHERE_ORDER.indexOf(sphere)
const sectorStart = (idx / nSectors) * 2 * Math.PI - Math.PI / 2
const sphere = normalizedSphere(sample, geometry)
const circle = normalizedCircle(sample, geometry)
const idx = sectorKeys.indexOf(sphere)
const sectorStart = (Math.max(0, idx) / nSectors) * 2 * Math.PI - Math.PI / 2
const sectorW = (2 * Math.PI) / nSectors
const pad = sectorW * 0.07
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
group.forEach((n, i) => {
const angle = k === 1
@@ -63,7 +85,8 @@ export function computePolarPositions(rawNodes, L) {
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 storedAngle = Number(n.map_angle)
if (Number.isFinite(ratio) && Number.isFinite(storedAngle)) {
@@ -73,9 +96,19 @@ export function nodeXY(n, layout, posById) {
}
const p = posById.get(n.id)
if (p) return p
const idx = SPHERE_ORDER.indexOf(normalizedSphere(n))
const sectorStart = (Math.max(0, idx) / SPHERE_ORDER.length) * 2 * Math.PI - Math.PI / 2
const r = ringRadius(normalizedCircle(n), layout)
const angle = sectorStart + (2 * Math.PI) / SPHERE_ORDER.length / 2
const idx = sectorKeys.indexOf(normalizedSphere(n, geometry))
const nSectors = sectorKeys.length || 1
const sectorStart = (Math.max(0, idx) / nSectors) * 2 * Math.PI - Math.PI / 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) }
}
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,
}))
}
+29 -6
View File
@@ -1,23 +1,46 @@
import { describe, it, expect } from 'vitest'
import { sphereByAngle, ringByRadius, nodeXY } from './positioning'
import {
SPHERE_ORDER,
sphereByAngle,
ringByRadius,
nodeXY,
defaultGeometry,
} from './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(0)).toBe('study')
})
it('maps radius to ring', () => {
const L = { rInner: 10, rMid: 20, rOuter: 30 }
it('maps angle with custom sector count', () => {
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(16, L)).toBe('productivity')
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', () => {
const L = { rInner: 10, rMid: 20, rOuter: 100 }
const p = nodeXY({ map_radius_ratio: 0.5, map_angle: 0 }, L, new Map())
const layout = { rInner: 10, rMid: 20, rOuter: 100, cx: 0, cy: 0 }
const p = nodeXY({ map_radius_ratio: 0.5, map_angle: 0 }, layout, new Map())
expect(p.x).toBeCloseTo(50)
expect(p.y).toBeCloseTo(0)
})
it('defaultGeometry matches SPHERE_ORDER', () => {
expect(defaultGeometry().sectorKeys).toEqual(SPHERE_ORDER)
})
})
+2 -1
View File
@@ -3,13 +3,14 @@ import { createPinia } from 'pinia'
import App from './App.vue'
import { bootstrapPlugins } from './core/bootstrapPlugins'
import { applyDexiePluginUpgrades } from './core/pluginRegistry'
import { localDb } from './infrastructure/db/localDb'
import { localDb, applyCoreDbUpgrades } from './infrastructure/db/localDb'
import { initRouter } from './router'
import './assets/style.css'
async function bootstrap() {
await bootstrapPlugins()
applyDexiePluginUpgrades(localDb)
applyCoreDbUpgrades(localDb)
const router = await initRouter()
const app = createApp(App)
+5
View File
@@ -36,6 +36,11 @@ const coreRoutes = [
name: 'Import',
component: () => import('../views/ImportView.vue'),
},
{
path: '/settings',
name: 'Settings',
component: () => import('../views/SettingsView.vue'),
},
]
let router = null
+87
View File
@@ -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()
},
},
})
+59 -48
View File
@@ -114,6 +114,8 @@
:open="mapFormOpen"
:initial="mapFormTarget"
:deletable="Boolean(mapFormTarget?.id)"
:map-types="typesStore.types"
:default-map-type-id="String(typesStore.defaultType?.id || '')"
@close="closeMapForm"
@submit="onMapFormSubmit"
@delete="onMapDelete"
@@ -138,14 +140,15 @@ import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
import { RELATION_COLORS } from '../lib/graph/relationColors'
import { applyIntensityToVisEdge } from '../lib/graph/relationIntensity'
import {
SPHERE_ORDER,
ringRadius,
ringByRadius,
sphereByAngle,
computePolarPositions,
nodeXY,
defaultGeometry,
circleLabelPositions,
} 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 RelationTypeFilters from '../components/RelationTypeFilters.vue'
import NetworkMapTopPanel from '../components/NetworkMapTopPanel.vue'
@@ -158,6 +161,7 @@ import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
import EditRelationModal from '../components/EditRelationModal.vue'
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
import { useNetworkMapsStore } from '../stores/networkMaps'
import { useNetworkMapTypesStore } from '../stores/networkMapTypes'
defineOptions({ name: 'NetworkMap' })
let themeObserver = null
@@ -196,8 +200,8 @@ async function persistNodePlacement(nodeId, canvasX, canvasY) {
const r = Math.sqrt(dx * dx + dy * dy)
const angle = Math.atan2(dy, dx)
const ratio = Math.max(0, Math.min(1, r / (L.rOuter || 1)))
const nextSphere = sphereByAngle(angle)
const nextCircle = ringByRadius(r, L)
const nextSphere = sphereByAngle(angle, mapGeometry.value)
const nextCircle = ringByRadius(r, L, mapGeometry.value)
if (
node.life_sphere === nextSphere &&
@@ -279,6 +283,7 @@ const route = useRoute()
const router = useRouter()
const store = useContactsStore()
const mapsStore = useNetworkMapsStore()
const typesStore = useNetworkMapTypesStore()
const mapId = computed(() => String(route.params.mapId || ''))
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 sphereLabels = 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 edgesDS = null
@@ -477,33 +491,25 @@ function drawPolarGuide(ctx) {
const L = layout.value
const net = network.value
if (!L.rOuter || !net) return
const { cx, cy, rInner, rMid, rOuter } = L
const n = SPHERE_ORDER.length
const { cx, cy, rOuter } = L
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.arc(cx, cy, rOuter, 0, 2 * Math.PI)
ctx.arc(cx, cy, rMid, 0, 2 * Math.PI, true)
ctx.arc(cx, cy, radii[i], 0, 2 * Math.PI)
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.fillStyle = 'rgba(91, 141, 238, 0.08)'
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.fillStyle = RING_FILL_COLORS[i % RING_FILL_COLORS.length]
ctx.fill()
}
ctx.strokeStyle = 'rgba(123, 130, 166, 0.55)'
ctx.lineWidth = 1
for (const r of [rInner, rMid, rOuter]) {
for (const r of radii) {
ctx.beginPath()
ctx.arc(cx, cy, r, 0, 2 * Math.PI)
ctx.stroke()
@@ -526,22 +532,18 @@ function drawPolarGuide(ctx) {
const labelR = rOuter + 36
for (let i = 0; i < n; i += 1) {
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
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.font = '13px system-ui, -apple-system, sans-serif'
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
ctx.fillText('Круг поддержки', cx + 12, cy - supportR)
ctx.fillText('Круг продуктивности', cx + 12, cy - productivityR)
ctx.fillText('Круг развития', cx + 12, cy - developmentR)
for (const { key, y } of circleLabelPositions(L, mapGeometry.value)) {
ctx.fillText(circleLabels.value[key] || key, cx + 12, y)
}
}
function measureLayout() {
@@ -591,11 +593,11 @@ function mapEdgeToVis(e) {
function buildVisNodes() {
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 palette = mapPalette()
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)
return {
id: String(n.id),
@@ -709,10 +711,10 @@ function refreshPositions() {
measureLayout()
if (!nodesDS || !network.value) return
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 updates = nodes.value.map((n) => {
const { x, y } = nodeXY(n, L, posById)
const { x, y } = nodeXY(n, L, posById, mapGeometry.value)
return {
id: String(n.id),
x: L.cx + x,
@@ -731,7 +733,7 @@ function refreshPositions() {
function refreshLabelsByZoom() {
if (!nodesDS || !network.value) return
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 palette = mapPalette()
const updates = nodes.value.map((n) => ({
@@ -776,25 +778,33 @@ function fitView() {
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() {
if (!mapId.value) return
loading.value = true
try {
if (!mapsStore.maps.length) await mapsStore.fetchMaps()
mapsStore.setActiveMapId(mapId.value)
const [bundle, mapChoices] = await Promise.all([
fetchGraphBundle({ mapId: mapId.value }),
fetchMapChoices(),
])
await resolveMapTypeLabels()
const bundle = await fetchGraphBundle({ mapId: mapId.value })
nodes.value = bundle.nodes
edges.value = bundle.edges
allRelationTypes.value = bundle.relationTypes
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 {
loading.value = false
}
@@ -817,6 +827,7 @@ function switchMap(nextId) {
}
function openCreateMap() {
typesStore.fetchTypes().catch(() => {})
mapFormTarget.value = {}
mapFormOpen.value = true
}
+145
View File
@@ -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>