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:
2026-06-25 11:51:06 +03:00
co-authored by Cursor
parent e80d0d0e88
commit 85b8b2e9b8
29 changed files with 744 additions and 132 deletions
@@ -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',
),
]
+16 -2
View File
@@ -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='Важность (15)',
)
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='Тип связи',
)
+3 -3
View File
@@ -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',
]
+10
View File
@@ -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],
+13 -3
View File
@@ -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 || '',
}
}
+16 -2
View File
@@ -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>
+19 -5
View File
@@ -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>
+14 -9
View File
@@ -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>
+89
View File
@@ -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
}
+1
View File
@@ -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,
+2
View File
@@ -27,3 +27,5 @@ export const INTERACTION_INTENSITIES = [
{ value: 'periodic', label: 'Периодические контакты' },
{ value: 'sparse', label: 'Редкие контакты' },
]
export { CONFLICT_RELATION_TYPES } from './conflictology'
+37 -3
View File
@@ -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()
@@ -113,4 +112,39 @@ 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,
+53
View File
@@ -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)
})
})
+31 -5
View File
@@ -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 () => {
+143 -55
View File
@@ -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>
+6 -1
View File
@@ -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>