From 986d36ff51eee4d92d63e619707b1290b41e148a Mon Sep 17 00:00:00 2001 From: gitrusprus Date: Mon, 6 Jul 2026 15:15:45 +0300 Subject: [PATCH] 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 --- .../src/components/AddContactToMapModal.vue | 27 +- frontend/src/components/ContactForm.vue | 18 +- .../components/ContactRelationsSection.vue | 276 +++++++++ .../src/components/CreateContactModal.vue | 12 +- .../src/components/GraphCanvasContextMenu.vue | 22 +- .../src/components/GraphEdgeContextMenu.vue | 30 +- frontend/src/components/GraphFiltersModal.vue | 81 +++ frontend/src/components/GraphHeaderPanel.vue | 4 +- .../src/components/GraphNodeContextMenu.vue | 31 +- .../src/components/NetworkMapSwitcher.vue | 63 ++- .../src/components/NetworkMapTopPanel.vue | 21 +- .../src/components/RelationLinkFields.vue | 92 +++ .../composables/useGraphNodeContextMenu.js | 64 ++- frontend/src/lib/graph/clusters.js | 79 +++ frontend/src/lib/graph/clusters.test.js | 32 +- frontend/src/lib/map/mapLayoutCache.js | 53 ++ .../src/lib/ui/topPanelCollapseStorage.js | 32 ++ frontend/src/stores/contacts.js | 12 +- frontend/src/views/ContactDetailView.vue | 322 +++-------- frontend/src/views/ContactsView.vue | 83 +-- frontend/src/views/GraphView.vue | 529 +++++++++++++----- frontend/src/views/NetworkMapView.vue | 378 +++++++++++-- 22 files changed, 1638 insertions(+), 623 deletions(-) create mode 100644 frontend/src/components/ContactRelationsSection.vue create mode 100644 frontend/src/components/GraphFiltersModal.vue create mode 100644 frontend/src/components/RelationLinkFields.vue create mode 100644 frontend/src/lib/map/mapLayoutCache.js create mode 100644 frontend/src/lib/ui/topPanelCollapseStorage.js diff --git a/frontend/src/components/AddContactToMapModal.vue b/frontend/src/components/AddContactToMapModal.vue index 698a37d..a30d99a 100644 --- a/frontend/src/components/AddContactToMapModal.vue +++ b/frontend/src/components/AddContactToMapModal.vue @@ -43,6 +43,18 @@

Выбран: {{ selectedContact.name }}

+ + +

{{ error }}

+ ({}) }, 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) } diff --git a/frontend/src/components/ContactRelationsSection.vue b/frontend/src/components/ContactRelationsSection.vue new file mode 100644 index 0000000..53197ab --- /dev/null +++ b/frontend/src/components/ContactRelationsSection.vue @@ -0,0 +1,276 @@ + + + + + diff --git a/frontend/src/components/CreateContactModal.vue b/frontend/src/components/CreateContactModal.vue index 7057943..968140c 100644 --- a/frontend/src/components/CreateContactModal.vue +++ b/frontend/src/components/CreateContactModal.vue @@ -8,6 +8,10 @@ @@ -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) } diff --git a/frontend/src/components/GraphCanvasContextMenu.vue b/frontend/src/components/GraphCanvasContextMenu.vue index f578d6d..1238340 100644 --- a/frontend/src/components/GraphCanvasContextMenu.vue +++ b/frontend/src/components/GraphCanvasContextMenu.vue @@ -14,8 +14,15 @@ @click.stop @contextmenu.prevent > - @@ -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() } diff --git a/frontend/src/components/GraphEdgeContextMenu.vue b/frontend/src/components/GraphEdgeContextMenu.vue index 3eaca8d..adf9804 100644 --- a/frontend/src/components/GraphEdgeContextMenu.vue +++ b/frontend/src/components/GraphEdgeContextMenu.vue @@ -14,18 +14,15 @@ @click.stop @contextmenu.prevent > -
{{ edgeTitle }}
+ + diff --git a/frontend/src/components/GraphHeaderPanel.vue b/frontend/src/components/GraphHeaderPanel.vue index 54a2222..a45f1dd 100644 --- a/frontend/src/components/GraphHeaderPanel.vue +++ b/frontend/src/components/GraphHeaderPanel.vue @@ -2,7 +2,8 @@

{{ title }}

- - Открыть карточку + Редактировать контакт
diff --git a/frontend/src/composables/useGraphNodeContextMenu.js b/frontend/src/composables/useGraphNodeContextMenu.js index c8aa759..b3ef5ac 100644 --- a/frontend/src/composables/useGraphNodeContextMenu.js +++ b/frontend/src/composables/useGraphNodeContextMenu.js @@ -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) } diff --git a/frontend/src/lib/graph/clusters.js b/frontend/src/lib/graph/clusters.js index 4efb74b..28e8aa8 100644 --- a/frontend/src/lib/graph/clusters.js +++ b/frontend/src/lib/graph/clusters.js @@ -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. * Связные компоненты из 2+ узлов получают уникальный индекс цвета, diff --git a/frontend/src/lib/graph/clusters.test.js b/frontend/src/lib/graph/clusters.test.js index c3ebd1c..e69c5ff 100644 --- a/frontend/src/lib/graph/clusters.test.js +++ b/frontend/src/lib/graph/clusters.test.js @@ -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')) + }) +}) diff --git a/frontend/src/lib/map/mapLayoutCache.js b/frontend/src/lib/map/mapLayoutCache.js new file mode 100644 index 0000000..2287832 --- /dev/null +++ b/frontend/src/lib/map/mapLayoutCache.js @@ -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 */ + } +} diff --git a/frontend/src/lib/ui/topPanelCollapseStorage.js b/frontend/src/lib/ui/topPanelCollapseStorage.js new file mode 100644 index 0000000..224ad97 --- /dev/null +++ b/frontend/src/lib/ui/topPanelCollapseStorage.js @@ -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) +} diff --git a/frontend/src/stores/contacts.js b/frontend/src/stores/contacts.js index dcf8c56..a1c06c2 100644 --- a/frontend/src/stores/contacts.js +++ b/frontend/src/stores/contacts.js @@ -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 diff --git a/frontend/src/views/ContactDetailView.vue b/frontend/src/views/ContactDetailView.vue index a0a564b..8ae822f 100644 --- a/frontend/src/views/ContactDetailView.vue +++ b/frontend/src/views/ContactDetailView.vue @@ -2,19 +2,32 @@
-
-
- +
+
+

Редактировать контакт

+ + +
+
+ +
+
-
-

Информация

- -
+

Информация

{{ contact.email || '—' }}
@@ -51,102 +64,8 @@
-
-
-

Связи ({{ contactRelations.length }})

- -
-
-

Нет связей с другими контактами.

-
-
-
-
-
- {{ otherContactName(rel) }} - {{ relLabel(rel.relation_type) }} - {{ intensityLabel(rel.interaction_intensity) }} -
-
{{ rel.description }}
-
-
- - -
-
-
-
-
-
- - - - - - - - @@ -154,15 +73,12 @@ diff --git a/frontend/src/views/NetworkMapView.vue b/frontend/src/views/NetworkMapView.vue index 57a8fc9..bbb60d8 100644 --- a/frontend/src/views/NetworkMapView.vue +++ b/frontend/src/views/NetworkMapView.vue @@ -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" >