Add import/export, graph UX improvements, and contact management fixes.

Support vCard import and multi-format export, bulk delete with reliable local persistence, Ctrl+link relation creation, cluster-colored graph visualization, and right-click context menus for node details.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 11:36:13 +03:00
co-authored by Cursor
parent 2a32c61934
commit 35ec4c81ec
27 changed files with 1819 additions and 110 deletions
+223 -53
View File
@@ -9,6 +9,12 @@
/>
<div 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"
@@ -61,18 +67,59 @@
</div>
</div>
</div>
<GraphNodeContextMenu
:open="contextMenuOpen"
:node="contextMenuNode"
:x="contextMenuX"
:y="contextMenuY"
@close="closeContextMenu"
@info="openNodeInfo"
/>
<CreateRelationModal
:open="relationModalOpen"
:source="relationPair?.[0]"
:target="relationPair?.[1]"
@close="closeRelationModal"
@created="onRelationCreated"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { RouterLink } 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 { RELATION_TYPES } from '../domain/networkChoices'
import { fetchGraphBundle } from '../composables/useGraphData'
import { 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 { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
let themeObserver = null
let detachContextHandler = null
const {
contextMenuOpen,
contextMenuNode,
contextMenuX,
contextMenuY,
closeContextMenu,
attachNodeContextHandlers,
} = useGraphNodeContextMenu()
function openNodeInfo(node) {
selectedNode.value = node || null
}
const store = useContactsStore()
const graphContainer = ref(null)
@@ -80,6 +127,20 @@ const loading = ref(true)
const network = ref(null)
const physicsEnabled = ref(true)
const selectedNode = ref(null)
const relationModalOpen = ref(false)
const relationPair = ref(null)
const {
linkSelection,
linkSelectionCount,
clearLinkSelection,
handleCtrlPickNode,
} = useCtrlLinkSelection({
onPairSelected(pair) {
relationPair.value = pair
relationModalOpen.value = true
},
})
let nodesDS = null
let edgesDS = null
@@ -87,12 +148,14 @@ let initRetryCount = 0
const INIT_RETRY_MAX = 40
let initRetryTimer = null
let resizeObserver = null
let themeObserver = null
const nodes = ref([])
const edges = ref([])
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
@@ -107,6 +170,7 @@ function graphPalette() {
return {
nodeBackground: cssVar('--surface-alt', '#22263a'),
nodeBorder: cssVar('--accent', '#5b8dee'),
nodeLinkBorder: cssVar('--green', '#4ecca3'),
nodeHighlightBackground: cssVar('--surface', '#1a1d27'),
nodeHighlightBorder: cssVar('--accent-hover', '#7aa5f5'),
nodeFont: cssVar('--text', '#e2e6f3'),
@@ -114,6 +178,130 @@ function graphPalette() {
}
}
function recomputeClusters() {
clusterMap.value = computeClusterMap(nodes.value, filteredEdges())
}
function nodeDegree(id) {
const sid = String(id)
return filteredEdges().filter(
(e) => String(e.from) === sid || String(e.to) === sid
).length
}
function isDarkTheme() {
return document.documentElement.getAttribute('data-theme') === 'dark'
}
function nodeVisColor(nodeId, linkIds) {
const sid = String(nodeId)
const palette = graphPalette()
if (linkIds.has(sid)) {
return {
background: palette.nodeBackground,
border: palette.nodeLinkBorder,
highlight: {
background: palette.nodeHighlightBackground,
border: palette.nodeHighlightBorder,
},
}
}
const cc = clusterColor(clusterMap.value.get(sid), isDarkTheme())
return {
background: cc.bg,
border: cc.border,
highlight: { background: cc.highlight, border: cc.border },
}
}
function mapGraphNodeToVis(n, linkIds = new Set()) {
const palette = graphPalette()
const degree = nodeDegree(n.id)
return {
id: String(n.id),
label: n.label || String(n.id),
title: n.title,
color: nodeVisColor(n.id, linkIds),
font: { color: palette.nodeFont, size: degree > 0 ? 13 : 11 },
shape: 'dot',
size: degree > 0 ? 12 + Math.min(degree, 6) * 2 : 9,
borderWidth: linkIds.has(String(n.id)) ? 3 : 2,
}
}
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()
}
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(' — ')
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,
font: { color: palette.edgeFont, size: 10, align: 'middle' },
arrows: { to: { enabled: false } },
smooth: { type: 'dynamic' },
}
}
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 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()
}
function closeRelationModal() {
relationModalOpen.value = false
relationPair.value = null
clearLinkSelection()
applyLinkHighlights()
}
function onRelationCreated(relation) {
relationModalOpen.value = false
relationPair.value = null
clearLinkSelection()
applyLinkHighlights()
appendRelationEdge(relation)
}
watch(linkSelection, () => {
applyLinkHighlights()
}, { deep: true })
async function loadGraph() {
loading.value = true
try {
@@ -127,6 +315,7 @@ async function loadGraph() {
}
// Контейнер #graph-container в DOM только когда loading=false и nodes.length > 0
if (nodes.value.length > 0) {
recomputeClusters()
await nextTick()
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
initNetwork()
@@ -165,33 +354,9 @@ function initNetwork() {
initRetryTimer = null
}
const palette = graphPalette()
const nodeList = nodes.value.map((n) => ({
id: String(n.id),
label: n.label || String(n.id),
title: n.title,
...(n.group != null && { group: n.group }),
color: {
background: palette.nodeBackground,
border: palette.nodeBorder,
highlight: { background: palette.nodeHighlightBackground, border: palette.nodeHighlightBorder },
},
font: { color: palette.nodeFont, size: 13 },
shape: 'dot',
size: 14,
}))
const edgeList = filteredEdges().map((e) => ({
id: String(e.id),
from: String(e.from),
to: String(e.to),
label: e.label,
title: e.title,
relation_type: e.relation_type,
color: RELATION_COLORS[e.relation_type] || RELATION_COLORS.other,
font: { color: palette.edgeFont, size: 10, align: 'middle' },
arrows: { to: { enabled: false } },
smooth: { type: 'curvedCW', roundness: 0.1 },
}))
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
const nodeList = nodes.value.map((n) => mapGraphNodeToVis(n, linkIds))
const edgeList = filteredEdges().map(mapGraphEdgeToVis)
nodesDS = new DataSet(nodeList)
edgesDS = new DataSet(edgeList)
@@ -201,27 +366,43 @@ function initNetwork() {
{
physics: {
enabled: true,
stabilization: { iterations: 150 },
barnesHut: { gravitationalConstant: -3000, springLength: 180 },
stabilization: { iterations: 250, fit: true },
barnesHut: {
gravitationalConstant: -12000,
centralGravity: 0.15,
springLength: 220,
springConstant: 0.035,
damping: 0.12,
avoidOverlap: 0.25,
},
},
interaction: {
tooltipDelay: 200,
hover: true,
hideEdgesOnDrag: true,
zoomView: true,
dragView: true,
dragNodes: true,
},
nodes: { borderWidth: 1.5 },
}
)
network.value.on('click', (params) => {
closeContextMenu()
if (params.nodes.length > 0) {
const id = params.nodes[0]
const node = nodes.value.find((n) => String(n.id) === id)
selectedNode.value = node || null
const domEvent = params.event?.srcEvent || params.event
if (domEvent && (domEvent.ctrlKey || domEvent.metaKey)) {
handleCtrlPickNode(node, domEvent)
}
}
})
detachContextHandler?.()
detachContextHandler = attachNodeContextHandlers(network.value, () => nodes.value)
network.value.once('stabilizationIterationsDone', () => {
network.value?.fit({ animation: { duration: 400 } })
setTimeout(() => {
@@ -250,34 +431,17 @@ function toggleFilter(type) {
activeFilters.value.push(type)
}
if (edgesDS) {
const palette = graphPalette()
recomputeClusters()
edgesDS.clear()
edgesDS.add(
filteredEdges().map((e) => ({
...e,
color: RELATION_COLORS[e.relation_type] || RELATION_COLORS.other,
font: { color: palette.edgeFont, size: 10, align: 'middle' },
arrows: { to: { enabled: false } },
smooth: { type: 'curvedCW', roundness: 0.1 },
}))
)
edgesDS.add(filteredEdges().map(mapGraphEdgeToVis))
refreshNodeStyles()
}
}
function applyThemeToNetwork() {
if (!nodesDS || !edgesDS || !network.value) return
refreshNodeStyles()
const palette = graphPalette()
nodesDS.update(
nodes.value.map((n) => ({
id: String(n.id),
color: {
background: palette.nodeBackground,
border: palette.nodeBorder,
highlight: { background: palette.nodeHighlightBackground, border: palette.nodeHighlightBorder },
},
font: { color: palette.nodeFont, size: 13 },
}))
)
edgesDS.update(
filteredEdges().map((e) => ({
id: String(e.id),
@@ -303,6 +467,8 @@ onMounted(() => {
})
onUnmounted(() => {
if (initRetryTimer) clearTimeout(initRetryTimer)
detachContextHandler?.()
closeContextMenu()
resizeObserver?.disconnect()
themeObserver?.disconnect()
network.value?.destroy()
@@ -321,6 +487,10 @@ onUnmounted(() => {
flex-shrink: 0;
padding: 0 28px 10px;
}
.graph-link-hint {
font-size: 12px;
margin: 0 0 8px;
}
.graph-area {
flex: 1;
min-height: 0;