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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user