From 85b8b2e9b81fb781506765ee20cc4c981acc0260 Mon Sep 17 00:00:00 2001 From: gitrusprus Date: Thu, 25 Jun 2026 11:51:06 +0300 Subject: [PATCH] Add conflictology map mode and fix graph rendering reliability. Support conflict types, involvement sizing, and map-type toggles while keeping polar sector layout; improve GraphView init retries and edge matching on network maps. Co-authored-by: Cursor --- .../contacts/migrations/0009_conflictology.py | 52 +++++ .../0010_conflictology_on_map_type.py | 32 +++ backend/contacts/models.py | 18 +- backend/contacts/serializers.py | 6 +- backend/core/choices.py | 10 + backend/graph/services.py | 16 +- .../application/services/graphDataService.js | 10 +- frontend/src/application/usecases/graph.js | 18 +- .../src/components/CreateRelationModal.vue | 24 ++- frontend/src/components/EditRelationModal.vue | 24 ++- .../src/components/GraphEdgeContextMenu.vue | 6 +- .../src/components/NetworkMapFormModal.vue | 42 +++- .../src/components/NetworkMapTopPanel.vue | 23 +- .../src/components/NetworkMapTypeForm.vue | 31 +++ frontend/src/domain/conflictology.js | 89 ++++++++ frontend/src/domain/mapTypeDefaults.js | 1 + frontend/src/domain/networkChoices.js | 2 + frontend/src/infrastructure/db/localDb.js | 74 +++++-- .../networkMapMembershipRepository.local.js | 1 + .../networkMapRepository.local.js | 1 + .../networkMapRepository.remote.js | 27 ++- .../networkMapTypeRepository.local.js | 2 + .../networkMapTypeRepository.remote.js | 30 ++- .../repositories/repositoryFactory.js | 2 + frontend/src/lib/map/conflictLayout.js | 53 +++++ frontend/src/lib/map/conflictLayout.test.js | 41 ++++ frontend/src/views/GraphView.vue | 36 +++- frontend/src/views/NetworkMapView.vue | 198 +++++++++++++----- frontend/src/views/SettingsView.vue | 7 +- 29 files changed, 744 insertions(+), 132 deletions(-) create mode 100644 backend/contacts/migrations/0009_conflictology.py create mode 100644 backend/contacts/migrations/0010_conflictology_on_map_type.py create mode 100644 frontend/src/domain/conflictology.js create mode 100644 frontend/src/lib/map/conflictLayout.js create mode 100644 frontend/src/lib/map/conflictLayout.test.js diff --git a/backend/contacts/migrations/0009_conflictology.py b/backend/contacts/migrations/0009_conflictology.py new file mode 100644 index 0000000..7f24189 --- /dev/null +++ b/backend/contacts/migrations/0009_conflictology.py @@ -0,0 +1,52 @@ +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contacts', '0008_alter_relation_interaction_intensity'), + ] + + operations = [ + migrations.AddField( + model_name='networkmap', + name='conflictology_enabled', + field=models.BooleanField(default=False, verbose_name='Режим конфликтологии'), + ), + migrations.AddField( + model_name='networkmap', + name='conflict_subject', + field=models.CharField(blank=True, max_length=255, verbose_name='Предмет конфликта (центр карты)'), + ), + migrations.AddField( + model_name='networkmapmembership', + name='conflict_involvement', + field=models.PositiveSmallIntegerField( + default=3, + validators=[MinValueValidator(1), MaxValueValidator(5)], + verbose_name='Вовлечённость в конфликт (1–5)', + ), + ), + migrations.AlterField( + model_name='relation', + name='relation_type', + field=models.CharField( + choices=[ + ('colleague', 'Коллега'), + ('friend', 'Друг'), + ('family', 'Родственник'), + ('acquaintance', 'Знакомый'), + ('business', 'Деловой партнёр'), + ('other', 'Другое'), + ('conflict_open', 'Открытый конфликт'), + ('conflict_tension', 'Напряжение'), + ('conflict_alliance', 'Союз / поддержка'), + ('conflict_neutral', 'Нейтральная связь'), + ], + default='acquaintance', + max_length=50, + verbose_name='Тип связи', + ), + ), + ] diff --git a/backend/contacts/migrations/0010_conflictology_on_map_type.py b/backend/contacts/migrations/0010_conflictology_on_map_type.py new file mode 100644 index 0000000..7eb561c --- /dev/null +++ b/backend/contacts/migrations/0010_conflictology_on_map_type.py @@ -0,0 +1,32 @@ +from django.db import migrations, models + + +def migrate_conflictology_to_map_type(apps, schema_editor): + NetworkMap = apps.get_model('contacts', 'NetworkMap') + NetworkMapType = apps.get_model('contacts', 'NetworkMapType') + + for network_map in NetworkMap.objects.filter(conflictology_enabled=True).select_related('map_type'): + map_type = network_map.map_type + if map_type and not map_type.conflictology_enabled: + map_type.conflictology_enabled = True + map_type.save(update_fields=['conflictology_enabled']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contacts', '0009_conflictology'), + ] + + operations = [ + migrations.AddField( + model_name='networkmaptype', + name='conflictology_enabled', + field=models.BooleanField(default=False, verbose_name='Режим конфликтологии'), + ), + migrations.RunPython(migrate_conflictology_to_map_type, migrations.RunPython.noop), + migrations.RemoveField( + model_name='networkmap', + name='conflictology_enabled', + ), + ] diff --git a/backend/contacts/models.py b/backend/contacts/models.py index 6344b79..b9f79fb 100644 --- a/backend/contacts/models.py +++ b/backend/contacts/models.py @@ -2,8 +2,8 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from core.choices import ( + ALL_RELATION_TYPES, INTERACTION_INTENSITY, - RELATION_TYPES, ) @@ -35,6 +35,10 @@ class NetworkMapType(models.Model): sectors = models.JSONField(default=list, verbose_name='Секторы') circles = models.JSONField(default=list, verbose_name='Круги') is_default = models.BooleanField(default=False, verbose_name='Тип по умолчанию') + conflictology_enabled = models.BooleanField( + default=False, + verbose_name='Режим конфликтологии', + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -58,6 +62,11 @@ class NetworkMap(models.Model): related_name='maps', verbose_name='Тип карты', ) + conflict_subject = models.CharField( + max_length=255, + blank=True, + verbose_name='Предмет конфликта (центр карты)', + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -100,6 +109,11 @@ class NetworkMapMembership(models.Model): validators=[MinValueValidator(1), MaxValueValidator(5)], verbose_name='Важность (1–5)', ) + conflict_involvement = models.PositiveSmallIntegerField( + default=3, + validators=[MinValueValidator(1), MaxValueValidator(5)], + verbose_name='Вовлечённость в конфликт (1–5)', + ) map_angle = models.FloatField(null=True, blank=True, verbose_name='Угол позиции на карте') map_radius_ratio = models.FloatField( null=True, @@ -136,7 +150,7 @@ class Relation(models.Model): ) relation_type = models.CharField( max_length=50, - choices=RELATION_TYPES, + choices=ALL_RELATION_TYPES, default='acquaintance', verbose_name='Тип связи', ) diff --git a/backend/contacts/serializers.py b/backend/contacts/serializers.py index 32cfbe0..3221ff1 100644 --- a/backend/contacts/serializers.py +++ b/backend/contacts/serializers.py @@ -48,7 +48,7 @@ class NetworkMapTypeSerializer(serializers.ModelSerializer): class Meta: model = NetworkMapType fields = [ - 'id', 'name', 'sectors', 'circles', 'is_default', + 'id', 'name', 'sectors', 'circles', 'is_default', 'conflictology_enabled', 'created_at', 'updated_at', ] read_only_fields = ['id', 'is_default', 'created_at', 'updated_at'] @@ -77,7 +77,7 @@ class NetworkMapSerializer(serializers.ModelSerializer): class Meta: model = NetworkMap fields = [ - 'id', 'name', 'description', 'map_type', + 'id', 'name', 'description', 'map_type', 'conflict_subject', 'created_at', 'updated_at', 'memberships_count', ] read_only_fields = ['id', 'created_at', 'updated_at', 'memberships_count'] @@ -102,7 +102,7 @@ class NetworkMapMembershipSerializer(serializers.ModelSerializer): model = NetworkMapMembership fields = [ 'id', 'map', 'map_name', 'contact', 'contact_name', - 'life_sphere', 'network_circle', 'importance', + 'life_sphere', 'network_circle', 'importance', 'conflict_involvement', 'map_angle', 'map_radius_ratio', 'created_at', 'updated_at', ] diff --git a/backend/core/choices.py b/backend/core/choices.py index 49ceefe..51a1010 100644 --- a/backend/core/choices.py +++ b/backend/core/choices.py @@ -30,10 +30,20 @@ RELATION_TYPES = [ ('other', 'Другое'), ] +CONFLICT_RELATION_TYPES = [ + ('conflict_open', 'Открытый конфликт'), + ('conflict_tension', 'Напряжение'), + ('conflict_alliance', 'Союз / поддержка'), + ('conflict_neutral', 'Нейтральная связь'), +] + +ALL_RELATION_TYPES = RELATION_TYPES + CONFLICT_RELATION_TYPES + def choices_payload(): return { 'relation_types': [{'value': v, 'label': l} for v, l in RELATION_TYPES], + 'conflict_relation_types': [{'value': v, 'label': l} for v, l in CONFLICT_RELATION_TYPES], '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], diff --git a/backend/graph/services.py b/backend/graph/services.py index b224db8..dead212 100644 --- a/backend/graph/services.py +++ b/backend/graph/services.py @@ -17,6 +17,7 @@ def node_from_membership(membership): 'life_sphere': membership.life_sphere, 'network_circle': membership.network_circle, 'importance': membership.importance, + 'conflict_involvement': membership.conflict_involvement, 'map_angle': membership.map_angle, 'map_radius_ratio': membership.map_radius_ratio, 'membership_id': membership.id, @@ -45,11 +46,15 @@ def build_full_graph(): def build_network_map_graph(map_id=None): if not map_id: - default_map = NetworkMap.objects.order_by('id').first() + default_map = NetworkMap.objects.select_related('map_type').order_by('id').first() if not default_map: - return {'nodes': [], 'edges': []} + return {'nodes': [], 'edges': [], 'conflictology': False, 'conflict_subject': ''} map_id = default_map.id + network_map = NetworkMap.objects.select_related('map_type').filter(pk=map_id).first() + if not network_map: + return {'nodes': [], 'edges': [], 'conflictology': False, 'conflict_subject': ''} + memberships = list( NetworkMapMembership.objects.filter(map_id=map_id) .select_related('contact') @@ -63,4 +68,9 @@ def build_network_map_graph(map_id=None): for r in relations if r.source_id in allowed_ids and r.target_id in allowed_ids ] - return {'nodes': nodes, 'edges': edges} + return { + 'nodes': nodes, + 'edges': edges, + 'conflictology': network_map.map_type.conflictology_enabled, + 'conflict_subject': network_map.conflict_subject or network_map.name, + } diff --git a/frontend/src/application/services/graphDataService.js b/frontend/src/application/services/graphDataService.js index e530d34..ed80492 100644 --- a/frontend/src/application/services/graphDataService.js +++ b/frontend/src/application/services/graphDataService.js @@ -3,6 +3,7 @@ import { getRelationTypes } from '../../infrastructure/repositories/repositoryFa import { isLocalMode } from '../../infrastructure/config/dataMode' import api from '../../api' import { applyGraphExtensions } from '../../core/pluginRegistry' +import { CONFLICT_RELATION_TYPES } from '../../domain/conflictology' /** * Unified graph data access for GraphView and NetworkMapView. @@ -17,12 +18,17 @@ export async function fetchGraphBundle({ mapId = null } = {}) { : '/graph/' const [gRes, rtRes] = await Promise.all([ api.get(endpoint), - api.get('/relation-types/'), + api.get('/meta/choices/'), ]) + const conflictology = Boolean(gRes.data.conflictology) bundle = { nodes: gRes.data.nodes || [], edges: gRes.data.edges || [], - relationTypes: rtRes.data || [], + relationTypes: conflictology + ? (rtRes.data.conflict_relation_types || CONFLICT_RELATION_TYPES) + : (rtRes.data.relation_types || await getRelationTypes()), + conflictology, + conflictSubject: gRes.data.conflict_subject || '', } } diff --git a/frontend/src/application/usecases/graph.js b/frontend/src/application/usecases/graph.js index e03053c..aa07687 100644 --- a/frontend/src/application/usecases/graph.js +++ b/frontend/src/application/usecases/graph.js @@ -1,7 +1,11 @@ import { listContacts } from './contacts' import { listRelations } from './relations' import { listMembershipsByMap } from './networkMaps' -import { getRelationTypes, getNetworkMapChoices } from '../../infrastructure/repositories/repositoryFactory' +import { getNetworkMapRepository, getRelationTypes, getNetworkMapChoices, getNetworkMapTypeRepository } from '../../infrastructure/repositories/repositoryFactory' +import { CONFLICT_RELATION_TYPES } from '../../domain/networkChoices' + +const mapRepo = () => getNetworkMapRepository() +const mapTypeRepo = () => getNetworkMapTypeRepository() function nodeFromContactAndMembership(contact, membership) { return { @@ -12,6 +16,7 @@ function nodeFromContactAndMembership(contact, membership) { life_sphere: membership.life_sphere, network_circle: membership.network_circle, importance: membership.importance, + conflict_involvement: membership.conflict_involvement ?? membership.conflictInvolvement ?? 3, map_angle: membership.map_angle, map_radius_ratio: membership.map_radius_ratio, membership_id: membership.id, @@ -63,6 +68,13 @@ export async function getGraphBundle({ mapId = null } = {}) { } } + const map = await mapRepo().getById(mapId) + const mapType = map?.mapTypeId ? await mapTypeRepo().getById(map.mapTypeId) : null + const conflictology = Boolean( + mapType?.conflictologyEnabled ?? mapType?.conflictology_enabled + ) + const types = conflictology ? CONFLICT_RELATION_TYPES : relationTypes + const memberships = await listMembershipsByMap(mapId) const contactById = new Map(contacts.map((c) => [String(c.id), c])) const allowedIds = new Set() @@ -84,7 +96,9 @@ export async function getGraphBundle({ mapId = null } = {}) { return { nodes, edges: scopedRelations.map(edgeFromRelation), - relationTypes, + relationTypes: types, + conflictology, + conflictSubject: map?.conflictSubject || map?.conflict_subject || map?.name || '', } } diff --git a/frontend/src/components/CreateRelationModal.vue b/frontend/src/components/CreateRelationModal.vue index fd2e191..784db8b 100644 --- a/frontend/src/components/CreateRelationModal.vue +++ b/frontend/src/components/CreateRelationModal.vue @@ -13,16 +13,22 @@ {{ targetName }}

+

+ Направление: кто оказывает давление (агрессор) → на кого (жертва). +

- - + +
-
+
@@ -46,11 +52,15 @@ import { computed, ref, watch } from 'vue' import { useContactsStore } from '../stores/contacts' import InteractionIntensitySelect from './InteractionIntensitySelect.vue' import RelationTypeSelect from './RelationTypeSelect.vue' +import { CONFLICT_RELATION_TYPES } from '../domain/conflictology' + +const conflictTypes = CONFLICT_RELATION_TYPES const props = defineProps({ open: { type: Boolean, default: false }, source: { type: Object, default: null }, target: { type: Object, default: null }, + conflictMode: { type: Boolean, default: false }, }) const emit = defineEmits(['close', 'created']) @@ -79,8 +89,8 @@ watch( swapped.value = false error.value = '' form.value = { - type: 'acquaintance', - intensity: 'intense', + type: props.conflictMode ? 'conflict_neutral' : 'acquaintance', + intensity: 'sparse', description: '', } } @@ -128,4 +138,8 @@ async function submit() { .swap-btn { margin-bottom: 16px; } +.direction-hint { + margin: -4px 0 12px; + font-size: 13px; +} diff --git a/frontend/src/components/EditRelationModal.vue b/frontend/src/components/EditRelationModal.vue index 1a1441c..515f986 100644 --- a/frontend/src/components/EditRelationModal.vue +++ b/frontend/src/components/EditRelationModal.vue @@ -13,12 +13,18 @@ {{ targetName }}

+

+ Направление: агрессор → жертва. +

- - + +
-
+
@@ -54,10 +60,14 @@ import { computed, ref, watch } from 'vue' import { useContactsStore } from '../stores/contacts' import InteractionIntensitySelect from './InteractionIntensitySelect.vue' import RelationTypeSelect from './RelationTypeSelect.vue' +import { CONFLICT_RELATION_TYPES } from '../domain/conflictology' + +const conflictTypes = CONFLICT_RELATION_TYPES const props = defineProps({ open: { type: Boolean, default: false }, relation: { type: Object, default: null }, + conflictMode: { type: Boolean, default: false }, }) const emit = defineEmits(['close', 'updated', 'deleted']) @@ -82,8 +92,8 @@ watch( if (!props.open || !props.relation) return error.value = '' form.value = { - type: props.relation.relation_type || 'acquaintance', - intensity: props.relation.interaction_intensity || 'intense', + type: props.relation.relation_type || (props.conflictMode ? 'conflict_neutral' : 'acquaintance'), + intensity: props.relation.interaction_intensity || 'sparse', description: props.relation.description || '', } }, @@ -140,6 +150,10 @@ async function onDelete() { margin: 0 8px; color: var(--text-muted); } +.direction-hint { + margin: -8px 0 16px; + font-size: 13px; +} .relation-edit-footer { display: flex; align-items: center; diff --git a/frontend/src/components/GraphEdgeContextMenu.vue b/frontend/src/components/GraphEdgeContextMenu.vue index 6d58180..3eaca8d 100644 --- a/frontend/src/components/GraphEdgeContextMenu.vue +++ b/frontend/src/components/GraphEdgeContextMenu.vue @@ -25,6 +25,7 @@ @@ -101,4 +136,9 @@ function onSubmit() { gap: 8px; margin-left: auto; } +.field-hint { + margin: 6px 0 0; + font-size: 13px; + line-height: 1.45; +} diff --git a/frontend/src/components/NetworkMapTopPanel.vue b/frontend/src/components/NetworkMapTopPanel.vue index 89f7072..addf784 100644 --- a/frontend/src/components/NetworkMapTopPanel.vue +++ b/frontend/src/components/NetworkMapTopPanel.vue @@ -18,7 +18,6 @@
-
-
+
@@ -50,17 +49,20 @@ defineEmits(['toggle-collapse', 'fit']) .network-map-top-panel { position: relative; border-bottom: 1px solid var(--border); + flex-shrink: 0; } .network-map-top-panel.collapsed { - height: 26px; + min-height: 0; + border-bottom: none; } .panel-toggle-btn { position: absolute; - top: 4px; - right: 8px; - z-index: 3; - width: 24px; - height: 18px; + bottom: -11px; + left: 50%; + z-index: 4; + width: 28px; + height: 20px; + margin-left: -14px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-alt); @@ -103,6 +105,9 @@ defineEmits(['toggle-collapse', 'fit']) flex-wrap: wrap; justify-content: flex-end; padding-left: 16px; - padding-right: 24px; + padding-right: 16px; +} +.network-map-legend { + padding: 0 28px 14px; } diff --git a/frontend/src/components/NetworkMapTypeForm.vue b/frontend/src/components/NetworkMapTypeForm.vue index 37ff386..d1a8759 100644 --- a/frontend/src/components/NetworkMapTypeForm.vue +++ b/frontend/src/components/NetworkMapTypeForm.vue @@ -11,6 +11,17 @@ +
+ +

+ Цветовая кодировка связей, стрелки давления, центр карты — предмет конфликта. + Секторы и круги настраиваются ниже как обычно. +

+
+
@@ -97,6 +108,7 @@ const form = reactive({ name: '', sectors: [], circles: [], + conflictologyEnabled: false, }) function blankSector(label = '') { @@ -119,6 +131,9 @@ function resetForm() { label: c.label, key: c.key, })) + form.conflictologyEnabled = Boolean( + props.initial?.conflictologyEnabled ?? props.initial?.conflictology_enabled + ) formError.value = '' } @@ -168,6 +183,7 @@ function onSubmit() { name: form.name.trim(), sectors, circles, + conflictologyEnabled: form.conflictologyEnabled, }) } @@ -200,4 +216,19 @@ function onSubmit() { .text-danger { color: var(--red, #e74c3c); } +.checkbox-row { + margin-bottom: 12px; +} +.checkbox-label { + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; + cursor: pointer; +} +.checkbox-hint { + margin: 6px 0 0; + font-size: 13px; + line-height: 1.45; +} diff --git a/frontend/src/domain/conflictology.js b/frontend/src/domain/conflictology.js new file mode 100644 index 0000000..116bb9f --- /dev/null +++ b/frontend/src/domain/conflictology.js @@ -0,0 +1,89 @@ +export const CONFLICT_RELATION_TYPES = [ + { value: 'conflict_open', label: 'Открытый конфликт / враждебность' }, + { value: 'conflict_tension', label: 'Напряжение / скрытое недовольство' }, + { value: 'conflict_alliance', label: 'Союз / поддержка' }, + { value: 'conflict_neutral', label: 'Нейтральная / слабая связь' }, +] + +export const CONFLICT_RELATION_STYLES = { + conflict_open: { + color: '#e74c3c', + highlight: '#ff6b6b', + width: 3.5, + dashes: false, + opacity: 0.95, + }, + conflict_tension: { + color: '#f1c40f', + highlight: '#f5d76e', + width: 2.5, + dashes: [8, 5], + opacity: 0.9, + }, + conflict_alliance: { + color: '#2ecc71', + highlight: '#58d68d', + width: 2.5, + dashes: false, + opacity: 0.9, + }, + conflict_neutral: { + color: '#95a5a6', + highlight: '#b2babb', + width: 1.5, + dashes: [5, 5], + opacity: 0.65, + }, +} + +export const CONFLICT_CENTER_NODE_ID = '__conflict_center__' + +const CONFLICT_TYPE_LABELS = Object.fromEntries( + CONFLICT_RELATION_TYPES.map((t) => [t.value, t.label]) +) + +export function isConflictRelationType(type) { + return String(type || '').startsWith('conflict_') +} + +export function conflictRelationLabel(type) { + return CONFLICT_TYPE_LABELS[type] || type +} + +export function conflictRelationToVisEdge(edge) { + const style = CONFLICT_RELATION_STYLES[edge.relation_type] || CONFLICT_RELATION_STYLES.conflict_neutral + return { + id: String(edge.id), + from: String(edge.from), + to: String(edge.to), + relation_type: edge.relation_type, + width: style.width, + dashes: style.dashes, + hoverWidth: style.width + 8, + selectionWidth: style.width + 12, + color: { + color: style.color, + highlight: style.highlight, + opacity: style.opacity, + }, + arrows: { to: { enabled: true, scaleFactor: 0.85 } }, + smooth: false, + title: edge.title || conflictRelationLabel(edge.relation_type), + } +} + +export function involvementNodeSize(involvement) { + const raw = Number(involvement) + const i = Number.isFinite(raw) ? Math.max(1, Math.min(5, raw)) : 3 + return 10 + (i - 1) * 4 +} + +export function involvementFromRadiusRatio(ratio) { + const safe = Math.max(0, Math.min(1, Number(ratio) || 0.5)) + return Math.max(1, Math.min(5, Math.round(5 - safe * 4))) +} + +export function radiusRatioFromInvolvement(involvement) { + const i = Math.max(1, Math.min(5, Number(involvement) || 3)) + return 0.92 - ((i - 1) / 4) * 0.67 +} diff --git a/frontend/src/domain/mapTypeDefaults.js b/frontend/src/domain/mapTypeDefaults.js index 0285c00..5bb312e 100644 --- a/frontend/src/domain/mapTypeDefaults.js +++ b/frontend/src/domain/mapTypeDefaults.js @@ -81,6 +81,7 @@ export function createDefaultMapTypeRecord(id, ts = new Date().toISOString()) { sectors: DEFAULT_MAP_TYPE_SECTORS, circles: DEFAULT_MAP_TYPE_CIRCLES, isDefault: true, + conflictologyEnabled: false, workspaceId: 'personal', version: 1, createdAt: ts, diff --git a/frontend/src/domain/networkChoices.js b/frontend/src/domain/networkChoices.js index d4bccad..5cfdf62 100644 --- a/frontend/src/domain/networkChoices.js +++ b/frontend/src/domain/networkChoices.js @@ -27,3 +27,5 @@ export const INTERACTION_INTENSITIES = [ { value: 'periodic', label: 'Периодические контакты' }, { value: 'sparse', label: 'Редкие контакты' }, ] + +export { CONFLICT_RELATION_TYPES } from './conflictology' diff --git a/frontend/src/infrastructure/db/localDb.js b/frontend/src/infrastructure/db/localDb.js index d09214e..f3293a7 100644 --- a/frontend/src/infrastructure/db/localDb.js +++ b/frontend/src/infrastructure/db/localDb.js @@ -76,11 +76,9 @@ class SocialGraphDb extends Dexie { export const localDb = new SocialGraphDb() /** - * Core schema v4: network map types (runs after plugin Dexie upgraders). + * Core schema upgrades (runs after plugin Dexie upgraders). */ export function applyCoreDbUpgrades(db) { - if (db.verno >= 4) return - const stores = { ...BASE_STORES_V2, networkMapTypes: 'id, name, updatedAt, deletedAt, workspaceId', @@ -89,28 +87,64 @@ export function applyCoreDbUpgrades(db) { stores.contactTags = CONTACT_TAGS_STORE } - db.version(4).stores(stores).upgrade(async (tx) => { - const ts = new Date().toISOString() - const types = await tx.table('networkMapTypes').toArray() - const activeTypes = types.filter((t) => !t.deletedAt) - let defaultType = activeTypes.find((t) => t.isDefault) + if (db.verno < 4) { + db.version(4).stores(stores).upgrade(async (tx) => { + const ts = new Date().toISOString() + const types = await tx.table('networkMapTypes').toArray() + const activeTypes = types.filter((t) => !t.deletedAt) + let defaultType = activeTypes.find((t) => t.isDefault) - if (!defaultType) { - const typeId = generateId() - defaultType = createDefaultMapTypeRecord(typeId, ts) - await tx.table('networkMapTypes').add(defaultType) - } + if (!defaultType) { + const typeId = generateId() + defaultType = createDefaultMapTypeRecord(typeId, ts) + await tx.table('networkMapTypes').add(defaultType) + } - const maps = await tx.table('networkMaps').toArray() - for (const map of maps) { - if (!map.deletedAt && !map.mapTypeId) { - await tx.table('networkMaps').update(map.id, { - mapTypeId: defaultType.id, + const maps = await tx.table('networkMaps').toArray() + for (const map of maps) { + if (!map.deletedAt && !map.mapTypeId) { + await tx.table('networkMaps').update(map.id, { + mapTypeId: defaultType.id, + updatedAt: ts, + }) + } + } + }) + } + + if (db.verno < 5) { + db.version(5).stores(stores).upgrade(async (tx) => { + const ts = new Date().toISOString() + const types = await tx.table('networkMapTypes').toArray() + const typeById = new Map(types.map((t) => [String(t.id), t])) + const maps = await tx.table('networkMaps').toArray() + + for (const map of maps) { + if (map.deletedAt) continue + if (map.conflictologyEnabled && map.mapTypeId) { + const mapType = typeById.get(String(map.mapTypeId)) + if (mapType && !mapType.conflictologyEnabled) { + await tx.table('networkMapTypes').update(mapType.id, { + conflictologyEnabled: true, + updatedAt: ts, + }) + } + } + if (map.conflictologyEnabled !== undefined) { + const { conflictologyEnabled, ...rest } = map + await tx.table('networkMaps').put({ ...rest, updatedAt: ts }) + } + } + + for (const mapType of types) { + if (mapType.deletedAt || mapType.conflictologyEnabled !== undefined) continue + await tx.table('networkMapTypes').update(mapType.id, { + conflictologyEnabled: false, updatedAt: ts, }) } - } - }) + }) + } } export { DEFAULT_MAP_NAME } diff --git a/frontend/src/infrastructure/repositories/networkMapMembershipRepository.local.js b/frontend/src/infrastructure/repositories/networkMapMembershipRepository.local.js index 3f7cadf..3654598 100644 --- a/frontend/src/infrastructure/repositories/networkMapMembershipRepository.local.js +++ b/frontend/src/infrastructure/repositories/networkMapMembershipRepository.local.js @@ -14,6 +14,7 @@ function withDefaults(payload = {}) { life_sphere: 'other', network_circle: 'productivity', importance: 3, + conflict_involvement: 3, map_angle: null, map_radius_ratio: null, workspaceId: 'personal', diff --git a/frontend/src/infrastructure/repositories/networkMapRepository.local.js b/frontend/src/infrastructure/repositories/networkMapRepository.local.js index 2c5f5c3..e027e2b 100644 --- a/frontend/src/infrastructure/repositories/networkMapRepository.local.js +++ b/frontend/src/infrastructure/repositories/networkMapRepository.local.js @@ -11,6 +11,7 @@ function withDefaults(payload = {}) { name: '', description: '', mapTypeId: payload.mapTypeId || null, + conflictSubject: '', workspaceId: 'personal', ...payload, } diff --git a/frontend/src/infrastructure/repositories/networkMapRepository.remote.js b/frontend/src/infrastructure/repositories/networkMapRepository.remote.js index 3b4f2c2..23eb295 100644 --- a/frontend/src/infrastructure/repositories/networkMapRepository.remote.js +++ b/frontend/src/infrastructure/repositories/networkMapRepository.remote.js @@ -1,31 +1,40 @@ import api from '../../api' import { fetchAllPages } from '../../lib/api/pagination' +function normalizeMap(data) { + return { + ...data, + mapTypeId: data.map_type, + conflictSubject: data.conflict_subject, + } +} + export const remoteNetworkMapRepository = { async list() { const pages = await fetchAllPages((page) => api.get('/network-maps/', { params: { page } })) - return pages.map((m) => ({ ...m, mapTypeId: m.map_type })) + return pages.map(normalizeMap) }, async getById(id) { const { data } = await api.get(`/network-maps/${id}/`) - return { ...data, mapTypeId: data.map_type } + return normalizeMap(data) }, async create(payload) { const { data } = await api.post('/network-maps/', { name: payload.name, description: payload.description, map_type: payload.mapTypeId || payload.map_type, + conflict_subject: payload.conflictSubject || '', }) - return { ...data, mapTypeId: data.map_type } + return normalizeMap(data) }, async update(id, payload) { - const body = { ...payload } - if (body.mapTypeId !== undefined) { - body.map_type = body.mapTypeId - delete body.mapTypeId - } + const body = {} + if (payload.name !== undefined) body.name = payload.name + if (payload.description !== undefined) body.description = payload.description + if (payload.mapTypeId !== undefined) body.map_type = payload.mapTypeId + if (payload.conflictSubject !== undefined) body.conflict_subject = payload.conflictSubject const { data } = await api.patch(`/network-maps/${id}/`, body) - return { ...data, mapTypeId: data.map_type } + return normalizeMap(data) }, async remove(id) { await api.delete(`/network-maps/${id}/`) diff --git a/frontend/src/infrastructure/repositories/networkMapTypeRepository.local.js b/frontend/src/infrastructure/repositories/networkMapTypeRepository.local.js index 9435b9b..749e4dc 100644 --- a/frontend/src/infrastructure/repositories/networkMapTypeRepository.local.js +++ b/frontend/src/infrastructure/repositories/networkMapTypeRepository.local.js @@ -11,6 +11,7 @@ function normalizeType(record) { return { ...record, isDefault: Boolean(record.isDefault), + conflictologyEnabled: Boolean(record.conflictologyEnabled), } } @@ -66,6 +67,7 @@ export const localNetworkMapTypeRepository = { name: payload.name || '', sectors: payload.sectors || [], circles: payload.circles || [], + conflictologyEnabled: Boolean(payload.conflictologyEnabled), isDefault: false, workspaceId: 'personal', version: 1, diff --git a/frontend/src/infrastructure/repositories/networkMapTypeRepository.remote.js b/frontend/src/infrastructure/repositories/networkMapTypeRepository.remote.js index 42bb0c2..9c37848 100644 --- a/frontend/src/infrastructure/repositories/networkMapTypeRepository.remote.js +++ b/frontend/src/infrastructure/repositories/networkMapTypeRepository.remote.js @@ -1,14 +1,22 @@ import api from '../../api' import { fetchAllPages } from '../../lib/api/pagination' +function normalizeType(data) { + return { + ...data, + isDefault: data.is_default, + conflictologyEnabled: data.conflictology_enabled, + } +} + export const remoteNetworkMapTypeRepository = { async list() { const rows = await fetchAllPages((page) => api.get('/network-map-types/', { params: { page } })) - return rows.map((t) => ({ ...t, isDefault: t.is_default })) + return rows.map(normalizeType) }, async getById(id) { const { data } = await api.get(`/network-map-types/${id}/`) - return { ...data, isDefault: data.is_default } + return normalizeType(data) }, async getDefault() { const list = await this.list() @@ -19,16 +27,20 @@ export const remoteNetworkMapTypeRepository = { name: payload.name, sectors: payload.sectors, circles: payload.circles, + conflictology_enabled: Boolean(payload.conflictologyEnabled), }) - return { ...data, isDefault: data.is_default } + return normalizeType(data) }, async update(id, payload) { - const { data } = await api.patch(`/network-map-types/${id}/`, { - name: payload.name, - sectors: payload.sectors, - circles: payload.circles, - }) - return { ...data, isDefault: data.is_default } + const body = {} + if (payload.name !== undefined) body.name = payload.name + if (payload.sectors !== undefined) body.sectors = payload.sectors + if (payload.circles !== undefined) body.circles = payload.circles + if (payload.conflictologyEnabled !== undefined) { + body.conflictology_enabled = payload.conflictologyEnabled + } + const { data } = await api.patch(`/network-map-types/${id}/`, body) + return normalizeType(data) }, async remove(id) { await api.delete(`/network-map-types/${id}/`) diff --git a/frontend/src/infrastructure/repositories/repositoryFactory.js b/frontend/src/infrastructure/repositories/repositoryFactory.js index e05f8ff..f32ff57 100644 --- a/frontend/src/infrastructure/repositories/repositoryFactory.js +++ b/frontend/src/infrastructure/repositories/repositoryFactory.js @@ -65,8 +65,10 @@ export async function getMetaChoices() { const { data } = await api.get('/meta/choices/') return data } + const { CONFLICT_RELATION_TYPES } = await import('../../domain/conflictology') return { relation_types: RELATION_TYPES, + conflict_relation_types: CONFLICT_RELATION_TYPES, life_spheres: LIFE_SPHERES, network_circles: NETWORK_CIRCLES, interaction_intensities: INTERACTION_INTENSITIES, diff --git a/frontend/src/lib/map/conflictLayout.js b/frontend/src/lib/map/conflictLayout.js new file mode 100644 index 0000000..dfa3cfa --- /dev/null +++ b/frontend/src/lib/map/conflictLayout.js @@ -0,0 +1,53 @@ +import { + CONFLICT_CENTER_NODE_ID, + radiusRatioFromInvolvement, + involvementFromRadiusRatio, +} from '../../domain/conflictology' + +export { CONFLICT_CENTER_NODE_ID } + +export function computeConflictPositions(nodes, layout) { + const posById = new Map() + const sorted = [...nodes].sort((a, b) => String(a.id).localeCompare(String(b.id))) + const n = sorted.length || 1 + + sorted.forEach((node, idx) => { + const involvement = node.conflict_involvement ?? 3 + const ratio = radiusRatioFromInvolvement(involvement) + const angle = (idx / n) * 2 * Math.PI - Math.PI / 2 + const r = layout.rOuter * ratio + posById.set(node.id, { x: r * Math.cos(angle), y: r * Math.sin(angle) }) + }) + + return posById +} + +export function conflictNodeXY(node, layout, posById) { + const ratio = Number(node.map_radius_ratio) + const storedAngle = Number(node.map_angle) + if (Number.isFinite(ratio) && Number.isFinite(storedAngle)) { + const safeRatio = Math.max(0, Math.min(1, ratio)) + const r = safeRatio * layout.rOuter + return { x: r * Math.cos(storedAngle), y: r * Math.sin(storedAngle) } + } + const p = posById.get(node.id) + if (p) return p + return { x: 0, y: 0 } +} + +export function placementFromCanvas(canvasX, canvasY, layout) { + const dx = canvasX - layout.cx + const dy = canvasY - layout.cy + const r = Math.sqrt(dx * dx + dy * dy) + const angle = Math.atan2(dy, dx) + const ratio = Math.max(0, Math.min(1, r / (layout.rOuter || 1))) + return { + map_angle: angle, + map_radius_ratio: ratio, + conflict_involvement: involvementFromRadiusRatio(ratio), + } +} + +export function involvementRingRadii(layout) { + return [1, 2, 3, 4, 5].map((level) => layout.rOuter * radiusRatioFromInvolvement(level)) +} diff --git a/frontend/src/lib/map/conflictLayout.test.js b/frontend/src/lib/map/conflictLayout.test.js new file mode 100644 index 0000000..ab45d39 --- /dev/null +++ b/frontend/src/lib/map/conflictLayout.test.js @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest' +import { + involvementFromRadiusRatio, + radiusRatioFromInvolvement, + involvementNodeSize, +} from '../../domain/conflictology' +import { computeConflictPositions, placementFromCanvas } from './conflictLayout' + +describe('conflictLayout', () => { + const layout = { cx: 200, cy: 200, rOuter: 100 } + + it('higher involvement means closer to center', () => { + expect(radiusRatioFromInvolvement(5)).toBeLessThan(radiusRatioFromInvolvement(1)) + }) + + it('maps radius ratio back to involvement', () => { + expect(involvementFromRadiusRatio(0.1)).toBeGreaterThanOrEqual(4) + expect(involvementFromRadiusRatio(0.9)).toBeLessThanOrEqual(2) + }) + + it('scales node size by involvement', () => { + expect(involvementNodeSize(5)).toBeGreaterThan(involvementNodeSize(1)) + }) + + it('places nodes around center', () => { + const pos = computeConflictPositions([ + { id: 'a', conflict_involvement: 5 }, + { id: 'b', conflict_involvement: 2 }, + ], layout) + expect(pos.get('a')).toBeTruthy() + const distA = Math.hypot(pos.get('a').x, pos.get('a').y) + const distB = Math.hypot(pos.get('b').x, pos.get('b').y) + expect(distA).toBeLessThan(distB) + }) + + it('derives placement from canvas coordinates', () => { + const p = placementFromCanvas(200, 120, layout) + expect(p.map_angle).toBeCloseTo(-Math.PI / 2, 1) + expect(p.conflict_involvement).toBeGreaterThanOrEqual(1) + }) +}) diff --git a/frontend/src/views/GraphView.vue b/frontend/src/views/GraphView.vue index c59adfd..135da69 100644 --- a/frontend/src/views/GraphView.vue +++ b/frontend/src/views/GraphView.vue @@ -580,7 +580,11 @@ function syncGraphToNetwork() { syncedRevision = store.dataRevision } +let ensureGraphReadyInFlight = null + async function ensureGraphReady({ showSpinner = true } = {}) { + if (ensureGraphReadyInFlight) return ensureGraphReadyInFlight + ensureGraphReadyInFlight = (async () => { const reconnecting = Boolean(network.value) if (showSpinner && !reconnecting) loading.value = true try { @@ -607,11 +611,17 @@ async function ensureGraphReady({ showSpinner = true } = {}) { if (syncedRevision !== store.dataRevision) { syncGraphToNetwork() } + })().finally(() => { + ensureGraphReadyInFlight = null + }) + return ensureGraphReadyInFlight } function filteredEdges() { - const nodeIds = new Set(nodes.value.map((n) => n.id)) - let list = edges.value.filter((e) => nodeIds.has(e.from) && nodeIds.has(e.to)) + const nodeIds = new Set(nodes.value.map((n) => String(n.id))) + let list = edges.value.filter( + (e) => nodeIds.has(String(e.from)) && nodeIds.has(String(e.to)), + ) if (activeFilters.value.length < allRelationTypes.value.length) { list = list.filter((e) => activeFilters.value.includes(e.relation_type)) } @@ -619,7 +629,13 @@ function filteredEdges() { } function initNetwork() { - if (network.value || !graphContainer.value) return + if (network.value) return + if (!graphContainer.value) { + if (initRetryCount >= INIT_RETRY_MAX) return + initRetryCount += 1 + initRetryTimer = setTimeout(initNetwork, 80) + return + } const el = graphContainer.value let width = el.offsetWidth let height = el.offsetHeight @@ -712,10 +728,17 @@ function initNetwork() { if (useCachedLayout) { restoreViewport() network.value.redraw() + finishInitialLayout({ fitView: false }) } else { - network.value.once('stabilizationIterationsDone', () => { + let layoutFinished = false + const completeLayout = () => { + if (layoutFinished) return + layoutFinished = true finishInitialLayout({ fitView: true }) - }) + } + network.value.once('stabilizationIterationsDone', completeLayout) + network.value.once('stabilized', completeLayout) + setTimeout(completeLayout, 2500) } resizeObserver = new ResizeObserver(() => { @@ -807,6 +830,9 @@ onMounted(() => { themeObserver = new MutationObserver(() => applyThemeToNetwork()) themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) document.addEventListener('fullscreenchange', onFullscreenChange) + nextTick(() => { + if (!network.value) ensureGraphReady() + }) }) onActivated(async () => { diff --git a/frontend/src/views/NetworkMapView.vue b/frontend/src/views/NetworkMapView.vue index 203d8fa..771b526 100644 --- a/frontend/src/views/NetworkMapView.vue +++ b/frontend/src/views/NetworkMapView.vue @@ -3,7 +3,7 @@ @@ -19,23 +19,30 @@ + Участник -