Add multiple network maps, graph UX improvements, and full backup import.

Support per-map contact membership with scoped graph views, relation editing on edges, layout caching, and automatic detection of full JSON backups so contacts and relations import together.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 17:54:51 +03:00
co-authored by Cursor
parent 35ec4c81ec
commit 5155b3a37a
50 changed files with 3176 additions and 372 deletions
@@ -0,0 +1,119 @@
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
def migrate_contact_map_data(apps, schema_editor):
Contact = apps.get_model('contacts', 'Contact')
NetworkMap = apps.get_model('contacts', 'NetworkMap')
NetworkMapMembership = apps.get_model('contacts', 'NetworkMapMembership')
default_map, _ = NetworkMap.objects.get_or_create(
name='Основная карта',
defaults={'description': 'Мигрировано из прежней единой карты сети'},
)
for contact in Contact.objects.filter(include_on_network_map=True):
NetworkMapMembership.objects.create(
map=default_map,
contact=contact,
life_sphere=contact.life_sphere,
network_circle=contact.network_circle,
importance=contact.importance,
map_angle=contact.map_angle,
map_radius_ratio=contact.map_radius_ratio,
)
class Migration(migrations.Migration):
dependencies = [
('contacts', '0005_contact_map_angle_contact_map_radius_ratio'),
]
operations = [
migrations.CreateModel(
name='NetworkMap',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Название')),
('description', models.TextField(blank=True, 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.CreateModel(
name='NetworkMapMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('life_sphere', models.CharField(
choices=[
('work', 'Работа'), ('study', 'Учёба'), ('hobby', 'Хобби'),
('family', 'Семья'), ('health', 'Здоровье'), ('other', 'Другое'),
],
default='other',
max_length=32,
verbose_name='Сфера жизни',
)),
('network_circle', models.CharField(
choices=[
('support', 'Круг поддержки'),
('productivity', 'Круг продуктивности'),
('development', 'Круг развития'),
],
default='productivity',
max_length=32,
verbose_name='Круг сети',
)),
('importance', models.PositiveSmallIntegerField(
default=3,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(5),
],
verbose_name='Важность (15)',
)),
('map_angle', models.FloatField(blank=True, null=True, verbose_name='Угол позиции на карте')),
('map_radius_ratio', models.FloatField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(0),
django.core.validators.MaxValueValidator(1),
],
verbose_name='Радиус позиции на карте (доля)',
)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('contact', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='map_memberships',
to='contacts.contact',
verbose_name='Контакт',
)),
('map', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='memberships',
to='contacts.networkmap',
verbose_name='Карта',
)),
],
options={
'verbose_name': 'Участие на карте',
'verbose_name_plural': 'Участия на картах',
'unique_together': {('map', 'contact')},
},
),
migrations.RunPython(migrate_contact_map_data, migrations.RunPython.noop),
migrations.RemoveField(model_name='contact', name='include_on_network_map'),
migrations.RemoveField(model_name='contact', name='life_sphere'),
migrations.RemoveField(model_name='contact', name='network_circle'),
migrations.RemoveField(model_name='contact', name='importance'),
migrations.RemoveField(model_name='contact', name='map_angle'),
migrations.RemoveField(model_name='contact', name='map_radius_ratio'),
]
+49 -8
View File
@@ -19,6 +19,7 @@ NETWORK_CIRCLES = [
INTERACTION_INTENSITY = [ INTERACTION_INTENSITY = [
('intense', 'Интенсивные контакты'), ('intense', 'Интенсивные контакты'),
('periodic', 'Периодические контакты'),
('sparse', 'Редкие контакты'), ('sparse', 'Редкие контакты'),
] ]
@@ -32,6 +33,50 @@ class Contact(models.Model):
organization = models.CharField(max_length=255, blank=True, verbose_name='Организация') organization = models.CharField(max_length=255, blank=True, verbose_name='Организация')
position = models.CharField(max_length=255, blank=True, verbose_name='Должность') position = models.CharField(max_length=255, blank=True, verbose_name='Должность')
notes = models.TextField(blank=True, verbose_name='Заметки') notes = models.TextField(blank=True, 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='Описание')
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 NetworkMapMembership(models.Model):
"""Участие контакта на конкретной карте сети."""
map = models.ForeignKey(
NetworkMap,
on_delete=models.CASCADE,
related_name='memberships',
verbose_name='Карта',
)
contact = models.ForeignKey(
Contact,
on_delete=models.CASCADE,
related_name='map_memberships',
verbose_name='Контакт',
)
life_sphere = models.CharField( life_sphere = models.CharField(
max_length=32, max_length=32,
choices=LIFE_SPHERES, choices=LIFE_SPHERES,
@@ -49,10 +94,6 @@ class Contact(models.Model):
validators=[MinValueValidator(1), MaxValueValidator(5)], validators=[MinValueValidator(1), MaxValueValidator(5)],
verbose_name='Важность (15)', verbose_name='Важность (15)',
) )
include_on_network_map = models.BooleanField(
default=False,
verbose_name='Показывать на карте сети',
)
map_angle = models.FloatField(null=True, blank=True, verbose_name='Угол позиции на карте') map_angle = models.FloatField(null=True, blank=True, verbose_name='Угол позиции на карте')
map_radius_ratio = models.FloatField( map_radius_ratio = models.FloatField(
null=True, null=True,
@@ -64,12 +105,12 @@ class Contact(models.Model):
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
ordering = ['name'] unique_together = ('map', 'contact')
verbose_name = 'Контакт' verbose_name = 'Участие на карте'
verbose_name_plural = 'Контакты' verbose_name_plural = 'Участия на картах'
def __str__(self): def __str__(self):
return self.name return f'{self.contact} на {self.map}'
RELATION_TYPES = [ RELATION_TYPES = [
+31 -7
View File
@@ -1,5 +1,5 @@
from rest_framework import serializers from rest_framework import serializers
from .models import Contact, Relation from .models import Contact, Relation, NetworkMap, NetworkMapMembership
class ContactSerializer(serializers.ModelSerializer): class ContactSerializer(serializers.ModelSerializer):
@@ -10,9 +10,6 @@ class ContactSerializer(serializers.ModelSerializer):
fields = [ fields = [
'id', 'name', 'email', 'phone', 'id', 'name', 'email', 'phone',
'organization', 'position', 'notes', 'organization', 'position', 'notes',
'life_sphere', 'network_circle', 'importance',
'include_on_network_map',
'map_angle', 'map_radius_ratio',
'created_at', 'updated_at', 'relations_count', 'created_at', 'updated_at', 'relations_count',
] ]
read_only_fields = ['id', 'created_at', 'updated_at', 'relations_count'] read_only_fields = ['id', 'created_at', 'updated_at', 'relations_count']
@@ -46,6 +43,36 @@ class RelationSerializer(serializers.ModelSerializer):
return data return data
class NetworkMapSerializer(serializers.ModelSerializer):
memberships_count = serializers.SerializerMethodField()
class Meta:
model = NetworkMap
fields = [
'id', 'name', 'description',
'created_at', 'updated_at', 'memberships_count',
]
read_only_fields = ['id', 'created_at', 'updated_at', 'memberships_count']
def get_memberships_count(self, obj):
return obj.memberships.count()
class NetworkMapMembershipSerializer(serializers.ModelSerializer):
contact_name = serializers.CharField(source='contact.name', read_only=True)
map_name = serializers.CharField(source='map.name', read_only=True)
class Meta:
model = NetworkMapMembership
fields = [
'id', 'map', 'map_name', 'contact', 'contact_name',
'life_sphere', 'network_circle', 'importance',
'map_angle', 'map_radius_ratio',
'created_at', 'updated_at',
]
read_only_fields = ['id', 'created_at', 'updated_at', 'contact_name', 'map_name']
class GraphSerializer(serializers.Serializer): class GraphSerializer(serializers.Serializer):
"""Граф для vis.js: nodes + edges.""" """Граф для vis.js: nodes + edges."""
@@ -60,9 +87,6 @@ class GraphSerializer(serializers.Serializer):
'label': c.name, 'label': c.name,
'title': f'{c.organization}\n{c.position}'.strip() or c.name, 'title': f'{c.organization}\n{c.position}'.strip() or c.name,
'group': c.organization or 'default', 'group': c.organization or 'default',
'life_sphere': c.life_sphere,
'network_circle': c.network_circle,
'importance': c.importance,
} }
for c in contacts for c in contacts
] ]
+16
View File
@@ -5,9 +5,25 @@ from . import views
router = DefaultRouter() router = DefaultRouter()
router.register('contacts', views.ContactViewSet) router.register('contacts', views.ContactViewSet)
router.register('relations', views.RelationViewSet) router.register('relations', views.RelationViewSet)
router.register('network-maps', views.NetworkMapViewSet)
urlpatterns = [ urlpatterns = [
path('', include(router.urls)), path('', include(router.urls)),
path(
'network-maps/<int:map_pk>/memberships/',
views.NetworkMapMembershipViewSet.as_view({'get': 'list', 'post': 'create'}),
name='network-map-memberships-list',
),
path(
'network-maps/<int:map_pk>/memberships/<int:pk>/',
views.NetworkMapMembershipViewSet.as_view({
'get': 'retrieve',
'patch': 'partial_update',
'put': 'update',
'delete': 'destroy',
}),
name='network-map-memberships-detail',
),
path('graph/', views.graph_data), path('graph/', views.graph_data),
path('network-map-graph/', views.network_map_graph), path('network-map-graph/', views.network_map_graph),
path('relation-types/', views.relation_types), path('relation-types/', views.relation_types),
+72 -46
View File
@@ -3,18 +3,25 @@ import io
import json import json
from rest_framework import viewsets, status from rest_framework import viewsets, status
from rest_framework.decorators import api_view, action from rest_framework.decorators import api_view
from rest_framework.response import Response from rest_framework.response import Response
from .models import ( from .models import (
Contact, Contact,
Relation, Relation,
NetworkMap,
NetworkMapMembership,
RELATION_TYPES, RELATION_TYPES,
LIFE_SPHERES, LIFE_SPHERES,
NETWORK_CIRCLES, NETWORK_CIRCLES,
INTERACTION_INTENSITY, INTERACTION_INTENSITY,
) )
from .serializers import ContactSerializer, RelationSerializer, GraphSerializer from .serializers import (
ContactSerializer,
RelationSerializer,
NetworkMapSerializer,
NetworkMapMembershipSerializer,
)
class ContactViewSet(viewsets.ModelViewSet): class ContactViewSet(viewsets.ModelViewSet):
@@ -34,6 +41,53 @@ class RelationViewSet(viewsets.ModelViewSet):
serializer_class = RelationSerializer serializer_class = RelationSerializer
class NetworkMapViewSet(viewsets.ModelViewSet):
queryset = NetworkMap.objects.all()
serializer_class = NetworkMapSerializer
class NetworkMapMembershipViewSet(viewsets.ModelViewSet):
serializer_class = NetworkMapMembershipSerializer
def get_queryset(self):
map_id = self.kwargs.get('map_pk')
return NetworkMapMembership.objects.filter(
map_id=map_id
).select_related('contact', 'map')
def perform_create(self, serializer):
map_id = self.kwargs.get('map_pk')
serializer.save(map_id=map_id)
def _node_from_membership(membership):
contact = membership.contact
return {
'id': contact.id,
'label': contact.name,
'title': '\n'.join(filter(None, [contact.organization, contact.position, contact.email])),
'group': contact.organization or 'default',
'life_sphere': membership.life_sphere,
'network_circle': membership.network_circle,
'importance': membership.importance,
'map_angle': membership.map_angle,
'map_radius_ratio': membership.map_radius_ratio,
'membership_id': membership.id,
}
def _edge_from_relation(r):
return {
'id': r.id,
'from': r.source_id,
'to': r.target_id,
'label': r.get_relation_type_display(),
'title': r.description or r.get_relation_type_display(),
'relation_type': r.relation_type,
'interaction_intensity': r.interaction_intensity,
}
@api_view(['GET']) @api_view(['GET'])
def graph_data(request): def graph_data(request):
"""Возвращает граф: nodes + edges для vis.js.""" """Возвращает граф: nodes + edges для vis.js."""
@@ -44,62 +98,34 @@ def graph_data(request):
'label': c.name, 'label': c.name,
'title': '\n'.join(filter(None, [c.organization, c.position, c.email])), 'title': '\n'.join(filter(None, [c.organization, c.position, c.email])),
'group': c.organization or 'default', 'group': c.organization or 'default',
'life_sphere': c.life_sphere,
'network_circle': c.network_circle,
'importance': c.importance,
'map_angle': c.map_angle,
'map_radius_ratio': c.map_radius_ratio,
} }
for c in contacts for c in contacts
] ]
relations = Relation.objects.select_related('source', 'target').all() relations = Relation.objects.select_related('source', 'target').all()
edges = [ edges = [_edge_from_relation(r) for r in relations]
{
'id': r.id,
'from': r.source_id,
'to': r.target_id,
'label': r.get_relation_type_display(),
'title': r.description or r.get_relation_type_display(),
'relation_type': r.relation_type,
'interaction_intensity': r.interaction_intensity,
}
for r in relations
]
return Response({'nodes': nodes, 'edges': edges}) return Response({'nodes': nodes, 'edges': edges})
@api_view(['GET']) @api_view(['GET'])
def network_map_graph(request): def network_map_graph(request):
"""Граф только для карты сети: контакты с include_on_network_map и связи между ними.""" """Граф для конкретной карты сети: участники и связи между ними."""
contacts = list( map_id = request.query_params.get('map_id')
Contact.objects.filter(include_on_network_map=True).order_by('name') if not map_id:
default_map = NetworkMap.objects.order_by('id').first()
if not default_map:
return Response({'nodes': [], 'edges': []})
map_id = default_map.id
memberships = list(
NetworkMapMembership.objects.filter(map_id=map_id)
.select_related('contact')
.order_by('contact__name')
) )
allowed_ids = {c.id for c in contacts} allowed_ids = {m.contact_id for m in memberships}
nodes = [ nodes = [_node_from_membership(m) for m in memberships]
{
'id': c.id,
'label': c.name,
'title': '\n'.join(filter(None, [c.organization, c.position, c.email])),
'group': c.organization or 'default',
'life_sphere': c.life_sphere,
'network_circle': c.network_circle,
'importance': c.importance,
'map_angle': c.map_angle,
'map_radius_ratio': c.map_radius_ratio,
}
for c in contacts
]
relations = Relation.objects.select_related('source', 'target').all() relations = Relation.objects.select_related('source', 'target').all()
edges = [ edges = [
{ _edge_from_relation(r)
'id': r.id,
'from': r.source_id,
'to': r.target_id,
'label': r.get_relation_type_display(),
'title': r.description or r.get_relation_type_display(),
'relation_type': r.relation_type,
'interaction_intensity': r.interaction_intensity,
}
for r in relations for r in relations
if r.source_id in allowed_ids and r.target_id in allowed_ids if r.source_id in allowed_ids and r.target_id in allowed_ids
] ]
+5 -1
View File
@@ -73,7 +73,11 @@
<!-- Main content --> <!-- Main content -->
<main class="main"> <main class="main">
<RouterView /> <RouterView v-slot="{ Component }">
<keep-alive include="Graph,NetworkMap">
<component :is="Component" />
</keep-alive>
</RouterView>
</main> </main>
</div> </div>
</template> </template>
+57 -17
View File
@@ -1,18 +1,20 @@
import { listContacts } from './contacts' import { listContacts } from './contacts'
import { listRelations } from './relations' import { listRelations } from './relations'
import { listMembershipsByMap } from './networkMaps'
import { getRelationTypes, getNetworkMapChoices } from '../../infrastructure/repositories/repositoryFactory' import { getRelationTypes, getNetworkMapChoices } from '../../infrastructure/repositories/repositoryFactory'
function nodeFromContact(c) { function nodeFromContactAndMembership(contact, membership) {
return { return {
id: c.id, id: contact.id,
label: c.name, label: contact.name,
title: [c.organization, c.position, c.email].filter(Boolean).join('\n'), title: [contact.organization, contact.position, contact.email].filter(Boolean).join('\n'),
group: c.organization || 'default', group: contact.organization || 'default',
life_sphere: c.life_sphere, life_sphere: membership.life_sphere,
network_circle: c.network_circle, network_circle: membership.network_circle,
importance: c.importance, importance: membership.importance,
map_angle: c.map_angle, map_angle: membership.map_angle,
map_radius_ratio: c.map_radius_ratio, map_radius_ratio: membership.map_radius_ratio,
membership_id: membership.id,
} }
} }
@@ -28,21 +30,59 @@ export function edgeFromRelation(r) {
} }
} }
export async function getGraphBundle({ networkMapOnly = false } = {}) { export function buildGraphFromStore(contacts, relations, relationTypes) {
return {
nodes: contacts.map((c) => ({
id: c.id,
label: c.name,
title: [c.organization, c.position, c.email].filter(Boolean).join('\n'),
group: c.organization || 'default',
})),
edges: relations.map(edgeFromRelation),
relationTypes,
}
}
export async function getGraphBundle({ mapId = null } = {}) {
const [contacts, relations, relationTypes] = await Promise.all([ const [contacts, relations, relationTypes] = await Promise.all([
listContacts(), listContacts(),
listRelations(), listRelations(),
getRelationTypes(), getRelationTypes(),
]) ])
const scopedContacts = networkMapOnly if (!mapId) {
? contacts.filter((c) => c.include_on_network_map) return {
: contacts nodes: contacts.map((c) => ({
const allowedIds = new Set(scopedContacts.map((c) => c.id)) id: c.id,
const scopedRelations = relations.filter((r) => allowedIds.has(r.source) && allowedIds.has(r.target)) label: c.name,
title: [c.organization, c.position, c.email].filter(Boolean).join('\n'),
group: c.organization || 'default',
})),
edges: relations.map(edgeFromRelation),
relationTypes,
}
}
const memberships = await listMembershipsByMap(mapId)
const contactById = new Map(contacts.map((c) => [String(c.id), c]))
const allowedIds = new Set()
const nodes = memberships
.map((membership) => {
const contactId = String(membership.contactId || membership.contact)
const contact = contactById.get(contactId)
if (!contact) return null
allowedIds.add(contactId)
return nodeFromContactAndMembership(contact, membership)
})
.filter(Boolean)
const scopedRelations = relations.filter(
(r) => allowedIds.has(String(r.source)) && allowedIds.has(String(r.target))
)
return { return {
nodes: scopedContacts.map(nodeFromContact), nodes,
edges: scopedRelations.map(edgeFromRelation), edges: scopedRelations.map(edgeFromRelation),
relationTypes, relationTypes,
} }
@@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { getGraphBundle } from './graph'
vi.mock('./contacts', () => ({
listContacts: vi.fn(),
}))
vi.mock('./relations', () => ({
listRelations: vi.fn(),
}))
vi.mock('./networkMaps', () => ({
listMembershipsByMap: vi.fn(),
}))
vi.mock('../infrastructure/repositories/repositoryFactory', () => ({
getRelationTypes: vi.fn(async () => [{ value: 'friend', label: 'Друг' }]),
}))
import { listContacts } from './contacts'
import { listRelations } from './relations'
import { listMembershipsByMap } from './networkMaps'
describe('getGraphBundle', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns full graph when mapId is not provided', async () => {
listContacts.mockResolvedValue([
{ id: '1', name: 'Анна', organization: 'Org' },
{ id: '2', name: 'Борис' },
])
listRelations.mockResolvedValue([
{ id: '10', source: '1', target: '2', relation_type: 'friend' },
])
const bundle = await getGraphBundle()
expect(bundle.nodes).toHaveLength(2)
expect(bundle.edges).toHaveLength(1)
expect(listMembershipsByMap).not.toHaveBeenCalled()
})
it('scopes nodes and edges to map memberships', async () => {
listContacts.mockResolvedValue([
{ id: '1', name: 'Анна' },
{ id: '2', name: 'Борис' },
{ id: '3', name: 'Вера' },
])
listRelations.mockResolvedValue([
{ id: '10', source: '1', target: '2', relation_type: 'friend' },
{ id: '11', source: '2', target: '3', relation_type: 'colleague' },
])
listMembershipsByMap.mockResolvedValue([
{
id: 'm1',
contactId: '1',
life_sphere: 'work',
network_circle: 'productivity',
importance: 4,
map_angle: 0.5,
map_radius_ratio: 0.6,
},
{
id: 'm2',
contactId: '2',
life_sphere: 'family',
network_circle: 'support',
importance: 3,
map_angle: 1.2,
map_radius_ratio: 0.4,
},
])
const bundle = await getGraphBundle({ mapId: 'map-1' })
expect(listMembershipsByMap).toHaveBeenCalledWith('map-1')
expect(bundle.nodes).toHaveLength(2)
expect(bundle.nodes[0]).toMatchObject({
id: '1',
life_sphere: 'work',
membership_id: 'm1',
})
expect(bundle.edges).toHaveLength(1)
expect(bundle.edges[0].id).toBe('10')
})
})
+166 -18
View File
@@ -1,6 +1,7 @@
import { listContacts, createContact } from './contacts' import { listContacts, createContact } from './contacts'
import { listRelations } from './relations' import { listRelations } from './relations'
import { localDb } from '../../infrastructure/db/localDb' import { localDb, DEFAULT_MAP_NAME } from '../../infrastructure/db/localDb'
import { generateId } from '../../lib/uuid'
import { parseVcf } from '../../lib/import/vcard' import { parseVcf } from '../../lib/import/vcard'
import { serializeContactsExport } from '../../lib/export/contacts' import { serializeContactsExport } from '../../lib/export/contacts'
@@ -10,12 +11,20 @@ function isLikelyEmail(value) {
function normalizeRows(raw) { function normalizeRows(raw) {
if (Array.isArray(raw)) return raw if (Array.isArray(raw)) return raw
if (raw && Array.isArray(raw.contacts)) return raw.contacts if (raw && Array.isArray(raw.contacts) && !isLocalDataDump(raw)) return raw.contacts
if (raw && Array.isArray(raw.results)) return raw.results if (raw && Array.isArray(raw.results)) return raw.results
if (raw && Array.isArray(raw.data)) return raw.data if (raw && Array.isArray(raw.data)) return raw.data
return [] return []
} }
export function isEncryptedLocalDump(raw) {
return Boolean(raw?.data && raw?.algorithm && raw?.iv && raw?.salt)
}
export function isLocalDataDump(raw) {
return Boolean(raw && Array.isArray(raw.contacts) && Array.isArray(raw.relations))
}
function parseCsv(text) { function parseCsv(text) {
const lines = text.split(/\r?\n/).filter(Boolean) const lines = text.split(/\r?\n/).filter(Boolean)
if (!lines.length) return [] if (!lines.length) return []
@@ -59,7 +68,20 @@ export async function importContactsFromFile(file) {
if (name.endsWith('.csv')) { if (name.endsWith('.csv')) {
rows = parseCsv(rawText) rows = parseCsv(rawText)
} else if (name.endsWith('.json')) { } else if (name.endsWith('.json')) {
rows = normalizeRows(JSON.parse(rawText)) const raw = JSON.parse(rawText)
if (isLocalDataDump(raw) || isEncryptedLocalDump(raw)) {
const summary = await importLocalDump(file)
return {
total: summary.importedContacts + summary.importedRelations,
created: summary.importedContacts,
importedRelations: summary.importedRelations,
importedMaps: summary.importedMaps,
skipped: 0,
errors: [],
isDump: true,
}
}
rows = normalizeRows(raw)
} else if (name.endsWith('.vcf') || name.endsWith('.vcard')) { } else if (name.endsWith('.vcf') || name.endsWith('.vcard')) {
rows = parseVcf(rawText) rows = parseVcf(rawText)
if (!rows.length) { if (!rows.length) {
@@ -147,12 +169,115 @@ async function deriveKey(passphrase, saltBytes) {
) )
} }
function stripLegacyContactFields(contact) {
const next = { ...contact }
delete next.include_on_network_map
delete next.life_sphere
delete next.network_circle
delete next.importance
delete next.map_angle
delete next.map_radius_ratio
delete next.relations_count
delete next.created_at
delete next.updated_at
return next
}
function normalizeDumpContact(contact) {
const ts = contact.createdAt || contact.created_at || new Date().toISOString()
return {
...stripLegacyContactFields(contact),
id: contact.id,
createdAt: contact.createdAt || contact.created_at || ts,
updatedAt: contact.updatedAt || contact.updated_at || ts,
deletedAt: contact.deletedAt ?? contact.deleted_at ?? null,
workspaceId: contact.workspaceId || 'personal',
ownerId: contact.ownerId || 'local-user',
version: contact.version ?? 1,
}
}
function normalizeDumpRelation(relation) {
const ts = relation.createdAt || relation.created_at || new Date().toISOString()
return {
id: relation.id,
source: relation.source,
target: relation.target,
relation_type: relation.relation_type || 'acquaintance',
description: relation.description || '',
interaction_intensity: relation.interaction_intensity || 'intense',
ownerId: relation.ownerId || 'local-user',
workspaceId: relation.workspaceId || 'personal',
version: relation.version ?? 1,
createdAt: ts,
updatedAt: relation.updatedAt || relation.updated_at || ts,
deletedAt: relation.deletedAt ?? relation.deleted_at ?? null,
}
}
function migrateV1Dump(dump) {
if ((dump.version || 1) >= 2) {
return {
...dump,
networkMaps: dump.networkMaps || [],
networkMapMemberships: dump.networkMapMemberships || [],
contacts: (dump.contacts || []).map(stripLegacyContactFields),
}
}
const ts = new Date().toISOString()
const mapId = generateId()
const networkMaps = [{
id: mapId,
name: DEFAULT_MAP_NAME,
description: 'Импортировано из бэкапа v1',
workspaceId: 'personal',
version: 1,
createdAt: ts,
updatedAt: ts,
deletedAt: null,
}]
const networkMapMemberships = []
const contacts = (dump.contacts || []).map((contact) => {
if (contact.include_on_network_map) {
networkMapMemberships.push({
id: generateId(),
mapId,
contactId: contact.id,
life_sphere: contact.life_sphere || 'other',
network_circle: contact.network_circle || 'productivity',
importance: contact.importance ?? 3,
map_angle: contact.map_angle ?? null,
map_radius_ratio: contact.map_radius_ratio ?? null,
workspaceId: 'personal',
version: 1,
createdAt: ts,
updatedAt: ts,
deletedAt: null,
})
}
return stripLegacyContactFields(contact)
})
return {
...dump,
version: 2,
contacts,
relations: dump.relations || [],
networkMaps,
networkMapMemberships,
changes: dump.changes || [],
}
}
export async function exportLocalData({ passphrase = '' } = {}) { export async function exportLocalData({ passphrase = '' } = {}) {
const payload = { const payload = {
version: 1, version: 2,
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
contacts: await listContacts(), contacts: await listContacts(),
relations: await listRelations(), relations: await listRelations(),
networkMaps: await localDb.networkMaps.toArray(),
networkMapMemberships: await localDb.networkMapMemberships.toArray(),
changes: await localDb.changelog.toArray(), changes: await localDb.changelog.toArray(),
} }
@@ -169,7 +294,7 @@ export async function exportLocalData({ passphrase = '' } = {}) {
const encoded = new TextEncoder().encode(JSON.stringify(payload)) const encoded = new TextEncoder().encode(JSON.stringify(payload))
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded) const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded)
const wrapped = { const wrapped = {
version: 1, version: 2,
algorithm: 'AES-GCM', algorithm: 'AES-GCM',
kdf: 'PBKDF2-SHA256', kdf: 'PBKDF2-SHA256',
iterations: 210000, iterations: 210000,
@@ -194,25 +319,48 @@ async function decryptPayload(raw, passphrase) {
export async function importLocalDump(file, passphrase = '') { export async function importLocalDump(file, passphrase = '') {
const raw = JSON.parse(await file.text()) const raw = JSON.parse(await file.text())
const dump = raw?.data ? await decryptPayload(raw, passphrase) : raw const dump = migrateV1Dump(raw?.data ? await decryptPayload(raw, passphrase) : raw)
if (!Array.isArray(dump.contacts) || !Array.isArray(dump.relations)) { if (!Array.isArray(dump.contacts) || !Array.isArray(dump.relations)) {
throw new Error('Некорректный формат экспортного файла') throw new Error('Некорректный формат экспортного файла')
} }
await localDb.transaction('rw', localDb.contacts, localDb.relations, localDb.changelog, async () => { await localDb.transaction(
for (const contact of dump.contacts) { 'rw',
await localDb.contacts.put(contact) localDb.contacts,
} localDb.relations,
for (const relation of dump.relations) { localDb.networkMaps,
await localDb.relations.put(relation) localDb.networkMapMemberships,
} localDb.changelog,
if (Array.isArray(dump.changes)) { async () => {
for (const change of dump.changes) { for (const contact of dump.contacts) {
await localDb.changelog.put(change) await localDb.contacts.put(normalizeDumpContact(contact))
}
for (const relation of dump.relations) {
await localDb.relations.put(normalizeDumpRelation(relation))
}
if (Array.isArray(dump.networkMaps)) {
for (const map of dump.networkMaps) {
await localDb.networkMaps.put(map)
}
}
if (Array.isArray(dump.networkMapMemberships)) {
for (const membership of dump.networkMapMemberships) {
await localDb.networkMapMemberships.put(membership)
}
}
if (Array.isArray(dump.changes)) {
for (const change of dump.changes) {
await localDb.changelog.put(change)
}
} }
} }
}) )
return { importedContacts: dump.contacts.length, importedRelations: dump.relations.length } return {
importedContacts: dump.contacts.length,
importedRelations: dump.relations.length,
importedMaps: dump.networkMaps?.length || 0,
importedMemberships: dump.networkMapMemberships?.length || 0,
}
} }
@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest'
import { isLocalDataDump, isEncryptedLocalDump } from './importExport'
// Test migration logic via exported helper pattern - inline replicate of migrateV1Dump
function stripLegacyContactFields(contact) {
const next = { ...contact }
delete next.include_on_network_map
delete next.life_sphere
delete next.network_circle
delete next.importance
delete next.map_angle
delete next.map_radius_ratio
return next
}
function migrateV1Dump(dump) {
if ((dump.version || 1) >= 2) {
return {
...dump,
networkMaps: dump.networkMaps || [],
networkMapMemberships: dump.networkMapMemberships || [],
contacts: (dump.contacts || []).map(stripLegacyContactFields),
}
}
const mapId = 'default-map'
const networkMaps = [{ id: mapId, name: 'Основная карта' }]
const networkMapMemberships = []
const contacts = (dump.contacts || []).map((contact) => {
if (contact.include_on_network_map) {
networkMapMemberships.push({
id: `m-${contact.id}`,
mapId,
contactId: contact.id,
life_sphere: contact.life_sphere || 'other',
network_circle: contact.network_circle || 'productivity',
importance: contact.importance ?? 3,
})
}
return stripLegacyContactFields(contact)
})
return {
...dump,
version: 2,
contacts,
networkMaps,
networkMapMemberships,
}
}
describe('local dump detection', () => {
it('detects full backup JSON', () => {
expect(isLocalDataDump({ version: 2, contacts: [], relations: [] })).toBe(true)
expect(isLocalDataDump({ contacts: [{ name: 'A' }] })).toBe(false)
expect(isLocalDataDump([{ name: 'A' }])).toBe(false)
})
it('detects encrypted backup wrapper', () => {
expect(isEncryptedLocalDump({
algorithm: 'AES-GCM',
salt: 'abc',
iv: 'def',
data: 'ghi',
})).toBe(true)
expect(isEncryptedLocalDump({ contacts: [], relations: [] })).toBe(false)
})
})
describe('backup v1 migration', () => {
it('creates default map and memberships from legacy contact fields', () => {
const migrated = migrateV1Dump({
version: 1,
contacts: [
{
id: 'c1',
name: 'Иван',
include_on_network_map: true,
life_sphere: 'work',
network_circle: 'support',
importance: 5,
},
{ id: 'c2', name: 'Мария', include_on_network_map: false },
],
relations: [],
})
expect(migrated.version).toBe(2)
expect(migrated.networkMaps).toHaveLength(1)
expect(migrated.networkMapMemberships).toHaveLength(1)
expect(migrated.networkMapMemberships[0]).toMatchObject({
contactId: 'c1',
life_sphere: 'work',
importance: 5,
})
expect(migrated.contacts[0].include_on_network_map).toBeUndefined()
expect(migrated.contacts[1].life_sphere).toBeUndefined()
})
})
@@ -0,0 +1,112 @@
import { appendChange } from '../../infrastructure/sync/changeLogRepository'
import {
getNetworkMapRepository,
getNetworkMapMembershipRepository,
} from '../../infrastructure/repositories/repositoryFactory'
const mapRepo = () => getNetworkMapRepository()
const membershipRepo = () => getNetworkMapMembershipRepository()
export async function listNetworkMaps() {
return mapRepo().list()
}
export async function getNetworkMapById(id) {
return mapRepo().getById(id)
}
export async function createNetworkMap(payload) {
const created = await mapRepo().create(payload)
await appendChange({
entityType: 'networkMap',
entityId: created.id,
op: 'created',
payloadPatch: created,
})
return created
}
export async function updateNetworkMap(id, payload) {
const updated = await mapRepo().update(id, payload)
await appendChange({
entityType: 'networkMap',
entityId: id,
op: 'updated',
payloadPatch: payload,
})
return updated
}
export async function deleteNetworkMap(id) {
await mapRepo().remove(id)
await appendChange({
entityType: 'networkMap',
entityId: id,
op: 'deleted',
payloadPatch: {},
})
}
export async function listMembershipsByMap(mapId) {
return membershipRepo().listByMap(mapId)
}
export async function listMembershipsByContact(contactId) {
return membershipRepo().listByContact(contactId)
}
export async function addContactToMap(mapId, contactId, extra = {}) {
const created = await membershipRepo().create(mapId, {
contact: contactId,
...extra,
})
await appendChange({
entityType: 'networkMapMembership',
entityId: created.id,
op: 'created',
payloadPatch: created,
})
return created
}
export async function updateMembership(mapId, membershipId, payload) {
const updated = await membershipRepo().update(mapId, membershipId, payload)
await appendChange({
entityType: 'networkMapMembership',
entityId: membershipId,
op: 'updated',
payloadPatch: payload,
})
return updated
}
export async function removeContactFromMap(mapId, membershipId) {
await membershipRepo().remove(mapId, membershipId)
await appendChange({
entityType: 'networkMapMembership',
entityId: membershipId,
op: 'deleted',
payloadPatch: {},
})
}
export async function setContactMapMemberships(contactId, mapIds = []) {
const existing = await listMembershipsByContact(contactId)
const existingMapIds = new Set(existing.map((m) => String(m.mapId || m.map)))
const targetMapIds = new Set(mapIds.map(String))
for (const membership of existing) {
const mid = String(membership.mapId || membership.map)
if (!targetMapIds.has(mid)) {
await removeContactFromMap(mid, membership.id)
}
}
for (const mapId of targetMapIds) {
if (!existingMapIds.has(mapId)) {
await addContactToMap(mapId, contactId)
}
}
return listMembershipsByContact(contactId)
}
@@ -27,3 +27,14 @@ export async function deleteRelation(id) {
payloadPatch: {}, payloadPatch: {},
}) })
} }
export async function updateRelation(id, payload) {
const relation = await relationRepo().update(id, payload)
await appendChange({
entityType: 'relation',
entityId: id,
op: 'updated',
payloadPatch: payload,
})
return relation
}
@@ -0,0 +1,72 @@
<template>
<div v-if="open" class="modal-overlay" @click.self="$emit('close')">
<div class="modal">
<div class="modal-header">
<h3>Добавить на карту</h3>
<button class="btn btn-secondary btn-sm" type="button" @click="$emit('close')"></button>
</div>
<div class="form-group">
<label>Контакт</label>
<SearchableSelect
v-model="selectedContactId"
:options="contactOptions"
placeholder="Выберите контакт..."
/>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="$emit('close')">Отмена</button>
<button
type="button"
class="btn btn-primary"
:disabled="!selectedContactId"
@click="onAdd"
>
Добавить
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import SearchableSelect from './SearchableSelect.vue'
const props = defineProps({
open: { type: Boolean, default: false },
contacts: { type: Array, default: () => [] },
memberContactIds: { type: Array, default: () => [] },
})
const emit = defineEmits(['close', 'add'])
const selectedContactId = ref('')
const memberSet = computed(() => new Set(props.memberContactIds.map(String)))
const contactOptions = computed(() =>
props.contacts
.filter((c) => !memberSet.value.has(String(c.id)))
.map((c) => ({ value: String(c.id), label: c.name }))
)
watch(
() => props.open,
(isOpen) => {
if (isOpen) selectedContactId.value = ''
}
)
function onAdd() {
if (!selectedContactId.value) return
emit('add', selectedContactId.value)
}
</script>
<style scoped>
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
</style>
+29 -15
View File
@@ -1,25 +1,39 @@
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import ContactForm from './ContactForm.vue' import ContactForm from './ContactForm.vue'
vi.mock('../api', () => ({ vi.mock('../stores/networkMaps', () => ({
default: { useNetworkMapsStore: () => ({
get: vi.fn(async () => ({ maps: [{ id: 'map-1', name: 'Основная карта' }],
data: { fetchMaps: vi.fn(async () => []),
life_spheres: [{ value: 'work', label: 'Работа' }], }),
network_circles: [{ value: 'support', label: 'Круг поддержки' }], }))
},
})), vi.mock('../application/usecases/networkMaps', () => ({
}, listMembershipsByContact: vi.fn(async () => []),
})) }))
describe('ContactForm', () => { describe('ContactForm', () => {
it('emits submit with normalized payload', async () => { beforeEach(() => {
vi.clearAllMocks()
})
it('emits submit with contact data and map ids', async () => {
const wrapper = mount(ContactForm, { props: { initial: { name: 'Иван' } } }) const wrapper = mount(ContactForm, { props: { initial: { name: 'Иван' } } })
await wrapper.get('form').trigger('submit.prevent') await wrapper.get('form').trigger('submit.prevent')
const payload = wrapper.emitted('submit')[0][0] const [contactData, mapIds] = wrapper.emitted('submit')[0]
expect(payload.name).toBe('Иван') expect(contactData.name).toBe('Иван')
expect(payload.importance).toBeDefined() expect(contactData.include_on_network_map).toBeUndefined()
expect(payload.include_on_network_map).toBe(false) expect(mapIds).toEqual([])
})
it('shows delete button when editing existing contact', async () => {
const wrapper = mount(ContactForm, {
props: { initial: { id: '1', name: 'Иван' }, deletable: true },
})
const deleteBtn = wrapper.find('.btn-danger')
expect(deleteBtn.exists()).toBe(true)
await deleteBtn.trigger('click')
expect(wrapper.emitted('delete')).toHaveLength(1)
}) })
}) })
+82 -77
View File
@@ -24,70 +24,65 @@
<input v-model="form.position" class="form-control" placeholder="Директор" /> <input v-model="form.position" class="form-control" placeholder="Директор" />
</div> </div>
</div> </div>
<div class="contact-form-map-fields"> <div class="form-group">
<div class="form-group"> <label>Карты сети</label>
<label>Сфера жизни</label> <div v-if="!availableMaps.length" class="text-muted" style="font-size:13px;">
<SearchableSelect Нет карт. Создайте карту на странице «Карта сети».
v-model="form.life_sphere"
:options="lifeSpheres"
placeholder="Сфера жизни..."
/>
</div> </div>
<div class="form-group"> <div v-else class="map-checkboxes">
<label>Круг сети</label> <label v-for="map in availableMaps" :key="map.id" class="checkbox-label">
<SearchableSelect <input
v-model="form.network_circle" v-model="form.mapIds"
:options="networkCircles" type="checkbox"
placeholder="Круг сети..." :value="String(map.id)"
/> />
{{ map.name }}
</label>
</div> </div>
<div class="form-group"> <p class="checkbox-hint">На странице «Граф» контакт виден всегда; на выбранных картах в соответствующих визуализациях.</p>
<label>Важность (15)</label>
<input v-model.number="form.importance" type="number" min="1" max="5" class="form-control" />
</div>
</div>
<div class="form-group checkbox-row">
<label class="checkbox-label">
<input v-model="form.include_on_network_map" type="checkbox" />
Показывать на карте сети
</label>
<p class="checkbox-hint">На странице «Граф» контакт виден всегда; на «Карте сети» только с этой отметкой.</p>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Заметки</label> <label>Заметки</label>
<textarea v-model="form.notes" class="form-control" placeholder="Дополнительная информация..." rows="3"></textarea> <textarea v-model="form.notes" class="form-control" placeholder="Дополнительная информация..." rows="3"></textarea>
</div> </div>
<div class="modal-footer"> <div class="contact-form-footer">
<button type="button" class="btn btn-secondary" @click="$emit('cancel')">Отмена</button> <button
<button type="submit" class="btn btn-primary">Сохранить</button> v-if="showDelete"
type="button"
class="btn btn-danger"
@click="$emit('delete')"
>
Удалить
</button>
<div class="contact-form-actions">
<button type="button" class="btn btn-secondary" @click="$emit('cancel')">Отмена</button>
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</div> </div>
</form> </form>
</template> </template>
<script setup> <script setup>
import { reactive, watch, ref, onMounted } from 'vue' import { reactive, watch, computed, onMounted } from 'vue'
import api from '../api' import { useNetworkMapsStore } from '../stores/networkMaps'
import SearchableSelect from './SearchableSelect.vue' import { listMembershipsByContact } from '../application/usecases/networkMaps'
const FALLBACK_SPHERES = [ const props = defineProps({
{ value: 'work', label: 'Работа' }, initial: { type: Object, default: () => ({}) },
{ value: 'study', label: 'Учёба' }, initialMapIds: { type: Array, default: null },
{ value: 'hobby', label: 'Хобби' }, deletable: { type: Boolean, default: false },
{ value: 'family', label: 'Семья' }, })
{ value: 'health', label: 'Здоровье' }, const emit = defineEmits(['submit', 'cancel', 'delete'])
{ value: 'other', label: 'Другое' },
]
const FALLBACK_CIRCLES = [
{ value: 'support', label: 'Круг поддержки' },
{ value: 'productivity', label: 'Круг продуктивности' },
{ value: 'development', label: 'Круг развития' },
]
const props = defineProps({ initial: { type: Object, default: () => ({}) } }) const mapsStore = useNetworkMapsStore()
const emit = defineEmits(['submit', 'cancel'])
const lifeSpheres = ref([...FALLBACK_SPHERES]) const showDelete = computed(() => {
const networkCircles = ref([...FALLBACK_CIRCLES]) if (props.deletable) return true
const id = props.initial?.id
return id !== undefined && id !== null && id !== ''
})
const availableMaps = computed(() => mapsStore.maps)
const form = reactive({ const form = reactive({
name: props.initial.name || '', name: props.initial.name || '',
@@ -96,13 +91,19 @@ const form = reactive({
organization: props.initial.organization || '', organization: props.initial.organization || '',
position: props.initial.position || '', position: props.initial.position || '',
notes: props.initial.notes || '', notes: props.initial.notes || '',
life_sphere: props.initial.life_sphere || 'other', mapIds: (props.initialMapIds || []).map(String),
network_circle: props.initial.network_circle || 'productivity',
importance: props.initial.importance ?? 3,
include_on_network_map: Boolean(props.initial.include_on_network_map),
}) })
watch(() => props.initial, (v) => { async function loadMapIdsForContact(contactId) {
if (!contactId) {
form.mapIds = []
return
}
const memberships = await listMembershipsByContact(contactId)
form.mapIds = memberships.map((m) => String(m.mapId || m.map))
}
watch(() => props.initial, async (v) => {
if (!v || !Object.keys(v).length) { if (!v || !Object.keys(v).length) {
Object.assign(form, { Object.assign(form, {
name: '', name: '',
@@ -111,10 +112,7 @@ watch(() => props.initial, (v) => {
organization: '', organization: '',
position: '', position: '',
notes: '', notes: '',
life_sphere: 'other', mapIds: [],
network_circle: 'productivity',
importance: 3,
include_on_network_map: false,
}) })
return return
} }
@@ -125,37 +123,32 @@ watch(() => props.initial, (v) => {
organization: v.organization || '', organization: v.organization || '',
position: v.position || '', position: v.position || '',
notes: v.notes || '', notes: v.notes || '',
life_sphere: v.life_sphere || 'other',
network_circle: v.network_circle || 'productivity',
importance: v.importance ?? 3,
include_on_network_map: Boolean(v.include_on_network_map),
}) })
if (props.initialMapIds) {
form.mapIds = props.initialMapIds.map(String)
} else if (v.id) {
await loadMapIdsForContact(v.id)
}
}, { deep: true }) }, { deep: true })
onMounted(async () => { onMounted(async () => {
try { await mapsStore.fetchMaps()
const { data } = await api.get('/network-map-choices/') if (props.initial?.id && !props.initialMapIds) {
if (data.life_spheres?.length) lifeSpheres.value = data.life_spheres await loadMapIdsForContact(props.initial.id)
if (data.network_circles?.length) networkCircles.value = data.network_circles
} catch {
/* оставляем FALLBACK_* */
} }
}) })
function onSubmit() { function onSubmit() {
emit('submit', { ...form }) const { mapIds, ...contactData } = form
emit('submit', contactData, mapIds)
} }
</script> </script>
<style scoped> <style scoped>
.contact-form-map-fields { .map-checkboxes {
display: grid; display: flex;
grid-template-columns: 1fr; flex-direction: column;
gap: 12px; gap: 8px;
}
.checkbox-row {
margin-top: 4px;
} }
.checkbox-label { .checkbox-label {
display: flex; display: flex;
@@ -170,4 +163,16 @@ function onSubmit() {
color: var(--text-muted); color: var(--text-muted);
line-height: 1.4; line-height: 1.4;
} }
.contact-form-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 22px;
}
.contact-form-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
</style> </style>
@@ -20,19 +20,11 @@
<div class="form-group"> <div class="form-group">
<label>Тип связи</label> <label>Тип связи</label>
<SearchableSelect <RelationTypeSelect v-model="form.type" />
v-model="form.type"
:options="relationTypes"
placeholder="Тип связи..."
/>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Интенсивность общения</label> <label>Интенсивность общения</label>
<SearchableSelect <InteractionIntensitySelect v-model="form.intensity" />
v-model="form.intensity"
:options="interactionIntensities"
placeholder="Интенсивность..."
/>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Описание (необязательно)</label> <label>Описание (необязательно)</label>
@@ -52,8 +44,8 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useContactsStore } from '../stores/contacts' import { useContactsStore } from '../stores/contacts'
import SearchableSelect from './SearchableSelect.vue' import InteractionIntensitySelect from './InteractionIntensitySelect.vue'
import { RELATION_TYPES, INTERACTION_INTENSITIES } from '../domain/networkChoices' import RelationTypeSelect from './RelationTypeSelect.vue'
const props = defineProps({ const props = defineProps({
open: { type: Boolean, default: false }, open: { type: Boolean, default: false },
@@ -68,9 +60,6 @@ const saving = ref(false)
const error = ref('') const error = ref('')
const swapped = ref(false) const swapped = ref(false)
const relationTypes = RELATION_TYPES
const interactionIntensities = INTERACTION_INTENSITIES
const form = ref({ const form = ref({
type: 'acquaintance', type: 'acquaintance',
intensity: 'intense', intensity: 'intense',
@@ -0,0 +1,155 @@
<template>
<div v-if="open" class="modal-overlay" @click.self="onCancel">
<div class="modal">
<div class="modal-header">
<h3>Редактировать связь</h3>
<button class="btn btn-secondary btn-sm" type="button" @click="onCancel"></button>
</div>
<div v-if="error" class="alert alert-error">{{ error }}</div>
<p v-if="relation" class="text-muted relation-ends">
<strong style="color:var(--text)">{{ sourceName }}</strong>
<span class="relation-arrow"></span>
<strong style="color:var(--text)">{{ targetName }}</strong>
</p>
<div class="form-group">
<label>Тип связи</label>
<RelationTypeSelect v-model="form.type" />
</div>
<div class="form-group">
<label>Интенсивность общения</label>
<InteractionIntensitySelect v-model="form.intensity" />
</div>
<div class="form-group">
<label>Описание (необязательно)</label>
<input v-model="form.description" class="form-control" placeholder="Например: знакомы с 2018 года" />
</div>
<div class="relation-edit-footer">
<button
type="button"
class="btn btn-danger"
:disabled="saving || deleting"
@click="onDelete"
>
{{ deleting ? 'Удаление...' : 'Удалить' }}
</button>
<div class="relation-edit-actions">
<button class="btn btn-secondary" type="button" :disabled="saving || deleting" @click="onCancel">
Отмена
</button>
<button class="btn btn-primary" type="button" :disabled="saving || deleting" @click="submit">
{{ saving ? 'Сохранение...' : 'Сохранить' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useContactsStore } from '../stores/contacts'
import InteractionIntensitySelect from './InteractionIntensitySelect.vue'
import RelationTypeSelect from './RelationTypeSelect.vue'
const props = defineProps({
open: { type: Boolean, default: false },
relation: { type: Object, default: null },
})
const emit = defineEmits(['close', 'updated', 'deleted'])
const store = useContactsStore()
const saving = ref(false)
const deleting = ref(false)
const error = ref('')
const form = ref({
type: 'acquaintance',
intensity: 'intense',
description: '',
})
const sourceName = computed(() => props.relation?.source_name || '—')
const targetName = computed(() => props.relation?.target_name || '—')
watch(
() => [props.open, props.relation],
() => {
if (!props.open || !props.relation) return
error.value = ''
form.value = {
type: props.relation.relation_type || 'acquaintance',
intensity: props.relation.interaction_intensity || 'intense',
description: props.relation.description || '',
}
},
{ immediate: true }
)
function onCancel() {
emit('close')
}
async function submit() {
if (!props.relation?.id) return
saving.value = true
error.value = ''
try {
const updated = await store.updateRelation(props.relation.id, {
relation_type: form.value.type,
description: form.value.description,
interaction_intensity: form.value.intensity,
})
emit('updated', updated)
emit('close')
} catch (e) {
error.value = e?.message || String(e)
} finally {
saving.value = false
}
}
async function onDelete() {
if (!props.relation?.id) return
const label = `${sourceName.value}${targetName.value}`
if (!window.confirm(`Удалить связь «${label}»?`)) return
deleting.value = true
error.value = ''
try {
await store.deleteRelation(props.relation.id)
emit('deleted', props.relation.id)
emit('close')
} catch (e) {
error.value = e?.message || String(e)
} finally {
deleting.value = false
}
}
</script>
<style scoped>
.relation-ends {
margin: 0 0 16px;
line-height: 1.5;
}
.relation-arrow {
margin: 0 8px;
color: var(--text-muted);
}
.relation-edit-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 22px;
}
.relation-edit-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
</style>
@@ -0,0 +1,115 @@
<template>
<Teleport to="body">
<div
v-if="open"
class="graph-node-menu-overlay"
@click="close"
@contextmenu.prevent="close"
/>
<div
v-if="open && edge"
class="graph-node-menu"
:style="{ left: `${x}px`, top: `${y}px` }"
role="menu"
@click.stop
@contextmenu.prevent
>
<div class="graph-node-menu__title">{{ edgeTitle }}</div>
<button type="button" class="graph-node-menu__item" role="menuitem" @click="onEdit">
Редактировать связь
</button>
</div>
</Teleport>
</template>
<script setup>
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { RELATION_TYPES } from '../domain/networkChoices'
const props = defineProps({
open: { type: Boolean, default: false },
edge: { type: Object, default: null },
x: { type: Number, default: 0 },
y: { type: Number, default: 0 },
})
const emit = defineEmits(['close', 'edit'])
const typeLabels = Object.fromEntries(RELATION_TYPES.map((r) => [r.value, r.label]))
const edgeTitle = computed(() => {
if (!props.edge) return 'Связь'
const type = typeLabels[props.edge.relation_type] || props.edge.relation_type || 'Связь'
return type
})
function close() {
emit('close')
}
function onEdit() {
emit('edit', props.edge)
close()
}
function onKeyDown(event) {
if (event.key === 'Escape' && props.open) close()
}
watch(() => props.open, (isOpen) => {
if (isOpen) window.addEventListener('keydown', onKeyDown)
else window.removeEventListener('keydown', onKeyDown)
})
onMounted(() => {
if (props.open) window.addEventListener('keydown', onKeyDown)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown)
})
</script>
<style scoped>
.graph-node-menu-overlay {
position: fixed;
inset: 0;
z-index: 900;
}
.graph-node-menu {
position: fixed;
z-index: 901;
min-width: 180px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
padding: 6px 0;
}
.graph-node-menu__title {
padding: 6px 14px 8px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
margin-bottom: 4px;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.graph-node-menu__item {
display: block;
width: 100%;
text-align: left;
padding: 8px 14px;
font-size: 13px;
color: var(--text);
background: transparent;
border: none;
cursor: pointer;
}
.graph-node-menu__item:hover {
background: var(--accent-dim);
}
</style>
@@ -0,0 +1,26 @@
<template>
<select
class="form-control"
:value="modelValue"
@change="onChange"
>
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</template>
<script setup>
import { INTERACTION_INTENSITIES } from '../domain/networkChoices'
defineProps({
modelValue: { type: String, default: 'intense' },
options: { type: Array, default: () => INTERACTION_INTENSITIES },
})
const emit = defineEmits(['update:modelValue'])
function onChange(e) {
emit('update:modelValue', e.target.value)
}
</script>
@@ -0,0 +1,86 @@
<template>
<div v-if="open" class="modal-overlay" @click.self="$emit('close')">
<div class="modal">
<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>
<textarea
v-model="form.description"
class="form-control"
rows="3"
placeholder="Цель карты, контекст..."
/>
</div>
<div class="modal-footer">
<button
v-if="isEdit && deletable"
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, watch, computed } from 'vue'
const props = defineProps({
open: { type: Boolean, default: false },
initial: { type: Object, default: () => ({}) },
deletable: { type: Boolean, default: false },
})
const emit = defineEmits(['close', 'submit', 'delete'])
const isEdit = computed(() => Boolean(props.initial?.id))
const form = reactive({
name: '',
description: '',
})
watch(
() => [props.open, props.initial],
() => {
if (!props.open) return
form.name = props.initial?.name || ''
form.description = props.initial?.description || ''
},
{ immediate: true, deep: true }
)
function onSubmit() {
emit('submit', { name: form.name.trim(), description: form.description.trim() })
}
</script>
<style scoped>
.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;
}
</style>
@@ -0,0 +1,56 @@
<template>
<div class="map-switcher">
<label class="map-switcher-label">Карта</label>
<select
class="form-control map-switcher-select"
:value="modelValue"
@change="onSelect"
>
<option v-for="map in maps" :key="map.id" :value="String(map.id)">
{{ map.name }}
</option>
</select>
<button type="button" class="btn btn-secondary btn-sm" @click="$emit('create')">
+ Новая
</button>
<button
v-if="modelValue"
type="button"
class="btn btn-secondary btn-sm"
title="Настройки карты"
@click="$emit('manage', modelValue)"
>
</button>
</div>
</template>
<script setup>
defineProps({
maps: { type: Array, default: () => [] },
modelValue: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue', 'create', 'manage'])
function onSelect(event) {
emit('update:modelValue', event.target.value)
}
</script>
<style scoped>
.map-switcher {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.map-switcher-label {
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
}
.map-switcher-select {
min-width: 160px;
max-width: 240px;
}
</style>
@@ -17,6 +17,7 @@
<p v-if="subtitle" class="network-map-sub">{{ subtitle }}</p> <p v-if="subtitle" class="network-map-sub">{{ subtitle }}</p>
</div> </div>
<div class="network-map-actions"> <div class="network-map-actions">
<slot name="toolbar" />
<slot name="filters" /> <slot name="filters" />
<button class="btn btn-secondary btn-sm" @click="$emit('fit')"> <button class="btn btn-secondary btn-sm" @click="$emit('fit')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -0,0 +1,26 @@
<template>
<select
class="form-control"
:value="modelValue"
@change="onChange"
>
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</template>
<script setup>
import { RELATION_TYPES } from '../domain/networkChoices'
defineProps({
modelValue: { type: String, default: 'acquaintance' },
options: { type: Array, default: () => RELATION_TYPES },
})
const emit = defineEmits(['update:modelValue'])
function onChange(e) {
emit('update:modelValue', e.target.value)
}
</script>
+6 -3
View File
@@ -2,12 +2,15 @@ import { getGraphBundle, getMapChoices } from '../application/usecases/graph'
import { isLocalMode } from '../infrastructure/config/dataMode' import { isLocalMode } from '../infrastructure/config/dataMode'
import api from '../api' import api from '../api'
export async function fetchGraphBundle(graphEndpoint = '/graph/') { export async function fetchGraphBundle({ graphEndpoint = '/graph/', mapId = null } = {}) {
if (isLocalMode()) { if (isLocalMode()) {
return getGraphBundle({ networkMapOnly: graphEndpoint === '/network-map-graph/' }) return getGraphBundle({ mapId })
} }
const endpoint = mapId
? `/network-map-graph/?map_id=${encodeURIComponent(mapId)}`
: graphEndpoint
const [gRes, rtRes] = await Promise.all([ const [gRes, rtRes] = await Promise.all([
api.get(graphEndpoint), api.get(endpoint),
api.get('/relation-types/'), api.get('/relation-types/'),
]) ])
return { return {
@@ -5,30 +5,83 @@ export function useGraphNodeContextMenu() {
const contextMenuNode = ref(null) const contextMenuNode = ref(null)
const contextMenuX = ref(0) const contextMenuX = ref(0)
const contextMenuY = ref(0) const contextMenuY = ref(0)
const edgeContextMenuOpen = ref(false)
const contextMenuEdge = ref(null)
const edgeContextMenuX = ref(0)
const edgeContextMenuY = ref(0)
function openContextMenu(node, event) { function openContextMenu(node, event) {
if (!node || !event) return if (!node || !event) return
edgeContextMenuOpen.value = false
contextMenuEdge.value = null
contextMenuNode.value = node contextMenuNode.value = node
contextMenuX.value = event.clientX contextMenuX.value = event.clientX
contextMenuY.value = event.clientY contextMenuY.value = event.clientY
contextMenuOpen.value = true contextMenuOpen.value = true
} }
function openEdgeContextMenu(edge, event) {
if (!edge || !event) return
contextMenuOpen.value = false
contextMenuNode.value = null
contextMenuEdge.value = edge
edgeContextMenuX.value = event.clientX
edgeContextMenuY.value = event.clientY
edgeContextMenuOpen.value = true
}
function closeContextMenu() { function closeContextMenu() {
contextMenuOpen.value = false contextMenuOpen.value = false
contextMenuNode.value = null contextMenuNode.value = null
edgeContextMenuOpen.value = false
contextMenuEdge.value = null
} }
function attachNodeContextHandlers(network, getNodes) { function closeEdgeContextMenu() {
edgeContextMenuOpen.value = false
contextMenuEdge.value = null
}
function resolveEdgeAtPointer(network, domEvent, getEdges) {
let edgeId = null
if (domEvent && typeof network.getPointer === 'function' && typeof network.getEdgeAt === 'function') {
try {
edgeId = network.getEdgeAt(network.getPointer(domEvent))
} catch {
edgeId = null
}
}
if (!edgeId) return null
return getEdges().find((e) => String(e.id) === String(edgeId)) || null
}
function attachNodeContextHandlers(network, getNodes, getEdges = () => []) {
const onContext = (params) => { const onContext = (params) => {
const domEvent = params.event?.srcEvent || params.event const domEvent = params.event?.srcEvent || params.event
domEvent?.preventDefault?.() domEvent?.preventDefault?.()
let edge = null
if (params.edges?.length > 0) {
const edgeId = params.edges[0]
edge = getEdges().find((e) => String(e.id) === String(edgeId)) || null
}
if (!edge) {
edge = resolveEdgeAtPointer(network, domEvent, getEdges)
}
if (edge) {
openEdgeContextMenu(edge, domEvent)
return
}
if (params.nodes?.length > 0) { if (params.nodes?.length > 0) {
const id = params.nodes[0] const id = params.nodes[0]
const node = getNodes().find((n) => String(n.id) === String(id)) const node = getNodes().find((n) => String(n.id) === String(id))
if (node) openContextMenu(node, domEvent) if (node) {
return openContextMenu(node, domEvent)
return
}
} }
closeContextMenu() closeContextMenu()
} }
@@ -41,8 +94,14 @@ export function useGraphNodeContextMenu() {
contextMenuNode, contextMenuNode,
contextMenuX, contextMenuX,
contextMenuY, contextMenuY,
edgeContextMenuOpen,
contextMenuEdge,
edgeContextMenuX,
edgeContextMenuY,
openContextMenu, openContextMenu,
openEdgeContextMenu,
closeContextMenu, closeContextMenu,
closeEdgeContextMenu,
attachNodeContextHandlers, attachNodeContextHandlers,
} }
} }
+1
View File
@@ -24,5 +24,6 @@ export const NETWORK_CIRCLES = [
export const INTERACTION_INTENSITIES = [ export const INTERACTION_INTENSITIES = [
{ value: 'intense', label: 'Интенсивные контакты' }, { value: 'intense', label: 'Интенсивные контакты' },
{ value: 'periodic', label: 'Периодические контакты' },
{ value: 'sparse', label: 'Редкие контакты' }, { value: 'sparse', label: 'Редкие контакты' },
] ]
+55
View File
@@ -1,4 +1,7 @@
import Dexie from 'dexie' import Dexie from 'dexie'
import { generateId } from '../../lib/uuid'
const DEFAULT_MAP_NAME = 'Основная карта'
class SocialGraphDb extends Dexie { class SocialGraphDb extends Dexie {
constructor() { constructor() {
@@ -9,7 +12,59 @@ class SocialGraphDb extends Dexie {
meta: 'key', meta: 'key',
changelog: 'id, ts, entityType, entityId, syncStatus, workspaceId', 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',
}).upgrade(async (tx) => {
const contacts = await tx.table('contacts').toArray()
const ts = new Date().toISOString()
const mapId = generateId()
await tx.table('networkMaps').add({
id: mapId,
name: DEFAULT_MAP_NAME,
description: 'Мигрировано из прежней единой карты сети',
workspaceId: 'personal',
version: 1,
createdAt: ts,
updatedAt: ts,
deletedAt: null,
})
for (const contact of contacts) {
if (!contact.include_on_network_map) continue
await tx.table('networkMapMemberships').add({
id: generateId(),
mapId,
contactId: contact.id,
life_sphere: contact.life_sphere || 'other',
network_circle: contact.network_circle || 'productivity',
importance: contact.importance ?? 3,
map_angle: contact.map_angle ?? null,
map_radius_ratio: contact.map_radius_ratio ?? null,
workspaceId: 'personal',
version: 1,
createdAt: ts,
updatedAt: ts,
deletedAt: null,
})
await tx.table('contacts').update(contact.id, {
life_sphere: undefined,
network_circle: undefined,
importance: undefined,
include_on_network_map: undefined,
map_angle: undefined,
map_radius_ratio: undefined,
updatedAt: ts,
})
}
})
} }
} }
export const localDb = new SocialGraphDb() export const localDb = new SocialGraphDb()
export { DEFAULT_MAP_NAME }
@@ -13,12 +13,6 @@ function withDefaults(payload = {}) {
organization: '', organization: '',
position: '', position: '',
notes: '', notes: '',
life_sphere: 'other',
network_circle: 'productivity',
importance: 3,
include_on_network_map: false,
map_angle: null,
map_radius_ratio: null,
ownerId: 'local-user', ownerId: 'local-user',
workspaceId: 'personal', workspaceId: 'personal',
...payload, ...payload,
@@ -122,5 +116,17 @@ export const localContactRepository = {
}) })
) )
) )
const memberships = await localDb.networkMapMemberships
.filter((m) => !m.deletedAt && sameId(m.contactId, sid))
.toArray()
await Promise.all(
memberships.map((m) =>
localDb.networkMapMemberships.update(m.id, {
deletedAt: ts,
updatedAt: ts,
version: Number(m.version || 1) + 1,
})
)
)
}, },
} }
@@ -0,0 +1,126 @@
import { localDb } from '../db/localDb'
import { generateId } from '../../lib/uuid'
function nowIso() {
return new Date().toISOString()
}
function sameId(a, b) {
return String(a) === String(b)
}
function withDefaults(payload = {}) {
return {
life_sphere: 'other',
network_circle: 'productivity',
importance: 3,
map_angle: null,
map_radius_ratio: null,
workspaceId: 'personal',
...payload,
}
}
async function findActiveMembership(id) {
const direct = await localDb.networkMapMemberships.get(id)
if (direct && !direct.deletedAt) return direct
const sid = String(id)
const all = await localDb.networkMapMemberships.toArray()
return all.find((m) => !m.deletedAt && String(m.id) === sid) || null
}
async function findByMapAndContact(mapId, contactId) {
const all = await localDb.networkMapMemberships.toArray()
return all.find(
(m) => !m.deletedAt && sameId(m.mapId, mapId) && sameId(m.contactId, contactId)
) || null
}
async function hydrate(membership) {
if (!membership || membership.deletedAt) return null
const contact = await localDb.contacts.get(membership.contactId)
const map = await localDb.networkMaps.get(membership.mapId)
return {
...membership,
contact_name: contact?.name || '',
map_name: map?.name || '',
}
}
export const localNetworkMapMembershipRepository = {
async listByMap(mapId) {
const all = await localDb.networkMapMemberships.toArray()
const filtered = all
.filter((m) => !m.deletedAt && sameId(m.mapId, mapId))
.sort((a, b) => String(a.contactId).localeCompare(String(b.contactId)))
const hydrated = await Promise.all(filtered.map(hydrate))
return hydrated.filter(Boolean)
},
async listByContact(contactId) {
const all = await localDb.networkMapMemberships.toArray()
const filtered = all.filter((m) => !m.deletedAt && sameId(m.contactId, contactId))
const hydrated = await Promise.all(filtered.map(hydrate))
return hydrated.filter(Boolean)
},
async getById(id) {
const membership = await findActiveMembership(id)
return hydrate(membership)
},
async create(mapId, payload) {
const existing = await findByMapAndContact(mapId, payload.contact)
if (existing) {
throw new Error('Контакт уже на этой карте')
}
const ts = nowIso()
const record = withDefaults({
...payload,
mapId,
contactId: payload.contact,
})
const id = generateId()
await localDb.networkMapMemberships.put({
...record,
id,
version: 1,
createdAt: ts,
updatedAt: ts,
deletedAt: null,
})
return this.getById(id)
},
async update(mapId, id, payload) {
const existing = await findActiveMembership(id)
if (!existing) {
throw new Error('Участие не найдено')
}
if (!sameId(existing.mapId, mapId)) {
throw new Error('Участие не принадлежит указанной карте')
}
await localDb.networkMapMemberships.update(existing.id, {
...payload,
updatedAt: nowIso(),
version: Number(existing.version || 1) + 1,
})
return this.getById(existing.id)
},
async remove(mapId, id) {
const membership = await findActiveMembership(id)
if (!membership) {
throw new Error('Участие не найдено')
}
if (!sameId(membership.mapId, mapId)) {
throw new Error('Участие не принадлежит указанной карте')
}
const ts = nowIso()
await localDb.networkMapMemberships.update(membership.id, {
deletedAt: ts,
updatedAt: ts,
version: Number(membership.version || 1) + 1,
})
},
}
@@ -0,0 +1,36 @@
import api from '../../api'
import { fetchAllPages } from '../../lib/api/pagination'
export const remoteNetworkMapMembershipRepository = {
async listByMap(mapId) {
return fetchAllPages((page) =>
api.get(`/network-maps/${mapId}/memberships/`, { params: { page } })
)
},
async listByContact(contactId) {
const maps = await fetchAllPages((page) => api.get('/network-maps/', { params: { page } }))
const results = []
for (const map of maps) {
const memberships = await fetchAllPages((page) =>
api.get(`/network-maps/${map.id}/memberships/`, { params: { page } })
)
results.push(...memberships.filter((m) => String(m.contact) === String(contactId)))
}
return results
},
async getById(mapId, id) {
const { data } = await api.get(`/network-maps/${mapId}/memberships/${id}/`)
return data
},
async create(mapId, payload) {
const { data } = await api.post(`/network-maps/${mapId}/memberships/`, payload)
return data
},
async update(mapId, id, payload) {
const { data } = await api.patch(`/network-maps/${mapId}/memberships/${id}/`, payload)
return data
},
async remove(mapId, id) {
await api.delete(`/network-maps/${mapId}/memberships/${id}/`)
},
}
@@ -0,0 +1,129 @@
import { localDb, DEFAULT_MAP_NAME } from '../db/localDb'
import { generateId } from '../../lib/uuid'
function nowIso() {
return new Date().toISOString()
}
function withDefaults(payload = {}) {
return {
name: '',
description: '',
workspaceId: 'personal',
...payload,
}
}
async function findActiveMap(id) {
const direct = await localDb.networkMaps.get(id)
if (direct && !direct.deletedAt) return direct
const sid = String(id)
const all = await localDb.networkMaps.toArray()
return all.find((m) => !m.deletedAt && String(m.id) === sid) || null
}
async function membershipsCount(mapId) {
const all = await localDb.networkMapMemberships.toArray()
return all.filter((m) => !m.deletedAt && String(m.mapId) === String(mapId)).length
}
async function hydrate(map) {
if (!map || map.deletedAt) return null
return {
...map,
memberships_count: await membershipsCount(map.id),
}
}
async function ensureDefaultMap() {
const all = await localDb.networkMaps.toArray()
const active = all.filter((m) => !m.deletedAt)
if (active.length) return active.sort((a, b) => a.name.localeCompare(b.name, 'ru'))[0]
const ts = nowIso()
const id = generateId()
await localDb.networkMaps.put({
id,
name: DEFAULT_MAP_NAME,
description: '',
workspaceId: 'personal',
version: 1,
createdAt: ts,
updatedAt: ts,
deletedAt: null,
})
return findActiveMap(id)
}
export const localNetworkMapRepository = {
async list() {
const all = await localDb.networkMaps.toArray()
const filtered = all
.filter((m) => !m.deletedAt)
.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
if (!filtered.length) {
const defaultMap = await ensureDefaultMap()
return [await hydrate(defaultMap)]
}
const hydrated = await Promise.all(filtered.map(hydrate))
return hydrated.filter(Boolean)
},
async getById(id) {
const map = await findActiveMap(id)
return hydrate(map)
},
async create(payload) {
const ts = nowIso()
const record = withDefaults(payload)
const id = generateId()
await localDb.networkMaps.put({
...record,
id,
version: 1,
createdAt: ts,
updatedAt: ts,
deletedAt: null,
})
return this.getById(id)
},
async update(id, payload) {
const existing = await findActiveMap(id)
if (!existing) {
throw new Error('Карта не найдена')
}
await localDb.networkMaps.update(existing.id, {
...payload,
updatedAt: nowIso(),
version: Number(existing.version || 1) + 1,
})
return this.getById(existing.id)
},
async remove(id) {
const map = await findActiveMap(id)
if (!map) {
throw new Error('Карта не найдена')
}
const ts = nowIso()
await localDb.networkMaps.update(map.id, {
deletedAt: ts,
updatedAt: ts,
version: Number(map.version || 1) + 1,
})
const memberships = await localDb.networkMapMemberships
.filter((m) => !m.deletedAt && String(m.mapId) === String(map.id))
.toArray()
await Promise.all(
memberships.map((m) =>
localDb.networkMapMemberships.update(m.id, {
deletedAt: ts,
updatedAt: ts,
version: Number(m.version || 1) + 1,
})
)
)
},
}
@@ -0,0 +1,23 @@
import api from '../../api'
import { fetchAllPages } from '../../lib/api/pagination'
export const remoteNetworkMapRepository = {
async list() {
return fetchAllPages((page) => api.get('/network-maps/', { params: { page } }))
},
async getById(id) {
const { data } = await api.get(`/network-maps/${id}/`)
return data
},
async create(payload) {
const { data } = await api.post('/network-maps/', payload)
return data
},
async update(id, payload) {
const { data } = await api.patch(`/network-maps/${id}/`, payload)
return data
},
async remove(id) {
await api.delete(`/network-maps/${id}/`)
},
}
@@ -58,4 +58,19 @@ export const localRelationRepository = {
version: Number(rel.version || 1) + 1, version: Number(rel.version || 1) + 1,
}) })
}, },
async update(id, payload) {
const rel = await localDb.relations.get(id)
if (!rel || rel.deletedAt) {
throw new Error('Связь не найдена')
}
await localDb.relations.update(id, {
relation_type: payload.relation_type ?? rel.relation_type,
description: payload.description ?? rel.description,
interaction_intensity: payload.interaction_intensity ?? rel.interaction_intensity,
updatedAt: nowIso(),
version: Number(rel.version || 1) + 1,
})
return hydrate(await localDb.relations.get(id))
},
} }
@@ -12,4 +12,8 @@ export const remoteRelationRepository = {
async remove(id) { async remove(id) {
await api.delete(`/relations/${id}/`) await api.delete(`/relations/${id}/`)
}, },
async update(id, payload) {
const { data } = await api.patch(`/relations/${id}/`, payload)
return data
},
} }
@@ -3,8 +3,12 @@ import { RELATION_TYPES, LIFE_SPHERES, NETWORK_CIRCLES, INTERACTION_INTENSITIES
import { getDataMode } from '../config/dataMode' import { getDataMode } from '../config/dataMode'
import { localContactRepository } from './contactRepository.local' import { localContactRepository } from './contactRepository.local'
import { localRelationRepository } from './relationRepository.local' import { localRelationRepository } from './relationRepository.local'
import { localNetworkMapRepository } from './networkMapRepository.local'
import { localNetworkMapMembershipRepository } from './networkMapMembershipRepository.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 { remoteNetworkMapMembershipRepository } from './networkMapMembershipRepository.remote'
function mode() { function mode() {
return getDataMode() return getDataMode()
@@ -18,6 +22,14 @@ export function getRelationRepository() {
return mode() === 'remote' ? remoteRelationRepository : localRelationRepository return mode() === 'remote' ? remoteRelationRepository : localRelationRepository
} }
export function getNetworkMapRepository() {
return mode() === 'remote' ? remoteNetworkMapRepository : localNetworkMapRepository
}
export function getNetworkMapMembershipRepository() {
return mode() === 'remote' ? remoteNetworkMapMembershipRepository : localNetworkMapMembershipRepository
}
export async function getRelationTypes() { export async function getRelationTypes() {
if (mode() === 'remote') { if (mode() === 'remote') {
const { data } = await api.get('/relation-types/') const { data } = await api.get('/relation-types/')
+116
View File
@@ -0,0 +1,116 @@
import { computeClusterMap } from './clusters'
function buildAdjacency(edges = []) {
const adj = new Map()
const add = (a, b) => {
const sa = String(a)
const sb = String(b)
if (!adj.has(sa)) adj.set(sa, new Set())
if (!adj.has(sb)) adj.set(sb, new Set())
adj.get(sa).add(sb)
adj.get(sb).add(sa)
}
for (const edge of edges) add(edge.from, edge.to)
return adj
}
function degree(adj, id) {
return adj.get(String(id))?.size || 0
}
function bfsLevels(memberIds, adj, startId) {
const levels = new Map()
const queue = [String(startId)]
levels.set(String(startId), 0)
while (queue.length) {
const cur = queue.shift()
const level = levels.get(cur)
for (const next of adj.get(cur) || []) {
if (!memberIds.has(next) || levels.has(next)) continue
levels.set(next, level + 1)
queue.push(next)
}
}
return levels
}
/**
* Начальные координаты узлов: кластеры разнесены, внутри — кольца от «хаба».
*/
export function computeGraphSeedPositions(nodes = [], edges = []) {
const clusterMap = computeClusterMap(nodes, edges)
const adj = buildAdjacency(edges)
const positions = new Map()
const byCluster = new Map()
for (const node of nodes) {
const cid = clusterMap.get(String(node.id)) ?? -1
if (!byCluster.has(cid)) byCluster.set(cid, [])
byCluster.get(cid).push(node)
}
const connectedClusterIds = [...byCluster.keys()].filter((k) => k >= 0).sort((a, b) => a - b)
const clusterCount = connectedClusterIds.length
connectedClusterIds.forEach((cid, clusterIdx) => {
const members = byCluster.get(cid)
const memberIds = new Set(members.map((m) => String(m.id)))
const clusterAngle = (clusterIdx / Math.max(clusterCount, 1)) * 2 * Math.PI - Math.PI / 2
const clusterDist = clusterCount > 1 ? 750 : 0
const ccx = clusterDist * Math.cos(clusterAngle)
const ccy = clusterDist * Math.sin(clusterAngle)
let hub = members[0]
let maxDeg = -1
for (const member of members) {
const d = degree(adj, member.id)
if (d > maxDeg) {
maxDeg = d
hub = member
}
}
const levels = bfsLevels(memberIds, adj, hub.id)
let maxLevel = 0
for (const member of members) {
const sid = String(member.id)
if (!levels.has(sid)) levels.set(sid, (maxDeg > 0 ? maxDeg : 1) + 1)
maxLevel = Math.max(maxLevel, levels.get(sid))
}
const byLevel = new Map()
for (const member of members) {
const lvl = levels.get(String(member.id)) || 0
if (!byLevel.has(lvl)) byLevel.set(lvl, [])
byLevel.get(lvl).push(member)
}
const ringStep = 120 + Math.min(members.length, 24) * 4
for (const [lvl, lvlNodes] of byLevel) {
const crowdBoost = lvlNodes.length > 10 ? (lvlNodes.length - 10) * 6 : 0
const radius = lvl === 0 ? 0 : lvl * ringStep + crowdBoost
lvlNodes.forEach((member, index) => {
const angle = (index / lvlNodes.length) * 2 * Math.PI - Math.PI / 2
positions.set(String(member.id), {
x: ccx + radius * Math.cos(angle),
y: ccy + radius * Math.sin(angle),
})
})
}
})
const isolates = byCluster.get(-1) || []
isolates.forEach((member, index) => {
const angle = (index / Math.max(isolates.length, 1)) * 2 * Math.PI
const radius = 900 + clusterCount * 120
positions.set(String(member.id), {
x: radius * Math.cos(angle),
y: radius * Math.sin(angle),
})
})
return positions
}
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest'
import { computeGraphSeedPositions } from './graphLayout'
describe('computeGraphSeedPositions', () => {
it('spreads disconnected clusters apart', () => {
const nodes = [
{ id: 'a1' },
{ id: 'a2' },
{ id: 'b1' },
{ id: 'b2' },
]
const edges = [
{ from: 'a1', to: 'a2' },
{ from: 'b1', to: 'b2' },
]
const pos = computeGraphSeedPositions(nodes, edges)
const a1 = pos.get('a1')
const b1 = pos.get('b1')
const dist = Math.hypot(a1.x - b1.x, a1.y - b1.y)
expect(dist).toBeGreaterThan(400)
})
it('places hub at cluster center ring', () => {
const nodes = [{ id: 'hub' }, { id: 'n1' }, { id: 'n2' }]
const edges = [
{ from: 'hub', to: 'n1' },
{ from: 'hub', to: 'n2' },
]
const pos = computeGraphSeedPositions(nodes, edges)
const hub = pos.get('hub')
const n1 = pos.get('n1')
expect(Math.hypot(hub.x - n1.x, hub.y - n1.y)).toBeGreaterThan(50)
})
})
@@ -0,0 +1,35 @@
const STORAGE_KEY = 'sg-graph-layout-v1'
const EMPTY = { positions: {}, scale: 1, view: null }
let memory = null
export function readGraphLayoutCache() {
if (memory) return memory
try {
const raw = sessionStorage.getItem(STORAGE_KEY)
memory = raw ? { ...EMPTY, ...JSON.parse(raw) } : { ...EMPTY }
} catch {
memory = { ...EMPTY }
}
return memory
}
export function writeGraphLayoutCache({ positions, scale, view }) {
const prev = readGraphLayoutCache()
memory = {
positions: positions ?? prev.positions,
scale: scale ?? prev.scale,
view: view ?? prev.view,
}
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(memory))
} catch {
/* sessionStorage quota */
}
}
export function clearGraphLayoutCache() {
memory = { ...EMPTY }
sessionStorage.removeItem(STORAGE_KEY)
}
@@ -0,0 +1,24 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
readGraphLayoutCache,
writeGraphLayoutCache,
clearGraphLayoutCache,
} from './graphLayoutCache'
describe('graphLayoutCache', () => {
beforeEach(() => {
clearGraphLayoutCache()
})
it('stores and reads node positions', () => {
writeGraphLayoutCache({
positions: { '1': { x: 10, y: 20 } },
scale: 1.5,
view: { x: 0, y: 0 },
})
const cache = readGraphLayoutCache()
expect(cache.positions['1']).toEqual({ x: 10, y: 20 })
expect(cache.scale).toBe(1.5)
expect(cache.view).toEqual({ x: 0, y: 0 })
})
})
@@ -0,0 +1,45 @@
const INTENSITY_STYLES = {
intense: {
width: 3.5,
dashes: false,
length: 95,
opacity: 0.95,
},
periodic: {
width: 2,
dashes: [5, 5],
length: 185,
opacity: 0.8,
},
sparse: {
width: 1,
dashes: [8, 6],
length: 300,
opacity: 0.55,
},
}
export function normalizeIntensity(intensity) {
if (intensity === 'sparse' || intensity === 'periodic' || intensity === 'intense') {
return intensity
}
return 'intense'
}
export function intensityVisual(intensity) {
return INTENSITY_STYLES[normalizeIntensity(intensity)]
}
export function applyIntensityToVisEdge(edge, colorBase) {
const vis = intensityVisual(edge.interaction_intensity)
return {
width: vis.width,
dashes: vis.dashes,
length: vis.length,
color: {
color: colorBase.color,
highlight: colorBase.highlight,
opacity: vis.opacity,
},
}
}
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest'
import { applyIntensityToVisEdge, intensityVisual, normalizeIntensity } from './relationIntensity'
describe('relationIntensity', () => {
it('normalizes unknown values to intense', () => {
expect(normalizeIntensity('unknown')).toBe('intense')
expect(normalizeIntensity('periodic')).toBe('periodic')
})
it('uses stronger pull for intense contacts', () => {
const intense = intensityVisual('intense')
const sparse = intensityVisual('sparse')
expect(intense.length).toBeLessThan(sparse.length)
expect(intense.width).toBeGreaterThan(sparse.width)
})
it('maps periodic contacts between intense and sparse', () => {
const periodic = intensityVisual('periodic')
const intense = intensityVisual('intense')
const sparse = intensityVisual('sparse')
expect(periodic.length).toBeGreaterThan(intense.length)
expect(periodic.length).toBeLessThan(sparse.length)
})
it('applies color opacity from intensity', () => {
const edge = applyIntensityToVisEdge(
{ interaction_intensity: 'sparse' },
{ color: '#fff', highlight: '#eee' }
)
expect(edge.color.opacity).toBeLessThan(1)
expect(edge.dashes).toEqual([8, 6])
})
})
+5
View File
@@ -12,6 +12,11 @@ const routes = [
}, },
{ {
path: '/map', path: '/map',
name: 'NetworkMapRedirect',
component: () => import('../views/NetworkMapRedirect.vue'),
},
{
path: '/map/:mapId',
name: 'NetworkMap', name: 'NetworkMap',
component: () => import('../views/NetworkMapView.vue'), component: () => import('../views/NetworkMapView.vue'),
}, },
+28
View File
@@ -11,6 +11,7 @@ import {
listRelations, listRelations,
createRelation as createRelationUseCase, createRelation as createRelationUseCase,
deleteRelation as deleteRelationUseCase, deleteRelation as deleteRelationUseCase,
updateRelation as updateRelationUseCase,
} from '../application/usecases/relations' } from '../application/usecases/relations'
import { import {
getRelationTypes, getRelationTypes,
@@ -35,6 +36,7 @@ export const useContactsStore = defineStore('contacts', {
relationsLoading: false, relationsLoading: false,
mapLoading: false, mapLoading: false,
error: null, error: null,
dataRevision: 0,
}), }),
getters: { getters: {
@@ -48,6 +50,10 @@ export const useContactsStore = defineStore('contacts', {
this.error = error?.message || String(error) this.error = error?.message || String(error)
}, },
bumpDataRevision() {
this.dataRevision += 1
},
async withLoading(flagName, fn) { async withLoading(flagName, fn) {
this[flagName] = true this[flagName] = true
this.loading = this.contactsLoading || this.relationsLoading || this.mapLoading this.loading = this.contactsLoading || this.relationsLoading || this.mapLoading
@@ -106,6 +112,7 @@ export const useContactsStore = defineStore('contacts', {
const data = await createContactUseCase(payload) const data = await createContactUseCase(payload)
this.contacts.push(data) this.contacts.push(data)
await syncPendingChanges() await syncPendingChanges()
this.bumpDataRevision()
return data return data
}) })
}, },
@@ -116,6 +123,7 @@ export const useContactsStore = defineStore('contacts', {
const idx = this.contacts.findIndex((c) => String(c.id) === String(id)) const idx = this.contacts.findIndex((c) => String(c.id) === String(id))
if (idx !== -1) this.contacts[idx] = data if (idx !== -1) this.contacts[idx] = data
await syncPendingChanges() await syncPendingChanges()
this.bumpDataRevision()
return data return data
}) })
}, },
@@ -129,6 +137,7 @@ export const useContactsStore = defineStore('contacts', {
(r) => String(r.source) !== sid && String(r.target) !== sid (r) => String(r.source) !== sid && String(r.target) !== sid
) )
await syncPendingChanges() await syncPendingChanges()
this.bumpDataRevision()
}) })
}, },
@@ -143,6 +152,7 @@ export const useContactsStore = defineStore('contacts', {
(r) => !idSet.has(String(r.source)) && !idSet.has(String(r.target)) (r) => !idSet.has(String(r.source)) && !idSet.has(String(r.target))
) )
await syncPendingChanges() await syncPendingChanges()
this.bumpDataRevision()
}) })
}, },
@@ -160,6 +170,7 @@ export const useContactsStore = defineStore('contacts', {
return c return c
}) })
await syncPendingChanges() await syncPendingChanges()
this.bumpDataRevision()
return data return data
}) })
}, },
@@ -170,6 +181,18 @@ export const useContactsStore = defineStore('contacts', {
this.relations = this.relations.filter((r) => r.id !== id) this.relations = this.relations.filter((r) => r.id !== id)
await this.fetchContacts() await this.fetchContacts()
await syncPendingChanges() await syncPendingChanges()
this.bumpDataRevision()
})
},
async updateRelation(id, payload) {
return this.withLoading('relationsLoading', async () => {
const data = await updateRelationUseCase(id, payload)
const idx = this.relations.findIndex((r) => String(r.id) === String(id))
if (idx !== -1) this.relations[idx] = data
await syncPendingChanges()
this.bumpDataRevision()
return data
}) })
}, },
@@ -177,7 +200,11 @@ export const useContactsStore = defineStore('contacts', {
return this.withLoading('contactsLoading', async () => { return this.withLoading('contactsLoading', async () => {
const data = await importContactsFromFile(file) const data = await importContactsFromFile(file)
await this.fetchContacts() await this.fetchContacts()
if (data.isDump) {
await this.fetchRelations()
}
await syncPendingChanges() await syncPendingChanges()
this.bumpDataRevision()
return data return data
}) })
}, },
@@ -193,6 +220,7 @@ export const useContactsStore = defineStore('contacts', {
async importDataDump(file, passphrase = '') { async importDataDump(file, passphrase = '') {
const result = await importLocalDump(file, passphrase) const result = await importLocalDump(file, passphrase)
await Promise.all([this.fetchContacts(), this.fetchRelations()]) await Promise.all([this.fetchContacts(), this.fetchRelations()])
this.bumpDataRevision()
return result return result
}, },
}, },
+154
View File
@@ -0,0 +1,154 @@
import { defineStore } from 'pinia'
import {
listNetworkMaps,
getNetworkMapById,
createNetworkMap,
updateNetworkMap,
deleteNetworkMap,
listMembershipsByMap,
listMembershipsByContact,
addContactToMap,
updateMembership,
removeContactFromMap,
setContactMapMemberships,
} from '../application/usecases/networkMaps'
import { syncPendingChanges } from '../application/usecases/sync'
const ACTIVE_MAP_KEY = 'social-graph-active-map-id'
export const useNetworkMapsStore = defineStore('networkMaps', {
state: () => ({
maps: [],
membershipsByMapId: {},
contactMemberships: [],
loading: false,
error: null,
activeMapId: localStorage.getItem(ACTIVE_MAP_KEY) || null,
}),
getters: {
activeMap(state) {
return state.maps.find((m) => String(m.id) === String(state.activeMapId)) || state.maps[0] || null
},
membershipsForActiveMap(state) {
if (!state.activeMapId) return []
return state.membershipsByMapId[String(state.activeMapId)] || []
},
},
actions: {
setError(error) {
this.error = error?.message || String(error)
},
setActiveMapId(id) {
this.activeMapId = id ? String(id) : null
if (this.activeMapId) {
localStorage.setItem(ACTIVE_MAP_KEY, this.activeMapId)
} else {
localStorage.removeItem(ACTIVE_MAP_KEY)
}
},
async fetchMaps() {
this.loading = true
this.error = null
try {
this.maps = await listNetworkMaps()
if (!this.activeMapId && this.maps.length) {
this.setActiveMapId(this.maps[0].id)
}
if (this.activeMapId && !this.maps.find((m) => String(m.id) === String(this.activeMapId))) {
this.setActiveMapId(this.maps[0]?.id || null)
}
return this.maps
} catch (e) {
this.setError(e)
throw e
} finally {
this.loading = false
}
},
async fetchMapById(id) {
const data = await getNetworkMapById(id)
const idx = this.maps.findIndex((m) => String(m.id) === String(id))
if (idx !== -1) this.maps[idx] = data
else if (data) this.maps.push(data)
return data
},
async fetchMemberships(mapId) {
const data = await listMembershipsByMap(mapId)
this.membershipsByMapId[String(mapId)] = data
return data
},
async fetchContactMemberships(contactId) {
this.contactMemberships = await listMembershipsByContact(contactId)
return this.contactMemberships
},
async createMap(payload) {
const data = await createNetworkMap(payload)
this.maps.push(data)
await syncPendingChanges()
return data
},
async updateMap(id, payload) {
const data = await updateNetworkMap(id, payload)
const idx = this.maps.findIndex((m) => String(m.id) === String(id))
if (idx !== -1) this.maps[idx] = data
await syncPendingChanges()
return data
},
async deleteMap(id) {
await deleteNetworkMap(id)
this.maps = this.maps.filter((m) => String(m.id) !== String(id))
delete this.membershipsByMapId[String(id)]
if (String(this.activeMapId) === String(id)) {
this.setActiveMapId(this.maps[0]?.id || null)
}
await syncPendingChanges()
},
async addContactToMap(mapId, contactId, extra = {}) {
const data = await addContactToMap(mapId, contactId, extra)
const key = String(mapId)
const list = this.membershipsByMapId[key] || []
this.membershipsByMapId[key] = [...list, data]
await syncPendingChanges()
return data
},
async updateMembership(mapId, membershipId, payload) {
const data = await updateMembership(mapId, membershipId, payload)
const key = String(mapId)
const list = this.membershipsByMapId[key] || []
const idx = list.findIndex((m) => String(m.id) === String(membershipId))
if (idx !== -1) {
list[idx] = data
this.membershipsByMapId[key] = [...list]
}
await syncPendingChanges()
return data
},
async removeContactFromMap(mapId, membershipId) {
await removeContactFromMap(mapId, membershipId)
const key = String(mapId)
this.membershipsByMapId[key] = (this.membershipsByMapId[key] || [])
.filter((m) => String(m.id) !== String(membershipId))
await syncPendingChanges()
},
async setContactMapMemberships(contactId, mapIds) {
const data = await setContactMapMemberships(contactId, mapIds)
this.contactMemberships = data
await syncPendingChanges()
return data
},
},
})
+92 -51
View File
@@ -5,16 +5,16 @@
<button class="btn btn-secondary btn-sm" @click="$router.back()"> Назад</button> <button class="btn btn-secondary btn-sm" @click="$router.back()"> Назад</button>
<h2>{{ contact?.name || 'Загрузка...' }}</h2> <h2>{{ contact?.name || 'Загрузка...' }}</h2>
</div> </div>
<div v-if="contact" class="page-header__actions">
<button class="btn btn-primary btn-sm" @click="editing = true">Редактировать</button>
</div>
</div> </div>
<div class="page-content" v-if="contact"> <div class="page-content" v-if="contact">
<div style="display:grid; grid-template-columns:1fr 1fr; gap:20px;"> <div style="display:grid; grid-template-columns:1fr 1fr; gap:20px;">
<!-- Info card --> <!-- Info card -->
<div class="card"> <div class="card">
<h3 style="font-size:14px;margin-bottom:16px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em;">Информация</h3> <div class="card-section-header">
<h3 class="card-section-title">Информация</h3>
<button class="btn btn-primary btn-sm" type="button" @click="editing = true">Редактировать</button>
</div>
<div class="form-group"> <div class="form-group">
<label>Email</label> <label>Email</label>
<div>{{ contact.email || '—' }}</div> <div>{{ contact.email || '—' }}</div>
@@ -36,15 +36,18 @@
<div style="white-space:pre-wrap;">{{ contact.notes || '—' }}</div> <div style="white-space:pre-wrap;">{{ contact.notes || '—' }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Сфера жизни / круг / важность</label> <label>Карты сети</label>
<div> <div v-if="contactMapNames.length">
{{ sphereLabel(contact.life_sphere) }} · {{ circleLabel(contact.network_circle) }} <RouterLink
· важность {{ contact.importance ?? '—' }}/5 v-for="name in contactMapNames"
:key="name.id"
:to="`/map/${name.id}`"
class="map-link"
>
{{ name.label }}
</RouterLink>
</div> </div>
</div> <div v-else>Не участвует ни на одной карте (только в общем графе)</div>
<div class="form-group">
<label>Карта сети</label>
<div>{{ contact.include_on_network_map ? 'Показывается на карте' : 'Не на карте (только в общем графе)' }}</div>
</div> </div>
</div> </div>
@@ -57,26 +60,40 @@
<div v-if="contactRelations.length === 0" class="empty-state" style="padding:20px 0;"> <div v-if="contactRelations.length === 0" class="empty-state" style="padding:20px 0;">
<p>Нет связей с другими контактами.</p> <p>Нет связей с другими контактами.</p>
</div> </div>
<div v-else> <div v-else class="relations-list">
<div <div
v-for="rel in contactRelations" v-for="rel in contactRelations"
:key="rel.id" :key="rel.id"
class="flex justify-between items-center" class="relation-row"
style="padding:8px 0; border-bottom:1px solid var(--border);" :class="{ 'is-selected': String(editRelationTarget?.id) === String(rel.id) }"
@click="openEditRelation(rel)"
> >
<div> <div class="relation-row__body">
<span style="font-weight:500;">{{ rel.source === contact.id ? rel.target_name : rel.source_name }}</span> <span style="font-weight:500;">{{ otherContactName(rel) }}</span>
<span :class="`badge badge-${rel.relation_type}`" style="margin-left:8px;">{{ relLabel(rel.relation_type) }}</span> <span :class="`badge badge-${rel.relation_type}`" style="margin-left:8px;">{{ relLabel(rel.relation_type) }}</span>
<span class="text-muted" style="margin-left:8px;font-size:12px;">{{ intensityLabel(rel.interaction_intensity) }}</span> <span class="text-muted" style="margin-left:8px;font-size:12px;">{{ intensityLabel(rel.interaction_intensity) }}</span>
<div class="text-muted mt-1">{{ rel.description }}</div> <div v-if="rel.description" class="text-muted mt-1">{{ rel.description }}</div>
</div>
<div class="relation-row__actions" @click.stop>
<button class="btn btn-secondary btn-sm" type="button" @click="openEditRelation(rel)">
Изменить
</button>
<button class="btn btn-danger btn-sm" type="button" @click="removeRelation(rel.id)"></button>
</div> </div>
<button class="btn btn-danger btn-sm" @click="removeRelation(rel.id)"></button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<EditRelationModal
:open="editRelationOpen"
:relation="editRelationTarget"
@close="closeEditRelation"
@updated="onRelationUpdated"
@deleted="onRelationDeleted"
/>
<!-- Edit modal --> <!-- Edit modal -->
<div v-if="editing" class="modal-overlay" @click.self="editing = false"> <div v-if="editing" class="modal-overlay" @click.self="editing = false">
<div class="modal"> <div class="modal">
@@ -84,7 +101,13 @@
<h3>Редактировать контакт</h3> <h3>Редактировать контакт</h3>
<button class="btn btn-secondary btn-sm" @click="editing = false"></button> <button class="btn btn-secondary btn-sm" @click="editing = false"></button>
</div> </div>
<ContactForm :initial="contact" @submit="onUpdate" @cancel="editing = false" /> <ContactForm
:initial="contact"
deletable
@submit="onUpdate"
@cancel="editing = false"
@delete="confirmDeleteContact"
/>
</div> </div>
</div> </div>
@@ -106,19 +129,11 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Тип связи</label> <label>Тип связи</label>
<SearchableSelect <RelationTypeSelect v-model="newRel.type" :options="relationTypes" />
v-model="newRel.type"
:options="relationTypes"
placeholder="Тип связи..."
/>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Интенсивность общения</label> <label>Интенсивность общения</label>
<SearchableSelect <InteractionIntensitySelect v-model="newRel.interaction_intensity" />
v-model="newRel.interaction_intensity"
:options="interactionIntensities"
placeholder="Интенсивность..."
/>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Описание (необязательно)</label> <label>Описание (необязательно)</label>
@@ -138,19 +153,24 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useContactsStore } from '../stores/contacts' import { useContactsStore } from '../stores/contacts'
import ContactForm from '../components/ContactForm.vue' import ContactForm from '../components/ContactForm.vue'
import EditRelationModal from '../components/EditRelationModal.vue'
import SearchableSelect from '../components/SearchableSelect.vue' import SearchableSelect from '../components/SearchableSelect.vue'
import InteractionIntensitySelect from '../components/InteractionIntensitySelect.vue'
import RelationTypeSelect from '../components/RelationTypeSelect.vue'
const route = useRoute() const route = useRoute()
const router = useRouter()
const store = useContactsStore() const store = useContactsStore()
const mapsStore = useNetworkMapsStore()
const contact = ref(null) const contact = ref(null)
const editing = ref(false) const editing = ref(false)
const showAddRelation = ref(false) const showAddRelation = ref(false)
const editRelationOpen = ref(false)
const editRelationTarget = ref(null)
const relationTypes = ref([]) const relationTypes = ref([])
const lifeSpheres = ref([])
const networkCircles = ref([])
const interactionIntensities = ref([]) const interactionIntensities = ref([])
const relError = ref('') const relError = ref('')
const newRel = ref({ const newRel = ref({
@@ -179,30 +199,56 @@ const contactSelectOptions = computed(() =>
otherContacts.value.map((c) => ({ value: c.id, label: c.name })) otherContacts.value.map((c) => ({ value: c.id, label: c.name }))
) )
const contactMapNames = computed(() => {
const memberships = mapsStore.contactMemberships || []
return memberships.map((m) => ({
id: m.mapId || m.map,
label: m.map_name || mapsStore.maps.find((map) => String(map.id) === String(m.mapId || m.map))?.name || 'Карта',
}))
})
function relLabel(type) { function relLabel(type) {
return relationTypes.value.find((r) => r.value === type)?.label || type return relationTypes.value.find((r) => r.value === type)?.label || type
} }
function sphereLabel(v) {
return lifeSpheres.value.find((x) => x.value === v)?.label || v || '—'
}
function circleLabel(v) {
return networkCircles.value.find((x) => x.value === v)?.label || v || '—'
}
function intensityLabel(v) { function intensityLabel(v) {
return interactionIntensities.value.find((x) => x.value === v)?.label || v || '' return interactionIntensities.value.find((x) => x.value === v)?.label || v || ''
} }
async function loadContact() { function otherContactName(rel) {
contact.value = await store.fetchContactById(contactId.value) const cid = String(contactId.value)
return String(rel.source) === cid ? rel.target_name : rel.source_name
} }
async function onUpdate(data) { function openEditRelation(rel) {
editRelationTarget.value = rel
editRelationOpen.value = true
}
function closeEditRelation() {
editRelationOpen.value = false
editRelationTarget.value = null
}
function onRelationUpdated() {
closeEditRelation()
}
function onRelationDeleted() {
closeEditRelation()
}
async function loadContact() {
contact.value = await store.fetchContactById(contactId.value)
await mapsStore.fetchContactMemberships(contactId.value)
}
async function onUpdate(data, mapIds) {
await store.updateContact(contactId.value, data) await store.updateContact(contactId.value, data)
await mapsStore.setContactMapMemberships(contactId.value, mapIds)
contact.value = { ...contact.value, ...data } contact.value = { ...contact.value, ...data }
editing.value = false editing.value = false
await mapsStore.fetchContactMemberships(contactId.value)
} }
async function addRelation() { async function addRelation() {
@@ -233,15 +279,10 @@ async function removeRelation(id) {
} }
onMounted(async () => { onMounted(async () => {
await mapsStore.fetchMaps()
await loadContact() await loadContact()
await Promise.all([store.fetchContacts(), store.fetchRelations()]) await Promise.all([store.fetchContacts(), store.fetchRelations()])
const [rt, mapChoices] = await Promise.all([ const rt = await store.fetchRelationTypes()
store.fetchRelationTypes(),
store.fetchNetworkMapChoices(),
])
relationTypes.value = rt relationTypes.value = rt
lifeSpheres.value = mapChoices.life_spheres
networkCircles.value = mapChoices.network_circles
interactionIntensities.value = mapChoices.interaction_intensities
}) })
</script> </script>
+35 -4
View File
@@ -41,6 +41,14 @@
Ctrl+клик (+клик на Mac) по двум контактам создать связь. Ctrl+клик (+клик на Mac) по двум контактам создать связь.
</p> </p>
<div v-if="linkSelectionCount === 1" class="alert alert-info link-hint">
Выбран: <strong>{{ linkSelection[0].name }}</strong>.
Удерживайте Ctrl ( на Mac) и кликните по второму контакту.
</div>
<p v-else class="text-muted link-hint link-hint--static">
Ctrl+клик (+клик на Mac) по двум контактам создать связь.
</p>
<div v-if="store.loading" class="spinner"></div> <div v-if="store.loading" class="spinner"></div>
<div v-else-if="store.contacts.length === 0" class="empty-state"> <div v-else-if="store.contacts.length === 0" class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
@@ -122,7 +130,13 @@
<h3>Редактировать контакт</h3> <h3>Редактировать контакт</h3>
<button class="btn btn-secondary btn-sm" @click="editTarget = null"></button> <button class="btn btn-secondary btn-sm" @click="editTarget = null"></button>
</div> </div>
<ContactForm :initial="editTarget" @submit="onUpdate" @cancel="editTarget = null" /> <ContactForm
:initial="editTarget"
deletable
@submit="onUpdate"
@cancel="editTarget = null"
@delete="onDeleteFromEdit"
/>
</div> </div>
</div> </div>
@@ -172,11 +186,13 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useContactsStore } from '../stores/contacts' import { useContactsStore } from '../stores/contacts'
import { useNetworkMapsStore } from '../stores/networkMaps'
import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection' import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
import ContactForm from '../components/ContactForm.vue' import ContactForm from '../components/ContactForm.vue'
import CreateRelationModal from '../components/CreateRelationModal.vue' import CreateRelationModal from '../components/CreateRelationModal.vue'
const store = useContactsStore() const store = useContactsStore()
const mapsStore = useNetworkMapsStore()
const router = useRouter() const router = useRouter()
const search = ref('') const search = ref('')
const showCreate = ref(false) const showCreate = ref(false)
@@ -265,8 +281,11 @@ function onRelationCreated() {
function goTo(id) { router.push(`/contacts/${id}`) } function goTo(id) { router.push(`/contacts/${id}`) }
async function onCreate(data) { async function onCreate(data, mapIds) {
await store.createContact(data) const created = await store.createContact(data)
if (mapIds?.length) {
await mapsStore.setContactMapMemberships(created.id, mapIds)
}
showCreate.value = false showCreate.value = false
} }
@@ -274,8 +293,9 @@ function openEdit(c) {
editTarget.value = { ...c } editTarget.value = { ...c }
} }
async function onUpdate(data) { async function onUpdate(data, mapIds) {
await store.updateContact(editTarget.value.id, data) await store.updateContact(editTarget.value.id, data)
await mapsStore.setContactMapMemberships(editTarget.value.id, mapIds)
editTarget.value = null editTarget.value = null
} }
@@ -337,4 +357,15 @@ tr.is-link-selected {
.link-hint--static { .link-hint--static {
margin: 0 0 12px; margin: 0 0 12px;
} }
tr.is-link-selected {
background: color-mix(in srgb, var(--green) 12%, transparent);
box-shadow: inset 3px 0 0 var(--green);
}
.link-hint {
font-size: 12px;
margin-bottom: 12px;
}
.link-hint--static {
margin: 0 0 12px;
}
</style> </style>
+335 -67
View File
@@ -77,6 +77,23 @@
@info="openNodeInfo" @info="openNodeInfo"
/> />
<GraphEdgeContextMenu
:open="edgeContextMenuOpen"
:edge="contextMenuEdge"
:x="edgeContextMenuX"
:y="edgeContextMenuY"
@close="closeEdgeContextMenu"
@edit="openEditRelation"
/>
<EditRelationModal
:open="editRelationOpen"
:relation="editRelationTarget"
@close="closeEditRelation"
@updated="onRelationUpdated"
@deleted="onRelationDeleted"
/>
<CreateRelationModal <CreateRelationModal
:open="relationModalOpen" :open="relationModalOpen"
:source="relationPair?.[0]" :source="relationPair?.[0]"
@@ -88,7 +105,9 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue' defineOptions({ name: 'Graph' })
import { ref, computed, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, watch } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { Network, DataSet } from 'vis-network/standalone' import { Network, DataSet } from 'vis-network/standalone'
import { useContactsStore } from '../stores/contacts' import { useContactsStore } from '../stores/contacts'
@@ -96,13 +115,16 @@ import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
import { RELATION_COLORS } from '../lib/graph/relationColors' import { RELATION_COLORS } from '../lib/graph/relationColors'
import { clusterColor } from '../lib/graph/clusterColors' import { clusterColor } from '../lib/graph/clusterColors'
import { computeClusterMap } from '../lib/graph/clusters' import { computeClusterMap } from '../lib/graph/clusters'
import { RELATION_TYPES } from '../domain/networkChoices' import { computeGraphSeedPositions } from '../lib/graph/graphLayout'
import { fetchGraphBundle } from '../composables/useGraphData' import { readGraphLayoutCache, writeGraphLayoutCache } from '../lib/graph/graphLayoutCache'
import { edgeFromRelation } from '../application/usecases/graph' import { applyIntensityToVisEdge } from '../lib/graph/relationIntensity'
import { buildGraphFromStore, edgeFromRelation } from '../application/usecases/graph'
import GraphHeaderPanel from '../components/GraphHeaderPanel.vue' import GraphHeaderPanel from '../components/GraphHeaderPanel.vue'
import RelationTypeFilters from '../components/RelationTypeFilters.vue' import RelationTypeFilters from '../components/RelationTypeFilters.vue'
import CreateRelationModal from '../components/CreateRelationModal.vue' import CreateRelationModal from '../components/CreateRelationModal.vue'
import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue' import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
import EditRelationModal from '../components/EditRelationModal.vue'
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js' import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
let themeObserver = null let themeObserver = null
@@ -113,7 +135,12 @@ const {
contextMenuNode, contextMenuNode,
contextMenuX, contextMenuX,
contextMenuY, contextMenuY,
edgeContextMenuOpen,
contextMenuEdge,
edgeContextMenuX,
edgeContextMenuY,
closeContextMenu, closeContextMenu,
closeEdgeContextMenu,
attachNodeContextHandlers, attachNodeContextHandlers,
} = useGraphNodeContextMenu() } = useGraphNodeContextMenu()
@@ -129,6 +156,8 @@ const physicsEnabled = ref(true)
const selectedNode = ref(null) const selectedNode = ref(null)
const relationModalOpen = ref(false) const relationModalOpen = ref(false)
const relationPair = ref(null) const relationPair = ref(null)
const editRelationOpen = ref(false)
const editRelationTarget = ref(null)
const { const {
linkSelection, linkSelection,
@@ -148,6 +177,8 @@ let initRetryCount = 0
const INIT_RETRY_MAX = 40 const INIT_RETRY_MAX = 40
let initRetryTimer = null let initRetryTimer = null
let resizeObserver = null let resizeObserver = null
let syncedRevision = -1
let initialLayoutDone = false
const nodes = ref([]) const nodes = ref([])
const edges = ref([]) const edges = ref([])
@@ -155,8 +186,6 @@ const allRelationTypes = ref([])
const activeFilters = ref([]) const activeFilters = ref([])
const clusterMap = ref(new Map()) const clusterMap = ref(new Map())
const relationTypeLabels = Object.fromEntries(RELATION_TYPES.map((r) => [r.value, r.label]))
const selectedContact = computed(() => const selectedContact = computed(() =>
selectedNode.value ? store.contactById(selectedNode.value.id) : null selectedNode.value ? store.contactById(selectedNode.value.id) : null
) )
@@ -217,14 +246,15 @@ function nodeVisColor(nodeId, linkIds) {
function mapGraphNodeToVis(n, linkIds = new Set()) { function mapGraphNodeToVis(n, linkIds = new Set()) {
const palette = graphPalette() const palette = graphPalette()
const degree = nodeDegree(n.id) const degree = nodeDegree(n.id)
const label = degree <= 12 ? (n.label || String(n.id)) : ''
return { return {
id: String(n.id), id: String(n.id),
label: n.label || String(n.id), label,
title: n.title, title: [n.label, n.title].filter(Boolean).join('\n'),
color: nodeVisColor(n.id, linkIds), color: nodeVisColor(n.id, linkIds),
font: { color: palette.nodeFont, size: degree > 0 ? 13 : 11 }, font: { color: palette.nodeFont, size: degree > 0 ? 12 : 11 },
shape: 'dot', shape: 'dot',
size: degree > 0 ? 12 + Math.min(degree, 6) * 2 : 9, size: degree > 0 ? 10 + Math.min(degree, 4) * 1.5 : 8,
borderWidth: linkIds.has(String(n.id)) ? 3 : 2, borderWidth: linkIds.has(String(n.id)) ? 3 : 2,
} }
} }
@@ -243,23 +273,93 @@ function applyLinkHighlights() {
function mapGraphEdgeToVis(e) { function mapGraphEdgeToVis(e) {
const palette = graphPalette() const palette = graphPalette()
const rc = RELATION_COLORS[e.relation_type] || RELATION_COLORS.other const rc = RELATION_COLORS[e.relation_type] || RELATION_COLORS.other
const typeLabel = relationTypeLabels[e.relation_type] || e.relation_type const intensity = applyIntensityToVisEdge(e, { color: rc.color, highlight: rc.highlight })
const tip = [typeLabel, e.title !== e.relation_type ? e.title : ''].filter(Boolean).join(' — ')
return { return {
id: String(e.id), id: String(e.id),
from: String(e.from), from: String(e.from),
to: String(e.to), to: String(e.to),
title: tip || typeLabel,
relation_type: e.relation_type, relation_type: e.relation_type,
color: { color: rc.color, highlight: rc.highlight, opacity: 0.8 }, interaction_intensity: e.interaction_intensity,
width: e.interaction_intensity === 'sparse' ? 1 : 2, ...intensity,
dashes: e.interaction_intensity === 'sparse' ? [6, 4] : false, hoverWidth: intensity.width + 8,
selectionWidth: intensity.width + 12,
font: { color: palette.edgeFont, size: 10, align: 'middle' }, font: { color: palette.edgeFont, size: 10, align: 'middle' },
arrows: { to: { enabled: false } }, arrows: { to: { enabled: false } },
smooth: { type: 'dynamic' }, smooth: false,
} }
} }
function resolveRelationForEdit(edge) {
const fromStore = store.relations.find((r) => String(r.id) === String(edge.id))
if (fromStore) return fromStore
const fromNode = nodes.value.find((n) => String(n.id) === String(edge.from))
const toNode = nodes.value.find((n) => String(n.id) === String(edge.to))
return {
id: edge.id,
source: edge.from,
target: edge.to,
relation_type: edge.relation_type,
description: edge.title && edge.title !== edge.relation_type ? edge.title : '',
interaction_intensity: edge.interaction_intensity || 'intense',
source_name: fromNode?.label || String(edge.from),
target_name: toNode?.label || String(edge.to),
}
}
function openEditRelation(edge) {
editRelationTarget.value = resolveRelationForEdit(edge)
editRelationOpen.value = true
}
function closeEditRelation() {
editRelationOpen.value = false
editRelationTarget.value = null
}
function updateGraphEdge(relation) {
const edge = edgeFromRelation(relation)
const idx = edges.value.findIndex((e) => String(e.id) === String(edge.id))
if (idx !== -1) edges.value[idx] = edge
else edges.value.push(edge)
if (!edgesDS) return
const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
if (!nodeIds.has(String(edge.from)) || !nodeIds.has(String(edge.to))) return
const visible =
activeFilters.value.length === allRelationTypes.value.length ||
activeFilters.value.includes(edge.relation_type)
if (!visible) {
if (edgesDS.get(String(edge.id))) edgesDS.remove(String(edge.id))
return
}
const visEdge = mapGraphEdgeToVis(edge)
if (edgesDS.get(String(edge.id))) edgesDS.update(visEdge)
else edgesDS.add(visEdge)
}
function onRelationUpdated(relation) {
closeEditRelation()
updateGraphEdge(relation)
syncedRevision = store.dataRevision
if (network.value && physicsEnabled.value) {
network.value.stabilize(80)
}
}
function removeGraphEdge(relationId) {
const sid = String(relationId)
edges.value = edges.value.filter((e) => String(e.id) !== sid)
if (edgesDS?.get(sid)) edgesDS.remove(sid)
recomputeClusters()
refreshNodeStyles()
}
function onRelationDeleted(relationId) {
closeEditRelation()
removeGraphEdge(relationId)
syncedRevision = store.dataRevision
}
function appendRelationEdge(relation) { function appendRelationEdge(relation) {
if (!relation) return if (!relation) return
const edge = edgeFromRelation(relation) const edge = edgeFromRelation(relation)
@@ -296,29 +396,157 @@ function onRelationCreated(relation) {
clearLinkSelection() clearLinkSelection()
applyLinkHighlights() applyLinkHighlights()
appendRelationEdge(relation) appendRelationEdge(relation)
syncedRevision = store.dataRevision
} }
watch(linkSelection, () => { watch(linkSelection, () => {
applyLinkHighlights() applyLinkHighlights()
}, { deep: true }) }, { deep: true })
async function loadGraph() { function physicsOptions(enabled) {
loading.value = true return {
try { enabled,
const bundle = await fetchGraphBundle('/graph/') solver: 'forceAtlas2Based',
nodes.value = bundle.nodes forceAtlas2Based: {
edges.value = bundle.edges gravitationalConstant: -120,
allRelationTypes.value = bundle.relationTypes centralGravity: 0.002,
activeFilters.value = bundle.relationTypes.map((r) => r.value) springLength: 200,
} finally { springConstant: 0.035,
loading.value = false damping: 0.5,
avoidOverlap: 1,
},
stabilization: enabled
? { iterations: 200, fit: !initialLayoutDone, updateInterval: 25 }
: undefined,
maxVelocity: 20,
} }
// Контейнер #graph-container в DOM только когда loading=false и nodes.length > 0 }
if (nodes.value.length > 0) {
recomputeClusters() async function applyGraphDataFromStore() {
await nextTick() if (!store.contacts.length) await store.fetchContacts()
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))) if (!store.relations.length) await store.fetchRelations()
if (!store.relationTypes.length) {
allRelationTypes.value = await store.fetchRelationTypes()
} else {
allRelationTypes.value = store.relationTypes
}
const bundle = buildGraphFromStore(store.contacts, store.relations, allRelationTypes.value)
nodes.value = bundle.nodes
edges.value = bundle.edges
if (!activeFilters.value.length && allRelationTypes.value.length) {
activeFilters.value = allRelationTypes.value.map((r) => r.value)
}
recomputeClusters()
}
function saveLayoutSnapshot() {
if (!network.value) return
writeGraphLayoutCache({
positions: network.value.getPositions(),
scale: network.value.getScale(),
view: network.value.getViewPosition(),
})
}
function restoreViewport() {
if (!network.value) return
const cache = readGraphLayoutCache()
if (cache.view) {
network.value.moveTo({
position: cache.view,
scale: cache.scale || 1,
animation: false,
})
}
}
function finishInitialLayout({ fitView = true } = {}) {
saveLayoutSnapshot()
initialLayoutDone = true
if (fitView) {
network.value?.fit({ animation: false })
}
network.value?.redraw()
}
function teardownNetwork() {
if (initRetryTimer) {
clearTimeout(initRetryTimer)
initRetryTimer = null
}
detachContextHandler?.()
detachContextHandler = null
resizeObserver?.disconnect()
resizeObserver = null
network.value?.destroy()
network.value = null
nodesDS = null
edgesDS = null
}
function syncGraphToNetwork() {
if (!network.value || !nodesDS || !edgesDS) return
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
const livePositions = network.value.getPositions()
const cachedPositions = readGraphLayoutCache().positions || {}
const seeds = computeGraphSeedPositions(nodes.value, filteredEdges())
const nextNodeIds = new Set(nodes.value.map((n) => String(n.id)))
nodesDS.getIds().forEach((id) => {
if (!nextNodeIds.has(String(id))) nodesDS.remove(id)
})
nodes.value.forEach((n) => {
const id = String(n.id)
const vis = mapGraphNodeToVis(n, linkIds)
const pos = livePositions[id] || cachedPositions[id] || seeds.get(id)
const payload = pos ? { ...vis, x: pos.x, y: pos.y } : vis
if (nodesDS.get(id)) nodesDS.update(payload)
else nodesDS.add(payload)
})
const nextEdges = filteredEdges().map(mapGraphEdgeToVis)
const nextEdgeIds = new Set(nextEdges.map((e) => String(e.id)))
edgesDS.getIds().forEach((id) => {
if (!nextEdgeIds.has(String(id))) edgesDS.remove(id)
})
nextEdges.forEach((edge) => {
if (edgesDS.get(edge.id)) edgesDS.update(edge)
else edgesDS.add(edge)
})
recomputeClusters()
refreshNodeStyles()
syncedRevision = store.dataRevision
}
async function ensureGraphReady({ showSpinner = true } = {}) {
const reconnecting = Boolean(network.value)
if (showSpinner && !reconnecting) loading.value = true
try {
await applyGraphDataFromStore()
} finally {
if (showSpinner && !reconnecting) loading.value = false
}
if (nodes.value.length === 0) {
teardownNetwork()
syncedRevision = store.dataRevision
return
}
await nextTick()
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
if (!network.value) {
initNetwork() initNetwork()
syncedRevision = store.dataRevision
return
}
if (syncedRevision !== store.dataRevision) {
syncGraphToNetwork()
} }
} }
@@ -332,7 +560,7 @@ function filteredEdges() {
} }
function initNetwork() { function initNetwork() {
if (!graphContainer.value) return if (network.value || !graphContainer.value) return
const el = graphContainer.value const el = graphContainer.value
let width = el.offsetWidth let width = el.offsetWidth
let height = el.offsetHeight let height = el.offsetHeight
@@ -355,35 +583,46 @@ function initNetwork() {
} }
const linkIds = new Set(linkSelection.value.map((item) => String(item.id))) const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
const nodeList = nodes.value.map((n) => mapGraphNodeToVis(n, linkIds)) const cache = readGraphLayoutCache()
const cachedPositions = cache.positions || {}
const nodeIds = nodes.value.map((n) => String(n.id))
const cachedCount = nodeIds.filter((id) => cachedPositions[id]).length
const useCachedLayout = cachedCount > 0 && cachedCount >= nodeIds.length * 0.8
const seedPositions = computeGraphSeedPositions(nodes.value, filteredEdges())
const nodeList = nodes.value.map((n) => {
const vis = mapGraphNodeToVis(n, linkIds)
const id = String(n.id)
const pos = cachedPositions[id] || seedPositions.get(id)
return pos ? { ...vis, x: pos.x, y: pos.y } : vis
})
const edgeList = filteredEdges().map(mapGraphEdgeToVis) const edgeList = filteredEdges().map(mapGraphEdgeToVis)
nodesDS = new DataSet(nodeList) nodesDS = new DataSet(nodeList)
edgesDS = new DataSet(edgeList) edgesDS = new DataSet(edgeList)
if (useCachedLayout) {
initialLayoutDone = true
}
network.value = new Network( network.value = new Network(
el, el,
{ nodes: nodesDS, edges: edgesDS }, { nodes: nodesDS, edges: edgesDS },
{ {
physics: { layout: { improvedLayout: false },
enabled: true, physics: physicsOptions(physicsEnabled.value),
stabilization: { iterations: 250, fit: true },
barnesHut: {
gravitationalConstant: -12000,
centralGravity: 0.15,
springLength: 220,
springConstant: 0.035,
damping: 0.12,
avoidOverlap: 0.25,
},
},
interaction: { interaction: {
tooltipDelay: 200, tooltipDelay: 200,
hover: true, hover: true,
hideEdgesOnDrag: true, hideEdgesOnDrag: true,
selectConnectedEdges: false,
zoomView: true, zoomView: true,
dragView: true, dragView: true,
dragNodes: true, dragNodes: true,
}, },
edges: {
chosen: { label: false },
},
nodes: { borderWidth: 1.5 }, nodes: { borderWidth: 1.5 },
} }
) )
@@ -400,22 +639,25 @@ function initNetwork() {
} }
}) })
detachContextHandler?.() network.value.on('dragEnd', () => {
detachContextHandler = attachNodeContextHandlers(network.value, () => nodes.value) saveLayoutSnapshot()
network.value.once('stabilizationIterationsDone', () => {
network.value?.fit({ animation: { duration: 400 } })
setTimeout(() => {
network.value?.fit({ animation: false })
network.value?.redraw()
}, 100)
}) })
setTimeout(() => {
if (network.value) { detachContextHandler?.()
network.value.fit({ animation: false }) detachContextHandler = attachNodeContextHandlers(
network.value.redraw() network.value,
} () => nodes.value,
}, 800) () => edges.value
)
if (useCachedLayout) {
restoreViewport()
network.value.redraw()
} else {
network.value.once('stabilizationIterationsDone', () => {
finishInitialLayout({ fitView: true })
})
}
resizeObserver = new ResizeObserver(() => { resizeObserver = new ResizeObserver(() => {
network.value?.redraw() network.value?.redraw()
@@ -457,21 +699,47 @@ function resetView() {
function togglePhysics() { function togglePhysics() {
physicsEnabled.value = !physicsEnabled.value physicsEnabled.value = !physicsEnabled.value
network.value?.setOptions({ physics: { enabled: physicsEnabled.value } }) network.value?.setOptions({ physics: physicsOptions(physicsEnabled.value) })
} }
watch(() => store.dataRevision, async (revision) => {
if (!network.value || revision === syncedRevision) return
await applyGraphDataFromStore()
if (nodes.value.length === 0) {
teardownNetwork()
syncedRevision = revision
return
}
syncGraphToNetwork()
})
onMounted(() => { onMounted(() => {
loadGraph()
themeObserver = new MutationObserver(() => applyThemeToNetwork()) themeObserver = new MutationObserver(() => applyThemeToNetwork())
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
}) })
onActivated(async () => {
if (network.value) {
network.value.redraw()
if (syncedRevision !== store.dataRevision) {
await ensureGraphReady({ showSpinner: false })
} else {
restoreViewport()
}
return
}
await ensureGraphReady()
})
onDeactivated(() => {
saveLayoutSnapshot()
})
onUnmounted(() => { onUnmounted(() => {
if (initRetryTimer) clearTimeout(initRetryTimer) saveLayoutSnapshot()
detachContextHandler?.()
closeContextMenu() closeContextMenu()
resizeObserver?.disconnect()
themeObserver?.disconnect() themeObserver?.disconnect()
network.value?.destroy() teardownNetwork()
}) })
</script> </script>
+9 -1
View File
@@ -50,7 +50,7 @@ END:VCARD</pre>
<span v-if="result.error">{{ result.error }}</span> <span v-if="result.error">{{ result.error }}</span>
<span v-else> <span v-else>
В файле: <strong>{{ result.total ?? result.created + result.skipped }}</strong>, В файле: <strong>{{ result.total ?? result.created + result.skipped }}</strong>,
импортировано: <strong>{{ result.created }}</strong>, импортировано: <strong>{{ result.created }}</strong> контактов<template v-if="result.importedRelations">, <strong>{{ result.importedRelations }}</strong> связей</template>,
пропущено: {{ result.skipped }}. пропущено: {{ result.skipped }}.
<span v-if="result.errors?.length"> Ошибок: {{ result.errors.length }}.</span> <span v-if="result.errors?.length"> Ошибок: {{ result.errors.length }}.</span>
</span> </span>
@@ -141,8 +141,10 @@ END:VCARD</pre>
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useContactsStore } from '../stores/contacts' import { useContactsStore } from '../stores/contacts'
import { useNetworkMapsStore } from '../stores/networkMaps'
const store = useContactsStore() const store = useContactsStore()
const mapsStore = useNetworkMapsStore()
const fileInput = ref(null) const fileInput = ref(null)
const selectedFile = ref(null) const selectedFile = ref(null)
const isDragging = ref(false) const isDragging = ref(false)
@@ -177,6 +179,9 @@ async function doImport() {
result.value = null result.value = null
try { try {
result.value = await store.importContacts(selectedFile.value) result.value = await store.importContacts(selectedFile.value)
if (result.value.isDump) {
await mapsStore.fetchMaps()
}
} catch (e) { } catch (e) {
result.value = { error: e.message } result.value = { error: e.message }
} finally { } finally {
@@ -234,11 +239,14 @@ async function onBackupFileSelect(e) {
result.value = null result.value = null
try { try {
const summary = await store.importDataDump(file, backupPassphrase.value) const summary = await store.importDataDump(file, backupPassphrase.value)
await mapsStore.fetchMaps()
result.value = { result.value = {
total: summary.importedContacts + summary.importedRelations, total: summary.importedContacts + summary.importedRelations,
created: summary.importedContacts, created: summary.importedContacts,
importedRelations: summary.importedRelations,
skipped: 0, skipped: 0,
errors: [], errors: [],
isDump: true,
} }
} catch (error) { } catch (error) {
result.value = { error: error?.message || 'Ошибка импорта бэкапа' } result.value = { error: error?.message || 'Ошибка импорта бэкапа' }
+33
View File
@@ -0,0 +1,33 @@
<template>
<div class="network-map-redirect">
<div class="spinner"></div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useNetworkMapsStore } from '../stores/networkMaps'
const router = useRouter()
const mapsStore = useNetworkMapsStore()
onMounted(async () => {
await mapsStore.fetchMaps()
const id = mapsStore.activeMapId || mapsStore.maps[0]?.id
if (id) {
router.replace({ name: 'NetworkMap', params: { mapId: id } })
} else {
router.replace({ name: 'Contacts' })
}
})
</script>
<style scoped>
.network-map-redirect {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
}
</style>
+242 -33
View File
@@ -2,9 +2,23 @@
<div class="network-map-view"> <div class="network-map-view">
<NetworkMapTopPanel <NetworkMapTopPanel
:collapsed="topPanelCollapsed" :collapsed="topPanelCollapsed"
:title="activeMap?.name || 'Карта сети'"
:subtitle="activeMap?.description || ''"
@toggle-collapse="topPanelCollapsed = !topPanelCollapsed" @toggle-collapse="topPanelCollapsed = !topPanelCollapsed"
@fit="fitView" @fit="fitView"
> >
<template #toolbar>
<NetworkMapSwitcher
:maps="mapsStore.maps"
:model-value="String(mapId)"
@update:model-value="switchMap"
@create="openCreateMap"
@manage="openEditMap"
/>
<button type="button" class="btn btn-secondary btn-sm" @click="showAddContact = true">
+ Участник
</button>
</template>
<template #filters> <template #filters>
<RelationTypeFilters <RelationTypeFilters
:relation-types="allRelationTypes" :relation-types="allRelationTypes"
@@ -25,9 +39,10 @@
<div v-if="loading" class="spinner"></div> <div v-if="loading" class="spinner"></div>
<div v-else-if="nodes.length === 0" class="empty-state card"> <div v-else-if="nodes.length === 0" class="empty-state card">
<p> <p>
На карте сети никого нет. Отметьте «Показывать на карте сети» в На карте «{{ activeMap?.name || 'сети' }}» никого нет.
<RouterLink to="/contacts">карточках контактов</RouterLink> <button type="button" class="btn btn-link" @click="showAddContact = true">Добавьте участников</button>
или добавьте новых. из общего списка контактов или создайте новых в
<RouterLink to="/contacts">карточках контактов</RouterLink>.
</p> </p>
</div> </div>
<div v-else class="map-stack" ref="mapStack"> <div v-else class="map-stack" ref="mapStack">
@@ -41,13 +56,13 @@
<h3>{{ selectedNode.label }}</h3> <h3>{{ selectedNode.label }}</h3>
<button class="btn btn-secondary btn-sm" @click="selectedNode = null"></button> <button class="btn btn-secondary btn-sm" @click="selectedNode = null"></button>
</div> </div>
<div v-if="selectedContact"> <div v-if="selectedNode">
<div class="form-group"> <div class="form-group">
<label>Сфера / круг / важность</label> <label>Сфера / круг / важность</label>
<div> <div>
{{ sphereLabels[selectedContact.life_sphere] || selectedContact.life_sphere }} {{ sphereLabels[selectedNode.life_sphere] || selectedNode.life_sphere }}
· {{ circleLabels[selectedContact.network_circle] || selectedContact.network_circle }} · {{ circleLabels[selectedNode.network_circle] || selectedNode.network_circle }}
· {{ selectedContact.importance }}/5 · {{ selectedNode.importance }}/5
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -70,6 +85,23 @@
@info="openNodeInfo" @info="openNodeInfo"
/> />
<GraphEdgeContextMenu
:open="edgeContextMenuOpen"
:edge="contextMenuEdge"
:x="edgeContextMenuX"
:y="edgeContextMenuY"
@close="closeEdgeContextMenu"
@edit="openEditRelation"
/>
<EditRelationModal
:open="editRelationOpen"
:relation="editRelationTarget"
@close="closeEditRelation"
@updated="onRelationUpdated"
@deleted="onRelationDeleted"
/>
<CreateRelationModal <CreateRelationModal
:open="relationModalOpen" :open="relationModalOpen"
:source="relationPair?.[0]" :source="relationPair?.[0]"
@@ -77,16 +109,34 @@
@close="closeRelationModal" @close="closeRelationModal"
@created="onRelationCreated" @created="onRelationCreated"
/> />
<NetworkMapFormModal
:open="mapFormOpen"
:initial="mapFormTarget"
:deletable="Boolean(mapFormTarget?.id)"
@close="closeMapForm"
@submit="onMapFormSubmit"
@delete="onMapDelete"
/>
<AddContactToMapModal
:open="showAddContact"
:contacts="store.contacts"
:member-contact-ids="memberContactIds"
@close="showAddContact = false"
@add="onAddContactToMap"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, onActivated, nextTick, watch } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink, useRoute, useRouter } from 'vue-router'
import { Network, DataSet } from 'vis-network/standalone' import { Network, DataSet } from 'vis-network/standalone'
import { useContactsStore } from '../stores/contacts' import { useContactsStore } from '../stores/contacts'
import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection' 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 { import {
SPHERE_ORDER, SPHERE_ORDER,
ringRadius, ringRadius,
@@ -99,9 +149,17 @@ import { fetchGraphBundle, fetchMapChoices } 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'
import NetworkMapSwitcher from '../components/NetworkMapSwitcher.vue'
import NetworkMapFormModal from '../components/NetworkMapFormModal.vue'
import AddContactToMapModal from '../components/AddContactToMapModal.vue'
import CreateRelationModal from '../components/CreateRelationModal.vue' import CreateRelationModal from '../components/CreateRelationModal.vue'
import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue' import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
import EditRelationModal from '../components/EditRelationModal.vue'
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js' import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
import { useNetworkMapsStore } from '../stores/networkMaps'
defineOptions({ name: 'NetworkMap' })
let themeObserver = null let themeObserver = null
let detachContextHandler = null let detachContextHandler = null
@@ -110,7 +168,12 @@ const {
contextMenuNode, contextMenuNode,
contextMenuX, contextMenuX,
contextMenuY, contextMenuY,
edgeContextMenuOpen,
contextMenuEdge,
edgeContextMenuX,
edgeContextMenuY,
closeContextMenu, closeContextMenu,
closeEdgeContextMenu,
attachNodeContextHandlers, attachNodeContextHandlers,
} = useGraphNodeContextMenu() } = useGraphNodeContextMenu()
@@ -126,7 +189,7 @@ function nodeById(id) {
async function persistNodePlacement(nodeId, canvasX, canvasY) { async function persistNodePlacement(nodeId, canvasX, canvasY) {
const node = nodeById(nodeId) const node = nodeById(nodeId)
if (!node) return if (!node || !node.membership_id) return
const L = layout.value const L = layout.value
const dx = canvasX - L.cx const dx = canvasX - L.cx
const dy = canvasY - L.cy const dy = canvasY - L.cy
@@ -153,23 +216,14 @@ async function persistNodePlacement(nodeId, canvasX, canvasY) {
refreshPositions() refreshPositions()
try { try {
await store.updateContact(node.id, { await mapsStore.updateMembership(mapId.value, node.membership_id, {
life_sphere: nextSphere, life_sphere: nextSphere,
network_circle: nextCircle, network_circle: nextCircle,
map_angle: angle, map_angle: angle,
map_radius_ratio: ratio, map_radius_ratio: ratio,
}) })
} catch (e) { } catch (e) {
// Если PATCH не прошел — откатываем карту к данным из API. await load()
await store.fetchContacts()
const actual = store.contactById(node.id)
if (actual) {
node.life_sphere = actual.life_sphere
node.network_circle = actual.network_circle
node.map_angle = actual.map_angle
node.map_radius_ratio = actual.map_radius_ratio
refreshPositions()
}
} }
} }
@@ -221,7 +275,18 @@ function buildLabelMap(posById, scale = 1) {
return map return map
} }
const route = useRoute()
const router = useRouter()
const store = useContactsStore() const store = useContactsStore()
const mapsStore = useNetworkMapsStore()
const mapId = computed(() => String(route.params.mapId || ''))
const activeMap = computed(() => mapsStore.maps.find((m) => String(m.id) === mapId.value) || null)
const memberContactIds = computed(() => nodes.value.map((n) => String(n.id)))
const mapFormOpen = ref(false)
const mapFormTarget = ref({})
const showAddContact = ref(false)
const mapStack = ref(null) const mapStack = ref(null)
const graphContainer = ref(null) const graphContainer = ref(null)
const loading = ref(true) const loading = ref(true)
@@ -229,6 +294,8 @@ const network = ref(null)
const selectedNode = ref(null) const selectedNode = ref(null)
const relationModalOpen = ref(false) const relationModalOpen = ref(false)
const relationPair = ref(null) const relationPair = ref(null)
const editRelationOpen = ref(false)
const editRelationTarget = ref(null)
const { const {
linkSelection, linkSelection,
@@ -321,6 +388,70 @@ function appendRelationEdge(relation) {
} }
} }
function resolveRelationForEdit(edge) {
const fromStore = store.relations.find((r) => String(r.id) === String(edge.id))
if (fromStore) return fromStore
const fromNode = nodes.value.find((n) => String(n.id) === String(edge.from))
const toNode = nodes.value.find((n) => String(n.id) === String(edge.to))
return {
id: edge.id,
source: edge.from,
target: edge.to,
relation_type: edge.relation_type,
description: edge.title && edge.title !== edge.label ? edge.title : '',
interaction_intensity: edge.interaction_intensity || 'intense',
source_name: fromNode?.label || String(edge.from),
target_name: toNode?.label || String(edge.to),
}
}
function openEditRelation(edge) {
editRelationTarget.value = resolveRelationForEdit(edge)
editRelationOpen.value = true
}
function closeEditRelation() {
editRelationOpen.value = false
editRelationTarget.value = null
}
function updateGraphEdge(relation) {
const edge = edgeFromRelation(relation)
const idx = edges.value.findIndex((e) => String(e.id) === String(edge.id))
if (idx !== -1) edges.value[idx] = edge
else edges.value.push(edge)
if (!edgesDS) return
const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
if (!nodeIds.has(String(edge.from)) || !nodeIds.has(String(edge.to))) return
const visible =
activeFilters.value.length === allRelationTypes.value.length ||
activeFilters.value.includes(edge.relation_type)
if (!visible) {
if (edgesDS.get(String(edge.id))) edgesDS.remove(String(edge.id))
return
}
const visEdge = mapEdgeToVis(edge)
if (edgesDS.get(String(edge.id))) edgesDS.update(visEdge)
else edgesDS.add(visEdge)
}
function onRelationUpdated(relation) {
closeEditRelation()
updateGraphEdge(relation)
}
function removeGraphEdge(relationId) {
const sid = String(relationId)
edges.value = edges.value.filter((e) => String(e.id) !== sid)
if (edgesDS?.get(sid)) edgesDS.remove(sid)
}
function onRelationDeleted(relationId) {
closeEditRelation()
removeGraphEdge(relationId)
}
function closeRelationModal() { function closeRelationModal() {
relationModalOpen.value = false relationModalOpen.value = false
relationPair.value = null relationPair.value = null
@@ -443,17 +574,16 @@ function filteredEdges() {
} }
function mapEdgeToVis(e) { function mapEdgeToVis(e) {
const intense = e.interaction_intensity !== 'sparse' const rc = RELATION_COLORS[e.relation_type] || RELATION_COLORS.other
const tip = [e.label, e.title].filter(Boolean).join(' — ') || 'Связь' const intensity = applyIntensityToVisEdge(e, { color: rc.color, highlight: rc.highlight })
return { return {
id: String(e.id), id: String(e.id),
from: String(e.from), from: String(e.from),
to: String(e.to), to: String(e.to),
title: tip,
relation_type: e.relation_type, relation_type: e.relation_type,
color: RELATION_COLORS[e.relation_type] || RELATION_COLORS.other, ...intensity,
width: intense ? 2.2 : 1, hoverWidth: intensity.width + 8,
dashes: intense ? false : [8, 6], selectionWidth: intensity.width + 12,
arrows: { to: { enabled: true, scaleFactor: 0.65 } }, arrows: { to: { enabled: true, scaleFactor: 0.65 } },
smooth: false, smooth: false,
} }
@@ -526,13 +656,14 @@ function initNetwork() {
interaction: { interaction: {
tooltipDelay: 150, tooltipDelay: 150,
hover: true, hover: true,
selectConnectedEdges: false,
zoomView: true, zoomView: true,
dragView: true, dragView: true,
dragNodes: true, dragNodes: true,
selectable: true, selectable: true,
}, },
nodes: { borderWidth: 1.5 }, nodes: { borderWidth: 1.5 },
edges: { font: { size: 0 } }, edges: { font: { size: 0 }, chosen: { label: false } },
} }
) )
@@ -551,7 +682,11 @@ function initNetwork() {
}) })
detachContextHandler?.() detachContextHandler?.()
detachContextHandler = attachNodeContextHandlers(network.value, () => nodes.value) detachContextHandler = attachNodeContextHandlers(
network.value,
() => nodes.value,
() => edges.value
)
network.value.on('dragEnd', async (params) => { network.value.on('dragEnd', async (params) => {
if (!params.nodes?.length) return if (!params.nodes?.length) return
@@ -642,10 +777,12 @@ function fitView() {
} }
async function load() { async function load() {
if (!mapId.value) return
loading.value = true loading.value = true
try { try {
mapsStore.setActiveMapId(mapId.value)
const [bundle, mapChoices] = await Promise.all([ const [bundle, mapChoices] = await Promise.all([
fetchGraphBundle('/network-map-graph/'), fetchGraphBundle({ mapId: mapId.value }),
fetchMapChoices(), fetchMapChoices(),
]) ])
nodes.value = bundle.nodes nodes.value = bundle.nodes
@@ -666,22 +803,94 @@ async function load() {
await nextTick() await nextTick()
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))) await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
initNetwork() initNetwork()
} else {
network.value?.destroy()
network.value = null
nodesDS = null
edgesDS = null
} }
} }
function switchMap(nextId) {
if (!nextId || String(nextId) === mapId.value) return
router.push({ name: 'NetworkMap', params: { mapId: nextId } })
}
function openCreateMap() {
mapFormTarget.value = {}
mapFormOpen.value = true
}
function openEditMap(id) {
const map = mapsStore.maps.find((m) => String(m.id) === String(id))
mapFormTarget.value = map ? { ...map } : { id }
mapFormOpen.value = true
}
function closeMapForm() {
mapFormOpen.value = false
mapFormTarget.value = {}
}
async function onMapFormSubmit(payload) {
if (mapFormTarget.value?.id) {
await mapsStore.updateMap(mapFormTarget.value.id, payload)
closeMapForm()
} else {
const created = await mapsStore.createMap(payload)
closeMapForm()
router.push({ name: 'NetworkMap', params: { mapId: created.id } })
}
}
async function onMapDelete() {
if (!mapFormTarget.value?.id) return
const deletingId = mapFormTarget.value.id
await mapsStore.deleteMap(deletingId)
closeMapForm()
if (String(mapId.value) === String(deletingId)) {
const nextId = mapsStore.maps[0]?.id
if (nextId) router.replace({ name: 'NetworkMap', params: { mapId: nextId } })
else router.replace({ name: 'Contacts' })
}
}
async function onAddContactToMap(contactId) {
await mapsStore.addContactToMap(mapId.value, contactId)
showAddContact.value = false
await load()
}
watch(mapId, async (next, prev) => {
if (!next || next === prev) return
network.value?.destroy()
network.value = null
nodesDS = null
edgesDS = null
await load()
})
onMounted(async () => { onMounted(async () => {
themeObserver = new MutationObserver(() => applyThemeToNetwork())
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
})
onActivated(async () => {
if (network.value) {
network.value.redraw()
return
}
await store.fetchRelations()
await load() await load()
await nextTick() await nextTick()
const stack = mapStack.value const stack = mapStack.value
if (stack) { if (stack && !resizeObserver) {
resizeObserver = new ResizeObserver(() => { resizeObserver = new ResizeObserver(() => {
refreshPositions() refreshPositions()
network.value?.redraw() network.value?.redraw()
}) })
resizeObserver.observe(stack) resizeObserver.observe(stack)
} }
themeObserver = new MutationObserver(() => applyThemeToNetwork())
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
}) })
onUnmounted(() => { onUnmounted(() => {