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 }}
+
+ Направление: кто оказывает давление (агрессор) → на кого (жертва).
+
⇄ Поменять направление
- Тип связи
-
+ {{ conflictMode ? 'Тип связи в конфликте' : 'Тип связи' }}
+
-
+
Интенсивность общения
@@ -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 }}
+
+ Направление: агрессор → жертва.
+
- Тип связи
-
+ {{ conflictMode ? 'Тип связи в конфликте' : 'Тип связи' }}
+
-
-
@@ -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 @@
+ Участник
-
-
+
+
+
+ В центре — предмет конфликта. Размер точки — вовлечённость. Стрелки: давление → жертва.
+ Ctrl+клик по двум участникам — добавить связь.
+
+
+ Ctrl+клик (⌘+клик) по двум контактам — создать связь.
+
+
+ Выбран: {{ linkSelection[0].name }} .
+
+
+
+ ● Открытый конфликт
+ - - Напряжение
+ ● Союз
+ - - Нейтральная
+
-
- Ctrl+клик (⌘+клик) по двум контактам — создать связь.
-
- Выбран: {{ linkSelection[0].name }} .
-
-
-
+
+
Сфера / круг / важность
{{ sphereLabels[selectedNode.life_sphere] || selectedNode.life_sphere }}
@@ -97,6 +115,7 @@
@@ -148,9 +168,13 @@ import {
circleLabelPositions,
} from '../lib/map/positioning'
import { geometryFromMapType, labelsFromMapType } from '../domain/mapTypeDefaults'
+import {
+ CONFLICT_CENTER_NODE_ID,
+ conflictRelationToVisEdge,
+ involvementNodeSize,
+} from '../domain/conflictology'
import { fetchGraphBundle } from '../composables/useGraphData'
import { edgeFromRelation } from '../application/usecases/graph'
-import RelationTypeFilters from '../components/RelationTypeFilters.vue'
import NetworkMapTopPanel from '../components/NetworkMapTopPanel.vue'
import NetworkMapSwitcher from '../components/NetworkMapSwitcher.vue'
import NetworkMapFormModal from '../components/NetworkMapFormModal.vue'
@@ -183,18 +207,36 @@ const {
function openNodeInfo(node) {
selectedNode.value = node || null
+ selectedInvolvement.value = Number(node?.conflict_involvement) || 3
}
-const GRID_SCALE = 5
+async function saveInvolvement() {
+ const node = selectedNode.value
+ if (!node?.membership_id) return
+ const involvement = Math.max(1, Math.min(5, Number(selectedInvolvement.value) || 3))
+ node.conflict_involvement = involvement
+ try {
+ await mapsStore.updateMembership(mapId.value, node.membership_id, {
+ conflict_involvement: involvement,
+ })
+ refreshPositions()
+ } catch (e) {
+ await load()
+ }
+}
+
+const GRID_SCALE = 5 / 1.5
function nodeById(id) {
return nodes.value.find((n) => String(n.id) === String(id))
}
async function persistNodePlacement(nodeId, canvasX, canvasY) {
+ if (String(nodeId) === CONFLICT_CENTER_NODE_ID) return
const node = nodeById(nodeId)
if (!node || !node.membership_id) return
const L = layout.value
+
const dx = canvasX - L.cx
const dy = canvasY - L.cy
const r = Math.sqrt(dx * dx + dy * dy)
@@ -231,6 +273,12 @@ async function persistNodePlacement(nodeId, canvasX, canvasY) {
}
}
+function participantNodeSize(n) {
+ return isConflictology.value
+ ? involvementNodeSize(n.conflict_involvement)
+ : importanceSize(n.importance)
+}
+
function importanceSize(importance) {
// Защита от "грязных" данных в БД: размер точки всегда в разумном диапазоне.
const raw = Number(importance)
@@ -287,6 +335,21 @@ const typesStore = useNetworkMapTypesStore()
const mapId = computed(() => String(route.params.mapId || ''))
const activeMap = computed(() => mapsStore.maps.find((m) => String(m.id) === mapId.value) || null)
+const activeMapType = computed(() => {
+ const typeId = activeMap.value?.mapTypeId
+ if (!typeId) return null
+ return typesStore.getById(typeId)
+})
+const isConflictology = computed(() => Boolean(activeMapType.value?.conflictologyEnabled))
+const mapSubtitle = computed(() => {
+ if (isConflictology.value) {
+ const subject = conflictSubject.value || activeMap.value?.conflictSubject || activeMap.value?.name
+ return subject ? `Конфликт: ${subject}` : 'Режим конфликтологии'
+ }
+ return activeMap.value?.description || ''
+})
+const conflictSubject = ref('')
+const selectedInvolvement = ref(3)
const memberContactIds = computed(() => nodes.value.map((n) => String(n.id)))
const mapFormOpen = ref(false)
@@ -316,8 +379,6 @@ const {
const nodes = ref([])
const edges = ref([])
-const allRelationTypes = ref([])
-const activeFilters = ref([])
const layout = ref({ w: 0, h: 0, cx: 0, cy: 0, rInner: 0, rMid: 0, rOuter: 0 })
const sphereLabels = ref({})
const circleLabels = ref({})
@@ -391,12 +452,6 @@ function appendRelationEdge(relation) {
const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
if (!nodeIds.has(String(edge.from)) || !nodeIds.has(String(edge.to))) return
- if (
- activeFilters.value.length < allRelationTypes.value.length &&
- !activeFilters.value.includes(edge.relation_type)
- ) {
- return
- }
if (!edgesDS.get(String(edge.id))) {
edgesDS.add(mapEdgeToVis(edge))
}
@@ -438,13 +493,6 @@ function updateGraphEdge(relation) {
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)
@@ -567,15 +615,14 @@ function measureLayout() {
}
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))
- if (activeFilters.value.length < allRelationTypes.value.length) {
- list = list.filter((e) => activeFilters.value.includes(e.relation_type))
- }
- return list
+ const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
+ return edges.value.filter(
+ (e) => nodeIds.has(String(e.from)) && nodeIds.has(String(e.to)),
+ )
}
function mapEdgeToVis(e) {
+ if (isConflictology.value) return conflictRelationToVisEdge(e)
const rc = RELATION_COLORS[e.relation_type] || RELATION_COLORS.other
const intensity = applyIntensityToVisEdge(e, { color: rc.color, highlight: rc.highlight })
return {
@@ -591,12 +638,33 @@ function mapEdgeToVis(e) {
}
}
+function buildCenterConflictNode(L) {
+ const palette = mapPalette()
+ const label = conflictSubject.value || activeMap.value?.name || 'Конфликт'
+ return {
+ id: CONFLICT_CENTER_NODE_ID,
+ label,
+ title: label,
+ x: L.cx,
+ y: L.cy,
+ fixed: { x: true, y: true },
+ shape: 'diamond',
+ size: 32,
+ color: {
+ background: '#c0392b',
+ border: '#922b21',
+ highlight: { background: '#e74c3c', border: '#c0392b' },
+ },
+ font: { color: palette.nodeFont, size: 12, multi: true, vadjust: 0 },
+ }
+}
+
function buildVisNodes() {
const L = layout.value
const posById = computePolarPositions(nodes.value, L, mapGeometry.value)
const labelMap = buildLabelMap(posById, network.value?.getScale?.() || 1)
const palette = mapPalette()
- return nodes.value.map((n) => {
+ const participants = nodes.value.map((n) => {
const { x, y } = nodeXY(n, L, posById, mapGeometry.value)
const name = n.label || String(n.id)
return {
@@ -613,10 +681,13 @@ function buildVisNodes() {
},
font: { color: palette.nodeFont, size: 11 },
shape: 'dot',
- size: importanceSize(n.importance),
- label: labelMap.get(n.id) || '',
+ size: participantNodeSize(n),
}
})
+ if (isConflictology.value) {
+ return [buildCenterConflictNode(L), ...participants]
+ }
+ return participants
}
function initNetwork() {
@@ -675,6 +746,7 @@ function initNetwork() {
closeContextMenu()
if (params.nodes.length > 0) {
const id = params.nodes[0]
+ if (id === CONFLICT_CENTER_NODE_ID) return
const node = nodes.value.find((n) => String(n.id) === id)
const domEvent = params.event?.srcEvent || params.event
if (domEvent && (domEvent.ctrlKey || domEvent.metaKey)) {
@@ -693,6 +765,7 @@ function initNetwork() {
network.value.on('dragEnd', async (params) => {
if (!params.nodes?.length) return
const id = params.nodes[0]
+ if (String(id) === CONFLICT_CENTER_NODE_ID) return
const p = network.value?.getPositions([id])?.[id]
if (!p) return
await persistNodePlacement(id, p.x, p.y)
@@ -720,10 +793,13 @@ function refreshPositions() {
x: L.cx + x,
y: L.cy + y,
fixed: false,
- size: importanceSize(n.importance),
+ size: participantNodeSize(n),
label: labelMap.get(n.id) || '',
}
})
+ if (isConflictology.value) {
+ updates.unshift(buildCenterConflictNode(L))
+ }
nodesDS.update(updates)
network.value.redraw()
}
@@ -764,16 +840,6 @@ function refreshEdges() {
edgesDS.add(filteredEdges().map(mapEdgeToVis))
}
-function toggleFilter(type) {
- if (activeFilters.value.includes(type)) {
- if (activeFilters.value.length === 1) return
- activeFilters.value = activeFilters.value.filter((f) => f !== type)
- } else {
- activeFilters.value.push(type)
- }
- refreshEdges()
-}
-
function fitView() {
network.value?.fit({ animation: { duration: 400, easingFunction: 'easeInOutQuad' }, padding: 56 })
}
@@ -801,10 +867,13 @@ async function load() {
mapsStore.setActiveMapId(mapId.value)
await resolveMapTypeLabels()
const bundle = await fetchGraphBundle({ mapId: mapId.value })
+ conflictSubject.value = bundle.conflictSubject
+ || activeMap.value?.conflictSubject
+ || activeMap.value?.conflict_subject
+ || activeMap.value?.name
+ || ''
nodes.value = bundle.nodes
edges.value = bundle.edges
- allRelationTypes.value = bundle.relationTypes
- activeFilters.value = bundle.relationTypes.map((r) => r.value)
} finally {
loading.value = false
}
@@ -927,12 +996,12 @@ onUnmounted(() => {
min-height: 0;
display: flex;
flex-direction: column;
- padding: 0 28px 20px;
+ padding: 8px 28px 20px;
position: relative;
}
.map-link-hint {
font-size: 12px;
- margin: 10px 0 8px;
+ margin: 0 0 6px;
flex-shrink: 0;
}
.map-stack {
@@ -951,4 +1020,23 @@ onUnmounted(() => {
width: 100%;
height: 100%;
}
+.involvement-row {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+.involvement-row .form-control {
+ max-width: 100px;
+}
+.conflict-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px 18px;
+ margin: 0 0 4px;
+ font-size: 12px;
+}
+.legend-open { color: #e74c3c; }
+.legend-tension { color: #f1c40f; }
+.legend-alliance { color: #2ecc71; }
+.legend-neutral { color: #95a5a6; }
diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
index 03844d9..048e074 100644
--- a/frontend/src/views/SettingsView.vue
+++ b/frontend/src/views/SettingsView.vue
@@ -10,7 +10,7 @@
+ Создать тип
- Тип задаёт подписи секторов и концентрических кругов. При создании карты сети выбирается один из типов.
+ Тип задаёт подписи секторов и концентрических кругов. Флаг «Конфликтология» включает особый режим связей и центра карты, не меняя секторы и круги.
@@ -22,6 +22,7 @@
{{ type.name }}
По умолчанию
+
Конфликтология
{{ type.sectors?.length || 0 }} секторов · {{ type.circles?.length || 0 }} кругов
@@ -142,4 +143,8 @@ async function onFormDelete() {
background: var(--accent-muted, rgba(91, 141, 238, 0.15));
color: var(--accent);
}
+.badge-conflict {
+ background: rgba(231, 76, 60, 0.15);
+ color: #e74c3c;
+}