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.
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 = ''
Binary file not shown.