Add multiple network maps, graph UX improvements, and full backup import.

Support per-map contact membership with scoped graph views, relation editing on edges, layout caching, and automatic detection of full JSON backups so contacts and relations import together.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 17:54:51 +03:00
co-authored by Cursor
parent 35ec4c81ec
commit 5155b3a37a
50 changed files with 3176 additions and 372 deletions
+335 -67
View File
@@ -77,6 +77,23 @@
@info="openNodeInfo"
/>
<GraphEdgeContextMenu
:open="edgeContextMenuOpen"
:edge="contextMenuEdge"
:x="edgeContextMenuX"
:y="edgeContextMenuY"
@close="closeEdgeContextMenu"
@edit="openEditRelation"
/>
<EditRelationModal
:open="editRelationOpen"
:relation="editRelationTarget"
@close="closeEditRelation"
@updated="onRelationUpdated"
@deleted="onRelationDeleted"
/>
<CreateRelationModal
:open="relationModalOpen"
:source="relationPair?.[0]"
@@ -88,7 +105,9 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
defineOptions({ name: 'Graph' })
import { ref, computed, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { Network, DataSet } from 'vis-network/standalone'
import { useContactsStore } from '../stores/contacts'
@@ -96,13 +115,16 @@ 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 { RELATION_TYPES } from '../domain/networkChoices'
import { fetchGraphBundle } from '../composables/useGraphData'
import { edgeFromRelation } from '../application/usecases/graph'
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 CreateRelationModal from '../components/CreateRelationModal.vue'
import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
import EditRelationModal from '../components/EditRelationModal.vue'
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
let themeObserver = null
@@ -113,7 +135,12 @@ const {
contextMenuNode,
contextMenuX,
contextMenuY,
edgeContextMenuOpen,
contextMenuEdge,
edgeContextMenuX,
edgeContextMenuY,
closeContextMenu,
closeEdgeContextMenu,
attachNodeContextHandlers,
} = useGraphNodeContextMenu()
@@ -129,6 +156,8 @@ const physicsEnabled = ref(true)
const selectedNode = ref(null)
const relationModalOpen = ref(false)
const relationPair = ref(null)
const editRelationOpen = ref(false)
const editRelationTarget = ref(null)
const {
linkSelection,
@@ -148,6 +177,8 @@ let initRetryCount = 0
const INIT_RETRY_MAX = 40
let initRetryTimer = null
let resizeObserver = null
let syncedRevision = -1
let initialLayoutDone = false
const nodes = ref([])
const edges = ref([])
@@ -155,8 +186,6 @@ const allRelationTypes = ref([])
const activeFilters = ref([])
const clusterMap = ref(new Map())
const relationTypeLabels = Object.fromEntries(RELATION_TYPES.map((r) => [r.value, r.label]))
const selectedContact = computed(() =>
selectedNode.value ? store.contactById(selectedNode.value.id) : null
)
@@ -217,14 +246,15 @@ function nodeVisColor(nodeId, linkIds) {
function mapGraphNodeToVis(n, linkIds = new Set()) {
const palette = graphPalette()
const degree = nodeDegree(n.id)
const label = degree <= 12 ? (n.label || String(n.id)) : ''
return {
id: String(n.id),
label: n.label || String(n.id),
title: n.title,
label,
title: [n.label, n.title].filter(Boolean).join('\n'),
color: nodeVisColor(n.id, linkIds),
font: { color: palette.nodeFont, size: degree > 0 ? 13 : 11 },
font: { color: palette.nodeFont, size: degree > 0 ? 12 : 11 },
shape: 'dot',
size: degree > 0 ? 12 + Math.min(degree, 6) * 2 : 9,
size: degree > 0 ? 10 + Math.min(degree, 4) * 1.5 : 8,
borderWidth: linkIds.has(String(n.id)) ? 3 : 2,
}
}
@@ -243,23 +273,93 @@ function applyLinkHighlights() {
function mapGraphEdgeToVis(e) {
const palette = graphPalette()
const rc = RELATION_COLORS[e.relation_type] || RELATION_COLORS.other
const typeLabel = relationTypeLabels[e.relation_type] || e.relation_type
const tip = [typeLabel, e.title !== e.relation_type ? e.title : ''].filter(Boolean).join(' — ')
const intensity = applyIntensityToVisEdge(e, { color: rc.color, highlight: rc.highlight })
return {
id: String(e.id),
from: String(e.from),
to: String(e.to),
title: tip || typeLabel,
relation_type: e.relation_type,
color: { color: rc.color, highlight: rc.highlight, opacity: 0.8 },
width: e.interaction_intensity === 'sparse' ? 1 : 2,
dashes: e.interaction_intensity === 'sparse' ? [6, 4] : false,
interaction_intensity: e.interaction_intensity,
...intensity,
hoverWidth: intensity.width + 8,
selectionWidth: intensity.width + 12,
font: { color: palette.edgeFont, size: 10, align: 'middle' },
arrows: { to: { enabled: false } },
smooth: { type: 'dynamic' },
smooth: false,
}
}
function resolveRelationForEdit(edge) {
const fromStore = store.relations.find((r) => String(r.id) === String(edge.id))
if (fromStore) return fromStore
const fromNode = nodes.value.find((n) => String(n.id) === String(edge.from))
const toNode = nodes.value.find((n) => String(n.id) === String(edge.to))
return {
id: edge.id,
source: edge.from,
target: edge.to,
relation_type: edge.relation_type,
description: edge.title && edge.title !== edge.relation_type ? edge.title : '',
interaction_intensity: edge.interaction_intensity || 'intense',
source_name: fromNode?.label || String(edge.from),
target_name: toNode?.label || String(edge.to),
}
}
function openEditRelation(edge) {
editRelationTarget.value = resolveRelationForEdit(edge)
editRelationOpen.value = true
}
function closeEditRelation() {
editRelationOpen.value = false
editRelationTarget.value = null
}
function updateGraphEdge(relation) {
const edge = edgeFromRelation(relation)
const idx = edges.value.findIndex((e) => String(e.id) === String(edge.id))
if (idx !== -1) edges.value[idx] = edge
else 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
const visible =
activeFilters.value.length === allRelationTypes.value.length ||
activeFilters.value.includes(edge.relation_type)
if (!visible) {
if (edgesDS.get(String(edge.id))) edgesDS.remove(String(edge.id))
return
}
const visEdge = mapGraphEdgeToVis(edge)
if (edgesDS.get(String(edge.id))) edgesDS.update(visEdge)
else edgesDS.add(visEdge)
}
function onRelationUpdated(relation) {
closeEditRelation()
updateGraphEdge(relation)
syncedRevision = store.dataRevision
if (network.value && physicsEnabled.value) {
network.value.stabilize(80)
}
}
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()
}
function onRelationDeleted(relationId) {
closeEditRelation()
removeGraphEdge(relationId)
syncedRevision = store.dataRevision
}
function appendRelationEdge(relation) {
if (!relation) return
const edge = edgeFromRelation(relation)
@@ -296,29 +396,157 @@ function onRelationCreated(relation) {
clearLinkSelection()
applyLinkHighlights()
appendRelationEdge(relation)
syncedRevision = store.dataRevision
}
watch(linkSelection, () => {
applyLinkHighlights()
}, { deep: true })
async function loadGraph() {
loading.value = true
try {
const bundle = await fetchGraphBundle('/graph/')
nodes.value = bundle.nodes
edges.value = bundle.edges
allRelationTypes.value = bundle.relationTypes
activeFilters.value = bundle.relationTypes.map((r) => r.value)
} finally {
loading.value = false
function physicsOptions(enabled) {
return {
enabled,
solver: 'forceAtlas2Based',
forceAtlas2Based: {
gravitationalConstant: -120,
centralGravity: 0.002,
springLength: 200,
springConstant: 0.035,
damping: 0.5,
avoidOverlap: 1,
},
stabilization: enabled
? { iterations: 200, fit: !initialLayoutDone, updateInterval: 25 }
: undefined,
maxVelocity: 20,
}
// Контейнер #graph-container в DOM только когда loading=false и nodes.length > 0
if (nodes.value.length > 0) {
recomputeClusters()
await nextTick()
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
}
async function applyGraphDataFromStore() {
if (!store.contacts.length) await store.fetchContacts()
if (!store.relations.length) await store.fetchRelations()
if (!store.relationTypes.length) {
allRelationTypes.value = await store.fetchRelationTypes()
} else {
allRelationTypes.value = store.relationTypes
}
const bundle = buildGraphFromStore(store.contacts, store.relations, allRelationTypes.value)
nodes.value = bundle.nodes
edges.value = bundle.edges
if (!activeFilters.value.length && allRelationTypes.value.length) {
activeFilters.value = allRelationTypes.value.map((r) => r.value)
}
recomputeClusters()
}
function saveLayoutSnapshot() {
if (!network.value) return
writeGraphLayoutCache({
positions: network.value.getPositions(),
scale: network.value.getScale(),
view: network.value.getViewPosition(),
})
}
function restoreViewport() {
if (!network.value) return
const cache = readGraphLayoutCache()
if (cache.view) {
network.value.moveTo({
position: cache.view,
scale: cache.scale || 1,
animation: false,
})
}
}
function finishInitialLayout({ fitView = true } = {}) {
saveLayoutSnapshot()
initialLayoutDone = true
if (fitView) {
network.value?.fit({ animation: false })
}
network.value?.redraw()
}
function teardownNetwork() {
if (initRetryTimer) {
clearTimeout(initRetryTimer)
initRetryTimer = null
}
detachContextHandler?.()
detachContextHandler = null
resizeObserver?.disconnect()
resizeObserver = null
network.value?.destroy()
network.value = null
nodesDS = null
edgesDS = null
}
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 seeds = computeGraphSeedPositions(nodes.value, filteredEdges())
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 = livePositions[id] || cachedPositions[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)
})
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()
refreshNodeStyles()
syncedRevision = store.dataRevision
}
async function ensureGraphReady({ showSpinner = true } = {}) {
const reconnecting = Boolean(network.value)
if (showSpinner && !reconnecting) loading.value = true
try {
await applyGraphDataFromStore()
} finally {
if (showSpinner && !reconnecting) loading.value = false
}
if (nodes.value.length === 0) {
teardownNetwork()
syncedRevision = store.dataRevision
return
}
await nextTick()
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
if (!network.value) {
initNetwork()
syncedRevision = store.dataRevision
return
}
if (syncedRevision !== store.dataRevision) {
syncGraphToNetwork()
}
}
@@ -332,7 +560,7 @@ function filteredEdges() {
}
function initNetwork() {
if (!graphContainer.value) return
if (network.value || !graphContainer.value) return
const el = graphContainer.value
let width = el.offsetWidth
let height = el.offsetHeight
@@ -355,35 +583,46 @@ function initNetwork() {
}
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
const nodeList = nodes.value.map((n) => mapGraphNodeToVis(n, linkIds))
const cache = readGraphLayoutCache()
const cachedPositions = cache.positions || {}
const nodeIds = nodes.value.map((n) => String(n.id))
const cachedCount = nodeIds.filter((id) => cachedPositions[id]).length
const useCachedLayout = cachedCount > 0 && cachedCount >= nodeIds.length * 0.8
const seedPositions = computeGraphSeedPositions(nodes.value, filteredEdges())
const nodeList = nodes.value.map((n) => {
const vis = mapGraphNodeToVis(n, linkIds)
const id = String(n.id)
const pos = cachedPositions[id] || seedPositions.get(id)
return pos ? { ...vis, x: pos.x, y: pos.y } : vis
})
const edgeList = filteredEdges().map(mapGraphEdgeToVis)
nodesDS = new DataSet(nodeList)
edgesDS = new DataSet(edgeList)
if (useCachedLayout) {
initialLayoutDone = true
}
network.value = new Network(
el,
{ nodes: nodesDS, edges: edgesDS },
{
physics: {
enabled: true,
stabilization: { iterations: 250, fit: true },
barnesHut: {
gravitationalConstant: -12000,
centralGravity: 0.15,
springLength: 220,
springConstant: 0.035,
damping: 0.12,
avoidOverlap: 0.25,
},
},
layout: { improvedLayout: false },
physics: physicsOptions(physicsEnabled.value),
interaction: {
tooltipDelay: 200,
hover: true,
hideEdgesOnDrag: true,
selectConnectedEdges: false,
zoomView: true,
dragView: true,
dragNodes: true,
},
edges: {
chosen: { label: false },
},
nodes: { borderWidth: 1.5 },
}
)
@@ -400,22 +639,25 @@ function initNetwork() {
}
})
detachContextHandler?.()
detachContextHandler = attachNodeContextHandlers(network.value, () => nodes.value)
network.value.once('stabilizationIterationsDone', () => {
network.value?.fit({ animation: { duration: 400 } })
setTimeout(() => {
network.value?.fit({ animation: false })
network.value?.redraw()
}, 100)
network.value.on('dragEnd', () => {
saveLayoutSnapshot()
})
setTimeout(() => {
if (network.value) {
network.value.fit({ animation: false })
network.value.redraw()
}
}, 800)
detachContextHandler?.()
detachContextHandler = attachNodeContextHandlers(
network.value,
() => nodes.value,
() => edges.value
)
if (useCachedLayout) {
restoreViewport()
network.value.redraw()
} else {
network.value.once('stabilizationIterationsDone', () => {
finishInitialLayout({ fitView: true })
})
}
resizeObserver = new ResizeObserver(() => {
network.value?.redraw()
@@ -457,21 +699,47 @@ function resetView() {
function togglePhysics() {
physicsEnabled.value = !physicsEnabled.value
network.value?.setOptions({ physics: { enabled: physicsEnabled.value } })
network.value?.setOptions({ physics: physicsOptions(physicsEnabled.value) })
}
watch(() => store.dataRevision, async (revision) => {
if (!network.value || revision === syncedRevision) return
await applyGraphDataFromStore()
if (nodes.value.length === 0) {
teardownNetwork()
syncedRevision = revision
return
}
syncGraphToNetwork()
})
onMounted(() => {
loadGraph()
themeObserver = new MutationObserver(() => applyThemeToNetwork())
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
})
onActivated(async () => {
if (network.value) {
network.value.redraw()
if (syncedRevision !== store.dataRevision) {
await ensureGraphReady({ showSpinner: false })
} else {
restoreViewport()
}
return
}
await ensureGraphReady()
})
onDeactivated(() => {
saveLayoutSnapshot()
})
onUnmounted(() => {
if (initRetryTimer) clearTimeout(initRetryTimer)
detachContextHandler?.()
saveLayoutSnapshot()
closeContextMenu()
resizeObserver?.disconnect()
themeObserver?.disconnect()
network.value?.destroy()
teardownNetwork()
})
</script>