Add network map UX and theme support.

Introduce network map view/components with filtering and positioning, expand contacts/map backend fields and migrations, and add a persistent light/dark theme toggle with graph label color updates for readability.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-28 13:24:48 +03:00
co-authored by Cursor
parent 81ba6cd076
commit b668ee8507
49 changed files with 4679 additions and 129 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,68 @@
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contacts', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='contact',
name='importance',
field=models.PositiveSmallIntegerField(
default=3,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(5),
],
verbose_name='Важность (15)',
),
),
migrations.AddField(
model_name='contact',
name='life_sphere',
field=models.CharField(
choices=[
('work', 'Работа'),
('study', 'Учёба'),
('hobby', 'Хобби'),
('family', 'Семья'),
('health', 'Здоровье'),
('other', 'Другое'),
],
default='other',
max_length=32,
verbose_name='Сфера жизни',
),
),
migrations.AddField(
model_name='contact',
name='network_circle',
field=models.CharField(
choices=[
('support', 'Круг поддержки'),
('productivity', 'Круг продуктивности'),
('development', 'Круг развития'),
],
default='productivity',
max_length=32,
verbose_name='Круг сети',
),
),
migrations.AddField(
model_name='relation',
name='interaction_intensity',
field=models.CharField(
choices=[
('intense', 'Интенсивные контакты'),
('sparse', 'Редкие контакты'),
],
default='intense',
max_length=32,
verbose_name='Интенсивность общения',
),
),
]
@@ -0,0 +1,29 @@
from django.db import migrations, models
def set_map_true_for_existing(apps, schema_editor):
Contact = apps.get_model('contacts', 'Contact')
Contact.objects.all().update(include_on_network_map=True)
def noop_reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('contacts', '0002_contact_importance_contact_life_sphere_and_more'),
]
operations = [
migrations.AddField(
model_name='contact',
name='include_on_network_map',
field=models.BooleanField(
default=False,
verbose_name='Показывать на карте сети',
),
),
migrations.RunPython(set_map_true_for_existing, noop_reverse),
]
@@ -0,0 +1,21 @@
from django.db import migrations
def set_map_false_for_all(apps, schema_editor):
Contact = apps.get_model('contacts', 'Contact')
Contact.objects.all().update(include_on_network_map=False)
def noop_reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('contacts', '0003_contact_include_on_network_map'),
]
operations = [
migrations.RunPython(set_map_false_for_all, noop_reverse),
]
@@ -0,0 +1,27 @@
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contacts', '0004_contact_map_default_off'),
]
operations = [
migrations.AddField(
model_name='contact',
name='map_angle',
field=models.FloatField(blank=True, null=True, verbose_name='Угол позиции на карте'),
),
migrations.AddField(
model_name='contact',
name='map_radius_ratio',
field=models.FloatField(
blank=True,
null=True,
validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1)],
verbose_name='Радиус позиции на карте (доля)',
),
),
]
+56
View File
@@ -1,6 +1,28 @@
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
LIFE_SPHERES = [
('work', 'Работа'),
('study', 'Учёба'),
('hobby', 'Хобби'),
('family', 'Семья'),
('health', 'Здоровье'),
('other', 'Другое'),
]
NETWORK_CIRCLES = [
('support', 'Круг поддержки'),
('productivity', 'Круг продуктивности'),
('development', 'Круг развития'),
]
INTERACTION_INTENSITY = [
('intense', 'Интенсивные контакты'),
('sparse', 'Редкие контакты'),
]
class Contact(models.Model):
"""Контакт в социальном графе."""
@@ -10,6 +32,34 @@ class Contact(models.Model):
organization = 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='Заметки')
life_sphere = models.CharField(
max_length=32,
choices=LIFE_SPHERES,
default='other',
verbose_name='Сфера жизни',
)
network_circle = models.CharField(
max_length=32,
choices=NETWORK_CIRCLES,
default='productivity',
verbose_name='Круг сети',
)
importance = models.PositiveSmallIntegerField(
default=3,
validators=[MinValueValidator(1), MaxValueValidator(5)],
verbose_name='Важность (15)',
)
include_on_network_map = models.BooleanField(
default=False,
verbose_name='Показывать на карте сети',
)
map_angle = models.FloatField(null=True, blank=True, verbose_name='Угол позиции на карте')
map_radius_ratio = models.FloatField(
null=True,
blank=True,
validators=[MinValueValidator(0), MaxValueValidator(1)],
verbose_name='Радиус позиции на карте (доля)',
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -54,6 +104,12 @@ class Relation(models.Model):
verbose_name='Тип связи',
)
description = models.CharField(max_length=255, blank=True, verbose_name='Описание')
interaction_intensity = models.CharField(
max_length=32,
choices=INTERACTION_INTENSITY,
default='intense',
verbose_name='Интенсивность общения',
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
+10 -1
View File
@@ -10,6 +10,9 @@ class ContactSerializer(serializers.ModelSerializer):
fields = [
'id', 'name', 'email', 'phone',
'organization', 'position', 'notes',
'life_sphere', 'network_circle', 'importance',
'include_on_network_map',
'map_angle', 'map_radius_ratio',
'created_at', 'updated_at', 'relations_count',
]
read_only_fields = ['id', 'created_at', 'updated_at', 'relations_count']
@@ -30,7 +33,8 @@ class RelationSerializer(serializers.ModelSerializer):
fields = [
'id', 'source', 'source_name',
'target', 'target_name',
'relation_type', 'description', 'created_at',
'relation_type', 'description', 'interaction_intensity',
'created_at',
]
read_only_fields = ['id', 'created_at', 'source_name', 'target_name']
@@ -56,6 +60,9 @@ class GraphSerializer(serializers.Serializer):
'label': c.name,
'title': f'{c.organization}\n{c.position}'.strip() or c.name,
'group': c.organization or 'default',
'life_sphere': c.life_sphere,
'network_circle': c.network_circle,
'importance': c.importance,
}
for c in contacts
]
@@ -69,6 +76,8 @@ class GraphSerializer(serializers.Serializer):
'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
]
+2
View File
@@ -9,6 +9,8 @@ router.register('relations', views.RelationViewSet)
urlpatterns = [
path('', include(router.urls)),
path('graph/', views.graph_data),
path('network-map-graph/', views.network_map_graph),
path('relation-types/', views.relation_types),
path('network-map-choices/', views.network_map_choices),
path('import/', views.import_contacts),
]
+62 -1
View File
@@ -6,7 +6,14 @@ from rest_framework import viewsets, status
from rest_framework.decorators import api_view, action
from rest_framework.response import Response
from .models import Contact, Relation, RELATION_TYPES
from .models import (
Contact,
Relation,
RELATION_TYPES,
LIFE_SPHERES,
NETWORK_CIRCLES,
INTERACTION_INTENSITY,
)
from .serializers import ContactSerializer, RelationSerializer, GraphSerializer
@@ -37,6 +44,11 @@ def graph_data(request):
'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
]
@@ -49,18 +61,67 @@ def graph_data(request):
'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})
@api_view(['GET'])
def network_map_graph(request):
"""Граф только для карты сети: контакты с include_on_network_map и связи между ними."""
contacts = list(
Contact.objects.filter(include_on_network_map=True).order_by('name')
)
allowed_ids = {c.id for c in contacts}
nodes = [
{
'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()
edges = [
{
'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
if r.source_id in allowed_ids and r.target_id in allowed_ids
]
return Response({'nodes': nodes, 'edges': edges})
@api_view(['GET'])
def relation_types(request):
"""Список допустимых типов связей."""
return Response([{'value': v, 'label': l} for v, l in RELATION_TYPES])
@api_view(['GET'])
def network_map_choices(request):
"""Подписи для карты сети: сферы, круги, интенсивность связей."""
return Response({
'life_spheres': [{'value': v, 'label': l} for v, l in LIFE_SPHERES],
'network_circles': [{'value': v, 'label': l} for v, l in NETWORK_CIRCLES],
'interaction_intensities': [{'value': v, 'label': l} for v, l in INTERACTION_INTENSITY],
})
def _monica_contact_fields(contact_data):
"""Из вложенного data контакта Monica (экспорт account.data) извлекает телефон, email, заметки."""
phone = ''