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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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='Тип связи',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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='Тип связи',
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 || '',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || '',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,16 +13,22 @@
|
||||
<span class="relation-arrow">→</span>
|
||||
<strong style="color:var(--text)">{{ targetName }}</strong>
|
||||
</p>
|
||||
<p v-if="conflictMode" class="text-muted direction-hint">
|
||||
Направление: кто оказывает давление (агрессор) → на кого (жертва).
|
||||
</p>
|
||||
|
||||
<button class="btn btn-secondary btn-sm swap-btn" type="button" @click="swapEnds">
|
||||
⇄ Поменять направление
|
||||
</button>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Тип связи</label>
|
||||
<RelationTypeSelect v-model="form.type" />
|
||||
<label>{{ conflictMode ? 'Тип связи в конфликте' : 'Тип связи' }}</label>
|
||||
<RelationTypeSelect
|
||||
v-model="form.type"
|
||||
:options="conflictMode ? conflictTypes : undefined"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div v-if="!conflictMode" class="form-group">
|
||||
<label>Интенсивность общения</label>
|
||||
<InteractionIntensitySelect v-model="form.intensity" />
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,12 +13,18 @@
|
||||
<span class="relation-arrow">→</span>
|
||||
<strong style="color:var(--text)">{{ targetName }}</strong>
|
||||
</p>
|
||||
<p v-if="conflictMode" class="text-muted direction-hint">
|
||||
Направление: агрессор → жертва.
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Тип связи</label>
|
||||
<RelationTypeSelect v-model="form.type" />
|
||||
<label>{{ conflictMode ? 'Тип связи в конфликте' : 'Тип связи' }}</label>
|
||||
<RelationTypeSelect
|
||||
v-model="form.type"
|
||||
:options="conflictMode ? conflictTypes : undefined"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div v-if="!conflictMode" class="form-group">
|
||||
<label>Интенсивность общения</label>
|
||||
<InteractionIntensitySelect v-model="form.intensity" />
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { RELATION_TYPES } from '../domain/networkChoices'
|
||||
import { CONFLICT_RELATION_TYPES } from '../domain/conflictology'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
@@ -35,7 +36,10 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['close', 'edit'])
|
||||
|
||||
const typeLabels = Object.fromEntries(RELATION_TYPES.map((r) => [r.value, r.label]))
|
||||
const typeLabels = Object.fromEntries([
|
||||
...RELATION_TYPES,
|
||||
...CONFLICT_RELATION_TYPES,
|
||||
].map((r) => [r.value, r.label]))
|
||||
|
||||
const edgeTitle = computed(() => {
|
||||
if (!props.edge) return 'Связь'
|
||||
|
||||
@@ -14,10 +14,22 @@
|
||||
<label>Тип карты *</label>
|
||||
<select v-model="form.mapTypeId" class="form-control" required>
|
||||
<option v-for="type in mapTypes" :key="type.id" :value="String(type.id)">
|
||||
{{ type.name }}{{ type.isDefault ? ' (по умолчанию)' : '' }}
|
||||
{{ type.name }}{{ type.isDefault ? ' (по умолчанию)' : '' }}{{ type.conflictologyEnabled ? ' · конфликтология' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="selectedMapType?.conflictologyEnabled" class="form-group">
|
||||
<label>Предмет / инцидент конфликта (в центре карты) *</label>
|
||||
<input
|
||||
v-model="form.conflictSubject"
|
||||
class="form-control"
|
||||
required
|
||||
placeholder="Например: Спор о зоне ответственности"
|
||||
/>
|
||||
<p class="text-muted field-hint">
|
||||
Секторы и круги задаются в типе карты; здесь — только название конфликта в центре.
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Описание</label>
|
||||
<textarea
|
||||
@@ -64,8 +76,13 @@ const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
mapTypeId: '',
|
||||
conflictSubject: '',
|
||||
})
|
||||
|
||||
const selectedMapType = computed(() =>
|
||||
props.mapTypes.find((t) => String(t.id) === String(form.mapTypeId)) || null
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.open, props.initial, props.defaultMapTypeId, props.mapTypes],
|
||||
() => {
|
||||
@@ -75,15 +92,33 @@ watch(
|
||||
form.mapTypeId = String(
|
||||
props.initial?.mapTypeId || props.defaultMapTypeId || props.mapTypes[0]?.id || ''
|
||||
)
|
||||
form.conflictSubject = props.initial?.conflictSubject
|
||||
|| props.initial?.conflict_subject
|
||||
|| ''
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [form.mapTypeId, form.name, selectedMapType.value?.conflictologyEnabled],
|
||||
() => {
|
||||
if (!selectedMapType.value?.conflictologyEnabled) {
|
||||
form.conflictSubject = ''
|
||||
return
|
||||
}
|
||||
if (!form.conflictSubject.trim() && form.name.trim()) {
|
||||
form.conflictSubject = form.name.trim()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function onSubmit() {
|
||||
const conflictType = selectedMapType.value?.conflictologyEnabled
|
||||
emit('submit', {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
mapTypeId: form.mapTypeId,
|
||||
conflictSubject: conflictType ? form.conflictSubject.trim() : '',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -101,4 +136,9 @@ function onSubmit() {
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.field-hint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
</div>
|
||||
<div class="network-map-actions">
|
||||
<slot name="toolbar" />
|
||||
<slot name="filters" />
|
||||
<button class="btn btn-secondary btn-sm" @click="$emit('fit')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
|
||||
@@ -28,7 +27,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="!collapsed">
|
||||
<div v-show="!collapsed" class="network-map-legend">
|
||||
<slot name="legend" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,6 +11,17 @@
|
||||
<input v-model="form.name" class="form-control" required placeholder="Например: Корпоративная" />
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox-row">
|
||||
<label class="checkbox-label">
|
||||
<input v-model="form.conflictologyEnabled" type="checkbox" />
|
||||
Конфликтология
|
||||
</label>
|
||||
<p class="text-muted checkbox-hint">
|
||||
Цветовая кодировка связей, стрелки давления, центр карты — предмет конфликта.
|
||||
Секторы и круги настраиваются ниже как обычно.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Секторы (подписи по кругу)</label>
|
||||
<div v-for="(sector, index) in form.sectors" :key="sector._id" class="dynamic-row">
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -27,3 +27,5 @@ export const INTERACTION_INTENSITIES = [
|
||||
{ value: 'periodic', label: 'Периодические контакты' },
|
||||
{ value: 'sparse', label: 'Редкие контакты' },
|
||||
]
|
||||
|
||||
export { CONFLICT_RELATION_TYPES } from './conflictology'
|
||||
|
||||
@@ -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,6 +87,7 @@ export function applyCoreDbUpgrades(db) {
|
||||
stores.contactTags = CONTACT_TAGS_STORE
|
||||
}
|
||||
|
||||
if (db.verno < 4) {
|
||||
db.version(4).stores(stores).upgrade(async (tx) => {
|
||||
const ts = new Date().toISOString()
|
||||
const types = await tx.table('networkMapTypes').toArray()
|
||||
@@ -111,6 +110,41 @@ export function applyCoreDbUpgrades(db) {
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -11,6 +11,7 @@ function withDefaults(payload = {}) {
|
||||
name: '',
|
||||
description: '',
|
||||
mapTypeId: payload.mapTypeId || null,
|
||||
conflictSubject: '',
|
||||
workspaceId: 'personal',
|
||||
...payload,
|
||||
}
|
||||
|
||||
@@ -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}/`)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}/`)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<NetworkMapTopPanel
|
||||
:collapsed="topPanelCollapsed"
|
||||
:title="activeMap?.name || 'Карта сети'"
|
||||
:subtitle="activeMap?.description || ''"
|
||||
:subtitle="mapSubtitle"
|
||||
@toggle-collapse="topPanelCollapsed = !topPanelCollapsed"
|
||||
@fit="fitView"
|
||||
>
|
||||
@@ -19,23 +19,30 @@
|
||||
+ Участник
|
||||
</button>
|
||||
</template>
|
||||
<template #filters>
|
||||
<RelationTypeFilters
|
||||
:relation-types="allRelationTypes"
|
||||
:active-values="activeFilters"
|
||||
@toggle="toggleFilter"
|
||||
/>
|
||||
<template #legend>
|
||||
<p v-if="!loading && nodes.length > 0" class="map-link-hint text-muted">
|
||||
<template v-if="isConflictology">
|
||||
В центре — предмет конфликта. Размер точки — вовлечённость. Стрелки: давление → жертва.
|
||||
Ctrl+клик по двум участникам — добавить связь.
|
||||
</template>
|
||||
<template v-else>
|
||||
Ctrl+клик (⌘+клик) по двум контактам — создать связь.
|
||||
</template>
|
||||
<span v-if="linkSelectionCount === 1">
|
||||
Выбран: <strong>{{ linkSelection[0].name }}</strong>.
|
||||
</span>
|
||||
</p>
|
||||
<p v-if="!loading && nodes.length > 0 && isConflictology" class="conflict-legend text-muted">
|
||||
<span class="legend-item legend-open">● Открытый конфликт</span>
|
||||
<span class="legend-item legend-tension">- - Напряжение</span>
|
||||
<span class="legend-item legend-alliance">● Союз</span>
|
||||
<span class="legend-item legend-neutral">- - Нейтральная</span>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
</NetworkMapTopPanel>
|
||||
|
||||
<div class="network-map-body">
|
||||
<p v-if="!loading && nodes.length > 0" class="map-link-hint text-muted">
|
||||
Ctrl+клик (⌘+клик) по двум контактам — создать связь.
|
||||
<span v-if="linkSelectionCount === 1">
|
||||
Выбран: <strong>{{ linkSelection[0].name }}</strong>.
|
||||
</span>
|
||||
</p>
|
||||
<div v-if="loading" class="spinner"></div>
|
||||
<div v-else-if="nodes.length === 0" class="empty-state card">
|
||||
<p>
|
||||
@@ -57,7 +64,18 @@
|
||||
<button class="btn btn-secondary btn-sm" @click="selectedNode = null">✕</button>
|
||||
</div>
|
||||
<div v-if="selectedNode">
|
||||
<div class="form-group">
|
||||
<div v-if="isConflictology" class="form-group">
|
||||
<label>Вовлечённость в конфликт</label>
|
||||
<div class="involvement-row">
|
||||
<select v-model.number="selectedInvolvement" class="form-control">
|
||||
<option v-for="n in 5" :key="n" :value="n">{{ n }}/5</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="saveInvolvement">
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="form-group">
|
||||
<label>Сфера / круг / важность</label>
|
||||
<div>
|
||||
{{ sphereLabels[selectedNode.life_sphere] || selectedNode.life_sphere }}
|
||||
@@ -97,6 +115,7 @@
|
||||
<EditRelationModal
|
||||
:open="editRelationOpen"
|
||||
:relation="editRelationTarget"
|
||||
:conflict-mode="isConflictology"
|
||||
@close="closeEditRelation"
|
||||
@updated="onRelationUpdated"
|
||||
@deleted="onRelationDeleted"
|
||||
@@ -106,6 +125,7 @@
|
||||
:open="relationModalOpen"
|
||||
:source="relationPair?.[0]"
|
||||
:target="relationPair?.[1]"
|
||||
:conflict-mode="isConflictology"
|
||||
@close="closeRelationModal"
|
||||
@created="onRelationCreated"
|
||||
/>
|
||||
@@ -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; }
|
||||
</style>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="openCreate">+ Создать тип</button>
|
||||
</div>
|
||||
<p class="text-muted section-subtitle">
|
||||
Тип задаёт подписи секторов и концентрических кругов. При создании карты сети выбирается один из типов.
|
||||
Тип задаёт подписи секторов и концентрических кругов. Флаг «Конфликтология» включает особый режим связей и центра карты, не меняя секторы и круги.
|
||||
</p>
|
||||
|
||||
<div v-if="typesStore.loading" class="spinner" style="margin: 24px auto;"></div>
|
||||
@@ -22,6 +22,7 @@
|
||||
<div>
|
||||
<strong>{{ type.name }}</strong>
|
||||
<span v-if="type.isDefault" class="badge">По умолчанию</span>
|
||||
<span v-if="type.conflictologyEnabled" class="badge badge-conflict">Конфликтология</span>
|
||||
<div class="text-muted type-meta">
|
||||
{{ type.sectors?.length || 0 }} секторов · {{ type.circles?.length || 0 }} кругов
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user