Cleanup: remove tracked pycache and db.sqlite3; add map membership backfill, new components, backups, docs

This commit is contained in:
gitrusprus
2026-06-28 22:16:01 +03:00
parent 4eb4c145b6
commit ce4e9ac5e6
46 changed files with 12125 additions and 176 deletions
@@ -83,4 +83,19 @@ describe('getGraphBundle', () => {
expect(bundle.edges).toHaveLength(1)
expect(bundle.edges[0].id).toBe('10')
})
it('returns empty graph when map has no memberships', async () => {
listContacts.mockResolvedValue([
{ id: '1', name: 'Анна' },
{ id: '2', name: 'Борис' },
])
listRelations.mockResolvedValue([
{ id: '10', source: '1', target: '2', relation_type: 'friend' },
])
listMembershipsByMap.mockResolvedValue([])
const bundle = await getGraphBundle({ mapId: 'map-1' })
expect(bundle.nodes).toHaveLength(0)
expect(bundle.edges).toHaveLength(0)
})
})
@@ -7,6 +7,8 @@ import {
const mapRepo = () => getNetworkMapRepository()
const membershipRepo = () => getNetworkMapMembershipRepository()
export const DEFAULT_NETWORK_MAP_NAME = 'Основная карта'
export async function listNetworkMaps() {
return mapRepo().list()
}
@@ -26,6 +28,19 @@ export async function createNetworkMap(payload) {
return created
}
export async function ensureDefaultNetworkMap() {
const maps = await listNetworkMaps()
if (maps.length) return maps[0]
const { getDefaultNetworkMapType } = await import('./networkMapTypes')
const defaultType = await getDefaultNetworkMapType()
return createNetworkMap({
name: DEFAULT_NETWORK_MAP_NAME,
description: '',
mapTypeId: defaultType?.id,
})
}
export async function updateNetworkMap(id, payload) {
const updated = await mapRepo().update(id, payload)
await appendChange({
+223 -38
View File
@@ -1,72 +1,257 @@
<template>
<div v-if="open" class="modal-overlay" @click.self="$emit('close')">
<div class="modal">
<div class="modal-header">
<h3>Добавить на карту</h3>
<button class="btn btn-secondary btn-sm" type="button" @click="$emit('close')"></button>
</div>
<div class="form-group">
<label>Контакт</label>
<SearchableSelect
v-model="selectedContactId"
:options="contactOptions"
placeholder="Выберите контакт..."
/>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="$emit('close')">Отмена</button>
<button
type="button"
class="btn btn-primary"
:disabled="!selectedContactId"
@click="onAdd"
>
Добавить
</button>
<Teleport to="body">
<div v-if="open" class="modal-overlay add-contact-modal" @click.self="onClose">
<div class="modal" role="dialog" aria-labelledby="add-contact-title">
<div class="modal-header">
<h3 id="add-contact-title">Добавить на карту</h3>
<button class="btn btn-secondary btn-sm" type="button" @click="onClose"></button>
</div>
<div class="form-group">
<label for="add-contact-search">Поиск контакта</label>
<input
id="add-contact-search"
ref="searchInputRef"
v-model="searchQuery"
type="search"
class="form-control"
placeholder="Введите имя..."
autocomplete="off"
@keydown.enter.prevent="selectFirstResult"
/>
<p v-if="!searchQuery.trim()" class="field-hint text-muted">
Начните вводить имя появится список контактов.
</p>
</div>
<div v-if="searchQuery.trim()" class="contact-results">
<p v-if="searching" class="text-muted results-status">Поиск</p>
<p v-else-if="!searchResults.length" class="text-muted results-status">Ничего не найдено</p>
<button
v-for="contact in searchResults"
:key="contact.id"
type="button"
class="contact-result"
:class="{ 'is-selected': String(selectedContactId) === String(contact.id) }"
@click="selectContact(contact)"
>
<span class="contact-result__name">{{ contact.name }}</span>
<span v-if="contact.organization" class="contact-result__meta">{{ contact.organization }}</span>
</button>
</div>
<p v-if="selectedContact" class="selected-summary">
Выбран: <strong>{{ selectedContact.name }}</strong>
</p>
<p v-if="error" class="form-error">{{ error }}</p>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="onClose">Отмена</button>
<button
type="button"
class="btn btn-primary"
:disabled="!selectedContactId || saving"
@click="submit"
>
{{ saving ? 'Добавление…' : 'Добавить' }}
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import SearchableSelect from './SearchableSelect.vue'
import { ref, computed, watch, nextTick } from 'vue'
import { listContacts } from '../application/usecases/contacts'
import { normalizeApiError } from '../lib/api/errors'
const props = defineProps({
open: { type: Boolean, default: false },
contacts: { type: Array, default: () => [] },
memberContactIds: { type: Array, default: () => [] },
onAdd: { type: Function, required: true },
})
const emit = defineEmits(['close', 'add'])
const emit = defineEmits(['close'])
const searchInputRef = ref(null)
const searchQuery = ref('')
const searchResults = ref([])
const selectedContactId = ref('')
const searching = ref(false)
const saving = ref(false)
const error = ref('')
let searchTimer = null
let searchRequestId = 0
const memberSet = computed(() => new Set(props.memberContactIds.map(String)))
const contactOptions = computed(() =>
props.contacts
.filter((c) => !memberSet.value.has(String(c.id)))
.map((c) => ({ value: String(c.id), label: c.name }))
const selectedContact = computed(() =>
searchResults.value.find((c) => String(c.id) === String(selectedContactId.value)) || null
)
function resetState() {
searchQuery.value = ''
searchResults.value = []
selectedContactId.value = ''
searching.value = false
saving.value = false
error.value = ''
}
function onClose() {
emit('close')
}
function selectContact(contact) {
selectedContactId.value = String(contact.id)
error.value = ''
}
function selectFirstResult() {
const first = searchResults.value[0]
if (first) selectContact(first)
}
async function runSearch(query) {
const requestId = ++searchRequestId
searching.value = true
error.value = ''
try {
const rows = await listContacts(query)
if (requestId !== searchRequestId) return
searchResults.value = rows.filter((c) => !memberSet.value.has(String(c.id)))
if (
selectedContactId.value
&& !searchResults.value.some((c) => String(c.id) === String(selectedContactId.value))
) {
selectedContactId.value = ''
}
} catch (e) {
if (requestId !== searchRequestId) return
searchResults.value = []
error.value = normalizeApiError(e).message
} finally {
if (requestId === searchRequestId) {
searching.value = false
}
}
}
watch(
() => props.open,
(isOpen) => {
if (isOpen) selectedContactId.value = ''
async (isOpen) => {
if (!isOpen) {
resetState()
return
}
resetState()
await nextTick()
searchInputRef.value?.focus()
}
)
function onAdd() {
if (!selectedContactId.value) return
emit('add', selectedContactId.value)
watch(searchQuery, (value) => {
clearTimeout(searchTimer)
selectedContactId.value = ''
error.value = ''
const query = value.trim()
if (!query) {
searchResults.value = []
searching.value = false
return
}
searchTimer = setTimeout(() => {
runSearch(query)
}, 250)
})
async function submit() {
if (!selectedContactId.value || saving.value) return
saving.value = true
error.value = ''
try {
await props.onAdd(selectedContactId.value)
} catch (e) {
error.value = normalizeApiError(e).message
} finally {
saving.value = false
}
}
</script>
<style scoped>
.add-contact-modal {
z-index: 1100;
}
.field-hint,
.results-status {
margin: 8px 0 0;
font-size: 13px;
}
.contact-results {
max-height: 260px;
overflow-y: auto;
margin-top: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-alt);
}
.contact-result {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
width: 100%;
padding: 10px 12px;
border: none;
border-bottom: 1px solid var(--border);
background: transparent;
color: var(--text);
text-align: left;
cursor: pointer;
}
.contact-result:last-child {
border-bottom: none;
}
.contact-result:hover,
.contact-result.is-selected {
background: rgba(91, 141, 238, 0.12);
}
.contact-result__name {
font-size: 14px;
font-weight: 500;
}
.contact-result__meta {
font-size: 12px;
color: var(--text-muted);
}
.selected-summary {
margin: 14px 0 0;
font-size: 13px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
.form-error {
margin: 12px 0 0;
font-size: 13px;
color: var(--red, #e74c3c);
}
</style>
@@ -0,0 +1,35 @@
<template>
<div v-if="open" class="modal-overlay" @click.self="onCancel">
<div class="modal">
<div class="modal-header">
<h3>Новый контакт</h3>
<button type="button" class="btn btn-secondary btn-sm" @click="onCancel"></button>
</div>
<ContactForm
:initial="{}"
:initial-map-ids="initialMapIds"
@submit="onSubmit"
@cancel="onCancel"
/>
</div>
</div>
</template>
<script setup>
import ContactForm from './ContactForm.vue'
defineProps({
open: { type: Boolean, default: false },
initialMapIds: { type: Array, default: () => [] },
})
const emit = defineEmits(['close', 'created'])
function onCancel() {
emit('close')
}
function onSubmit(contactData, mapIds, pluginPayload) {
emit('created', contactData, mapIds, pluginPayload)
}
</script>
@@ -0,0 +1,92 @@
<template>
<Teleport to="body">
<div
v-if="open"
class="graph-node-menu-overlay"
@click="close"
@contextmenu.prevent="close"
/>
<div
v-if="open"
class="graph-node-menu"
:style="{ left: `${x}px`, top: `${y}px` }"
role="menu"
@click.stop
@contextmenu.prevent
>
<button type="button" class="graph-node-menu__item" role="menuitem" @click="onCreateContact">
Добавить контакт
</button>
</div>
</Teleport>
</template>
<script setup>
import { onMounted, onUnmounted, watch } from 'vue'
const props = defineProps({
open: { type: Boolean, default: false },
x: { type: Number, default: 0 },
y: { type: Number, default: 0 },
})
const emit = defineEmits(['close', 'create-contact'])
function close() {
emit('close')
}
function onCreateContact() {
emit('create-contact')
close()
}
function onKeyDown(event) {
if (event.key === 'Escape' && props.open) close()
}
watch(() => props.open, (isOpen) => {
if (isOpen) window.addEventListener('keydown', onKeyDown)
else window.removeEventListener('keydown', onKeyDown)
})
onMounted(() => {
if (props.open) window.addEventListener('keydown', onKeyDown)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown)
})
</script>
<style scoped>
.graph-node-menu-overlay {
position: fixed;
inset: 0;
z-index: 900;
}
.graph-node-menu {
position: fixed;
z-index: 901;
min-width: 180px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
padding: 6px 0;
}
.graph-node-menu__item {
display: block;
width: 100%;
text-align: left;
padding: 8px 14px;
font-size: 13px;
color: var(--text);
background: transparent;
border: none;
cursor: pointer;
}
.graph-node-menu__item:hover {
background: var(--accent-dim);
}
</style>
+5 -2
View File
@@ -16,7 +16,7 @@
@blur="onBlur"
@keydown="onKeydown"
/>
<ul v-if="open && filteredOptions.length" class="searchable-select__list" role="listbox">
<ul v-if="open && query.trim() && filteredOptions.length" class="searchable-select__list" role="listbox">
<li
v-for="(opt, index) in filteredOptions"
:key="String(opt.value)"
@@ -32,6 +32,9 @@
{{ opt.label }}
</li>
</ul>
<p v-else-if="open && !query.trim()" class="searchable-select__empty">
Начните вводить имя для поиска
</p>
<p v-else-if="open && query.trim() && !filteredOptions.length" class="searchable-select__empty">
Ничего не найдено
</p>
@@ -62,7 +65,7 @@ const allOptions = computed(() =>
const filteredOptions = computed(() => {
const q = query.value.trim().toLocaleLowerCase('ru')
if (!q) return allOptions.value
if (!q) return []
return allOptions.value.filter((o) =>
o.label.toLocaleLowerCase('ru').includes(q)
)
@@ -9,11 +9,15 @@ export function useGraphNodeContextMenu() {
const contextMenuEdge = ref(null)
const edgeContextMenuX = ref(0)
const edgeContextMenuY = ref(0)
const canvasContextMenuOpen = ref(false)
const canvasContextMenuX = ref(0)
const canvasContextMenuY = ref(0)
function openContextMenu(node, event) {
if (!node || !event) return
edgeContextMenuOpen.value = false
contextMenuEdge.value = null
canvasContextMenuOpen.value = false
contextMenuNode.value = node
contextMenuX.value = event.clientX
contextMenuY.value = event.clientY
@@ -24,17 +28,30 @@ export function useGraphNodeContextMenu() {
if (!edge || !event) return
contextMenuOpen.value = false
contextMenuNode.value = null
canvasContextMenuOpen.value = false
contextMenuEdge.value = edge
edgeContextMenuX.value = event.clientX
edgeContextMenuY.value = event.clientY
edgeContextMenuOpen.value = true
}
function openCanvasContextMenu(event) {
if (!event) return
contextMenuOpen.value = false
contextMenuNode.value = null
edgeContextMenuOpen.value = false
contextMenuEdge.value = null
canvasContextMenuX.value = event.clientX
canvasContextMenuY.value = event.clientY
canvasContextMenuOpen.value = true
}
function closeContextMenu() {
contextMenuOpen.value = false
contextMenuNode.value = null
edgeContextMenuOpen.value = false
contextMenuEdge.value = null
canvasContextMenuOpen.value = false
}
function closeEdgeContextMenu() {
@@ -42,6 +59,10 @@ export function useGraphNodeContextMenu() {
contextMenuEdge.value = null
}
function closeCanvasContextMenu() {
canvasContextMenuOpen.value = false
}
function resolveEdgeAtPointer(network, domEvent, getEdges) {
let edgeId = null
if (domEvent && typeof network.getPointer === 'function' && typeof network.getEdgeAt === 'function') {
@@ -82,7 +103,7 @@ export function useGraphNodeContextMenu() {
}
}
closeContextMenu()
openCanvasContextMenu(domEvent)
}
network.on('oncontext', onContext)
@@ -98,10 +119,15 @@ export function useGraphNodeContextMenu() {
contextMenuEdge,
edgeContextMenuX,
edgeContextMenuY,
canvasContextMenuOpen,
canvasContextMenuX,
canvasContextMenuY,
openContextMenu,
openEdgeContextMenu,
openCanvasContextMenu,
closeContextMenu,
closeEdgeContextMenu,
closeCanvasContextMenu,
attachNodeContextHandlers,
}
}
@@ -23,7 +23,10 @@ export const remoteNetworkMapMembershipRepository = {
return data
},
async create(mapId, payload) {
const { data } = await api.post(`/network-maps/${mapId}/memberships/`, payload)
const { data } = await api.post(`/network-maps/${mapId}/memberships/`, {
map: mapId,
...payload,
})
return data
},
async update(mapId, id, payload) {
+9 -6
View File
@@ -3,6 +3,7 @@ import {
radiusRatioFromInvolvement,
involvementFromRadiusRatio,
} from '../../domain/conflictology'
import { hasStoredMapPlacement } from './positioning'
export { CONFLICT_CENTER_NODE_ID }
@@ -23,12 +24,14 @@ export function computeConflictPositions(nodes, layout) {
}
export function conflictNodeXY(node, layout, posById) {
const ratio = Number(node.map_radius_ratio)
const storedAngle = Number(node.map_angle)
if (Number.isFinite(ratio) && Number.isFinite(storedAngle)) {
const safeRatio = Math.max(0, Math.min(1, ratio))
const r = safeRatio * layout.rOuter
return { x: r * Math.cos(storedAngle), y: r * Math.sin(storedAngle) }
if (hasStoredMapPlacement(node)) {
const ratio = Number(node.map_radius_ratio)
const storedAngle = Number(node.map_angle)
if (Number.isFinite(ratio) && Number.isFinite(storedAngle)) {
const safeRatio = Math.max(0, Math.min(1, ratio))
const r = safeRatio * layout.rOuter
return { x: r * Math.cos(storedAngle), y: r * Math.sin(storedAngle) }
}
}
const p = posById.get(node.id)
if (p) return p
+14 -6
View File
@@ -53,6 +53,12 @@ export function normalizedCircle(n, geometry = defaultGeometry()) {
return circleKeys[Math.floor(circleKeys.length / 2)] || circleKeys[0] || 'productivity'
}
export function hasStoredMapPlacement(node) {
const angle = node?.map_angle
const ratio = node?.map_radius_ratio
return angle != null && ratio != null && angle !== '' && ratio !== ''
}
export function computePolarPositions(rawNodes, layout, geometry = defaultGeometry()) {
const { sectorKeys } = geometry
const groups = new Map()
@@ -87,12 +93,14 @@ export function computePolarPositions(rawNodes, layout, geometry = defaultGeomet
export function nodeXY(n, layout, posById, geometry = defaultGeometry()) {
const { sectorKeys } = geometry
const ratio = Number(n.map_radius_ratio)
const storedAngle = Number(n.map_angle)
if (Number.isFinite(ratio) && Number.isFinite(storedAngle)) {
const safeRatio = Math.max(0, Math.min(1, ratio))
const r = safeRatio * layout.rOuter
return { x: r * Math.cos(storedAngle), y: r * Math.sin(storedAngle) }
if (hasStoredMapPlacement(n)) {
const ratio = Number(n.map_radius_ratio)
const storedAngle = Number(n.map_angle)
if (Number.isFinite(ratio) && Number.isFinite(storedAngle)) {
const safeRatio = Math.max(0, Math.min(1, ratio))
const r = safeRatio * layout.rOuter
return { x: r * Math.cos(storedAngle), y: r * Math.sin(storedAngle) }
}
}
const p = posById.get(n.id)
if (p) return p
+14
View File
@@ -4,6 +4,7 @@ import {
sphereByAngle,
ringByRadius,
nodeXY,
computePolarPositions,
defaultGeometry,
} from './positioning'
@@ -40,6 +41,19 @@ describe('map positioning', () => {
expect(p.y).toBeCloseTo(0)
})
it('ignores null persisted coordinates and spreads nodes in sector', () => {
const layout = { rInner: 10, rMid: 20, rOuter: 100, cx: 0, cy: 0 }
const nodes = [
{ id: 1, life_sphere: 'other', network_circle: 'productivity', map_angle: null, map_radius_ratio: null },
{ id: 2, life_sphere: 'other', network_circle: 'productivity', map_angle: null, map_radius_ratio: null },
]
const posById = computePolarPositions(nodes, layout)
const p1 = nodeXY(nodes[0], layout, posById)
const p2 = nodeXY(nodes[1], layout, posById)
expect(p1.x).not.toBeCloseTo(p2.x)
expect(Math.hypot(p1.x, p1.y)).toBeGreaterThan(0)
})
it('defaultGeometry matches SPHERE_ORDER', () => {
expect(defaultGeometry().sectorKeys).toEqual(SPHERE_ORDER)
})
+47 -2
View File
@@ -34,7 +34,7 @@
</div>
</div>
<div class="graph-area" ref="graphArea">
<div class="graph-area" ref="graphArea" @contextmenu.prevent="onGraphAreaContextMenu">
<div class="graph-chrome-bar">
<button
type="button"
@@ -77,7 +77,7 @@
</svg>
</button>
<div v-if="loading" class="spinner"></div>
<div v-else-if="nodes.length === 0" class="empty-state card">
<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">
<circle cx="5" cy="12" r="3"/><circle cx="19" cy="5" r="3"/><circle cx="19" cy="19" r="3"/>
</svg>
@@ -139,6 +139,20 @@
@edit="openEditRelation"
/>
<GraphCanvasContextMenu
:open="canvasContextMenuOpen"
:x="canvasContextMenuX"
:y="canvasContextMenuY"
@close="closeContextMenu"
@create-contact="openCreateContact"
/>
<CreateContactModal
:open="createContactOpen"
@close="createContactOpen = false"
@created="onContactCreated"
/>
<EditRelationModal
:open="editRelationOpen"
:relation="editRelationTarget"
@@ -177,8 +191,11 @@ import RelationTypeFilters from '../components/RelationTypeFilters.vue'
import CreateRelationModal from '../components/CreateRelationModal.vue'
import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
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 { useNetworkMapsStore } from '../stores/networkMaps'
import { getGraphToolbarActions } from '../core/pluginRegistry'
let themeObserver = null
@@ -193,16 +210,44 @@ const {
contextMenuEdge,
edgeContextMenuX,
edgeContextMenuY,
canvasContextMenuOpen,
canvasContextMenuX,
canvasContextMenuY,
openCanvasContextMenu,
closeContextMenu,
closeEdgeContextMenu,
attachNodeContextHandlers,
} = useGraphNodeContextMenu()
const createContactOpen = ref(false)
function openNodeInfo(node) {
selectedNode.value = node || null
}
function openCreateContact() {
closeContextMenu()
createContactOpen.value = true
}
function onGraphAreaContextMenu(event) {
if (nodes.value.length > 0) return
openCanvasContextMenu(event)
}
async function onContactCreated(data, mapIds, pluginPayload) {
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)
createContactOpen.value = false
await ensureGraphReady({ showSpinner: false })
}
const store = useContactsStore()
const mapsStore = useNetworkMapsStore()
const router = useRouter()
const graphToolbarActions = getGraphToolbarActions()
const graphArea = ref(null)
+10 -5
View File
@@ -7,6 +7,7 @@
<script setup>
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ensureDefaultNetworkMap } from '../application/usecases/networkMaps'
import { useNetworkMapsStore } from '../stores/networkMaps'
const router = useRouter()
@@ -14,12 +15,16 @@ const mapsStore = useNetworkMapsStore()
onMounted(async () => {
await mapsStore.fetchMaps()
const id = mapsStore.activeMapId || mapsStore.maps[0]?.id
if (id) {
router.replace({ name: 'NetworkMap', params: { mapId: id } })
} else {
router.replace({ name: 'Contacts' })
const existing = mapsStore.maps.find(
(m) => String(m.id) === String(mapsStore.activeMapId),
) || mapsStore.maps[0]
const map = existing || await ensureDefaultNetworkMap()
if (!existing) {
await mapsStore.fetchMaps()
}
mapsStore.setActiveMapId(map.id)
router.replace({ name: 'NetworkMap', params: { mapId: map.id } })
})
</script>
+93 -11
View File
@@ -15,7 +15,7 @@
@create="openCreateMap"
@manage="openEditMap"
/>
<button type="button" class="btn btn-secondary btn-sm" @click="showAddContact = true">
<button type="button" class="btn btn-secondary btn-sm" @click="openAddContact">
+ Участник
</button>
</template>
@@ -42,15 +42,18 @@
</NetworkMapTopPanel>
<div class="network-map-body">
<div class="network-map-body" @contextmenu.prevent="onMapBodyContextMenu">
<div v-if="loading" class="spinner"></div>
<div v-else-if="nodes.length === 0" class="empty-state card">
<p>
На карте «{{ activeMap?.name || 'сети' }}» никого нет.
<button type="button" class="btn btn-link" @click="showAddContact = true">Добавьте участников</button>
из общего списка контактов или создайте новых в
<RouterLink to="/contacts">карточках контактов</RouterLink>.
</p>
<div class="empty-state-actions">
<button type="button" class="btn btn-primary btn-sm" @click="openAddContact">
+ Добавить участника
</button>
<span class="text-muted">или создайте контакт в <RouterLink to="/contacts">карточках контактов</RouterLink></span>
</div>
</div>
<div v-else class="map-stack" ref="mapStack">
<div id="network-map-container" ref="graphContainer" class="map-vis"></div>
@@ -112,6 +115,21 @@
@edit="openEditRelation"
/>
<GraphCanvasContextMenu
:open="canvasContextMenuOpen"
:x="canvasContextMenuX"
:y="canvasContextMenuY"
@close="closeContextMenu"
@create-contact="openCreateContact"
/>
<CreateContactModal
:open="createContactOpen"
:initial-map-ids="createContactMapIds"
@close="createContactOpen = false"
@created="onContactCreated"
/>
<EditRelationModal
:open="editRelationOpen"
:relation="editRelationTarget"
@@ -143,10 +161,9 @@
<AddContactToMapModal
:open="showAddContact"
:contacts="store.contacts"
:member-contact-ids="memberContactIds"
:on-add="onAddContactToMap"
@close="showAddContact = false"
@add="onAddContactToMap"
/>
</div>
</template>
@@ -174,6 +191,7 @@ import {
involvementNodeSize,
} from '../domain/conflictology'
import { fetchGraphBundle } from '../composables/useGraphData'
import { ensureDefaultNetworkMap } from '../application/usecases/networkMaps'
import { edgeFromRelation } from '../application/usecases/graph'
import NetworkMapTopPanel from '../components/NetworkMapTopPanel.vue'
import NetworkMapSwitcher from '../components/NetworkMapSwitcher.vue'
@@ -182,6 +200,8 @@ import AddContactToMapModal from '../components/AddContactToMapModal.vue'
import CreateRelationModal from '../components/CreateRelationModal.vue'
import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
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 { useNetworkMapsStore } from '../stores/networkMaps'
@@ -200,16 +220,46 @@ const {
contextMenuEdge,
edgeContextMenuX,
edgeContextMenuY,
canvasContextMenuOpen,
canvasContextMenuX,
canvasContextMenuY,
openCanvasContextMenu,
closeContextMenu,
closeEdgeContextMenu,
attachNodeContextHandlers,
} = useGraphNodeContextMenu()
const createContactOpen = ref(false)
function openNodeInfo(node) {
selectedNode.value = node || null
selectedInvolvement.value = Number(node?.conflict_involvement) || 3
}
function openCreateContact() {
closeContextMenu()
createContactOpen.value = true
}
function onMapBodyContextMenu(event) {
if (loading.value || nodes.value.length > 0) return
openCanvasContextMenu(event)
}
async function onContactCreated(data, mapIds, pluginPayload) {
const created = await store.createContact(data)
const targetMapIds = mapIds?.length
? mapIds
: (mapId.value ? [String(mapId.value)] : [])
if (targetMapIds.length) {
await mapsStore.setContactMapMemberships(created.id, targetMapIds)
}
const { saveContactPluginData } = await import('../application/services/contactPluginService')
await saveContactPluginData(created.id, pluginPayload)
createContactOpen.value = false
await load()
}
async function saveInvolvement() {
const node = selectedNode.value
if (!node?.membership_id) return
@@ -334,6 +384,9 @@ const mapsStore = useNetworkMapsStore()
const typesStore = useNetworkMapTypesStore()
const mapId = computed(() => String(route.params.mapId || ''))
const createContactMapIds = computed(() => (
mapId.value ? [String(mapId.value)] : []
))
const activeMap = computed(() => mapsStore.maps.find((m) => String(m.id) === mapId.value) || null)
const activeMapType = computed(() => {
const typeId = activeMap.value?.mapTypeId
@@ -691,7 +744,12 @@ function buildVisNodes() {
}
function initNetwork() {
if (!graphContainer.value) return
if (!graphContainer.value) {
if (initRetryCount >= INIT_RETRY_MAX) return
initRetryCount += 1
initRetryTimer = setTimeout(initNetwork, 80)
return
}
measureLayout()
const el = graphContainer.value
let { w, h } = layout.value
@@ -877,8 +935,9 @@ async function load() {
} finally {
loading.value = false
}
await store.fetchContacts()
if (nodes.value.length > 0) {
await store.fetchContacts()
initRetryCount = 0
await nextTick()
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
initNetwork()
@@ -890,6 +949,10 @@ async function load() {
}
}
async function openAddContact() {
showAddContact.value = true
}
function switchMap(nextId) {
if (!nextId || String(nextId) === mapId.value) return
router.push({ name: 'NetworkMap', params: { mapId: nextId } })
@@ -930,8 +993,13 @@ async function onMapDelete() {
closeMapForm()
if (String(mapId.value) === String(deletingId)) {
const nextId = mapsStore.maps[0]?.id
if (nextId) router.replace({ name: 'NetworkMap', params: { mapId: nextId } })
else router.replace({ name: 'Contacts' })
if (nextId) {
router.replace({ name: 'NetworkMap', params: { mapId: nextId } })
return
}
const created = await ensureDefaultNetworkMap()
await mapsStore.fetchMaps()
router.replace({ name: 'NetworkMap', params: { mapId: created.id } })
}
}
@@ -941,6 +1009,13 @@ async function onAddContactToMap(contactId) {
await load()
}
watch(graphContainer, (el) => {
if (el && nodes.value.length > 0 && !network.value) {
initRetryCount = 0
initNetwork()
}
})
watch(mapId, async (next, prev) => {
if (!next || next === prev) return
network.value?.destroy()
@@ -1039,4 +1114,11 @@ onUnmounted(() => {
.legend-tension { color: #f1c40f; }
.legend-alliance { color: #2ecc71; }
.legend-neutral { color: #95a5a6; }
.empty-state-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 12px;
}
</style>