Improve graph/map UX and move contact editing to dedicated pages.
Add viewport controls, collapsible panels with persisted state, filters modal, and refined context menus; preserve map layout on navigation and let contacts be edited with relations on /contacts/:id instead of list modals. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -43,6 +43,18 @@
|
||||
<p v-if="selectedContact" class="selected-summary">
|
||||
Выбран: <strong>{{ selectedContact.name }}</strong>
|
||||
</p>
|
||||
|
||||
<RelationLinkFields
|
||||
v-if="availableLinkTargets.length"
|
||||
ref="relationLinkRef"
|
||||
:options="availableLinkTargets"
|
||||
:initial-target-id="initialLinkToId"
|
||||
:conflict-mode="conflictMode"
|
||||
label="Связать с участником карты"
|
||||
placeholder="Выберите участника..."
|
||||
hint="Необязательно — при добавлении на карту будет создана связь."
|
||||
/>
|
||||
|
||||
<p v-if="error" class="form-error">{{ error }}</p>
|
||||
|
||||
<div class="modal-footer">
|
||||
@@ -65,16 +77,21 @@
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { listContacts } from '../application/usecases/contacts'
|
||||
import { normalizeApiError } from '../lib/api/errors'
|
||||
import RelationLinkFields from './RelationLinkFields.vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
memberContactIds: { type: Array, default: () => [] },
|
||||
linkTargets: { type: Array, default: () => [] },
|
||||
initialLinkToId: { type: [String, Number], default: '' },
|
||||
conflictMode: { type: Boolean, default: false },
|
||||
onAdd: { type: Function, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const searchInputRef = ref(null)
|
||||
const relationLinkRef = ref(null)
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref([])
|
||||
const selectedContactId = ref('')
|
||||
@@ -91,6 +108,12 @@ const selectedContact = computed(() =>
|
||||
searchResults.value.find((c) => String(c.id) === String(selectedContactId.value)) || null
|
||||
)
|
||||
|
||||
const availableLinkTargets = computed(() => {
|
||||
const selectedId = String(selectedContactId.value)
|
||||
if (!selectedId) return props.linkTargets
|
||||
return props.linkTargets.filter((t) => String(t.value) !== selectedId)
|
||||
})
|
||||
|
||||
function resetState() {
|
||||
searchQuery.value = ''
|
||||
searchResults.value = []
|
||||
@@ -98,6 +121,7 @@ function resetState() {
|
||||
searching.value = false
|
||||
saving.value = false
|
||||
error.value = ''
|
||||
relationLinkRef.value?.reset?.()
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
@@ -174,7 +198,8 @@ async function submit() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await props.onAdd(selectedContactId.value)
|
||||
const relationLink = relationLinkRef.value?.getRelationLink?.() ?? null
|
||||
await props.onAdd(selectedContactId.value, relationLink)
|
||||
} catch (e) {
|
||||
error.value = normalizeApiError(e).message
|
||||
} finally {
|
||||
|
||||
@@ -45,6 +45,13 @@
|
||||
<label>Заметки</label>
|
||||
<textarea v-model="form.notes" class="form-control" placeholder="Дополнительная информация..." rows="3"></textarea>
|
||||
</div>
|
||||
<RelationLinkFields
|
||||
v-if="showRelationLink"
|
||||
ref="relationLinkRef"
|
||||
:options="linkToOptions"
|
||||
:initial-target-id="initialLinkToId"
|
||||
:conflict-mode="conflictMode"
|
||||
/>
|
||||
<component
|
||||
:is="Ext"
|
||||
v-for="(Ext, index) in contactFormExtensions"
|
||||
@@ -74,17 +81,23 @@ import { reactive, ref, watch, computed, onMounted } from 'vue'
|
||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||
import { listMembershipsByContact } from '../application/usecases/networkMaps'
|
||||
import { getContactFormExtensions } from '../core/pluginRegistry'
|
||||
import RelationLinkFields from './RelationLinkFields.vue'
|
||||
|
||||
const props = defineProps({
|
||||
initial: { type: Object, default: () => ({}) },
|
||||
initialMapIds: { type: Array, default: null },
|
||||
deletable: { type: Boolean, default: false },
|
||||
showRelationLink: { type: Boolean, default: false },
|
||||
linkToOptions: { type: Array, default: () => [] },
|
||||
initialLinkToId: { type: [String, Number], default: '' },
|
||||
conflictMode: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['submit', 'cancel', 'delete'])
|
||||
|
||||
const mapsStore = useNetworkMapsStore()
|
||||
const contactFormExtensions = getContactFormExtensions()
|
||||
const pluginTags = ref([])
|
||||
const relationLinkRef = ref(null)
|
||||
|
||||
const showDelete = computed(() => {
|
||||
if (props.deletable) return true
|
||||
@@ -150,7 +163,10 @@ onMounted(async () => {
|
||||
|
||||
function onSubmit() {
|
||||
const { mapIds, ...contactData } = form
|
||||
emit('submit', contactData, mapIds, { tags: [...pluginTags.value] })
|
||||
const relationLink = props.showRelationLink
|
||||
? relationLinkRef.value?.getRelationLink?.() ?? null
|
||||
: null
|
||||
emit('submit', contactData, mapIds, { tags: [...pluginTags.value] }, relationLink)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<div class="contact-relations-section" :class="{ 'contact-relations-section--standalone': standalone }">
|
||||
<div class="contact-relations-section__header">
|
||||
<h4>Связи ({{ contactRelations.length }})</h4>
|
||||
<button class="btn btn-primary btn-sm" type="button" @click="showAddRelation = true">
|
||||
+ Добавить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="contactRelations.length === 0" class="contact-relations-empty text-muted">
|
||||
Нет связей с другими контактами.
|
||||
</div>
|
||||
<div v-else class="relations-list">
|
||||
<div
|
||||
v-for="rel in contactRelations"
|
||||
:key="rel.id"
|
||||
class="relation-row"
|
||||
@click="openEditRelation(rel)"
|
||||
>
|
||||
<div class="relation-row__body">
|
||||
<div class="relation-row__main">
|
||||
<span class="relation-row__name">{{ otherContactName(rel) }}</span>
|
||||
<span :class="`badge badge-${rel.relation_type}`">{{ relLabel(rel.relation_type) }}</span>
|
||||
<span class="relation-row__intensity">{{ intensityLabel(rel.interaction_intensity) }}</span>
|
||||
</div>
|
||||
<div v-if="rel.description" class="relation-row__desc">{{ rel.description }}</div>
|
||||
</div>
|
||||
<div class="relation-row__actions" @click.stop>
|
||||
<button class="btn btn-secondary btn-sm" type="button" @click="openEditRelation(rel)">
|
||||
Изменить
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" type="button" @click="removeRelation(rel.id)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EditRelationModal
|
||||
:open="editRelationOpen"
|
||||
:relation="editRelationTarget"
|
||||
@close="closeEditRelation"
|
||||
@updated="closeEditRelation"
|
||||
@deleted="closeEditRelation"
|
||||
/>
|
||||
|
||||
<div v-if="showAddRelation" class="modal-overlay nested-overlay" @click.self="showAddRelation = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Добавить связь</h3>
|
||||
<button class="btn btn-secondary btn-sm" type="button" @click="showAddRelation = false">✕</button>
|
||||
</div>
|
||||
<div v-if="relError" class="alert alert-error">{{ relError }}</div>
|
||||
<div class="form-group">
|
||||
<label>С кем связать</label>
|
||||
<SearchableSelect
|
||||
v-model="newRel.targetId"
|
||||
:options="contactSelectOptions"
|
||||
placeholder="Введите имя контакта..."
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Тип связи</label>
|
||||
<RelationTypeSelect v-model="newRel.type" :options="relationTypes" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Интенсивность общения</label>
|
||||
<InteractionIntensitySelect v-model="newRel.interaction_intensity" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Описание (необязательно)</label>
|
||||
<input v-model="newRel.description" class="form-control" placeholder="Например: знакомы с 2018 года" />
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" type="button" @click="showAddRelation = false">Отмена</button>
|
||||
<button class="btn btn-primary" type="button" :disabled="!newRel.targetId" @click="addRelation">
|
||||
Создать связь
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useContactsStore } from '../stores/contacts'
|
||||
import EditRelationModal from './EditRelationModal.vue'
|
||||
import SearchableSelect from './SearchableSelect.vue'
|
||||
import InteractionIntensitySelect from './InteractionIntensitySelect.vue'
|
||||
import RelationTypeSelect from './RelationTypeSelect.vue'
|
||||
|
||||
const props = defineProps({
|
||||
contactId: { type: [String, Number], required: true },
|
||||
standalone: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const store = useContactsStore()
|
||||
const showAddRelation = ref(false)
|
||||
const editRelationOpen = ref(false)
|
||||
const editRelationTarget = ref(null)
|
||||
const relationTypes = ref([])
|
||||
const interactionIntensities = ref([])
|
||||
const relError = ref('')
|
||||
const newRel = ref({
|
||||
targetId: '',
|
||||
type: 'acquaintance',
|
||||
description: '',
|
||||
interaction_intensity: 'intense',
|
||||
})
|
||||
|
||||
const contactRelations = computed(() =>
|
||||
store.relations.filter(
|
||||
(r) => String(r.source) === String(props.contactId) || String(r.target) === String(props.contactId)
|
||||
)
|
||||
)
|
||||
|
||||
const otherContacts = computed(() =>
|
||||
store.contacts
|
||||
.filter((c) => String(c.id) !== String(props.contactId))
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
|
||||
)
|
||||
|
||||
const contactSelectOptions = computed(() =>
|
||||
otherContacts.value.map((c) => ({ value: c.id, label: c.name }))
|
||||
)
|
||||
|
||||
function relLabel(type) {
|
||||
return relationTypes.value.find((r) => r.value === type)?.label || type
|
||||
}
|
||||
|
||||
function intensityLabel(v) {
|
||||
return interactionIntensities.value.find((x) => x.value === v)?.label || v || ''
|
||||
}
|
||||
|
||||
function otherContactName(rel) {
|
||||
const cid = String(props.contactId)
|
||||
return String(rel.source) === cid ? rel.target_name : rel.source_name
|
||||
}
|
||||
|
||||
function openEditRelation(rel) {
|
||||
editRelationTarget.value = rel
|
||||
editRelationOpen.value = true
|
||||
}
|
||||
|
||||
function closeEditRelation() {
|
||||
editRelationOpen.value = false
|
||||
editRelationTarget.value = null
|
||||
}
|
||||
|
||||
async function addRelation() {
|
||||
relError.value = ''
|
||||
try {
|
||||
await store.createRelation({
|
||||
source: props.contactId,
|
||||
target: newRel.value.targetId,
|
||||
relation_type: newRel.value.type,
|
||||
description: newRel.value.description,
|
||||
interaction_intensity: newRel.value.interaction_intensity,
|
||||
})
|
||||
showAddRelation.value = false
|
||||
newRel.value = {
|
||||
targetId: '',
|
||||
type: 'acquaintance',
|
||||
description: '',
|
||||
interaction_intensity: 'intense',
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e.response?.data
|
||||
relError.value = typeof msg === 'object' ? JSON.stringify(msg) : String(msg)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRelation(id) {
|
||||
if (!window.confirm('Удалить связь?')) return
|
||||
await store.deleteRelation(id)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!store.relations.length) {
|
||||
await store.fetchRelations()
|
||||
}
|
||||
if (!store.contacts.length) {
|
||||
await store.fetchContacts()
|
||||
}
|
||||
const [rt, intensities] = await Promise.all([
|
||||
store.fetchRelationTypes(),
|
||||
store.fetchNetworkMapChoices(),
|
||||
])
|
||||
relationTypes.value = rt
|
||||
interactionIntensities.value = intensities?.interaction_intensities || []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contact-relations-section:not(.contact-relations-section--standalone) {
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.contact-relations-section--standalone .contact-relations-section__header h4 {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.contact-relations-section__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.contact-relations-section__header h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.contact-relations-empty {
|
||||
font-size: 13px;
|
||||
}
|
||||
.relations-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.relation-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
}
|
||||
.relation-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.relation-row:hover {
|
||||
background: var(--surface-alt);
|
||||
margin: 0 -8px;
|
||||
padding: 10px 8px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.relation-row__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.relation-row__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.relation-row__name {
|
||||
font-weight: 500;
|
||||
}
|
||||
.relation-row__intensity {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.relation-row__desc {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.relation-row__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nested-overlay {
|
||||
z-index: 950;
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,10 @@
|
||||
<ContactForm
|
||||
:initial="{}"
|
||||
:initial-map-ids="initialMapIds"
|
||||
:show-relation-link="showRelationLink"
|
||||
:link-to-options="linkToOptions"
|
||||
:initial-link-to-id="initialLinkToId"
|
||||
:conflict-mode="conflictMode"
|
||||
@submit="onSubmit"
|
||||
@cancel="onCancel"
|
||||
/>
|
||||
@@ -21,6 +25,10 @@ import ContactForm from './ContactForm.vue'
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
initialMapIds: { type: Array, default: () => [] },
|
||||
showRelationLink: { type: Boolean, default: false },
|
||||
linkToOptions: { type: Array, default: () => [] },
|
||||
initialLinkToId: { type: [String, Number], default: '' },
|
||||
conflictMode: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'created'])
|
||||
@@ -29,7 +37,7 @@ function onCancel() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onSubmit(contactData, mapIds, pluginPayload) {
|
||||
emit('created', contactData, mapIds, pluginPayload)
|
||||
function onSubmit(contactData, mapIds, pluginPayload, relationLink) {
|
||||
emit('created', contactData, mapIds, pluginPayload, relationLink)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -14,8 +14,15 @@
|
||||
@click.stop
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<button type="button" class="graph-node-menu__item" role="menuitem" @click="onCreateContact">
|
||||
Добавить контакт
|
||||
<button
|
||||
v-for="item in actions"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="graph-node-menu__item"
|
||||
role="menuitem"
|
||||
@click="onSelect(item.id)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
@@ -28,16 +35,21 @@ const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
x: { type: Number, default: 0 },
|
||||
y: { type: Number, default: 0 },
|
||||
actions: {
|
||||
type: Array,
|
||||
default: () => [{ id: 'create-contact', label: 'Добавить контакт' }],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'create-contact'])
|
||||
const emit = defineEmits(['close', 'create-contact', 'select'])
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onCreateContact() {
|
||||
emit('create-contact')
|
||||
function onSelect(id) {
|
||||
emit('select', id)
|
||||
if (id === 'create-contact') emit('create-contact')
|
||||
close()
|
||||
}
|
||||
|
||||
|
||||
@@ -14,18 +14,15 @@
|
||||
@click.stop
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<div class="graph-node-menu__title">{{ edgeTitle }}</div>
|
||||
<button type="button" class="graph-node-menu__item" role="menuitem" @click="onEdit">
|
||||
Редактировать связь
|
||||
Редактировать
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { RELATION_TYPES } from '../domain/networkChoices'
|
||||
import { CONFLICT_RELATION_TYPES } from '../domain/conflictology'
|
||||
import { onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
@@ -36,17 +33,6 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['close', 'edit'])
|
||||
|
||||
const typeLabels = Object.fromEntries([
|
||||
...RELATION_TYPES,
|
||||
...CONFLICT_RELATION_TYPES,
|
||||
].map((r) => [r.value, r.label]))
|
||||
|
||||
const edgeTitle = computed(() => {
|
||||
if (!props.edge) return 'Связь'
|
||||
const type = typeLabels[props.edge.relation_type] || props.edge.relation_type || 'Связь'
|
||||
return type
|
||||
})
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
@@ -90,18 +76,6 @@ onUnmounted(() => {
|
||||
box-shadow: var(--shadow);
|
||||
padding: 6px 0;
|
||||
}
|
||||
.graph-node-menu__title {
|
||||
padding: 6px 14px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 4px;
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.graph-node-menu__item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div v-if="open" class="modal-overlay" @click.self="close">
|
||||
<div class="modal graph-filters-modal">
|
||||
<div class="modal-header">
|
||||
<h3>Фильтры</h3>
|
||||
<button class="btn btn-secondary btn-sm" type="button" @click="close">✕</button>
|
||||
</div>
|
||||
|
||||
<p class="graph-filters-hint text-muted">
|
||||
Ctrl+клик (⌘+клик) по двум узлам — создать связь.
|
||||
<span v-if="linkSelectionCount === 1">
|
||||
Выбран: <strong>{{ linkSelection[0].name }}</strong>.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Типы связей</label>
|
||||
<RelationTypeFilters
|
||||
:relation-types="relationTypes"
|
||||
:active-values="activeFilters"
|
||||
@toggle="$emit('toggle', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="toolbarActions.length" class="form-group">
|
||||
<label>Дополнительно</label>
|
||||
<div class="graph-filters-actions">
|
||||
<button
|
||||
v-for="action in toolbarActions"
|
||||
:key="action.id"
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="onToolbarAction(action)"
|
||||
>
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import RelationTypeFilters from './RelationTypeFilters.vue'
|
||||
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
relationTypes: { type: Array, default: () => [] },
|
||||
activeFilters: { type: Array, default: () => [] },
|
||||
linkSelectionCount: { type: Number, default: 0 },
|
||||
linkSelection: { type: Array, default: () => [] },
|
||||
toolbarActions: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'toggle', 'toolbar-action'])
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onToolbarAction(action) {
|
||||
emit('toolbar-action', action)
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.graph-filters-modal {
|
||||
max-width: 520px;
|
||||
}
|
||||
.graph-filters-hint {
|
||||
font-size: 13px;
|
||||
margin: 0 0 16px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.graph-filters-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,8 @@
|
||||
<div class="graph-view-header">
|
||||
<h2>{{ title }}</h2>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-secondary btn-sm" @click="$emit('reset')">
|
||||
<slot name="actions" />
|
||||
<button v-if="showReset" class="btn btn-secondary btn-sm" @click="$emit('reset')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||
<path d="M3 3v5h5"/>
|
||||
@@ -19,6 +20,7 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: { type: String, default: 'Граф связей' },
|
||||
showReset: { type: Boolean, default: true },
|
||||
resetLabel: { type: String, default: 'Сбросить вид' },
|
||||
showPhysicsToggle: { type: Boolean, default: false },
|
||||
physicsEnabled: { type: Boolean, default: true },
|
||||
|
||||
@@ -14,24 +14,20 @@
|
||||
@click.stop
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<div class="graph-node-menu__title">{{ nodeLabel }}</div>
|
||||
<button type="button" class="graph-node-menu__item" role="menuitem" @click="onInfo">
|
||||
Информация
|
||||
</button>
|
||||
<RouterLink
|
||||
:to="`/contacts/${node.id}`"
|
||||
:to="{ path: `/contacts/${node.id}`, query: { edit: '1' } }"
|
||||
class="graph-node-menu__item graph-node-menu__link"
|
||||
role="menuitem"
|
||||
@click="close"
|
||||
>
|
||||
Открыть карточку
|
||||
Редактировать контакт
|
||||
</RouterLink>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { onMounted, onUnmounted, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -41,19 +37,12 @@ const props = defineProps({
|
||||
y: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'info'])
|
||||
|
||||
const nodeLabel = computed(() => props.node?.label || props.node?.name || '')
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onInfo() {
|
||||
emit('info', props.node)
|
||||
close()
|
||||
}
|
||||
|
||||
function onKeyDown(event) {
|
||||
if (event.key === 'Escape' && props.open) close()
|
||||
}
|
||||
@@ -88,18 +77,6 @@ onUnmounted(() => {
|
||||
box-shadow: var(--shadow);
|
||||
padding: 6px 0;
|
||||
}
|
||||
.graph-node-menu__title {
|
||||
padding: 6px 14px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 4px;
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.graph-node-menu__item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
<template>
|
||||
<div class="map-switcher">
|
||||
<label class="map-switcher-label">Карта</label>
|
||||
<select
|
||||
class="form-control map-switcher-select"
|
||||
:value="modelValue"
|
||||
@change="onSelect"
|
||||
>
|
||||
<option v-for="map in maps" :key="map.id" :value="String(map.id)">
|
||||
{{ map.name }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="$emit('create')">
|
||||
+ Новая
|
||||
</button>
|
||||
<button
|
||||
v-if="modelValue"
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
title="Настройки карты"
|
||||
@click="$emit('manage', modelValue)"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
<div class="map-switcher-controls">
|
||||
<select
|
||||
class="form-control map-switcher-select"
|
||||
:value="modelValue"
|
||||
@change="onSelect"
|
||||
>
|
||||
<option v-for="map in maps" :key="map.id" :value="String(map.id)">
|
||||
{{ map.name }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="$emit('create')">
|
||||
+ Новая
|
||||
</button>
|
||||
<button
|
||||
v-if="modelValue"
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm map-switcher-settings"
|
||||
title="Настройки карты"
|
||||
@click="$emit('manage', modelValue)"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -42,15 +44,28 @@ function onSelect(event) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.map-switcher-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.map-switcher-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.map-switcher-select {
|
||||
min-width: 160px;
|
||||
max-width: 240px;
|
||||
min-width: 140px;
|
||||
max-width: 220px;
|
||||
width: auto;
|
||||
}
|
||||
.map-switcher-settings {
|
||||
min-width: 34px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,16 +18,10 @@
|
||||
</div>
|
||||
<div class="network-map-actions">
|
||||
<slot name="toolbar" />
|
||||
<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" />
|
||||
</svg>
|
||||
По центру
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="!collapsed" class="network-map-legend">
|
||||
<div v-show="!collapsed && showLegend" class="network-map-legend">
|
||||
<slot name="legend" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,25 +35,28 @@ defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showLegend: { type: Boolean, default: true },
|
||||
})
|
||||
defineEmits(['toggle-collapse', 'fit'])
|
||||
defineEmits(['toggle-collapse'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.network-map-top-panel {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.network-map-top-panel.collapsed {
|
||||
min-height: 0;
|
||||
border-bottom: none;
|
||||
min-height: 28px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.panel-toggle-btn {
|
||||
position: absolute;
|
||||
bottom: -11px;
|
||||
bottom: 4px;
|
||||
left: 50%;
|
||||
z-index: 4;
|
||||
z-index: 6;
|
||||
width: 28px;
|
||||
height: 20px;
|
||||
margin-left: -14px;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="relation-link-fields">
|
||||
<div class="form-group">
|
||||
<label>{{ label }}</label>
|
||||
<SearchableSelect
|
||||
v-model="targetId"
|
||||
:options="options"
|
||||
:placeholder="placeholder"
|
||||
/>
|
||||
<p v-if="hint" class="field-hint text-muted">{{ hint }}</p>
|
||||
</div>
|
||||
|
||||
<template v-if="targetId">
|
||||
<div class="form-group">
|
||||
<label>{{ conflictMode ? 'Тип связи в конфликте' : 'Тип связи' }}</label>
|
||||
<RelationTypeSelect
|
||||
v-model="relationType"
|
||||
:options="conflictMode ? conflictTypes : undefined"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!conflictMode" class="form-group">
|
||||
<label>Интенсивность общения</label>
|
||||
<InteractionIntensitySelect v-model="intensity" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import SearchableSelect from './SearchableSelect.vue'
|
||||
import RelationTypeSelect from './RelationTypeSelect.vue'
|
||||
import InteractionIntensitySelect from './InteractionIntensitySelect.vue'
|
||||
import { CONFLICT_RELATION_TYPES } from '../domain/conflictology'
|
||||
|
||||
const conflictTypes = CONFLICT_RELATION_TYPES
|
||||
|
||||
const props = defineProps({
|
||||
options: { type: Array, default: () => [] },
|
||||
initialTargetId: { type: [String, Number], default: '' },
|
||||
conflictMode: { type: Boolean, default: false },
|
||||
label: { type: String, default: 'Связать с контактом' },
|
||||
placeholder: { type: String, default: 'Выберите контакт...' },
|
||||
hint: {
|
||||
type: String,
|
||||
default: 'Необязательно — контакт появится на графе и будет связан с выбранным.',
|
||||
},
|
||||
})
|
||||
|
||||
const targetId = ref('')
|
||||
const relationType = ref('acquaintance')
|
||||
const intensity = ref('intense')
|
||||
|
||||
const relationLink = computed(() => {
|
||||
if (!targetId.value) return null
|
||||
return {
|
||||
targetId: targetId.value,
|
||||
type: relationType.value,
|
||||
intensity: intensity.value,
|
||||
description: '',
|
||||
}
|
||||
})
|
||||
|
||||
function reset() {
|
||||
targetId.value = props.initialTargetId ? String(props.initialTargetId) : ''
|
||||
relationType.value = props.conflictMode ? 'conflict_neutral' : 'acquaintance'
|
||||
intensity.value = 'intense'
|
||||
}
|
||||
|
||||
function getRelationLink() {
|
||||
return relationLink.value
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.initialTargetId, props.conflictMode],
|
||||
() => reset(),
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
defineExpose({ getRelationLink, reset })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field-hint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.relation-link-fields + .relation-link-fields {
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ref } from 'vue'
|
||||
import { CONFLICT_CENTER_NODE_ID } from '../domain/conflictology'
|
||||
|
||||
export function useGraphNodeContextMenu() {
|
||||
const contextMenuOpen = ref(false)
|
||||
@@ -63,14 +64,39 @@ export function useGraphNodeContextMenu() {
|
||||
canvasContextMenuOpen.value = false
|
||||
}
|
||||
|
||||
function canvasPointerFromEvent(network, domEvent) {
|
||||
const canvas = network.canvas?.frame?.canvas
|
||||
if (!canvas || !domEvent) return null
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
return {
|
||||
x: domEvent.clientX - rect.left,
|
||||
y: domEvent.clientY - rect.top,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNodeAtPointer(network, domEvent, getNodes) {
|
||||
if (!domEvent || typeof network.getNodeAt !== 'function') return null
|
||||
const pointer = canvasPointerFromEvent(network, domEvent)
|
||||
if (!pointer) return null
|
||||
let nodeId = null
|
||||
try {
|
||||
nodeId = network.getNodeAt(pointer)
|
||||
} catch {
|
||||
nodeId = null
|
||||
}
|
||||
if (!nodeId) return null
|
||||
return getNodes().find((n) => String(n.id) === String(nodeId)) || null
|
||||
}
|
||||
|
||||
function resolveEdgeAtPointer(network, domEvent, getEdges) {
|
||||
if (!domEvent || typeof network.getEdgeAt !== 'function') return null
|
||||
const pointer = canvasPointerFromEvent(network, domEvent)
|
||||
if (!pointer) return null
|
||||
let edgeId = null
|
||||
if (domEvent && typeof network.getPointer === 'function' && typeof network.getEdgeAt === 'function') {
|
||||
try {
|
||||
edgeId = network.getEdgeAt(network.getPointer(domEvent))
|
||||
} catch {
|
||||
edgeId = null
|
||||
}
|
||||
try {
|
||||
edgeId = network.getEdgeAt(pointer)
|
||||
} catch {
|
||||
edgeId = null
|
||||
}
|
||||
if (!edgeId) return null
|
||||
return getEdges().find((e) => String(e.id) === String(edgeId)) || null
|
||||
@@ -81,28 +107,26 @@ export function useGraphNodeContextMenu() {
|
||||
const domEvent = params.event?.srcEvent || params.event
|
||||
domEvent?.preventDefault?.()
|
||||
|
||||
let edge = null
|
||||
if (params.edges?.length > 0) {
|
||||
let node = resolveNodeAtPointer(network, domEvent, getNodes)
|
||||
if (!node && params.nodes?.length > 0) {
|
||||
const id = params.nodes[0]
|
||||
node = getNodes().find((n) => String(n.id) === String(id)) || null
|
||||
}
|
||||
if (node && String(node.id) !== CONFLICT_CENTER_NODE_ID) {
|
||||
openContextMenu(node, domEvent)
|
||||
return
|
||||
}
|
||||
|
||||
let edge = resolveEdgeAtPointer(network, domEvent, getEdges)
|
||||
if (!edge && params.edges?.length > 0) {
|
||||
const edgeId = params.edges[0]
|
||||
edge = getEdges().find((e) => String(e.id) === String(edgeId)) || null
|
||||
}
|
||||
if (!edge) {
|
||||
edge = resolveEdgeAtPointer(network, domEvent, getEdges)
|
||||
}
|
||||
if (edge) {
|
||||
openEdgeContextMenu(edge, domEvent)
|
||||
return
|
||||
}
|
||||
|
||||
if (params.nodes?.length > 0) {
|
||||
const id = params.nodes[0]
|
||||
const node = getNodes().find((n) => String(n.id) === String(id))
|
||||
if (node) {
|
||||
openContextMenu(node, domEvent)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
openCanvasContextMenu(domEvent)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,85 @@ function union(parent, a, b) {
|
||||
if (ra !== rb) parent.set(ra, rb)
|
||||
}
|
||||
|
||||
function buildAdjacency(edges = []) {
|
||||
const adjacency = new Map()
|
||||
const touch = (id) => {
|
||||
const sid = String(id)
|
||||
if (!adjacency.has(sid)) adjacency.set(sid, new Set())
|
||||
return adjacency.get(sid)
|
||||
}
|
||||
for (const edge of edges) {
|
||||
const from = String(edge.from)
|
||||
const to = String(edge.to)
|
||||
touch(from).add(to)
|
||||
touch(to).add(from)
|
||||
}
|
||||
return adjacency
|
||||
}
|
||||
|
||||
function collectComponent(seedId, adjacency) {
|
||||
const start = String(seedId)
|
||||
if (!adjacency.has(start)) return new Set([start])
|
||||
const seen = new Set([start])
|
||||
const queue = [start]
|
||||
while (queue.length) {
|
||||
const current = queue.pop()
|
||||
for (const next of adjacency.get(current) || []) {
|
||||
if (seen.has(next)) continue
|
||||
seen.add(next)
|
||||
queue.push(next)
|
||||
}
|
||||
}
|
||||
return seen
|
||||
}
|
||||
|
||||
function nextClusterIndex(clusterMap) {
|
||||
let max = -1
|
||||
for (const value of clusterMap.values()) {
|
||||
if (value >= 0) max = Math.max(max, value)
|
||||
}
|
||||
return max + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Пересчитывает кластеры только для компонент, затронутых seedNodeIds.
|
||||
* Мутирует clusterMap на месте. Возвращает Set обновлённых nodeId.
|
||||
*/
|
||||
export function updateClusterMapForNodes(clusterMap, nodes, edges, seedNodeIds = []) {
|
||||
const seeds = [...new Set(seedNodeIds.map(String))].filter(Boolean)
|
||||
if (!seeds.length) return new Set()
|
||||
|
||||
const nodeIds = new Set(nodes.map((n) => String(n.id)))
|
||||
const adjacency = buildAdjacency(edges)
|
||||
const affected = new Set()
|
||||
const visited = new Set()
|
||||
|
||||
for (const seed of seeds) {
|
||||
if (!nodeIds.has(seed) || visited.has(seed)) continue
|
||||
const component = collectComponent(seed, adjacency)
|
||||
component.forEach((id) => {
|
||||
visited.add(id)
|
||||
if (nodeIds.has(id)) affected.add(id)
|
||||
})
|
||||
|
||||
const size = [...component].filter((id) => nodeIds.has(id)).length
|
||||
let clusterIdx = -1
|
||||
if (size >= 2) {
|
||||
const existing = [...component]
|
||||
.filter((id) => nodeIds.has(id))
|
||||
.map((id) => clusterMap.get(id))
|
||||
.filter((value) => value !== undefined && value >= 0)
|
||||
clusterIdx = existing.length ? Math.min(...existing) : nextClusterIndex(clusterMap)
|
||||
}
|
||||
|
||||
for (const id of component) {
|
||||
if (nodeIds.has(id)) clusterMap.set(id, clusterIdx)
|
||||
}
|
||||
}
|
||||
|
||||
return affected
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает Map<nodeId, clusterIndex>.
|
||||
* Связные компоненты из 2+ узлов получают уникальный индекс цвета,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { computeClusterMap } from './clusters'
|
||||
import { computeClusterMap, updateClusterMapForNodes } from './clusters'
|
||||
|
||||
describe('computeClusterMap', () => {
|
||||
const nodes = [
|
||||
@@ -32,3 +32,33 @@ describe('computeClusterMap', () => {
|
||||
expect(map.get('a')).not.toBe(map.get('c'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateClusterMapForNodes', () => {
|
||||
const nodes = [
|
||||
{ id: 'a' },
|
||||
{ id: 'b' },
|
||||
{ id: 'c' },
|
||||
{ id: 'd' },
|
||||
]
|
||||
|
||||
it('updates only the merged component when a new edge connects groups', () => {
|
||||
const clusterMap = computeClusterMap(nodes, [
|
||||
{ from: 'a', to: 'b' },
|
||||
{ from: 'c', to: 'd' },
|
||||
])
|
||||
const beforeD = clusterMap.get('d')
|
||||
|
||||
const affected = updateClusterMapForNodes(clusterMap, nodes, [
|
||||
{ from: 'a', to: 'b' },
|
||||
{ from: 'c', to: 'd' },
|
||||
{ from: 'b', to: 'c' },
|
||||
], ['b', 'c'])
|
||||
|
||||
expect(affected.has('a')).toBe(true)
|
||||
expect(affected.has('b')).toBe(true)
|
||||
expect(affected.has('c')).toBe(true)
|
||||
expect(affected.has('d')).toBe(true)
|
||||
expect(clusterMap.get('a')).toBe(clusterMap.get('d'))
|
||||
expect(beforeD).not.toBe(clusterMap.get('d'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
const STORAGE_KEY = 'sg-map-layout-v1'
|
||||
|
||||
const EMPTY = { positions: {}, scale: 1, view: null }
|
||||
|
||||
let memoryByMapId = null
|
||||
|
||||
function readAll() {
|
||||
if (memoryByMapId) return memoryByMapId
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY)
|
||||
memoryByMapId = raw ? JSON.parse(raw) : {}
|
||||
} catch {
|
||||
memoryByMapId = {}
|
||||
}
|
||||
return memoryByMapId
|
||||
}
|
||||
|
||||
export function readMapLayoutCache(mapId) {
|
||||
if (!mapId) return { ...EMPTY }
|
||||
const all = readAll()
|
||||
const entry = all[String(mapId)]
|
||||
return entry ? { ...EMPTY, ...entry } : { ...EMPTY }
|
||||
}
|
||||
|
||||
export function writeMapLayoutCache(mapId, { positions, scale, view }) {
|
||||
if (!mapId) return
|
||||
const key = String(mapId)
|
||||
const all = readAll()
|
||||
const prev = all[key] || { ...EMPTY }
|
||||
all[key] = {
|
||||
positions: positions ?? prev.positions,
|
||||
scale: scale ?? prev.scale,
|
||||
view: view ?? prev.view,
|
||||
}
|
||||
memoryByMapId = all
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(all))
|
||||
} catch {
|
||||
/* sessionStorage quota */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearMapLayoutCache(mapId) {
|
||||
if (!mapId) return
|
||||
const all = readAll()
|
||||
delete all[String(mapId)]
|
||||
memoryByMapId = all
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(all))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
const STORAGE_KEYS = {
|
||||
graph: 'ui.graph.topPanelCollapsed',
|
||||
map: 'ui.map.topPanelCollapsed',
|
||||
}
|
||||
|
||||
function readCollapsed(key) {
|
||||
try {
|
||||
return localStorage.getItem(key) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function writeCollapsed(key, collapsed) {
|
||||
try {
|
||||
if (collapsed) {
|
||||
localStorage.setItem(key, '1')
|
||||
} else {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage errors (private mode, quota, etc.).
|
||||
}
|
||||
}
|
||||
|
||||
export function loadTopPanelCollapsed(scope) {
|
||||
return readCollapsed(STORAGE_KEYS[scope])
|
||||
}
|
||||
|
||||
export function saveTopPanelCollapsed(scope, collapsed) {
|
||||
writeCollapsed(STORAGE_KEYS[scope], collapsed)
|
||||
}
|
||||
@@ -162,13 +162,15 @@ export const useContactsStore = defineStore('contacts', {
|
||||
this.relations.push(data)
|
||||
const sid = String(data.source)
|
||||
const tid = String(data.target)
|
||||
this.contacts = this.contacts.map((c) => {
|
||||
const id = String(c.id)
|
||||
for (let i = 0; i < this.contacts.length; i += 1) {
|
||||
const id = String(this.contacts[i].id)
|
||||
if (id === sid || id === tid) {
|
||||
return { ...c, relations_count: Number(c.relations_count || 0) + 1 }
|
||||
this.contacts[i] = {
|
||||
...this.contacts[i],
|
||||
relations_count: Number(this.contacts[i].relations_count || 0) + 1,
|
||||
}
|
||||
}
|
||||
return c
|
||||
})
|
||||
}
|
||||
await syncPendingChanges()
|
||||
this.bumpDataRevision()
|
||||
return data
|
||||
|
||||
@@ -2,19 +2,32 @@
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<div class="page-header__title">
|
||||
<button class="btn btn-secondary btn-sm" @click="$router.back()">← Назад</button>
|
||||
<button class="btn btn-secondary btn-sm" type="button" @click="goBack">← Назад</button>
|
||||
<h2>{{ contact?.name || 'Загрузка...' }}</h2>
|
||||
</div>
|
||||
<div v-if="contact && !editing" class="page-header__actions">
|
||||
<button class="btn btn-primary btn-sm" type="button" @click="startEdit">Редактировать</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-content" v-if="contact">
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:20px;">
|
||||
<!-- Info card -->
|
||||
<div v-if="contact && editing" class="page-content">
|
||||
<div class="card contact-edit-page">
|
||||
<h3 class="contact-edit-page__title">Редактировать контакт</h3>
|
||||
<ContactForm
|
||||
:initial="contact"
|
||||
deletable
|
||||
@submit="onUpdate"
|
||||
@cancel="closeEdit"
|
||||
@delete="confirmDeleteContact"
|
||||
/>
|
||||
<ContactRelationsSection :contact-id="contactId" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="contact" class="page-content">
|
||||
<div class="contact-detail-grid">
|
||||
<div class="card">
|
||||
<div class="card-section-header">
|
||||
<h3 class="card-section-title">Информация</h3>
|
||||
<button class="btn btn-primary btn-sm" type="button" @click="editing = true">Редактировать</button>
|
||||
</div>
|
||||
<h3 class="card-section-title">Информация</h3>
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<div>{{ contact.email || '—' }}</div>
|
||||
@@ -51,102 +64,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Relations card -->
|
||||
<div class="card">
|
||||
<div class="flex justify-between items-center" style="margin-bottom:16px;">
|
||||
<h3 style="font-size:14px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em;">Связи ({{ contactRelations.length }})</h3>
|
||||
<button class="btn btn-primary btn-sm" @click="showAddRelation = true">+ Добавить</button>
|
||||
</div>
|
||||
<div v-if="contactRelations.length === 0" class="empty-state" style="padding:20px 0;">
|
||||
<p>Нет связей с другими контактами.</p>
|
||||
</div>
|
||||
<div v-else class="relations-list">
|
||||
<div
|
||||
v-for="rel in contactRelations"
|
||||
:key="rel.id"
|
||||
class="relation-row"
|
||||
:class="{ 'is-selected': String(editRelationTarget?.id) === String(rel.id) }"
|
||||
@click="openEditRelation(rel)"
|
||||
>
|
||||
<div class="relation-row__body">
|
||||
<div class="relation-row__main">
|
||||
<span class="relation-row__name">{{ otherContactName(rel) }}</span>
|
||||
<span :class="`badge badge-${rel.relation_type}`">{{ relLabel(rel.relation_type) }}</span>
|
||||
<span class="relation-row__intensity">{{ intensityLabel(rel.interaction_intensity) }}</span>
|
||||
</div>
|
||||
<div v-if="rel.description" class="relation-row__desc">{{ rel.description }}</div>
|
||||
</div>
|
||||
<div class="relation-row__actions" @click.stop>
|
||||
<button class="btn btn-secondary btn-sm" type="button" @click="openEditRelation(rel)">
|
||||
Изменить
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" type="button" @click="removeRelation(rel.id)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EditRelationModal
|
||||
:open="editRelationOpen"
|
||||
:relation="editRelationTarget"
|
||||
@close="closeEditRelation"
|
||||
@updated="onRelationUpdated"
|
||||
@deleted="onRelationDeleted"
|
||||
/>
|
||||
|
||||
<!-- Edit modal -->
|
||||
<div v-if="editing" class="modal-overlay" @click.self="editing = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Редактировать контакт</h3>
|
||||
<button class="btn btn-secondary btn-sm" @click="editing = false">✕</button>
|
||||
</div>
|
||||
<ContactForm
|
||||
:initial="contact"
|
||||
deletable
|
||||
@submit="onUpdate"
|
||||
@cancel="editing = false"
|
||||
@delete="confirmDeleteContact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add relation modal -->
|
||||
<div v-if="showAddRelation" class="modal-overlay" @click.self="showAddRelation = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Добавить связь</h3>
|
||||
<button class="btn btn-secondary btn-sm" @click="showAddRelation = false">✕</button>
|
||||
</div>
|
||||
<div v-if="relError" class="alert alert-error">{{ relError }}</div>
|
||||
<div class="form-group">
|
||||
<label>С кем связать</label>
|
||||
<SearchableSelect
|
||||
v-model="newRel.targetId"
|
||||
:options="contactSelectOptions"
|
||||
placeholder="Введите имя контакта..."
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Тип связи</label>
|
||||
<RelationTypeSelect v-model="newRel.type" :options="relationTypes" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Интенсивность общения</label>
|
||||
<InteractionIntensitySelect v-model="newRel.interaction_intensity" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Описание (необязательно)</label>
|
||||
<input v-model="newRel.description" class="form-control" placeholder="Например: знакомы с 2018 года" />
|
||||
</div>
|
||||
<p class="text-muted" style="font-size:12px;margin:0 0 8px;">
|
||||
Стрелка на карте сети идёт от вас к выбранному контакту: вы указаны как источник связи.
|
||||
</p>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showAddRelation = false">Отмена</button>
|
||||
<button class="btn btn-primary" :disabled="!newRel.targetId" @click="addRelation">Создать связь</button>
|
||||
<ContactRelationsSection :contact-id="contactId" standalone />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,15 +73,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useContactsStore } from '../stores/contacts'
|
||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||
import ContactForm from '../components/ContactForm.vue'
|
||||
import EditRelationModal from '../components/EditRelationModal.vue'
|
||||
import SearchableSelect from '../components/SearchableSelect.vue'
|
||||
import InteractionIntensitySelect from '../components/InteractionIntensitySelect.vue'
|
||||
import RelationTypeSelect from '../components/RelationTypeSelect.vue'
|
||||
import ContactRelationsSection from '../components/ContactRelationsSection.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -170,38 +86,9 @@ const store = useContactsStore()
|
||||
const mapsStore = useNetworkMapsStore()
|
||||
const contact = ref(null)
|
||||
const editing = ref(false)
|
||||
const showAddRelation = ref(false)
|
||||
const editRelationOpen = ref(false)
|
||||
const editRelationTarget = ref(null)
|
||||
const relationTypes = ref([])
|
||||
const interactionIntensities = ref([])
|
||||
const relError = ref('')
|
||||
const newRel = ref({
|
||||
targetId: '',
|
||||
type: 'acquaintance',
|
||||
description: '',
|
||||
interaction_intensity: 'intense',
|
||||
})
|
||||
|
||||
const contactId = computed(() => route.params.id)
|
||||
|
||||
const contactRelations = computed(() =>
|
||||
store.relations.filter(
|
||||
(r) => String(r.source) === String(contactId.value) || String(r.target) === String(contactId.value)
|
||||
)
|
||||
)
|
||||
|
||||
const otherContacts = computed(() =>
|
||||
store.contacts
|
||||
.filter((c) => String(c.id) !== String(contactId.value))
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
|
||||
)
|
||||
|
||||
const contactSelectOptions = computed(() =>
|
||||
otherContacts.value.map((c) => ({ value: c.id, label: c.name }))
|
||||
)
|
||||
|
||||
const contactMapNames = computed(() => {
|
||||
const memberships = mapsStore.contactMemberships || []
|
||||
return memberships.map((m) => ({
|
||||
@@ -210,35 +97,34 @@ const contactMapNames = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
function relLabel(type) {
|
||||
return relationTypes.value.find((r) => r.value === type)?.label || type
|
||||
function isEditQuery(value) {
|
||||
return value === '1' || value === 'true'
|
||||
}
|
||||
|
||||
function intensityLabel(v) {
|
||||
return interactionIntensities.value.find((x) => x.value === v)?.label || v || ''
|
||||
function startEdit() {
|
||||
editing.value = true
|
||||
router.replace({ path: `/contacts/${contactId.value}`, query: { edit: '1' } })
|
||||
}
|
||||
|
||||
function otherContactName(rel) {
|
||||
const cid = String(contactId.value)
|
||||
return String(rel.source) === cid ? rel.target_name : rel.source_name
|
||||
function closeEdit() {
|
||||
editing.value = false
|
||||
if (route.query.edit) {
|
||||
router.replace({ path: `/contacts/${contactId.value}` })
|
||||
}
|
||||
}
|
||||
|
||||
function openEditRelation(rel) {
|
||||
editRelationTarget.value = rel
|
||||
editRelationOpen.value = true
|
||||
function goBack() {
|
||||
if (editing.value) {
|
||||
closeEdit()
|
||||
return
|
||||
}
|
||||
router.back()
|
||||
}
|
||||
|
||||
function closeEditRelation() {
|
||||
editRelationOpen.value = false
|
||||
editRelationTarget.value = null
|
||||
}
|
||||
|
||||
function onRelationUpdated() {
|
||||
closeEditRelation()
|
||||
}
|
||||
|
||||
function onRelationDeleted() {
|
||||
closeEditRelation()
|
||||
function confirmDeleteContact() {
|
||||
if (!contact.value) return
|
||||
if (!window.confirm(`Удалить контакт «${contact.value.name}» и все его связи?`)) return
|
||||
store.deleteContact(contact.value.id).then(() => router.push('/contacts'))
|
||||
}
|
||||
|
||||
async function loadContact() {
|
||||
@@ -252,118 +138,44 @@ async function onUpdate(data, mapIds, pluginPayload) {
|
||||
const { saveContactPluginData } = await import('../application/services/contactPluginService')
|
||||
await saveContactPluginData(contactId.value, pluginPayload)
|
||||
contact.value = { ...contact.value, ...data }
|
||||
editing.value = false
|
||||
closeEdit()
|
||||
await mapsStore.fetchContactMemberships(contactId.value)
|
||||
}
|
||||
|
||||
async function addRelation() {
|
||||
relError.value = ''
|
||||
try {
|
||||
await store.createRelation({
|
||||
source: contactId.value,
|
||||
target: newRel.value.targetId,
|
||||
relation_type: newRel.value.type,
|
||||
description: newRel.value.description,
|
||||
interaction_intensity: newRel.value.interaction_intensity,
|
||||
})
|
||||
showAddRelation.value = false
|
||||
newRel.value = {
|
||||
targetId: '',
|
||||
type: 'acquaintance',
|
||||
description: '',
|
||||
interaction_intensity: 'intense',
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e.response?.data
|
||||
relError.value = typeof msg === 'object' ? JSON.stringify(msg) : String(msg)
|
||||
}
|
||||
}
|
||||
watch(
|
||||
() => route.query.edit,
|
||||
(value) => {
|
||||
editing.value = isEditQuery(value)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function removeRelation(id) {
|
||||
await store.deleteRelation(id)
|
||||
}
|
||||
watch(
|
||||
() => route.params.id,
|
||||
async (id) => {
|
||||
if (!id) return
|
||||
await loadContact()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await mapsStore.fetchMaps()
|
||||
await loadContact()
|
||||
await Promise.all([store.fetchContacts(), store.fetchRelations()])
|
||||
const [rt, intensities] = await Promise.all([
|
||||
store.fetchRelationTypes(),
|
||||
store.fetchNetworkMapChoices(),
|
||||
])
|
||||
relationTypes.value = rt
|
||||
interactionIntensities.value = intensities?.interaction_intensities || []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.relations-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.contact-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.relation-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
.contact-edit-page__title {
|
||||
margin: 0 0 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.relation-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.relation-row:hover {
|
||||
background: var(--surface-alt);
|
||||
margin: 0 -12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.relation-row.is-selected {
|
||||
background: var(--accent-dim);
|
||||
margin: 0 -12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.relation-row__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.relation-row__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.relation-row__name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.relation-row__intensity {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.relation-row__desc {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.relation-row__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.map-link {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
|
||||
@@ -41,14 +41,6 @@
|
||||
Ctrl+клик (⌘+клик на Mac) по двум контактам — создать связь.
|
||||
</p>
|
||||
|
||||
<div v-if="linkSelectionCount === 1" class="alert alert-info link-hint">
|
||||
Выбран: <strong>{{ linkSelection[0].name }}</strong>.
|
||||
Удерживайте Ctrl (⌘ на Mac) и кликните по второму контакту.
|
||||
</div>
|
||||
<p v-else class="text-muted link-hint link-hint--static">
|
||||
Ctrl+клик (⌘+клик на Mac) по двум контактам — создать связь.
|
||||
</p>
|
||||
|
||||
<div v-if="store.loading" class="spinner"></div>
|
||||
<div v-else-if="store.contacts.length === 0" class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
@@ -63,9 +55,9 @@
|
||||
<tr>
|
||||
<th class="col-check">
|
||||
<input
|
||||
ref="selectAllCheckbox"
|
||||
type="checkbox"
|
||||
:checked="allSelected"
|
||||
:indeterminate="someSelected && !allSelected"
|
||||
aria-label="Выбрать все"
|
||||
@click.stop.prevent="toggleSelectAll"
|
||||
/>
|
||||
@@ -87,16 +79,23 @@
|
||||
}"
|
||||
@click="onRowClick(c, $event)"
|
||||
>
|
||||
<td class="col-check" @click.stop>
|
||||
<td class="col-check" @click.stop="toggleSelect(c.id)">
|
||||
<input
|
||||
v-model="selectedIds"
|
||||
type="checkbox"
|
||||
:checked="isSelected(c.id)"
|
||||
:value="String(c.id)"
|
||||
:aria-label="`Выбрать ${c.name}`"
|
||||
@click.stop.prevent="toggleSelect(c.id)"
|
||||
@click.stop
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div style="font-weight:500;">{{ c.name }}</div>
|
||||
<router-link
|
||||
:to="`/contacts/${c.id}`"
|
||||
class="contact-name"
|
||||
@click.stop
|
||||
>
|
||||
{{ c.name }}
|
||||
</router-link>
|
||||
<div class="text-muted mt-1">{{ c.position }}</div>
|
||||
</td>
|
||||
<td>{{ c.organization || '—' }}</td>
|
||||
@@ -124,22 +123,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="editTarget" class="modal-overlay" @click.self="editTarget = null">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Редактировать контакт</h3>
|
||||
<button class="btn btn-secondary btn-sm" @click="editTarget = null">✕</button>
|
||||
</div>
|
||||
<ContactForm
|
||||
:initial="editTarget"
|
||||
deletable
|
||||
@submit="onUpdate"
|
||||
@cancel="editTarget = null"
|
||||
@delete="onDeleteFromEdit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="deleteTarget" class="modal-overlay" @click.self="deleteTarget = null">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
@@ -183,7 +166,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useContactsStore } from '../stores/contacts'
|
||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||
@@ -191,17 +174,17 @@ import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
|
||||
import ContactForm from '../components/ContactForm.vue'
|
||||
import CreateRelationModal from '../components/CreateRelationModal.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useContactsStore()
|
||||
const mapsStore = useNetworkMapsStore()
|
||||
const router = useRouter()
|
||||
const search = ref('')
|
||||
const showCreate = ref(false)
|
||||
const editTarget = ref(null)
|
||||
const deleteTarget = ref(null)
|
||||
const bulkDeleteOpen = ref(false)
|
||||
const bulkDeleting = ref(false)
|
||||
const deleting = ref(false)
|
||||
const selectedIds = ref([])
|
||||
const selectAllCheckbox = ref(null)
|
||||
const relationModalOpen = ref(false)
|
||||
const relationPair = ref(null)
|
||||
|
||||
@@ -226,6 +209,12 @@ const allSelected = computed(() =>
|
||||
|
||||
const someSelected = computed(() => selectedCount.value > 0)
|
||||
|
||||
watch([allSelected, someSelected], () => {
|
||||
if (selectAllCheckbox.value) {
|
||||
selectAllCheckbox.value.indeterminate = someSelected.value && !allSelected.value
|
||||
}
|
||||
}, { flush: 'post' })
|
||||
|
||||
function isSelected(id) {
|
||||
const sid = String(id)
|
||||
return selectedIds.value.includes(sid)
|
||||
@@ -264,7 +253,7 @@ function onSearch() {
|
||||
|
||||
function onRowClick(c, event) {
|
||||
if (handleCtrlPick(c, event)) return
|
||||
goTo(c.id)
|
||||
toggleSelect(c.id)
|
||||
}
|
||||
|
||||
function closeRelationModal() {
|
||||
@@ -279,8 +268,6 @@ function onRelationCreated() {
|
||||
clearLinkSelection()
|
||||
}
|
||||
|
||||
function goTo(id) { router.push(`/contacts/${id}`) }
|
||||
|
||||
async function onCreate(data, mapIds, pluginPayload) {
|
||||
const created = await store.createContact(data)
|
||||
if (mapIds?.length) {
|
||||
@@ -292,15 +279,7 @@ async function onCreate(data, mapIds, pluginPayload) {
|
||||
}
|
||||
|
||||
function openEdit(c) {
|
||||
editTarget.value = { ...c }
|
||||
}
|
||||
|
||||
async function onUpdate(data, mapIds, pluginPayload) {
|
||||
await store.updateContact(editTarget.value.id, data)
|
||||
await mapsStore.setContactMapMemberships(editTarget.value.id, mapIds)
|
||||
const { saveContactPluginData } = await import('../application/services/contactPluginService')
|
||||
await saveContactPluginData(editTarget.value.id, pluginPayload)
|
||||
editTarget.value = null
|
||||
router.push({ name: 'ContactDetail', params: { id: c.id }, query: { edit: '1' } })
|
||||
}
|
||||
|
||||
function confirmDelete(c) { deleteTarget.value = c }
|
||||
@@ -354,16 +333,14 @@ tr.is-link-selected {
|
||||
background: color-mix(in srgb, var(--green) 12%, transparent);
|
||||
box-shadow: inset 3px 0 0 var(--green);
|
||||
}
|
||||
.link-hint {
|
||||
font-size: 12px;
|
||||
margin-bottom: 12px;
|
||||
.contact-name {
|
||||
display: inline-block;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.link-hint--static {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
tr.is-link-selected {
|
||||
background: color-mix(in srgb, var(--green) 12%, transparent);
|
||||
box-shadow: inset 3px 0 0 var(--green);
|
||||
.contact-name:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.link-hint {
|
||||
font-size: 12px;
|
||||
|
||||
+379
-150
@@ -3,36 +3,29 @@
|
||||
<GraphHeaderPanel
|
||||
v-show="!chromeCollapsed"
|
||||
title="Граф связей"
|
||||
:show-reset="false"
|
||||
:show-physics-toggle="true"
|
||||
:physics-enabled="physicsEnabled"
|
||||
@reset="resetView"
|
||||
@toggle-physics="togglePhysics"
|
||||
/>
|
||||
|
||||
<div v-show="!chromeCollapsed" class="graph-view-toolbar">
|
||||
<p class="graph-link-hint text-muted">
|
||||
Ctrl+клик (⌘+клик) по двум узлам — создать связь.
|
||||
<span v-if="linkSelectionCount === 1">
|
||||
Выбран: <strong>{{ linkSelection[0].name }}</strong>.
|
||||
</span>
|
||||
</p>
|
||||
<RelationTypeFilters
|
||||
:relation-types="allRelationTypes"
|
||||
:active-values="activeFilters"
|
||||
@toggle="toggleFilter"
|
||||
/>
|
||||
<div v-if="graphToolbarActions.length" class="graph-plugin-actions">
|
||||
<button
|
||||
v-for="action in graphToolbarActions"
|
||||
:key="action.id"
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="runGraphToolbarAction(action)"
|
||||
>
|
||||
{{ action.label }}
|
||||
>
|
||||
<template #actions>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="filtersOpen = true">
|
||||
Фильтры
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</GraphHeaderPanel>
|
||||
|
||||
<GraphFiltersModal
|
||||
:open="filtersOpen"
|
||||
:relation-types="allRelationTypes"
|
||||
:active-filters="activeFilters"
|
||||
:link-selection-count="linkSelectionCount"
|
||||
:link-selection="linkSelection"
|
||||
:toolbar-actions="graphToolbarActions"
|
||||
@close="filtersOpen = false"
|
||||
@toggle="toggleFilter"
|
||||
@toolbar-action="runGraphToolbarAction"
|
||||
/>
|
||||
|
||||
<div class="graph-area" ref="graphArea" @contextmenu.prevent="onGraphAreaContextMenu">
|
||||
<div class="graph-chrome-bar">
|
||||
@@ -56,26 +49,6 @@
|
||||
<span>{{ chromeCollapsed ? 'Показать панели' : 'Свернуть панели' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="!loading && nodes.length > 0"
|
||||
type="button"
|
||||
class="graph-fullscreen-btn"
|
||||
:title="isFullscreen ? 'Выйти из полноэкранного режима' : 'На весь экран'"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<svg v-if="!isFullscreen" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3"/>
|
||||
<path d="M21 8V5a2 2 0 0 0-2-2h-3"/>
|
||||
<path d="M3 16v3a2 2 0 0 0 2 2h3"/>
|
||||
<path d="M16 21h3a2 2 0 0 0 2-2v-3"/>
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 14h6v6"/>
|
||||
<path d="M20 10h-6V4"/>
|
||||
<path d="M14 10l7-7"/>
|
||||
<path d="M3 21l7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="loading" class="spinner"></div>
|
||||
<div v-else-if="nodes.length === 0" class="empty-state card" @contextmenu.prevent="onGraphAreaContextMenu">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
@@ -83,7 +56,41 @@
|
||||
</svg>
|
||||
<p>Нет контактов. <RouterLink to="/contacts">Добавьте контакты</RouterLink> или <RouterLink to="/import">импортируйте файл</RouterLink>.</p>
|
||||
</div>
|
||||
<div v-else id="graph-container" ref="graphContainer"></div>
|
||||
<div v-else class="graph-stack" ref="graphStack">
|
||||
<div class="graph-view-tools">
|
||||
<button
|
||||
type="button"
|
||||
class="graph-fit-btn btn btn-secondary btn-sm"
|
||||
title="По центру"
|
||||
@click="fitView"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
По центру
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="graph-fullscreen-btn"
|
||||
:title="isFullscreen ? 'Выйти из полноэкранного режима' : 'На весь экран'"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<svg v-if="!isFullscreen" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3"/>
|
||||
<path d="M21 8V5a2 2 0 0 0-2-2h-3"/>
|
||||
<path d="M3 16v3a2 2 0 0 0 2 2h3"/>
|
||||
<path d="M16 21h3a2 2 0 0 0 2-2v-3"/>
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 14h6v6"/>
|
||||
<path d="M20 10h-6V4"/>
|
||||
<path d="M14 10l7-7"/>
|
||||
<path d="M3 21l7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="graph-container" ref="graphContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Node detail panel -->
|
||||
@@ -127,7 +134,6 @@
|
||||
:x="contextMenuX"
|
||||
:y="contextMenuY"
|
||||
@close="closeContextMenu"
|
||||
@info="openNodeInfo"
|
||||
/>
|
||||
|
||||
<GraphEdgeContextMenu
|
||||
@@ -149,6 +155,9 @@
|
||||
|
||||
<CreateContactModal
|
||||
:open="createContactOpen"
|
||||
:show-relation-link="graphContactOptions.length > 0"
|
||||
:link-to-options="graphContactOptions"
|
||||
:initial-link-to-id="linkSelection[0]?.id"
|
||||
@close="createContactOpen = false"
|
||||
@created="onContactCreated"
|
||||
/>
|
||||
@@ -175,19 +184,19 @@
|
||||
defineOptions({ name: 'Graph' })
|
||||
|
||||
import { ref, computed, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, watch } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { RouterLink, useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||
import { Network, DataSet } from 'vis-network/standalone'
|
||||
import { useContactsStore } from '../stores/contacts'
|
||||
import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
|
||||
import { RELATION_COLORS } from '../lib/graph/relationColors'
|
||||
import { clusterColor } from '../lib/graph/clusterColors'
|
||||
import { computeClusterMap } from '../lib/graph/clusters'
|
||||
import { computeClusterMap, updateClusterMapForNodes } from '../lib/graph/clusters'
|
||||
import { computeGraphSeedPositions } from '../lib/graph/graphLayout'
|
||||
import { readGraphLayoutCache, writeGraphLayoutCache } from '../lib/graph/graphLayoutCache'
|
||||
import { applyIntensityToVisEdge } from '../lib/graph/relationIntensity'
|
||||
import { buildGraphFromStore, edgeFromRelation } from '../application/usecases/graph'
|
||||
import GraphHeaderPanel from '../components/GraphHeaderPanel.vue'
|
||||
import RelationTypeFilters from '../components/RelationTypeFilters.vue'
|
||||
import GraphFiltersModal from '../components/GraphFiltersModal.vue'
|
||||
import CreateRelationModal from '../components/CreateRelationModal.vue'
|
||||
import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
|
||||
import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
|
||||
@@ -197,6 +206,7 @@ import EditRelationModal from '../components/EditRelationModal.vue'
|
||||
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
|
||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||
import { getGraphToolbarActions } from '../core/pluginRegistry'
|
||||
import { loadTopPanelCollapsed, saveTopPanelCollapsed } from '../lib/ui/topPanelCollapseStorage'
|
||||
|
||||
let themeObserver = null
|
||||
let detachContextHandler = null
|
||||
@@ -221,10 +231,6 @@ const {
|
||||
|
||||
const createContactOpen = ref(false)
|
||||
|
||||
function openNodeInfo(node) {
|
||||
selectedNode.value = node || null
|
||||
}
|
||||
|
||||
function openCreateContact() {
|
||||
closeContextMenu()
|
||||
createContactOpen.value = true
|
||||
@@ -235,15 +241,29 @@ function onGraphAreaContextMenu(event) {
|
||||
openCanvasContextMenu(event)
|
||||
}
|
||||
|
||||
async function onContactCreated(data, mapIds, pluginPayload) {
|
||||
async function onContactCreated(data, mapIds, pluginPayload, relationLink) {
|
||||
const created = await store.createContact(data)
|
||||
if (mapIds?.length) {
|
||||
await mapsStore.setContactMapMemberships(created.id, mapIds)
|
||||
}
|
||||
const { saveContactPluginData } = await import('../application/services/contactPluginService')
|
||||
await saveContactPluginData(created.id, pluginPayload)
|
||||
|
||||
let relation = null
|
||||
if (relationLink?.targetId && String(relationLink.targetId) !== String(created.id)) {
|
||||
relation = await store.createRelation({
|
||||
source: created.id,
|
||||
target: relationLink.targetId,
|
||||
relation_type: relationLink.type,
|
||||
description: relationLink.description || '',
|
||||
interaction_intensity: relationLink.intensity,
|
||||
})
|
||||
}
|
||||
|
||||
createContactOpen.value = false
|
||||
clearLinkSelection()
|
||||
await ensureGraphReady({ showSpinner: false })
|
||||
if (relation) appendRelationEdge(relation)
|
||||
}
|
||||
|
||||
const store = useContactsStore()
|
||||
@@ -251,10 +271,12 @@ const mapsStore = useNetworkMapsStore()
|
||||
const router = useRouter()
|
||||
const graphToolbarActions = getGraphToolbarActions()
|
||||
const graphArea = ref(null)
|
||||
const graphStack = ref(null)
|
||||
const graphContainer = ref(null)
|
||||
const loading = ref(true)
|
||||
const isFullscreen = ref(false)
|
||||
const chromeCollapsed = ref(false)
|
||||
const filtersOpen = ref(false)
|
||||
const chromeCollapsed = ref(loadTopPanelCollapsed('graph'))
|
||||
const network = ref(null)
|
||||
const physicsEnabled = ref(true)
|
||||
const selectedNode = ref(null)
|
||||
@@ -282,6 +304,8 @@ const INIT_RETRY_MAX = 40
|
||||
let initRetryTimer = null
|
||||
let resizeObserver = null
|
||||
let syncedRevision = -1
|
||||
let graphViewActive = false
|
||||
let layoutSnapshotOnLeave = null
|
||||
let initialLayoutDone = false
|
||||
|
||||
const nodes = ref([])
|
||||
@@ -289,11 +313,19 @@ const edges = ref([])
|
||||
const allRelationTypes = ref([])
|
||||
const activeFilters = ref([])
|
||||
const clusterMap = ref(new Map())
|
||||
let prevLinkSelectionIds = new Set()
|
||||
|
||||
const selectedContact = computed(() =>
|
||||
selectedNode.value ? store.contactById(selectedNode.value.id) : null
|
||||
)
|
||||
|
||||
const graphContactOptions = computed(() =>
|
||||
store.contacts.map((c) => ({
|
||||
value: String(c.id),
|
||||
label: [c.name, c.organization].filter(Boolean).join(' · '),
|
||||
}))
|
||||
)
|
||||
|
||||
function cssVar(name, fallback) {
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
return value || fallback
|
||||
@@ -315,6 +347,89 @@ function recomputeClusters() {
|
||||
clusterMap.value = computeClusterMap(nodes.value, filteredEdges())
|
||||
}
|
||||
|
||||
function updateClustersLocal(seedNodeIds) {
|
||||
const map = clusterMap.value
|
||||
const affected = updateClusterMapForNodes(
|
||||
map,
|
||||
nodes.value,
|
||||
filteredEdges(),
|
||||
seedNodeIds
|
||||
)
|
||||
clusterMap.value = map
|
||||
return affected
|
||||
}
|
||||
|
||||
function refreshNodeStylesForIds(nodeIds) {
|
||||
if (!nodesDS || !nodeIds?.size) return
|
||||
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
|
||||
const livePositions = network.value?.getPositions() || {}
|
||||
const idSet = new Set([...nodeIds].map(String))
|
||||
const targets = nodes.value.filter((n) => idSet.has(String(n.id)))
|
||||
if (!targets.length) return
|
||||
nodesDS.update(
|
||||
targets.map((n) => {
|
||||
const id = String(n.id)
|
||||
const vis = mapGraphNodeToVis(n, linkIds)
|
||||
const pos = livePositions[id]
|
||||
return pos ? { ...vis, x: pos.x, y: pos.y } : vis
|
||||
})
|
||||
)
|
||||
network.value?.redraw()
|
||||
}
|
||||
|
||||
function refreshNodeStyles() {
|
||||
refreshNodeStylesForIds(new Set(nodes.value.map((n) => String(n.id))))
|
||||
}
|
||||
|
||||
function withPhysicsPaused(fn) {
|
||||
if (!network.value) {
|
||||
fn()
|
||||
return
|
||||
}
|
||||
|
||||
const savedPositions = network.value.getPositions()
|
||||
const savedView = {
|
||||
position: network.value.getViewPosition(),
|
||||
scale: network.value.getScale(),
|
||||
}
|
||||
const wasEnabled = physicsEnabled.value
|
||||
|
||||
if (wasEnabled) {
|
||||
network.value.setOptions({ physics: physicsOptions(false) })
|
||||
}
|
||||
|
||||
try {
|
||||
fn()
|
||||
} finally {
|
||||
if (nodesDS && savedPositions) {
|
||||
nodesDS.update(
|
||||
Object.entries(savedPositions).map(([id, pos]) => ({ id, x: pos.x, y: pos.y }))
|
||||
)
|
||||
}
|
||||
if (wasEnabled) {
|
||||
network.value.setOptions({ physics: physicsOptions(true) })
|
||||
}
|
||||
if (savedView.position) {
|
||||
network.value.moveTo({
|
||||
position: savedView.position,
|
||||
scale: savedView.scale || 1,
|
||||
animation: false,
|
||||
})
|
||||
}
|
||||
saveLayoutSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
function applyLocalEdgeChange(seedNodeIds, mutate) {
|
||||
withPhysicsPaused(() => {
|
||||
mutate()
|
||||
const affected = updateClustersLocal(seedNodeIds)
|
||||
const styleIds = new Set([...seedNodeIds].map(String))
|
||||
affected.forEach((id) => styleIds.add(id))
|
||||
refreshNodeStylesForIds(styleIds)
|
||||
})
|
||||
}
|
||||
|
||||
function nodeDegree(id) {
|
||||
const sid = String(id)
|
||||
return filteredEdges().filter(
|
||||
@@ -363,15 +478,11 @@ function mapGraphNodeToVis(n, linkIds = new Set()) {
|
||||
}
|
||||
}
|
||||
|
||||
function refreshNodeStyles() {
|
||||
if (!nodesDS) return
|
||||
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
|
||||
nodesDS.update(nodes.value.map((n) => mapGraphNodeToVis(n, linkIds)))
|
||||
network.value?.redraw()
|
||||
}
|
||||
|
||||
function applyLinkHighlights() {
|
||||
refreshNodeStyles()
|
||||
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
|
||||
const affected = new Set([...linkIds, ...prevLinkSelectionIds])
|
||||
prevLinkSelectionIds = linkIds
|
||||
refreshNodeStylesForIds(affected)
|
||||
}
|
||||
|
||||
function mapGraphEdgeToVis(e) {
|
||||
@@ -443,48 +554,53 @@ function updateGraphEdge(relation) {
|
||||
|
||||
function onRelationUpdated(relation) {
|
||||
closeEditRelation()
|
||||
updateGraphEdge(relation)
|
||||
syncedRevision = store.dataRevision
|
||||
if (network.value && physicsEnabled.value) {
|
||||
network.value.stabilize(80)
|
||||
}
|
||||
const edge = edgeFromRelation(relation)
|
||||
const seedNodeIds = [edge.from, edge.to]
|
||||
applyLocalEdgeChange(seedNodeIds, () => {
|
||||
updateGraphEdge(relation)
|
||||
})
|
||||
}
|
||||
|
||||
function removeGraphEdge(relationId) {
|
||||
const sid = String(relationId)
|
||||
edges.value = edges.value.filter((e) => String(e.id) !== sid)
|
||||
if (edgesDS?.get(sid)) edgesDS.remove(sid)
|
||||
recomputeClusters()
|
||||
refreshNodeStyles()
|
||||
const removed = edges.value.find((e) => String(e.id) === sid)
|
||||
const seedNodeIds = removed ? [removed.from, removed.to] : []
|
||||
applyLocalEdgeChange(seedNodeIds, () => {
|
||||
edges.value = edges.value.filter((e) => String(e.id) !== sid)
|
||||
if (edgesDS?.get(sid)) edgesDS.remove(sid)
|
||||
})
|
||||
}
|
||||
|
||||
function onRelationDeleted(relationId) {
|
||||
closeEditRelation()
|
||||
removeGraphEdge(relationId)
|
||||
syncedRevision = store.dataRevision
|
||||
removeGraphEdge(relationId)
|
||||
}
|
||||
|
||||
function appendRelationEdge(relation) {
|
||||
if (!relation) return
|
||||
const edge = edgeFromRelation(relation)
|
||||
if (!edges.value.some((e) => String(e.id) === String(edge.id))) {
|
||||
edges.value.push(edge)
|
||||
}
|
||||
if (!edgesDS) return
|
||||
const seedNodeIds = [edge.from, edge.to]
|
||||
|
||||
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(mapGraphEdgeToVis(edge))
|
||||
}
|
||||
recomputeClusters()
|
||||
refreshNodeStyles()
|
||||
applyLocalEdgeChange(seedNodeIds, () => {
|
||||
if (!edges.value.some((e) => String(e.id) === String(edge.id))) {
|
||||
edges.value.push(edge)
|
||||
}
|
||||
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
|
||||
if (
|
||||
activeFilters.value.length < allRelationTypes.value.length &&
|
||||
!activeFilters.value.includes(edge.relation_type)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!edgesDS.get(String(edge.id))) {
|
||||
edgesDS.add(mapGraphEdgeToVis(edge))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function closeRelationModal() {
|
||||
@@ -495,12 +611,12 @@ function closeRelationModal() {
|
||||
}
|
||||
|
||||
function onRelationCreated(relation) {
|
||||
syncedRevision = store.dataRevision
|
||||
relationModalOpen.value = false
|
||||
relationPair.value = null
|
||||
clearLinkSelection()
|
||||
applyLinkHighlights()
|
||||
appendRelationEdge(relation)
|
||||
syncedRevision = store.dataRevision
|
||||
}
|
||||
|
||||
watch(linkSelection, () => {
|
||||
@@ -552,6 +668,40 @@ function saveLayoutSnapshot() {
|
||||
})
|
||||
}
|
||||
|
||||
function captureLayoutSnapshot() {
|
||||
if (!network.value) return null
|
||||
const view = network.value.getViewPosition()
|
||||
return {
|
||||
positions: { ...network.value.getPositions() },
|
||||
scale: network.value.getScale(),
|
||||
view: view ? { x: view.x, y: view.y } : null,
|
||||
}
|
||||
}
|
||||
|
||||
function applyLayoutSnapshot(snapshot) {
|
||||
if (!network.value || !nodesDS || !snapshot) return
|
||||
const updates = Object.entries(snapshot.positions || {})
|
||||
.filter(([id]) => nodesDS.get(id))
|
||||
.map(([id, pos]) => ({ id, x: pos.x, y: pos.y }))
|
||||
if (updates.length) nodesDS.update(updates)
|
||||
if (snapshot.view) {
|
||||
network.value.moveTo({
|
||||
position: snapshot.view,
|
||||
scale: snapshot.scale || 1,
|
||||
animation: false,
|
||||
})
|
||||
}
|
||||
writeGraphLayoutCache(snapshot)
|
||||
}
|
||||
|
||||
function resizeNetworkCanvas() {
|
||||
if (!network.value || !graphContainer.value) return
|
||||
const { offsetWidth: w, offsetHeight: h } = graphContainer.value
|
||||
if (w > 10 && h > 10) {
|
||||
network.value.setSize(`${w}px`, `${h}px`)
|
||||
}
|
||||
}
|
||||
|
||||
function restoreViewport() {
|
||||
if (!network.value) return
|
||||
const cache = readGraphLayoutCache()
|
||||
@@ -588,12 +738,45 @@ function teardownNetwork() {
|
||||
edgesDS = null
|
||||
}
|
||||
|
||||
function syncGraphMetadataOnly(lockedPositions) {
|
||||
if (!network.value || !nodesDS || !edgesDS) return
|
||||
|
||||
const positions = lockedPositions || network.value.getPositions()
|
||||
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
|
||||
|
||||
const nextNodeIds = new Set(nodes.value.map((n) => String(n.id)))
|
||||
nodesDS.getIds().forEach((id) => {
|
||||
if (!nextNodeIds.has(String(id))) nodesDS.remove(id)
|
||||
})
|
||||
|
||||
nodes.value.forEach((n) => {
|
||||
const id = String(n.id)
|
||||
const vis = mapGraphNodeToVis(n, linkIds)
|
||||
const pos = positions[id]
|
||||
const payload = pos ? { ...vis, x: pos.x, y: pos.y } : vis
|
||||
if (nodesDS.get(id)) nodesDS.update(payload)
|
||||
else nodesDS.add(payload)
|
||||
})
|
||||
|
||||
const nextEdges = filteredEdges().map(mapGraphEdgeToVis)
|
||||
const nextEdgeIds = new Set(nextEdges.map((e) => String(e.id)))
|
||||
edgesDS.getIds().forEach((id) => {
|
||||
if (!nextEdgeIds.has(String(id))) edgesDS.remove(id)
|
||||
})
|
||||
nextEdges.forEach((edge) => {
|
||||
if (edgesDS.get(edge.id)) edgesDS.update(edge)
|
||||
else edgesDS.add(edge)
|
||||
})
|
||||
|
||||
recomputeClusters()
|
||||
}
|
||||
|
||||
function syncGraphToNetwork() {
|
||||
if (!network.value || !nodesDS || !edgesDS) return
|
||||
|
||||
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
|
||||
const livePositions = network.value.getPositions()
|
||||
const cachedPositions = readGraphLayoutCache().positions || {}
|
||||
const livePositions = network.value.getPositions()
|
||||
const seeds = computeGraphSeedPositions(nodes.value, filteredEdges())
|
||||
|
||||
const nextNodeIds = new Set(nodes.value.map((n) => String(n.id)))
|
||||
@@ -604,7 +787,7 @@ function syncGraphToNetwork() {
|
||||
nodes.value.forEach((n) => {
|
||||
const id = String(n.id)
|
||||
const vis = mapGraphNodeToVis(n, linkIds)
|
||||
const pos = livePositions[id] || cachedPositions[id] || seeds.get(id)
|
||||
const pos = cachedPositions[id] || livePositions[id] || seeds.get(id)
|
||||
const payload = pos ? { ...vis, x: pos.x, y: pos.y } : vis
|
||||
if (nodesDS.get(id)) nodesDS.update(payload)
|
||||
else nodesDS.add(payload)
|
||||
@@ -623,6 +806,7 @@ function syncGraphToNetwork() {
|
||||
recomputeClusters()
|
||||
refreshNodeStyles()
|
||||
syncedRevision = store.dataRevision
|
||||
restoreViewport()
|
||||
}
|
||||
|
||||
let ensureGraphReadyInFlight = null
|
||||
@@ -820,8 +1004,8 @@ function applyThemeToNetwork() {
|
||||
network.value.redraw()
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
network.value?.fit({ animation: { duration: 500, easingFunction: 'easeInOutQuad' } })
|
||||
function fitView() {
|
||||
network.value?.fit({ animation: { duration: 400, easingFunction: 'easeInOutQuad' }, padding: 56 })
|
||||
}
|
||||
|
||||
function togglePhysics() {
|
||||
@@ -830,7 +1014,7 @@ function togglePhysics() {
|
||||
}
|
||||
|
||||
async function toggleFullscreen() {
|
||||
const el = graphArea.value
|
||||
const el = graphStack.value
|
||||
if (!el) return
|
||||
try {
|
||||
if (document.fullscreenElement === el) {
|
||||
@@ -844,7 +1028,7 @@ async function toggleFullscreen() {
|
||||
}
|
||||
|
||||
function onFullscreenChange() {
|
||||
isFullscreen.value = document.fullscreenElement === graphArea.value
|
||||
isFullscreen.value = document.fullscreenElement === graphStack.value
|
||||
nextTick(() => {
|
||||
network.value?.redraw()
|
||||
network.value?.fit({ animation: false })
|
||||
@@ -853,6 +1037,7 @@ function onFullscreenChange() {
|
||||
|
||||
function toggleChrome() {
|
||||
chromeCollapsed.value = !chromeCollapsed.value
|
||||
saveTopPanelCollapsed('graph', chromeCollapsed.value)
|
||||
nextTick(() => network.value?.redraw())
|
||||
}
|
||||
|
||||
@@ -861,7 +1046,9 @@ function runGraphToolbarAction(action) {
|
||||
}
|
||||
|
||||
watch(() => store.dataRevision, async (revision) => {
|
||||
if (!network.value || revision === syncedRevision) return
|
||||
if (!network.value || !graphViewActive) return
|
||||
await nextTick()
|
||||
if (revision === syncedRevision) return
|
||||
await applyGraphDataFromStore()
|
||||
if (nodes.value.length === 0) {
|
||||
teardownNetwork()
|
||||
@@ -880,20 +1067,58 @@ onMounted(() => {
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
if (network.value && physicsEnabled.value) {
|
||||
network.value.setOptions({ physics: physicsOptions(false) })
|
||||
}
|
||||
layoutSnapshotOnLeave = captureLayoutSnapshot()
|
||||
saveLayoutSnapshot()
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
graphViewActive = true
|
||||
const snapshot = layoutSnapshotOnLeave
|
||||
layoutSnapshotOnLeave = null
|
||||
|
||||
if (network.value) {
|
||||
network.value.redraw()
|
||||
network.value.setOptions({ physics: physicsOptions(false) })
|
||||
await nextTick()
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
||||
resizeNetworkCanvas()
|
||||
|
||||
const lockedPositions = snapshot?.positions
|
||||
|| readGraphLayoutCache().positions
|
||||
|| {}
|
||||
|
||||
if (syncedRevision !== store.dataRevision) {
|
||||
await ensureGraphReady({ showSpinner: false })
|
||||
await applyGraphDataFromStore()
|
||||
syncGraphMetadataOnly(lockedPositions)
|
||||
syncedRevision = store.dataRevision
|
||||
}
|
||||
|
||||
if (snapshot) {
|
||||
applyLayoutSnapshot(snapshot)
|
||||
} else {
|
||||
restoreViewport()
|
||||
}
|
||||
|
||||
if (physicsEnabled.value) {
|
||||
network.value.setOptions({ physics: physicsOptions(true) })
|
||||
}
|
||||
network.value.redraw()
|
||||
return
|
||||
}
|
||||
await ensureGraphReady()
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
graphViewActive = false
|
||||
if (network.value && physicsEnabled.value) {
|
||||
network.value.setOptions({ physics: physicsOptions(false) })
|
||||
}
|
||||
if (!layoutSnapshotOnLeave) {
|
||||
layoutSnapshotOnLeave = captureLayoutSnapshot()
|
||||
}
|
||||
saveLayoutSnapshot()
|
||||
})
|
||||
|
||||
@@ -901,7 +1126,7 @@ onUnmounted(() => {
|
||||
saveLayoutSnapshot()
|
||||
closeContextMenu()
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||
if (document.fullscreenElement === graphArea.value) {
|
||||
if (document.fullscreenElement === graphStack.value) {
|
||||
document.exitFullscreen().catch(() => {})
|
||||
}
|
||||
themeObserver?.disconnect()
|
||||
@@ -917,15 +1142,6 @@ onUnmounted(() => {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.graph-view-toolbar {
|
||||
flex-shrink: 0;
|
||||
padding: 0 28px 10px;
|
||||
}
|
||||
.graph-plugin-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.graph-chrome-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -959,10 +1175,6 @@ onUnmounted(() => {
|
||||
.graph-view--chrome-collapsed .graph-area {
|
||||
padding-top: 4px;
|
||||
}
|
||||
.graph-link-hint {
|
||||
font-size: 12px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.graph-area {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
@@ -971,41 +1183,8 @@ onUnmounted(() => {
|
||||
flex-direction: column;
|
||||
padding: 0 28px 20px;
|
||||
}
|
||||
.graph-area:fullscreen {
|
||||
padding: 12px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.graph-area:fullscreen #graph-container {
|
||||
min-height: 0;
|
||||
}
|
||||
.graph-fullscreen-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 36px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.graph-fullscreen-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
.graph-area:fullscreen .graph-fullscreen-btn {
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
}
|
||||
#graph-container {
|
||||
.graph-stack {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
width: 100%;
|
||||
@@ -1014,4 +1193,54 @@ onUnmounted(() => {
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.graph-stack:fullscreen {
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
background: var(--bg);
|
||||
}
|
||||
.graph-view-tools {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.graph-fit-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.graph-fullscreen-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow);
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.graph-fullscreen-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
.graph-stack:fullscreen .graph-view-tools {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
#graph-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
:collapsed="topPanelCollapsed"
|
||||
:title="activeMap?.name || 'Карта сети'"
|
||||
:subtitle="mapSubtitle"
|
||||
@toggle-collapse="topPanelCollapsed = !topPanelCollapsed"
|
||||
@fit="fitView"
|
||||
:show-legend="isConflictology && !loading && nodes.length > 0"
|
||||
@toggle-collapse="toggleTopPanel"
|
||||
>
|
||||
<template #toolbar>
|
||||
<NetworkMapSwitcher
|
||||
@@ -15,24 +15,9 @@
|
||||
@create="openCreateMap"
|
||||
@manage="openEditMap"
|
||||
/>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="openAddContact">
|
||||
+ Участник
|
||||
</button>
|
||||
</template>
|
||||
<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">
|
||||
<p 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>
|
||||
@@ -56,6 +41,38 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="map-stack" ref="mapStack">
|
||||
<div class="map-view-tools">
|
||||
<button
|
||||
type="button"
|
||||
class="map-fit-btn btn btn-secondary btn-sm"
|
||||
title="По центру"
|
||||
@click="fitView"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
По центру
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="map-fullscreen-btn"
|
||||
:title="isFullscreen ? 'Выйти из полноэкранного режима' : 'На весь экран'"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<svg v-if="!isFullscreen" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3"/>
|
||||
<path d="M21 8V5a2 2 0 0 0-2-2h-3"/>
|
||||
<path d="M3 16v3a2 2 0 0 0 2 2h3"/>
|
||||
<path d="M16 21h3a2 2 0 0 0 2-2v-3"/>
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 14h6v6"/>
|
||||
<path d="M20 10h-6V4"/>
|
||||
<path d="M14 10l7-7"/>
|
||||
<path d="M3 21l7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="network-map-container" ref="graphContainer" class="map-vis"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,7 +120,6 @@
|
||||
:x="contextMenuX"
|
||||
:y="contextMenuY"
|
||||
@close="closeContextMenu"
|
||||
@info="openNodeInfo"
|
||||
/>
|
||||
|
||||
<GraphEdgeContextMenu
|
||||
@@ -119,13 +135,18 @@
|
||||
:open="canvasContextMenuOpen"
|
||||
:x="canvasContextMenuX"
|
||||
:y="canvasContextMenuY"
|
||||
:actions="mapCanvasMenuActions"
|
||||
@close="closeContextMenu"
|
||||
@create-contact="openCreateContact"
|
||||
@select="onCanvasMenuSelect"
|
||||
/>
|
||||
|
||||
<CreateContactModal
|
||||
:open="createContactOpen"
|
||||
:initial-map-ids="createContactMapIds"
|
||||
:show-relation-link="mapMemberLinkTargets.length > 0"
|
||||
:link-to-options="mapMemberLinkTargets"
|
||||
:initial-link-to-id="linkSelection[0]?.id"
|
||||
:conflict-mode="isConflictology"
|
||||
@close="createContactOpen = false"
|
||||
@created="onContactCreated"
|
||||
/>
|
||||
@@ -162,6 +183,9 @@
|
||||
<AddContactToMapModal
|
||||
:open="showAddContact"
|
||||
:member-contact-ids="memberContactIds"
|
||||
:link-targets="mapMemberLinkTargets"
|
||||
:initial-link-to-id="linkSelection[0]?.id"
|
||||
:conflict-mode="isConflictology"
|
||||
:on-add="onAddContactToMap"
|
||||
@close="showAddContact = false"
|
||||
/>
|
||||
@@ -169,8 +193,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, onActivated, nextTick, watch } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { ref, computed, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, watch } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||
import { Network, DataSet } from 'vis-network/standalone'
|
||||
import { useContactsStore } from '../stores/contacts'
|
||||
import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
|
||||
@@ -193,6 +217,7 @@ import {
|
||||
import { fetchGraphBundle } from '../composables/useGraphData'
|
||||
import { ensureDefaultNetworkMap } from '../application/usecases/networkMaps'
|
||||
import { edgeFromRelation } from '../application/usecases/graph'
|
||||
import { readMapLayoutCache, writeMapLayoutCache } from '../lib/map/mapLayoutCache'
|
||||
import NetworkMapTopPanel from '../components/NetworkMapTopPanel.vue'
|
||||
import NetworkMapSwitcher from '../components/NetworkMapSwitcher.vue'
|
||||
import NetworkMapFormModal from '../components/NetworkMapFormModal.vue'
|
||||
@@ -204,6 +229,7 @@ import GraphCanvasContextMenu from '../components/GraphCanvasContextMenu.vue'
|
||||
import CreateContactModal from '../components/CreateContactModal.vue'
|
||||
import EditRelationModal from '../components/EditRelationModal.vue'
|
||||
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
|
||||
import { loadTopPanelCollapsed, saveTopPanelCollapsed } from '../lib/ui/topPanelCollapseStorage'
|
||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||
import { useNetworkMapTypesStore } from '../stores/networkMapTypes'
|
||||
|
||||
@@ -231,9 +257,17 @@ const {
|
||||
|
||||
const createContactOpen = ref(false)
|
||||
|
||||
function openNodeInfo(node) {
|
||||
selectedNode.value = node || null
|
||||
selectedInvolvement.value = Number(node?.conflict_involvement) || 3
|
||||
const mapCanvasMenuActions = [
|
||||
{ id: 'add-participant', label: 'Добавить участника' },
|
||||
{ id: 'create-contact', label: 'Добавить контакт' },
|
||||
]
|
||||
|
||||
function onCanvasMenuSelect(actionId) {
|
||||
if (actionId === 'add-participant') {
|
||||
openAddContact()
|
||||
} else if (actionId === 'create-contact') {
|
||||
openCreateContact()
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateContact() {
|
||||
@@ -242,11 +276,12 @@ function openCreateContact() {
|
||||
}
|
||||
|
||||
function onMapBodyContextMenu(event) {
|
||||
if (loading.value || nodes.value.length > 0) return
|
||||
if (loading.value) return
|
||||
if (nodes.value.length > 0) return
|
||||
openCanvasContextMenu(event)
|
||||
}
|
||||
|
||||
async function onContactCreated(data, mapIds, pluginPayload) {
|
||||
async function onContactCreated(data, mapIds, pluginPayload, relationLink) {
|
||||
const created = await store.createContact(data)
|
||||
const targetMapIds = mapIds?.length
|
||||
? mapIds
|
||||
@@ -256,7 +291,19 @@ async function onContactCreated(data, mapIds, pluginPayload) {
|
||||
}
|
||||
const { saveContactPluginData } = await import('../application/services/contactPluginService')
|
||||
await saveContactPluginData(created.id, pluginPayload)
|
||||
|
||||
if (relationLink?.targetId && String(relationLink.targetId) !== String(created.id)) {
|
||||
await store.createRelation({
|
||||
source: created.id,
|
||||
target: relationLink.targetId,
|
||||
relation_type: relationLink.type,
|
||||
description: relationLink.description || '',
|
||||
interaction_intensity: relationLink.intensity,
|
||||
})
|
||||
}
|
||||
|
||||
createContactOpen.value = false
|
||||
clearLinkSelection()
|
||||
await load()
|
||||
}
|
||||
|
||||
@@ -405,11 +452,21 @@ const conflictSubject = ref('')
|
||||
const selectedInvolvement = ref(3)
|
||||
const memberContactIds = computed(() => nodes.value.map((n) => String(n.id)))
|
||||
|
||||
const mapMemberLinkTargets = computed(() =>
|
||||
nodes.value
|
||||
.filter((n) => String(n.id) !== CONFLICT_CENTER_NODE_ID)
|
||||
.map((n) => ({
|
||||
value: String(n.id),
|
||||
label: n.label || String(n.id),
|
||||
}))
|
||||
)
|
||||
|
||||
const mapFormOpen = ref(false)
|
||||
const mapFormTarget = ref({})
|
||||
const showAddContact = ref(false)
|
||||
const mapStack = ref(null)
|
||||
const graphContainer = ref(null)
|
||||
const isFullscreen = ref(false)
|
||||
const loading = ref(true)
|
||||
const network = ref(null)
|
||||
const selectedNode = ref(null)
|
||||
@@ -420,7 +477,6 @@ const editRelationTarget = ref(null)
|
||||
|
||||
const {
|
||||
linkSelection,
|
||||
linkSelectionCount,
|
||||
clearLinkSelection,
|
||||
handleCtrlPickNode,
|
||||
} = useCtrlLinkSelection({
|
||||
@@ -448,6 +504,8 @@ const RING_FILL_COLORS = [
|
||||
let nodesDS = null
|
||||
let edgesDS = null
|
||||
let resizeObserver = null
|
||||
let syncedMapRevision = -1
|
||||
let mapLayoutSnapshotOnLeave = null
|
||||
let initRetryTimer = null
|
||||
let initRetryCount = 0
|
||||
const INIT_RETRY_MAX = 40
|
||||
@@ -455,7 +513,12 @@ const INIT_RETRY_MAX = 40
|
||||
const selectedContact = computed(() =>
|
||||
selectedNode.value ? store.contactById(selectedNode.value.id) : null
|
||||
)
|
||||
const topPanelCollapsed = ref(false)
|
||||
const topPanelCollapsed = ref(loadTopPanelCollapsed('map'))
|
||||
|
||||
function toggleTopPanel() {
|
||||
topPanelCollapsed.value = !topPanelCollapsed.value
|
||||
saveTopPanelCollapsed('map', topPanelCollapsed.value)
|
||||
}
|
||||
|
||||
function cssVar(name, fallback) {
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
@@ -528,6 +591,7 @@ function resolveRelationForEdit(edge) {
|
||||
}
|
||||
|
||||
function openEditRelation(edge) {
|
||||
saveMapLayoutSnapshot()
|
||||
editRelationTarget.value = resolveRelationForEdit(edge)
|
||||
editRelationOpen.value = true
|
||||
}
|
||||
@@ -554,6 +618,10 @@ function updateGraphEdge(relation) {
|
||||
function onRelationUpdated(relation) {
|
||||
closeEditRelation()
|
||||
updateGraphEdge(relation)
|
||||
nextTick(() => {
|
||||
refreshPositions()
|
||||
restoreMapViewport()
|
||||
})
|
||||
}
|
||||
|
||||
function removeGraphEdge(relationId) {
|
||||
@@ -565,6 +633,10 @@ function removeGraphEdge(relationId) {
|
||||
function onRelationDeleted(relationId) {
|
||||
closeEditRelation()
|
||||
removeGraphEdge(relationId)
|
||||
nextTick(() => {
|
||||
refreshPositions()
|
||||
restoreMapViewport()
|
||||
})
|
||||
}
|
||||
|
||||
function closeRelationModal() {
|
||||
@@ -667,6 +739,67 @@ function measureLayout() {
|
||||
}
|
||||
}
|
||||
|
||||
function saveMapLayoutSnapshot() {
|
||||
if (!network.value || !mapId.value) return
|
||||
writeMapLayoutCache(mapId.value, {
|
||||
positions: network.value.getPositions(),
|
||||
scale: network.value.getScale(),
|
||||
view: network.value.getViewPosition(),
|
||||
})
|
||||
}
|
||||
|
||||
function captureMapLayoutSnapshot() {
|
||||
if (!network.value) return null
|
||||
const view = network.value.getViewPosition()
|
||||
return {
|
||||
positions: { ...network.value.getPositions() },
|
||||
scale: network.value.getScale(),
|
||||
view: view ? { x: view.x, y: view.y } : null,
|
||||
}
|
||||
}
|
||||
|
||||
function applyMapLayoutSnapshot(snapshot) {
|
||||
if (!network.value || !nodesDS || !snapshot) return
|
||||
const updates = Object.entries(snapshot.positions || {})
|
||||
.filter(([id]) => nodesDS.get(id))
|
||||
.map(([id, pos]) => ({ id, x: pos.x, y: pos.y }))
|
||||
if (updates.length) nodesDS.update(updates)
|
||||
if (snapshot.view) {
|
||||
network.value.moveTo({
|
||||
position: snapshot.view,
|
||||
scale: snapshot.scale || 1,
|
||||
animation: false,
|
||||
})
|
||||
}
|
||||
if (mapId.value) writeMapLayoutCache(mapId.value, snapshot)
|
||||
}
|
||||
|
||||
function resizeMapNetworkCanvas() {
|
||||
if (!network.value || !graphContainer.value) return
|
||||
const { offsetWidth: w, offsetHeight: h } = graphContainer.value
|
||||
if (w > 10 && h > 10) {
|
||||
network.value.setSize(`${w}px`, `${h}px`)
|
||||
}
|
||||
}
|
||||
|
||||
function restoreMapViewport() {
|
||||
if (!network.value) return
|
||||
const cache = readMapLayoutCache(mapId.value)
|
||||
if (!cache.view) return
|
||||
network.value.moveTo({
|
||||
position: cache.view,
|
||||
scale: cache.scale || 1,
|
||||
animation: false,
|
||||
})
|
||||
}
|
||||
|
||||
function currentMapViewport() {
|
||||
if (!network.value) return null
|
||||
const view = network.value.getViewPosition()
|
||||
if (!view) return null
|
||||
return { view, scale: network.value.getScale() || 1 }
|
||||
}
|
||||
|
||||
function filteredEdges() {
|
||||
const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
|
||||
return edges.value.filter(
|
||||
@@ -771,6 +904,7 @@ function initNetwork() {
|
||||
initRetryTimer = null
|
||||
}
|
||||
|
||||
saveMapLayoutSnapshot()
|
||||
network.value?.destroy()
|
||||
network.value = null
|
||||
|
||||
@@ -831,14 +965,25 @@ function initNetwork() {
|
||||
|
||||
network.value.on('zoom', () => {
|
||||
refreshLabelsByZoom()
|
||||
saveMapLayoutSnapshot()
|
||||
})
|
||||
|
||||
nextTick(() => {
|
||||
network.value?.fit({ animation: false, padding: 56 })
|
||||
const cache = readMapLayoutCache(mapId.value)
|
||||
if (cache.view) {
|
||||
restoreMapViewport()
|
||||
} else {
|
||||
network.value?.fit({ animation: false, padding: 56 })
|
||||
saveMapLayoutSnapshot()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function refreshPositions() {
|
||||
function refreshPositions({ preserveView = true } = {}) {
|
||||
const viewport = preserveView
|
||||
? (currentMapViewport() || readMapLayoutCache(mapId.value))
|
||||
: null
|
||||
|
||||
measureLayout()
|
||||
if (!nodesDS || !network.value) return
|
||||
const L = layout.value
|
||||
@@ -859,7 +1004,17 @@ function refreshPositions() {
|
||||
updates.unshift(buildCenterConflictNode(L))
|
||||
}
|
||||
nodesDS.update(updates)
|
||||
|
||||
if (viewport?.view) {
|
||||
network.value.moveTo({
|
||||
position: viewport.view,
|
||||
scale: viewport.scale || 1,
|
||||
animation: false,
|
||||
})
|
||||
}
|
||||
|
||||
network.value.redraw()
|
||||
saveMapLayoutSnapshot()
|
||||
}
|
||||
|
||||
|
||||
@@ -898,8 +1053,65 @@ function refreshEdges() {
|
||||
edgesDS.add(filteredEdges().map(mapEdgeToVis))
|
||||
}
|
||||
|
||||
async function syncMapDataFromStore(lockedPositions) {
|
||||
if (!mapId.value || !network.value) return
|
||||
const bundle = await fetchGraphBundle({ mapId: mapId.value })
|
||||
edges.value = bundle.edges
|
||||
const prevById = new Map(nodes.value.map((n) => [String(n.id), n]))
|
||||
nodes.value = bundle.nodes.map((fresh) => {
|
||||
const prev = prevById.get(String(fresh.id))
|
||||
return {
|
||||
...fresh,
|
||||
map_angle: prev?.map_angle ?? fresh.map_angle,
|
||||
map_radius_ratio: prev?.map_radius_ratio ?? fresh.map_radius_ratio,
|
||||
}
|
||||
})
|
||||
refreshEdges()
|
||||
if (!nodesDS) return
|
||||
const positions = lockedPositions || network.value.getPositions()
|
||||
nodesDS.update(
|
||||
nodes.value.map((n) => {
|
||||
const id = String(n.id)
|
||||
const pos = positions[id]
|
||||
return {
|
||||
id,
|
||||
label: n.label || '',
|
||||
title: [n.label, n.title].filter(Boolean).join('\n'),
|
||||
size: participantNodeSize(n),
|
||||
...(pos ? { x: pos.x, y: pos.y } : {}),
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function fitView() {
|
||||
network.value?.fit({ animation: { duration: 400, easingFunction: 'easeInOutQuad' }, padding: 56 })
|
||||
saveMapLayoutSnapshot()
|
||||
}
|
||||
|
||||
async function toggleFullscreen() {
|
||||
const el = mapStack.value
|
||||
if (!el) return
|
||||
try {
|
||||
if (document.fullscreenElement === el) {
|
||||
await document.exitFullscreen()
|
||||
} else {
|
||||
await el.requestFullscreen()
|
||||
}
|
||||
} catch {
|
||||
// Browser may block fullscreen without user gesture.
|
||||
}
|
||||
}
|
||||
|
||||
function onFullscreenChange() {
|
||||
isFullscreen.value = document.fullscreenElement === mapStack.value
|
||||
nextTick(() => {
|
||||
resizeMapNetworkCanvas()
|
||||
network.value?.redraw()
|
||||
if (isFullscreen.value) {
|
||||
fitView()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveMapTypeLabels() {
|
||||
@@ -947,9 +1159,11 @@ async function load() {
|
||||
nodesDS = null
|
||||
edgesDS = null
|
||||
}
|
||||
syncedMapRevision = store.dataRevision
|
||||
}
|
||||
|
||||
async function openAddContact() {
|
||||
closeContextMenu()
|
||||
showAddContact.value = true
|
||||
}
|
||||
|
||||
@@ -1003,9 +1217,19 @@ async function onMapDelete() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onAddContactToMap(contactId) {
|
||||
async function onAddContactToMap(contactId, relationLink) {
|
||||
await mapsStore.addContactToMap(mapId.value, contactId)
|
||||
if (relationLink?.targetId && String(relationLink.targetId) !== String(contactId)) {
|
||||
await store.createRelation({
|
||||
source: contactId,
|
||||
target: relationLink.targetId,
|
||||
relation_type: relationLink.type,
|
||||
description: relationLink.description || '',
|
||||
interaction_intensity: relationLink.intensity,
|
||||
})
|
||||
}
|
||||
showAddContact.value = false
|
||||
clearLinkSelection()
|
||||
await load()
|
||||
}
|
||||
|
||||
@@ -1028,10 +1252,37 @@ watch(mapId, async (next, prev) => {
|
||||
onMounted(async () => {
|
||||
themeObserver = new MutationObserver(() => applyThemeToNetwork())
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
})
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
mapLayoutSnapshotOnLeave = captureMapLayoutSnapshot()
|
||||
saveMapLayoutSnapshot()
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
const snapshot = mapLayoutSnapshotOnLeave
|
||||
mapLayoutSnapshotOnLeave = null
|
||||
|
||||
if (network.value) {
|
||||
await nextTick()
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
||||
resizeMapNetworkCanvas()
|
||||
|
||||
const lockedPositions = snapshot?.positions
|
||||
|| readMapLayoutCache(mapId.value).positions
|
||||
|| {}
|
||||
|
||||
if (syncedMapRevision !== store.dataRevision) {
|
||||
await syncMapDataFromStore(lockedPositions)
|
||||
syncedMapRevision = store.dataRevision
|
||||
}
|
||||
|
||||
if (snapshot) {
|
||||
applyMapLayoutSnapshot(snapshot)
|
||||
} else {
|
||||
restoreMapViewport()
|
||||
}
|
||||
network.value.redraw()
|
||||
return
|
||||
}
|
||||
@@ -1041,14 +1292,26 @@ onActivated(async () => {
|
||||
const stack = mapStack.value
|
||||
if (stack && !resizeObserver) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (editRelationOpen.value || relationModalOpen.value) return
|
||||
refreshPositions()
|
||||
network.value?.redraw()
|
||||
})
|
||||
resizeObserver.observe(stack)
|
||||
}
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
if (!mapLayoutSnapshotOnLeave) {
|
||||
mapLayoutSnapshotOnLeave = captureMapLayoutSnapshot()
|
||||
}
|
||||
saveMapLayoutSnapshot()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
saveMapLayoutSnapshot()
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||
if (document.fullscreenElement === mapStack.value) {
|
||||
document.exitFullscreen().catch(() => {})
|
||||
}
|
||||
if (initRetryTimer) clearTimeout(initRetryTimer)
|
||||
detachContextHandler?.()
|
||||
closeContextMenu()
|
||||
@@ -1074,11 +1337,6 @@ onUnmounted(() => {
|
||||
padding: 8px 28px 20px;
|
||||
position: relative;
|
||||
}
|
||||
.map-link-hint {
|
||||
font-size: 12px;
|
||||
margin: 0 0 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.map-stack {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
@@ -1089,6 +1347,50 @@ onUnmounted(() => {
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.map-stack:fullscreen {
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
background: var(--bg);
|
||||
}
|
||||
.map-view-tools {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.map-fit-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.map-fullscreen-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow);
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.map-fullscreen-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
.map-stack:fullscreen .map-view-tools {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
.map-vis {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
Reference in New Issue
Block a user