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
@@ -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>